diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index f8b22ee7033..15ff97fe005 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -20,7 +20,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' - name: Unit tests working-directory: backend run: make test-unit @@ -60,7 +60,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/.github/workflows/docker-push.yml b/.github/workflows/docker-push.yml new file mode 100644 index 00000000000..4e192d96996 --- /dev/null +++ b/.github/workflows/docker-push.yml @@ -0,0 +1,39 @@ +name: Deploy Sub2api to GHCR + +on: + push: + branches: [ "main" ] # 只有推送到 main 分支时触发,如果是 master 请修改 + +env: + REGISTRY: ghcr.io + # 镜像名会自动设为:用户名/仓库名 + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest # 使用 GitHub 提供的 amd64 环境构建 + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + # 明确指定 amd64,确保服务器能跑 + platforms: linux/amd64 + tags: | + ghcr.io/${{ env.IMAGE_NAME }}:latest + ghcr.io/${{ env.IMAGE_NAME }}:${{ github.sha }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26ed8524141..80bc9850dae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' # Docker setup for GoReleaser - name: Set up QEMU diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 600fd2faecc..ef8e59e54ac 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -23,7 +23,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' - name: Run govulncheck working-directory: backend run: | diff --git a/.gitignore b/.gitignore index cf251f0715a..cc338cb2ad7 100644 --- a/.gitignore +++ b/.gitignore @@ -131,8 +131,12 @@ docs/* !docs/PAYMENT.md !docs/PAYMENT_CN.md !docs/ADMIN_PAYMENT_INTEGRATION_API.md +!docs/CHAT_COMPLETION_FEATURE_DEVELOPMENT.md .serena/ .codex/ frontend/coverage/ aicodex output/ + +./docs/* +./docs diff --git a/.superpowers/brainstorm/13206-1778653382/.server-stopped b/.superpowers/brainstorm/13206-1778653382/.server-stopped new file mode 100644 index 00000000000..f72eadd2002 --- /dev/null +++ b/.superpowers/brainstorm/13206-1778653382/.server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1778656022182} diff --git a/.superpowers/brainstorm/13206-1778653382/.server.pid b/.superpowers/brainstorm/13206-1778653382/.server.pid new file mode 100644 index 00000000000..ece240fb701 --- /dev/null +++ b/.superpowers/brainstorm/13206-1778653382/.server.pid @@ -0,0 +1 @@ +13206 diff --git a/.superpowers/brainstorm/13206-1778653382/chat-capabilities-layout-v2.html b/.superpowers/brainstorm/13206-1778653382/chat-capabilities-layout-v2.html new file mode 100644 index 00000000000..a2a0d4e1460 --- /dev/null +++ b/.superpowers/brainstorm/13206-1778653382/chat-capabilities-layout-v2.html @@ -0,0 +1,73 @@ +

Revised Chat Layout

+

Right-side balance panel removed. Usage and cost are kept lightweight inside the conversation, while detailed balance remains in Usage Records.

+ +
+
Desktop: history + conversation, no balance sidebar
+
+
+ + +
+
+
+ Chat +

Key · gpt-5.4 · streaming ready

+
+
+ + +
+
+
+
写一个 SSE 调用示例
+
+

下面是一个 Markdown 响应:

+
fetch('/v1/chat/completions')
+
+

Generated in 2.4s · cost pending usage sync

+
+
+
+ +
+
+
+
+
+ +
+
+
A
+
+

Local-first history

+

Session history is stored in the browser. Fast and simple; not shared across devices.

+
+
+
+
B
+
+

Server-backed history

+

History is stored in backend APIs. Shared across devices; needs database and backend work.

+
+
+
+
C
+
+

Hybrid incremental

+

Start with local history and UI contracts, then add server-backed sync later.

+
+
+
diff --git a/.superpowers/brainstorm/13206-1778653382/chat-capabilities-layout.html b/.superpowers/brainstorm/13206-1778653382/chat-capabilities-layout.html new file mode 100644 index 00000000000..b295968fe99 --- /dev/null +++ b/.superpowers/brainstorm/13206-1778653382/chat-capabilities-layout.html @@ -0,0 +1,90 @@ +

Chat Page Capability Layout

+

A proposed way to fit streaming, Markdown/code tools, history, costs, and mobile behavior into one usable chat workspace.

+ +
+
Desktop: chat workbench with history and billing awareness
+
+
+ + +
+
+
+ Chat +

Key · gpt-5.4 · streaming ready

+
+
+ + +
+
+
+
写一个 SSE 调用示例
+
+

下面是一个 Markdown 响应:

+
fetch('/v1/chat/completions')
+
+
+
+
+ +
+
+ + +
+
+
+ +
+
+
A
+
+

Local-first history

+

Fastest path: store chat sessions in browser localStorage; usage cost is estimated or fetched from recent usage logs after completion.

+
+
+
+
B
+
+

Server-backed history

+

More complete: persist conversations and exact cost server-side; requires backend tables/APIs and a larger implementation.

+
+
+
+
C
+
+

Hybrid incremental

+

Build polished frontend history now, then attach exact server-side history/cost APIs later without changing the UI model.

+
+
+
diff --git a/.superpowers/brainstorm/13206-1778653382/waiting.html b/.superpowers/brainstorm/13206-1778653382/waiting.html new file mode 100644 index 00000000000..ef076525ab6 --- /dev/null +++ b/.superpowers/brainstorm/13206-1778653382/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/Dockerfile b/Dockerfile index 890bda0bfa8..a4dac7be340 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.2-alpine +ARG GOLANG_IMAGE=golang:1.26.3-alpine ARG ALPINE_IMAGE=alpine:3.21 ARG POSTGRES_IMAGE=postgres:18-alpine ARG GOPROXY=https://goproxy.cn,direct @@ -24,7 +24,7 @@ WORKDIR /app/frontend RUN corepack enable && corepack prepare pnpm@latest --activate # Install dependencies first (better caching) -COPY frontend/package.json frontend/pnpm-lock.yaml ./ +COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile # Copy frontend source and build diff --git a/README.md b/README.md index 718730c63ca..756720ef915 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ English | [中文](README_CN.md) | [日本語](README_JA.md) --- -## Demo +## DemoA Try Sub2API online: **[https://demo.sub2api.org/](https://demo.sub2api.org/)** @@ -72,8 +72,8 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot -silkapi -Thanks to SilkAPI for sponsoring this project! SilkAPI is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay. +silkapi +Thanks to SilkAPI for sponsoring this project! SilkAPI is a relay service built on Sub2API, specializing in providing high-speed and stable Codex API relay. diff --git a/README_CN.md b/README_CN.md index 24600e0e524..e13f86deefe 100644 --- a/README_CN.md +++ b/README_CN.md @@ -71,8 +71,8 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的 -silkapi -感谢 丝绸API 赞助了本项目! 丝绸API 是基于 Sub2API 搭建的中转服务,专注于提供 Codex 高速稳定API中转。 +silkapi +感谢 丝绸API 赞助了本项目! 丝绸API 是基于 Sub2API 搭建的中转服务,专注于提供 Codex 高速稳定API中转。 diff --git a/README_JA.md b/README_JA.md index 1e89610c969..73331a0707a 100644 --- a/README_JA.md +++ b/README_JA.md @@ -71,8 +71,8 @@ Sub2API は、AI 製品のサブスクリプションから API クォータを -silkapi -SilkAPI のご支援に感謝します!SilkAPI は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。 +silkapi +SilkAPI のご支援に感謝します!SilkAPI は Sub2API をベースに構築された中継サービスで、高速かつ安定した Codex API 中継の提供に特化しています。 diff --git a/backend/Dockerfile b/backend/Dockerfile index aeb20fdb667..f153d686679 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.25.7-alpine +FROM golang:1.26.3-alpine WORKDIR /app diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index aa3e27071d1..5076ee8063a 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.122 +0.1.125 diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 40f0191ce5a..3da684e7bc0 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -74,7 +74,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService) userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) redeemCache := repository.NewRedeemCache(redisClient) - redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) + redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService) secretEncryptor, err := repository.NewAESEncryptor(configConfig) if err != nil { return nil, err @@ -96,6 +96,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorRepository := repository.NewChannelMonitorRepository(client, db) channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor) channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService) + chatSessionHandler := handler.NewChatSessionHandler(client) dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db) dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig) dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig) @@ -210,6 +211,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { adminSubscriptionHandler := admin.NewSubscriptionHandler(subscriptionService) usageCleanupRepository := repository.NewUsageCleanupRepository(client, db) usageCleanupService := service.ProvideUsageCleanupService(usageCleanupRepository, timingWheelService, dashboardAggregationService, configConfig) + imageTaskRepository := repository.NewImageTaskRepository(db) + imageTaskService := service.ProvideImageTaskService(imageTaskRepository, timingWheelService) adminUsageHandler := admin.NewUsageHandler(usageService, apiKeyService, adminService, usageCleanupService) userAttributeDefinitionRepository := repository.NewUserAttributeDefinitionRepository(client) userAttributeValueRepository := repository.NewUserAttributeValueRepository(client) @@ -230,22 +233,26 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorRequestTemplateRepository := repository.NewChannelMonitorRequestTemplateRepository(client, db) channelMonitorRequestTemplateService := service.NewChannelMonitorRequestTemplateService(channelMonitorRequestTemplateRepository) channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService) + contentModerationRepository := repository.NewContentModerationRepository(db) + contentModerationHashCache := repository.NewContentModerationHashCache(redisClient) + contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService) + contentModerationHandler := admin.NewContentModerationHandler(contentModerationService) paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService) affiliateHandler := admin.NewAffiliateHandler(affiliateService, adminService) - adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler, affiliateHandler) + adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, paymentHandler, affiliateHandler) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig) - gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, userMessageQueueService, configConfig, settingService) - openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, configConfig) + gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService) + openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, imageTaskService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, configConfig) handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo) totpHandler := handler.NewTotpHandler(totpService) handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService, channelService) paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry) - availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService) + availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService, billingService) idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig) idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig) - handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, idempotencyCoordinator, idempotencyCleanupService) + handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, chatSessionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, idempotencyCoordinator, idempotencyCleanupService) jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService) adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService) apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig) @@ -254,7 +261,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig) opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig) opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig) - opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig, channelMonitorService) + opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig, channelMonitorService, settingRepository, opsService) opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI) accountExpiryService := service.ProvideAccountExpiryService(accountRepository) diff --git a/backend/ent/apikey.go b/backend/ent/apikey.go index 9ee660c2da6..d7650fdb337 100644 --- a/backend/ent/apikey.go +++ b/backend/ent/apikey.go @@ -80,9 +80,11 @@ type APIKeyEdges struct { Group *Group `json:"group,omitempty"` // UsageLogs holds the value of the usage_logs edge. UsageLogs []*UsageLog `json:"usage_logs,omitempty"` + // ChatSessions holds the value of the chat_sessions edge. + ChatSessions []*ChatSession `json:"chat_sessions,omitempty"` // loadedTypes holds the information for reporting if a // type was loaded (or requested) in eager-loading or not. - loadedTypes [3]bool + loadedTypes [4]bool } // UserOrErr returns the User value or an error if the edge @@ -116,6 +118,15 @@ func (e APIKeyEdges) UsageLogsOrErr() ([]*UsageLog, error) { return nil, &NotLoadedError{edge: "usage_logs"} } +// ChatSessionsOrErr returns the ChatSessions value or an error if the edge +// was not loaded in eager-loading. +func (e APIKeyEdges) ChatSessionsOrErr() ([]*ChatSession, error) { + if e.loadedTypes[3] { + return e.ChatSessions, nil + } + return nil, &NotLoadedError{edge: "chat_sessions"} +} + // scanValues returns the types for scanning values from sql.Rows. func (*APIKey) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) @@ -329,6 +340,11 @@ func (_m *APIKey) QueryUsageLogs() *UsageLogQuery { return NewAPIKeyClient(_m.config).QueryUsageLogs(_m) } +// QueryChatSessions queries the "chat_sessions" edge of the APIKey entity. +func (_m *APIKey) QueryChatSessions() *ChatSessionQuery { + return NewAPIKeyClient(_m.config).QueryChatSessions(_m) +} + // Update returns a builder for updating this APIKey. // Note that you need to call APIKey.Unwrap() before calling this method if this APIKey // was returned from a transaction, and the transaction was committed or rolled back. diff --git a/backend/ent/apikey/apikey.go b/backend/ent/apikey/apikey.go index d398a027b86..db6b181eff3 100644 --- a/backend/ent/apikey/apikey.go +++ b/backend/ent/apikey/apikey.go @@ -67,6 +67,8 @@ const ( EdgeGroup = "group" // EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations. EdgeUsageLogs = "usage_logs" + // EdgeChatSessions holds the string denoting the chat_sessions edge name in mutations. + EdgeChatSessions = "chat_sessions" // Table holds the table name of the apikey in the database. Table = "api_keys" // UserTable is the table that holds the user relation/edge. @@ -90,6 +92,13 @@ const ( UsageLogsInverseTable = "usage_logs" // UsageLogsColumn is the table column denoting the usage_logs relation/edge. UsageLogsColumn = "api_key_id" + // ChatSessionsTable is the table that holds the chat_sessions relation/edge. + ChatSessionsTable = "chat_sessions" + // ChatSessionsInverseTable is the table name for the ChatSession entity. + // It exists in this package in order to avoid circular dependency with the "chatsession" package. + ChatSessionsInverseTable = "chat_sessions" + // ChatSessionsColumn is the table column denoting the chat_sessions relation/edge. + ChatSessionsColumn = "api_key_id" ) // Columns holds all SQL columns for apikey fields. @@ -310,6 +319,20 @@ func ByUsageLogs(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { sqlgraph.OrderByNeighborTerms(s, newUsageLogsStep(), append([]sql.OrderTerm{term}, terms...)...) } } + +// ByChatSessionsCount orders the results by chat_sessions count. +func ByChatSessionsCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newChatSessionsStep(), opts...) + } +} + +// ByChatSessions orders the results by chat_sessions terms. +func ByChatSessions(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newChatSessionsStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} func newUserStep() *sqlgraph.Step { return sqlgraph.NewStep( sqlgraph.From(Table, FieldID), @@ -331,3 +354,10 @@ func newUsageLogsStep() *sqlgraph.Step { sqlgraph.Edge(sqlgraph.O2M, false, UsageLogsTable, UsageLogsColumn), ) } +func newChatSessionsStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(ChatSessionsInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, ChatSessionsTable, ChatSessionsColumn), + ) +} diff --git a/backend/ent/apikey/where.go b/backend/ent/apikey/where.go index edd2652baae..e3797f3292d 100644 --- a/backend/ent/apikey/where.go +++ b/backend/ent/apikey/where.go @@ -1194,6 +1194,29 @@ func HasUsageLogsWith(preds ...predicate.UsageLog) predicate.APIKey { }) } +// HasChatSessions applies the HasEdge predicate on the "chat_sessions" edge. +func HasChatSessions() predicate.APIKey { + return predicate.APIKey(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, ChatSessionsTable, ChatSessionsColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasChatSessionsWith applies the HasEdge predicate on the "chat_sessions" edge with a given conditions (other predicates). +func HasChatSessionsWith(preds ...predicate.ChatSession) predicate.APIKey { + return predicate.APIKey(func(s *sql.Selector) { + step := newChatSessionsStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.APIKey) predicate.APIKey { return predicate.APIKey(sql.AndPredicates(predicates...)) diff --git a/backend/ent/apikey_create.go b/backend/ent/apikey_create.go index 4ec8aeaae4e..def779af5d6 100644 --- a/backend/ent/apikey_create.go +++ b/backend/ent/apikey_create.go @@ -12,6 +12,7 @@ import ( "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/user" @@ -332,6 +333,21 @@ func (_c *APIKeyCreate) AddUsageLogs(v ...*UsageLog) *APIKeyCreate { return _c.AddUsageLogIDs(ids...) } +// AddChatSessionIDs adds the "chat_sessions" edge to the ChatSession entity by IDs. +func (_c *APIKeyCreate) AddChatSessionIDs(ids ...int64) *APIKeyCreate { + _c.mutation.AddChatSessionIDs(ids...) + return _c +} + +// AddChatSessions adds the "chat_sessions" edges to the ChatSession entity. +func (_c *APIKeyCreate) AddChatSessions(v ...*ChatSession) *APIKeyCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddChatSessionIDs(ids...) +} + // Mutation returns the APIKeyMutation object of the builder. func (_c *APIKeyCreate) Mutation() *APIKeyMutation { return _c.mutation @@ -645,6 +661,22 @@ func (_c *APIKeyCreate) createSpec() (*APIKey, *sqlgraph.CreateSpec) { } _spec.Edges = append(_spec.Edges, edge) } + if nodes := _c.mutation.ChatSessionsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: apikey.ChatSessionsTable, + Columns: []string{apikey.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } return _node, _spec } diff --git a/backend/ent/apikey_query.go b/backend/ent/apikey_query.go index 9eee4077bd3..bba78117900 100644 --- a/backend/ent/apikey_query.go +++ b/backend/ent/apikey_query.go @@ -14,6 +14,7 @@ import ( "entgo.io/ent/dialect/sql/sqlgraph" "entgo.io/ent/schema/field" "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" "github.com/Wei-Shaw/sub2api/ent/usagelog" @@ -23,14 +24,15 @@ import ( // APIKeyQuery is the builder for querying APIKey entities. type APIKeyQuery struct { config - ctx *QueryContext - order []apikey.OrderOption - inters []Interceptor - predicates []predicate.APIKey - withUser *UserQuery - withGroup *GroupQuery - withUsageLogs *UsageLogQuery - modifiers []func(*sql.Selector) + ctx *QueryContext + order []apikey.OrderOption + inters []Interceptor + predicates []predicate.APIKey + withUser *UserQuery + withGroup *GroupQuery + withUsageLogs *UsageLogQuery + withChatSessions *ChatSessionQuery + modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector path func(context.Context) (*sql.Selector, error) @@ -133,6 +135,28 @@ func (_q *APIKeyQuery) QueryUsageLogs() *UsageLogQuery { return query } +// QueryChatSessions chains the current query on the "chat_sessions" edge. +func (_q *APIKeyQuery) QueryChatSessions() *ChatSessionQuery { + query := (&ChatSessionClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(apikey.Table, apikey.FieldID, selector), + sqlgraph.To(chatsession.Table, chatsession.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, apikey.ChatSessionsTable, apikey.ChatSessionsColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + // First returns the first APIKey entity from the query. // Returns a *NotFoundError when no APIKey was found. func (_q *APIKeyQuery) First(ctx context.Context) (*APIKey, error) { @@ -320,14 +344,15 @@ func (_q *APIKeyQuery) Clone() *APIKeyQuery { return nil } return &APIKeyQuery{ - config: _q.config, - ctx: _q.ctx.Clone(), - order: append([]apikey.OrderOption{}, _q.order...), - inters: append([]Interceptor{}, _q.inters...), - predicates: append([]predicate.APIKey{}, _q.predicates...), - withUser: _q.withUser.Clone(), - withGroup: _q.withGroup.Clone(), - withUsageLogs: _q.withUsageLogs.Clone(), + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]apikey.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.APIKey{}, _q.predicates...), + withUser: _q.withUser.Clone(), + withGroup: _q.withGroup.Clone(), + withUsageLogs: _q.withUsageLogs.Clone(), + withChatSessions: _q.withChatSessions.Clone(), // clone intermediate query. sql: _q.sql.Clone(), path: _q.path, @@ -367,6 +392,17 @@ func (_q *APIKeyQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *APIKeyQuery return _q } +// WithChatSessions tells the query-builder to eager-load the nodes that are connected to +// the "chat_sessions" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *APIKeyQuery) WithChatSessions(opts ...func(*ChatSessionQuery)) *APIKeyQuery { + query := (&ChatSessionClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withChatSessions = query + return _q +} + // GroupBy is used to group vertices by one or more fields/columns. // It is often used with aggregate functions, like: count, max, mean, min, sum. // @@ -445,10 +481,11 @@ func (_q *APIKeyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*APIKe var ( nodes = []*APIKey{} _spec = _q.querySpec() - loadedTypes = [3]bool{ + loadedTypes = [4]bool{ _q.withUser != nil, _q.withGroup != nil, _q.withUsageLogs != nil, + _q.withChatSessions != nil, } ) _spec.ScanValues = func(columns []string) ([]any, error) { @@ -491,6 +528,13 @@ func (_q *APIKeyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*APIKe return nil, err } } + if query := _q.withChatSessions; query != nil { + if err := _q.loadChatSessions(ctx, query, nodes, + func(n *APIKey) { n.Edges.ChatSessions = []*ChatSession{} }, + func(n *APIKey, e *ChatSession) { n.Edges.ChatSessions = append(n.Edges.ChatSessions, e) }); err != nil { + return nil, err + } + } return nodes, nil } @@ -585,6 +629,36 @@ func (_q *APIKeyQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, } return nil } +func (_q *APIKeyQuery) loadChatSessions(ctx context.Context, query *ChatSessionQuery, nodes []*APIKey, init func(*APIKey), assign func(*APIKey, *ChatSession)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[int64]*APIKey) + for i := range nodes { + fks = append(fks, nodes[i].ID) + nodeids[nodes[i].ID] = nodes[i] + if init != nil { + init(nodes[i]) + } + } + if len(query.ctx.Fields) > 0 { + query.ctx.AppendFieldOnce(chatsession.FieldAPIKeyID) + } + query.Where(predicate.ChatSession(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(apikey.ChatSessionsColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.APIKeyID + node, ok := nodeids[fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "api_key_id" returned %v for node %v`, fk, n.ID) + } + assign(node, n) + } + return nil +} func (_q *APIKeyQuery) sqlCount(ctx context.Context) (int, error) { _spec := _q.querySpec() diff --git a/backend/ent/apikey_update.go b/backend/ent/apikey_update.go index db341e4c9d5..02c07ead0b5 100644 --- a/backend/ent/apikey_update.go +++ b/backend/ent/apikey_update.go @@ -13,6 +13,7 @@ import ( "entgo.io/ent/dialect/sql/sqljson" "entgo.io/ent/schema/field" "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" "github.com/Wei-Shaw/sub2api/ent/usagelog" @@ -463,6 +464,21 @@ func (_u *APIKeyUpdate) AddUsageLogs(v ...*UsageLog) *APIKeyUpdate { return _u.AddUsageLogIDs(ids...) } +// AddChatSessionIDs adds the "chat_sessions" edge to the ChatSession entity by IDs. +func (_u *APIKeyUpdate) AddChatSessionIDs(ids ...int64) *APIKeyUpdate { + _u.mutation.AddChatSessionIDs(ids...) + return _u +} + +// AddChatSessions adds the "chat_sessions" edges to the ChatSession entity. +func (_u *APIKeyUpdate) AddChatSessions(v ...*ChatSession) *APIKeyUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddChatSessionIDs(ids...) +} + // Mutation returns the APIKeyMutation object of the builder. func (_u *APIKeyUpdate) Mutation() *APIKeyMutation { return _u.mutation @@ -501,6 +517,27 @@ func (_u *APIKeyUpdate) RemoveUsageLogs(v ...*UsageLog) *APIKeyUpdate { return _u.RemoveUsageLogIDs(ids...) } +// ClearChatSessions clears all "chat_sessions" edges to the ChatSession entity. +func (_u *APIKeyUpdate) ClearChatSessions() *APIKeyUpdate { + _u.mutation.ClearChatSessions() + return _u +} + +// RemoveChatSessionIDs removes the "chat_sessions" edge to ChatSession entities by IDs. +func (_u *APIKeyUpdate) RemoveChatSessionIDs(ids ...int64) *APIKeyUpdate { + _u.mutation.RemoveChatSessionIDs(ids...) + return _u +} + +// RemoveChatSessions removes "chat_sessions" edges to ChatSession entities. +func (_u *APIKeyUpdate) RemoveChatSessions(v ...*ChatSession) *APIKeyUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveChatSessionIDs(ids...) +} + // Save executes the query and returns the number of nodes affected by the update operation. func (_u *APIKeyUpdate) Save(ctx context.Context) (int, error) { if err := _u.defaults(); err != nil { @@ -799,6 +836,51 @@ func (_u *APIKeyUpdate) sqlSave(ctx context.Context) (_node int, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.ChatSessionsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: apikey.ChatSessionsTable, + Columns: []string{apikey.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedChatSessionsIDs(); len(nodes) > 0 && !_u.mutation.ChatSessionsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: apikey.ChatSessionsTable, + Columns: []string{apikey.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.ChatSessionsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: apikey.ChatSessionsTable, + Columns: []string{apikey.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{apikey.Label} @@ -1250,6 +1332,21 @@ func (_u *APIKeyUpdateOne) AddUsageLogs(v ...*UsageLog) *APIKeyUpdateOne { return _u.AddUsageLogIDs(ids...) } +// AddChatSessionIDs adds the "chat_sessions" edge to the ChatSession entity by IDs. +func (_u *APIKeyUpdateOne) AddChatSessionIDs(ids ...int64) *APIKeyUpdateOne { + _u.mutation.AddChatSessionIDs(ids...) + return _u +} + +// AddChatSessions adds the "chat_sessions" edges to the ChatSession entity. +func (_u *APIKeyUpdateOne) AddChatSessions(v ...*ChatSession) *APIKeyUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddChatSessionIDs(ids...) +} + // Mutation returns the APIKeyMutation object of the builder. func (_u *APIKeyUpdateOne) Mutation() *APIKeyMutation { return _u.mutation @@ -1288,6 +1385,27 @@ func (_u *APIKeyUpdateOne) RemoveUsageLogs(v ...*UsageLog) *APIKeyUpdateOne { return _u.RemoveUsageLogIDs(ids...) } +// ClearChatSessions clears all "chat_sessions" edges to the ChatSession entity. +func (_u *APIKeyUpdateOne) ClearChatSessions() *APIKeyUpdateOne { + _u.mutation.ClearChatSessions() + return _u +} + +// RemoveChatSessionIDs removes the "chat_sessions" edge to ChatSession entities by IDs. +func (_u *APIKeyUpdateOne) RemoveChatSessionIDs(ids ...int64) *APIKeyUpdateOne { + _u.mutation.RemoveChatSessionIDs(ids...) + return _u +} + +// RemoveChatSessions removes "chat_sessions" edges to ChatSession entities. +func (_u *APIKeyUpdateOne) RemoveChatSessions(v ...*ChatSession) *APIKeyUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveChatSessionIDs(ids...) +} + // Where appends a list predicates to the APIKeyUpdate builder. func (_u *APIKeyUpdateOne) Where(ps ...predicate.APIKey) *APIKeyUpdateOne { _u.mutation.Where(ps...) @@ -1616,6 +1734,51 @@ func (_u *APIKeyUpdateOne) sqlSave(ctx context.Context) (_node *APIKey, err erro } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.ChatSessionsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: apikey.ChatSessionsTable, + Columns: []string{apikey.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedChatSessionsIDs(); len(nodes) > 0 && !_u.mutation.ChatSessionsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: apikey.ChatSessionsTable, + Columns: []string{apikey.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.ChatSessionsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: apikey.ChatSessionsTable, + Columns: []string{apikey.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } _node = &APIKey{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/backend/ent/chatmessage.go b/backend/ent/chatmessage.go new file mode 100644 index 00000000000..413f0f9e3e9 --- /dev/null +++ b/backend/ent/chatmessage.go @@ -0,0 +1,311 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "fmt" + "strings" + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/ent/usagelog" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// ChatMessage is the model entity for the ChatMessage schema. +type ChatMessage struct { + config `json:"-"` + // ID of the ent. + ID int64 `json:"id,omitempty"` + // CreatedAt holds the value of the "created_at" field. + CreatedAt time.Time `json:"created_at,omitempty"` + // UpdatedAt holds the value of the "updated_at" field. + UpdatedAt time.Time `json:"updated_at,omitempty"` + // SessionID holds the value of the "session_id" field. + SessionID int64 `json:"session_id,omitempty"` + // UserID holds the value of the "user_id" field. + UserID int64 `json:"user_id,omitempty"` + // Role holds the value of the "role" field. + Role string `json:"role,omitempty"` + // Content holds the value of the "content" field. + Content string `json:"content,omitempty"` + // Status holds the value of the "status" field. + Status string `json:"status,omitempty"` + // Model holds the value of the "model" field. + Model *string `json:"model,omitempty"` + // DurationMs holds the value of the "duration_ms" field. + DurationMs *int `json:"duration_ms,omitempty"` + // UsageLogID holds the value of the "usage_log_id" field. + UsageLogID *int64 `json:"usage_log_id,omitempty"` + // ActualCost holds the value of the "actual_cost" field. + ActualCost *float64 `json:"actual_cost,omitempty"` + // ErrorMessage holds the value of the "error_message" field. + ErrorMessage *string `json:"error_message,omitempty"` + // Edges holds the relations/edges for other nodes in the graph. + // The values are being populated by the ChatMessageQuery when eager-loading is set. + Edges ChatMessageEdges `json:"edges"` + selectValues sql.SelectValues +} + +// ChatMessageEdges holds the relations/edges for other nodes in the graph. +type ChatMessageEdges struct { + // Session holds the value of the session edge. + Session *ChatSession `json:"session,omitempty"` + // User holds the value of the user edge. + User *User `json:"user,omitempty"` + // UsageLog holds the value of the usage_log edge. + UsageLog *UsageLog `json:"usage_log,omitempty"` + // loadedTypes holds the information for reporting if a + // type was loaded (or requested) in eager-loading or not. + loadedTypes [3]bool +} + +// SessionOrErr returns the Session value or an error if the edge +// was not loaded in eager-loading, or loaded but was not found. +func (e ChatMessageEdges) SessionOrErr() (*ChatSession, error) { + if e.Session != nil { + return e.Session, nil + } else if e.loadedTypes[0] { + return nil, &NotFoundError{label: chatsession.Label} + } + return nil, &NotLoadedError{edge: "session"} +} + +// UserOrErr returns the User value or an error if the edge +// was not loaded in eager-loading, or loaded but was not found. +func (e ChatMessageEdges) UserOrErr() (*User, error) { + if e.User != nil { + return e.User, nil + } else if e.loadedTypes[1] { + return nil, &NotFoundError{label: user.Label} + } + return nil, &NotLoadedError{edge: "user"} +} + +// UsageLogOrErr returns the UsageLog value or an error if the edge +// was not loaded in eager-loading, or loaded but was not found. +func (e ChatMessageEdges) UsageLogOrErr() (*UsageLog, error) { + if e.UsageLog != nil { + return e.UsageLog, nil + } else if e.loadedTypes[2] { + return nil, &NotFoundError{label: usagelog.Label} + } + return nil, &NotLoadedError{edge: "usage_log"} +} + +// scanValues returns the types for scanning values from sql.Rows. +func (*ChatMessage) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case chatmessage.FieldActualCost: + values[i] = new(sql.NullFloat64) + case chatmessage.FieldID, chatmessage.FieldSessionID, chatmessage.FieldUserID, chatmessage.FieldDurationMs, chatmessage.FieldUsageLogID: + values[i] = new(sql.NullInt64) + case chatmessage.FieldRole, chatmessage.FieldContent, chatmessage.FieldStatus, chatmessage.FieldModel, chatmessage.FieldErrorMessage: + values[i] = new(sql.NullString) + case chatmessage.FieldCreatedAt, chatmessage.FieldUpdatedAt: + values[i] = new(sql.NullTime) + default: + values[i] = new(sql.UnknownType) + } + } + return values, nil +} + +// assignValues assigns the values that were returned from sql.Rows (after scanning) +// to the ChatMessage fields. +func (_m *ChatMessage) assignValues(columns []string, values []any) error { + if m, n := len(values), len(columns); m < n { + return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) + } + for i := range columns { + switch columns[i] { + case chatmessage.FieldID: + value, ok := values[i].(*sql.NullInt64) + if !ok { + return fmt.Errorf("unexpected type %T for field id", value) + } + _m.ID = int64(value.Int64) + case chatmessage.FieldCreatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field created_at", values[i]) + } else if value.Valid { + _m.CreatedAt = value.Time + } + case chatmessage.FieldUpdatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field updated_at", values[i]) + } else if value.Valid { + _m.UpdatedAt = value.Time + } + case chatmessage.FieldSessionID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field session_id", values[i]) + } else if value.Valid { + _m.SessionID = value.Int64 + } + case chatmessage.FieldUserID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field user_id", values[i]) + } else if value.Valid { + _m.UserID = value.Int64 + } + case chatmessage.FieldRole: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field role", values[i]) + } else if value.Valid { + _m.Role = value.String + } + case chatmessage.FieldContent: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field content", values[i]) + } else if value.Valid { + _m.Content = value.String + } + case chatmessage.FieldStatus: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field status", values[i]) + } else if value.Valid { + _m.Status = value.String + } + case chatmessage.FieldModel: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field model", values[i]) + } else if value.Valid { + _m.Model = new(string) + *_m.Model = value.String + } + case chatmessage.FieldDurationMs: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field duration_ms", values[i]) + } else if value.Valid { + _m.DurationMs = new(int) + *_m.DurationMs = int(value.Int64) + } + case chatmessage.FieldUsageLogID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field usage_log_id", values[i]) + } else if value.Valid { + _m.UsageLogID = new(int64) + *_m.UsageLogID = value.Int64 + } + case chatmessage.FieldActualCost: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field actual_cost", values[i]) + } else if value.Valid { + _m.ActualCost = new(float64) + *_m.ActualCost = value.Float64 + } + case chatmessage.FieldErrorMessage: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field error_message", values[i]) + } else if value.Valid { + _m.ErrorMessage = new(string) + *_m.ErrorMessage = value.String + } + default: + _m.selectValues.Set(columns[i], values[i]) + } + } + return nil +} + +// Value returns the ent.Value that was dynamically selected and assigned to the ChatMessage. +// This includes values selected through modifiers, order, etc. +func (_m *ChatMessage) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// QuerySession queries the "session" edge of the ChatMessage entity. +func (_m *ChatMessage) QuerySession() *ChatSessionQuery { + return NewChatMessageClient(_m.config).QuerySession(_m) +} + +// QueryUser queries the "user" edge of the ChatMessage entity. +func (_m *ChatMessage) QueryUser() *UserQuery { + return NewChatMessageClient(_m.config).QueryUser(_m) +} + +// QueryUsageLog queries the "usage_log" edge of the ChatMessage entity. +func (_m *ChatMessage) QueryUsageLog() *UsageLogQuery { + return NewChatMessageClient(_m.config).QueryUsageLog(_m) +} + +// Update returns a builder for updating this ChatMessage. +// Note that you need to call ChatMessage.Unwrap() before calling this method if this ChatMessage +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *ChatMessage) Update() *ChatMessageUpdateOne { + return NewChatMessageClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the ChatMessage entity that was returned from a transaction after it was closed, +// so that all future queries will be executed through the driver which created the transaction. +func (_m *ChatMessage) Unwrap() *ChatMessage { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("ent: ChatMessage is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *ChatMessage) String() string { + var builder strings.Builder + builder.WriteString("ChatMessage(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("created_at=") + builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("updated_at=") + builder.WriteString(_m.UpdatedAt.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("session_id=") + builder.WriteString(fmt.Sprintf("%v", _m.SessionID)) + builder.WriteString(", ") + builder.WriteString("user_id=") + builder.WriteString(fmt.Sprintf("%v", _m.UserID)) + builder.WriteString(", ") + builder.WriteString("role=") + builder.WriteString(_m.Role) + builder.WriteString(", ") + builder.WriteString("content=") + builder.WriteString(_m.Content) + builder.WriteString(", ") + builder.WriteString("status=") + builder.WriteString(_m.Status) + builder.WriteString(", ") + if v := _m.Model; v != nil { + builder.WriteString("model=") + builder.WriteString(*v) + } + builder.WriteString(", ") + if v := _m.DurationMs; v != nil { + builder.WriteString("duration_ms=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.UsageLogID; v != nil { + builder.WriteString("usage_log_id=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.ActualCost; v != nil { + builder.WriteString("actual_cost=") + builder.WriteString(fmt.Sprintf("%v", *v)) + } + builder.WriteString(", ") + if v := _m.ErrorMessage; v != nil { + builder.WriteString("error_message=") + builder.WriteString(*v) + } + builder.WriteByte(')') + return builder.String() +} + +// ChatMessages is a parsable slice of ChatMessage. +type ChatMessages []*ChatMessage diff --git a/backend/ent/chatmessage/chatmessage.go b/backend/ent/chatmessage/chatmessage.go new file mode 100644 index 00000000000..5e2a6b77458 --- /dev/null +++ b/backend/ent/chatmessage/chatmessage.go @@ -0,0 +1,226 @@ +// Code generated by ent, DO NOT EDIT. + +package chatmessage + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" +) + +const ( + // Label holds the string label denoting the chatmessage type in the database. + Label = "chat_message" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldCreatedAt holds the string denoting the created_at field in the database. + FieldCreatedAt = "created_at" + // FieldUpdatedAt holds the string denoting the updated_at field in the database. + FieldUpdatedAt = "updated_at" + // FieldSessionID holds the string denoting the session_id field in the database. + FieldSessionID = "session_id" + // FieldUserID holds the string denoting the user_id field in the database. + FieldUserID = "user_id" + // FieldRole holds the string denoting the role field in the database. + FieldRole = "role" + // FieldContent holds the string denoting the content field in the database. + FieldContent = "content" + // FieldStatus holds the string denoting the status field in the database. + FieldStatus = "status" + // FieldModel holds the string denoting the model field in the database. + FieldModel = "model" + // FieldDurationMs holds the string denoting the duration_ms field in the database. + FieldDurationMs = "duration_ms" + // FieldUsageLogID holds the string denoting the usage_log_id field in the database. + FieldUsageLogID = "usage_log_id" + // FieldActualCost holds the string denoting the actual_cost field in the database. + FieldActualCost = "actual_cost" + // FieldErrorMessage holds the string denoting the error_message field in the database. + FieldErrorMessage = "error_message" + // EdgeSession holds the string denoting the session edge name in mutations. + EdgeSession = "session" + // EdgeUser holds the string denoting the user edge name in mutations. + EdgeUser = "user" + // EdgeUsageLog holds the string denoting the usage_log edge name in mutations. + EdgeUsageLog = "usage_log" + // Table holds the table name of the chatmessage in the database. + Table = "chat_messages" + // SessionTable is the table that holds the session relation/edge. + SessionTable = "chat_messages" + // SessionInverseTable is the table name for the ChatSession entity. + // It exists in this package in order to avoid circular dependency with the "chatsession" package. + SessionInverseTable = "chat_sessions" + // SessionColumn is the table column denoting the session relation/edge. + SessionColumn = "session_id" + // UserTable is the table that holds the user relation/edge. + UserTable = "chat_messages" + // UserInverseTable is the table name for the User entity. + // It exists in this package in order to avoid circular dependency with the "user" package. + UserInverseTable = "users" + // UserColumn is the table column denoting the user relation/edge. + UserColumn = "user_id" + // UsageLogTable is the table that holds the usage_log relation/edge. + UsageLogTable = "chat_messages" + // UsageLogInverseTable is the table name for the UsageLog entity. + // It exists in this package in order to avoid circular dependency with the "usagelog" package. + UsageLogInverseTable = "usage_logs" + // UsageLogColumn is the table column denoting the usage_log relation/edge. + UsageLogColumn = "usage_log_id" +) + +// Columns holds all SQL columns for chatmessage fields. +var Columns = []string{ + FieldID, + FieldCreatedAt, + FieldUpdatedAt, + FieldSessionID, + FieldUserID, + FieldRole, + FieldContent, + FieldStatus, + FieldModel, + FieldDurationMs, + FieldUsageLogID, + FieldActualCost, + FieldErrorMessage, +} + +// ValidColumn reports if the column name is valid (part of the table columns). +func ValidColumn(column string) bool { + for i := range Columns { + if column == Columns[i] { + return true + } + } + return false +} + +var ( + // DefaultCreatedAt holds the default value on creation for the "created_at" field. + DefaultCreatedAt func() time.Time + // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. + DefaultUpdatedAt func() time.Time + // UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field. + UpdateDefaultUpdatedAt func() time.Time + // RoleValidator is a validator for the "role" field. It is called by the builders before save. + RoleValidator func(string) error + // DefaultContent holds the default value on creation for the "content" field. + DefaultContent string + // DefaultStatus holds the default value on creation for the "status" field. + DefaultStatus string + // StatusValidator is a validator for the "status" field. It is called by the builders before save. + StatusValidator func(string) error + // ModelValidator is a validator for the "model" field. It is called by the builders before save. + ModelValidator func(string) error +) + +// OrderOption defines the ordering options for the ChatMessage queries. +type OrderOption func(*sql.Selector) + +// ByID orders the results by the id field. +func ByID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldID, opts...).ToFunc() +} + +// ByCreatedAt orders the results by the created_at field. +func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() +} + +// ByUpdatedAt orders the results by the updated_at field. +func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc() +} + +// BySessionID orders the results by the session_id field. +func BySessionID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldSessionID, opts...).ToFunc() +} + +// ByUserID orders the results by the user_id field. +func ByUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserID, opts...).ToFunc() +} + +// ByRole orders the results by the role field. +func ByRole(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldRole, opts...).ToFunc() +} + +// ByContent orders the results by the content field. +func ByContent(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldContent, opts...).ToFunc() +} + +// ByStatus orders the results by the status field. +func ByStatus(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldStatus, opts...).ToFunc() +} + +// ByModel orders the results by the model field. +func ByModel(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldModel, opts...).ToFunc() +} + +// ByDurationMs orders the results by the duration_ms field. +func ByDurationMs(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldDurationMs, opts...).ToFunc() +} + +// ByUsageLogID orders the results by the usage_log_id field. +func ByUsageLogID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUsageLogID, opts...).ToFunc() +} + +// ByActualCost orders the results by the actual_cost field. +func ByActualCost(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldActualCost, opts...).ToFunc() +} + +// ByErrorMessage orders the results by the error_message field. +func ByErrorMessage(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldErrorMessage, opts...).ToFunc() +} + +// BySessionField orders the results by session field. +func BySessionField(field string, opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newSessionStep(), sql.OrderByField(field, opts...)) + } +} + +// ByUserField orders the results by user field. +func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newUserStep(), sql.OrderByField(field, opts...)) + } +} + +// ByUsageLogField orders the results by usage_log field. +func ByUsageLogField(field string, opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newUsageLogStep(), sql.OrderByField(field, opts...)) + } +} +func newSessionStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(SessionInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, SessionTable, SessionColumn), + ) +} +func newUserStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(UserInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, UserTable, UserColumn), + ) +} +func newUsageLogStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(UsageLogInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, UsageLogTable, UsageLogColumn), + ) +} diff --git a/backend/ent/chatmessage/where.go b/backend/ent/chatmessage/where.go new file mode 100644 index 00000000000..b3b9bc69cf8 --- /dev/null +++ b/backend/ent/chatmessage/where.go @@ -0,0 +1,795 @@ +// Code generated by ent, DO NOT EDIT. + +package chatmessage + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/Wei-Shaw/sub2api/ent/predicate" +) + +// ID filters vertices based on their ID field. +func ID(id int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLTE(FieldID, id)) +} + +// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. +func CreatedAt(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldCreatedAt, v)) +} + +// UpdatedAt applies equality check predicate on the "updated_at" field. It's identical to UpdatedAtEQ. +func UpdatedAt(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// SessionID applies equality check predicate on the "session_id" field. It's identical to SessionIDEQ. +func SessionID(v int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldSessionID, v)) +} + +// UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. +func UserID(v int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldUserID, v)) +} + +// Role applies equality check predicate on the "role" field. It's identical to RoleEQ. +func Role(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldRole, v)) +} + +// Content applies equality check predicate on the "content" field. It's identical to ContentEQ. +func Content(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldContent, v)) +} + +// Status applies equality check predicate on the "status" field. It's identical to StatusEQ. +func Status(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldStatus, v)) +} + +// Model applies equality check predicate on the "model" field. It's identical to ModelEQ. +func Model(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldModel, v)) +} + +// DurationMs applies equality check predicate on the "duration_ms" field. It's identical to DurationMsEQ. +func DurationMs(v int) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldDurationMs, v)) +} + +// UsageLogID applies equality check predicate on the "usage_log_id" field. It's identical to UsageLogIDEQ. +func UsageLogID(v int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldUsageLogID, v)) +} + +// ActualCost applies equality check predicate on the "actual_cost" field. It's identical to ActualCostEQ. +func ActualCost(v float64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldActualCost, v)) +} + +// ErrorMessage applies equality check predicate on the "error_message" field. It's identical to ErrorMessageEQ. +func ErrorMessage(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldErrorMessage, v)) +} + +// CreatedAtEQ applies the EQ predicate on the "created_at" field. +func CreatedAtEQ(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldCreatedAt, v)) +} + +// CreatedAtNEQ applies the NEQ predicate on the "created_at" field. +func CreatedAtNEQ(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldCreatedAt, v)) +} + +// CreatedAtIn applies the In predicate on the "created_at" field. +func CreatedAtIn(vs ...time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldCreatedAt, vs...)) +} + +// CreatedAtNotIn applies the NotIn predicate on the "created_at" field. +func CreatedAtNotIn(vs ...time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldCreatedAt, vs...)) +} + +// CreatedAtGT applies the GT predicate on the "created_at" field. +func CreatedAtGT(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGT(FieldCreatedAt, v)) +} + +// CreatedAtGTE applies the GTE predicate on the "created_at" field. +func CreatedAtGTE(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGTE(FieldCreatedAt, v)) +} + +// CreatedAtLT applies the LT predicate on the "created_at" field. +func CreatedAtLT(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLT(FieldCreatedAt, v)) +} + +// CreatedAtLTE applies the LTE predicate on the "created_at" field. +func CreatedAtLTE(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLTE(FieldCreatedAt, v)) +} + +// UpdatedAtEQ applies the EQ predicate on the "updated_at" field. +func UpdatedAtEQ(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field. +func UpdatedAtNEQ(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtIn applies the In predicate on the "updated_at" field. +func UpdatedAtIn(vs ...time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field. +func UpdatedAtNotIn(vs ...time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtGT applies the GT predicate on the "updated_at" field. +func UpdatedAtGT(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGT(FieldUpdatedAt, v)) +} + +// UpdatedAtGTE applies the GTE predicate on the "updated_at" field. +func UpdatedAtGTE(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGTE(FieldUpdatedAt, v)) +} + +// UpdatedAtLT applies the LT predicate on the "updated_at" field. +func UpdatedAtLT(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLT(FieldUpdatedAt, v)) +} + +// UpdatedAtLTE applies the LTE predicate on the "updated_at" field. +func UpdatedAtLTE(v time.Time) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLTE(FieldUpdatedAt, v)) +} + +// SessionIDEQ applies the EQ predicate on the "session_id" field. +func SessionIDEQ(v int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldSessionID, v)) +} + +// SessionIDNEQ applies the NEQ predicate on the "session_id" field. +func SessionIDNEQ(v int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldSessionID, v)) +} + +// SessionIDIn applies the In predicate on the "session_id" field. +func SessionIDIn(vs ...int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldSessionID, vs...)) +} + +// SessionIDNotIn applies the NotIn predicate on the "session_id" field. +func SessionIDNotIn(vs ...int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldSessionID, vs...)) +} + +// UserIDEQ applies the EQ predicate on the "user_id" field. +func UserIDEQ(v int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldUserID, v)) +} + +// UserIDNEQ applies the NEQ predicate on the "user_id" field. +func UserIDNEQ(v int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldUserID, v)) +} + +// UserIDIn applies the In predicate on the "user_id" field. +func UserIDIn(vs ...int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldUserID, vs...)) +} + +// UserIDNotIn applies the NotIn predicate on the "user_id" field. +func UserIDNotIn(vs ...int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldUserID, vs...)) +} + +// RoleEQ applies the EQ predicate on the "role" field. +func RoleEQ(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldRole, v)) +} + +// RoleNEQ applies the NEQ predicate on the "role" field. +func RoleNEQ(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldRole, v)) +} + +// RoleIn applies the In predicate on the "role" field. +func RoleIn(vs ...string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldRole, vs...)) +} + +// RoleNotIn applies the NotIn predicate on the "role" field. +func RoleNotIn(vs ...string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldRole, vs...)) +} + +// RoleGT applies the GT predicate on the "role" field. +func RoleGT(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGT(FieldRole, v)) +} + +// RoleGTE applies the GTE predicate on the "role" field. +func RoleGTE(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGTE(FieldRole, v)) +} + +// RoleLT applies the LT predicate on the "role" field. +func RoleLT(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLT(FieldRole, v)) +} + +// RoleLTE applies the LTE predicate on the "role" field. +func RoleLTE(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLTE(FieldRole, v)) +} + +// RoleContains applies the Contains predicate on the "role" field. +func RoleContains(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldContains(FieldRole, v)) +} + +// RoleHasPrefix applies the HasPrefix predicate on the "role" field. +func RoleHasPrefix(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldHasPrefix(FieldRole, v)) +} + +// RoleHasSuffix applies the HasSuffix predicate on the "role" field. +func RoleHasSuffix(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldHasSuffix(FieldRole, v)) +} + +// RoleEqualFold applies the EqualFold predicate on the "role" field. +func RoleEqualFold(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEqualFold(FieldRole, v)) +} + +// RoleContainsFold applies the ContainsFold predicate on the "role" field. +func RoleContainsFold(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldContainsFold(FieldRole, v)) +} + +// ContentEQ applies the EQ predicate on the "content" field. +func ContentEQ(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldContent, v)) +} + +// ContentNEQ applies the NEQ predicate on the "content" field. +func ContentNEQ(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldContent, v)) +} + +// ContentIn applies the In predicate on the "content" field. +func ContentIn(vs ...string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldContent, vs...)) +} + +// ContentNotIn applies the NotIn predicate on the "content" field. +func ContentNotIn(vs ...string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldContent, vs...)) +} + +// ContentGT applies the GT predicate on the "content" field. +func ContentGT(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGT(FieldContent, v)) +} + +// ContentGTE applies the GTE predicate on the "content" field. +func ContentGTE(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGTE(FieldContent, v)) +} + +// ContentLT applies the LT predicate on the "content" field. +func ContentLT(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLT(FieldContent, v)) +} + +// ContentLTE applies the LTE predicate on the "content" field. +func ContentLTE(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLTE(FieldContent, v)) +} + +// ContentContains applies the Contains predicate on the "content" field. +func ContentContains(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldContains(FieldContent, v)) +} + +// ContentHasPrefix applies the HasPrefix predicate on the "content" field. +func ContentHasPrefix(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldHasPrefix(FieldContent, v)) +} + +// ContentHasSuffix applies the HasSuffix predicate on the "content" field. +func ContentHasSuffix(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldHasSuffix(FieldContent, v)) +} + +// ContentEqualFold applies the EqualFold predicate on the "content" field. +func ContentEqualFold(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEqualFold(FieldContent, v)) +} + +// ContentContainsFold applies the ContainsFold predicate on the "content" field. +func ContentContainsFold(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldContainsFold(FieldContent, v)) +} + +// StatusEQ applies the EQ predicate on the "status" field. +func StatusEQ(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldStatus, v)) +} + +// StatusNEQ applies the NEQ predicate on the "status" field. +func StatusNEQ(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldStatus, v)) +} + +// StatusIn applies the In predicate on the "status" field. +func StatusIn(vs ...string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldStatus, vs...)) +} + +// StatusNotIn applies the NotIn predicate on the "status" field. +func StatusNotIn(vs ...string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldStatus, vs...)) +} + +// StatusGT applies the GT predicate on the "status" field. +func StatusGT(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGT(FieldStatus, v)) +} + +// StatusGTE applies the GTE predicate on the "status" field. +func StatusGTE(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGTE(FieldStatus, v)) +} + +// StatusLT applies the LT predicate on the "status" field. +func StatusLT(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLT(FieldStatus, v)) +} + +// StatusLTE applies the LTE predicate on the "status" field. +func StatusLTE(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLTE(FieldStatus, v)) +} + +// StatusContains applies the Contains predicate on the "status" field. +func StatusContains(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldContains(FieldStatus, v)) +} + +// StatusHasPrefix applies the HasPrefix predicate on the "status" field. +func StatusHasPrefix(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldHasPrefix(FieldStatus, v)) +} + +// StatusHasSuffix applies the HasSuffix predicate on the "status" field. +func StatusHasSuffix(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldHasSuffix(FieldStatus, v)) +} + +// StatusEqualFold applies the EqualFold predicate on the "status" field. +func StatusEqualFold(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEqualFold(FieldStatus, v)) +} + +// StatusContainsFold applies the ContainsFold predicate on the "status" field. +func StatusContainsFold(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldContainsFold(FieldStatus, v)) +} + +// ModelEQ applies the EQ predicate on the "model" field. +func ModelEQ(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldModel, v)) +} + +// ModelNEQ applies the NEQ predicate on the "model" field. +func ModelNEQ(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldModel, v)) +} + +// ModelIn applies the In predicate on the "model" field. +func ModelIn(vs ...string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldModel, vs...)) +} + +// ModelNotIn applies the NotIn predicate on the "model" field. +func ModelNotIn(vs ...string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldModel, vs...)) +} + +// ModelGT applies the GT predicate on the "model" field. +func ModelGT(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGT(FieldModel, v)) +} + +// ModelGTE applies the GTE predicate on the "model" field. +func ModelGTE(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGTE(FieldModel, v)) +} + +// ModelLT applies the LT predicate on the "model" field. +func ModelLT(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLT(FieldModel, v)) +} + +// ModelLTE applies the LTE predicate on the "model" field. +func ModelLTE(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLTE(FieldModel, v)) +} + +// ModelContains applies the Contains predicate on the "model" field. +func ModelContains(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldContains(FieldModel, v)) +} + +// ModelHasPrefix applies the HasPrefix predicate on the "model" field. +func ModelHasPrefix(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldHasPrefix(FieldModel, v)) +} + +// ModelHasSuffix applies the HasSuffix predicate on the "model" field. +func ModelHasSuffix(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldHasSuffix(FieldModel, v)) +} + +// ModelIsNil applies the IsNil predicate on the "model" field. +func ModelIsNil() predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIsNull(FieldModel)) +} + +// ModelNotNil applies the NotNil predicate on the "model" field. +func ModelNotNil() predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotNull(FieldModel)) +} + +// ModelEqualFold applies the EqualFold predicate on the "model" field. +func ModelEqualFold(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEqualFold(FieldModel, v)) +} + +// ModelContainsFold applies the ContainsFold predicate on the "model" field. +func ModelContainsFold(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldContainsFold(FieldModel, v)) +} + +// DurationMsEQ applies the EQ predicate on the "duration_ms" field. +func DurationMsEQ(v int) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldDurationMs, v)) +} + +// DurationMsNEQ applies the NEQ predicate on the "duration_ms" field. +func DurationMsNEQ(v int) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldDurationMs, v)) +} + +// DurationMsIn applies the In predicate on the "duration_ms" field. +func DurationMsIn(vs ...int) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldDurationMs, vs...)) +} + +// DurationMsNotIn applies the NotIn predicate on the "duration_ms" field. +func DurationMsNotIn(vs ...int) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldDurationMs, vs...)) +} + +// DurationMsGT applies the GT predicate on the "duration_ms" field. +func DurationMsGT(v int) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGT(FieldDurationMs, v)) +} + +// DurationMsGTE applies the GTE predicate on the "duration_ms" field. +func DurationMsGTE(v int) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGTE(FieldDurationMs, v)) +} + +// DurationMsLT applies the LT predicate on the "duration_ms" field. +func DurationMsLT(v int) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLT(FieldDurationMs, v)) +} + +// DurationMsLTE applies the LTE predicate on the "duration_ms" field. +func DurationMsLTE(v int) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLTE(FieldDurationMs, v)) +} + +// DurationMsIsNil applies the IsNil predicate on the "duration_ms" field. +func DurationMsIsNil() predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIsNull(FieldDurationMs)) +} + +// DurationMsNotNil applies the NotNil predicate on the "duration_ms" field. +func DurationMsNotNil() predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotNull(FieldDurationMs)) +} + +// UsageLogIDEQ applies the EQ predicate on the "usage_log_id" field. +func UsageLogIDEQ(v int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldUsageLogID, v)) +} + +// UsageLogIDNEQ applies the NEQ predicate on the "usage_log_id" field. +func UsageLogIDNEQ(v int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldUsageLogID, v)) +} + +// UsageLogIDIn applies the In predicate on the "usage_log_id" field. +func UsageLogIDIn(vs ...int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldUsageLogID, vs...)) +} + +// UsageLogIDNotIn applies the NotIn predicate on the "usage_log_id" field. +func UsageLogIDNotIn(vs ...int64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldUsageLogID, vs...)) +} + +// UsageLogIDIsNil applies the IsNil predicate on the "usage_log_id" field. +func UsageLogIDIsNil() predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIsNull(FieldUsageLogID)) +} + +// UsageLogIDNotNil applies the NotNil predicate on the "usage_log_id" field. +func UsageLogIDNotNil() predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotNull(FieldUsageLogID)) +} + +// ActualCostEQ applies the EQ predicate on the "actual_cost" field. +func ActualCostEQ(v float64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldActualCost, v)) +} + +// ActualCostNEQ applies the NEQ predicate on the "actual_cost" field. +func ActualCostNEQ(v float64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldActualCost, v)) +} + +// ActualCostIn applies the In predicate on the "actual_cost" field. +func ActualCostIn(vs ...float64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldActualCost, vs...)) +} + +// ActualCostNotIn applies the NotIn predicate on the "actual_cost" field. +func ActualCostNotIn(vs ...float64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldActualCost, vs...)) +} + +// ActualCostGT applies the GT predicate on the "actual_cost" field. +func ActualCostGT(v float64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGT(FieldActualCost, v)) +} + +// ActualCostGTE applies the GTE predicate on the "actual_cost" field. +func ActualCostGTE(v float64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGTE(FieldActualCost, v)) +} + +// ActualCostLT applies the LT predicate on the "actual_cost" field. +func ActualCostLT(v float64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLT(FieldActualCost, v)) +} + +// ActualCostLTE applies the LTE predicate on the "actual_cost" field. +func ActualCostLTE(v float64) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLTE(FieldActualCost, v)) +} + +// ActualCostIsNil applies the IsNil predicate on the "actual_cost" field. +func ActualCostIsNil() predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIsNull(FieldActualCost)) +} + +// ActualCostNotNil applies the NotNil predicate on the "actual_cost" field. +func ActualCostNotNil() predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotNull(FieldActualCost)) +} + +// ErrorMessageEQ applies the EQ predicate on the "error_message" field. +func ErrorMessageEQ(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEQ(FieldErrorMessage, v)) +} + +// ErrorMessageNEQ applies the NEQ predicate on the "error_message" field. +func ErrorMessageNEQ(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNEQ(FieldErrorMessage, v)) +} + +// ErrorMessageIn applies the In predicate on the "error_message" field. +func ErrorMessageIn(vs ...string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIn(FieldErrorMessage, vs...)) +} + +// ErrorMessageNotIn applies the NotIn predicate on the "error_message" field. +func ErrorMessageNotIn(vs ...string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotIn(FieldErrorMessage, vs...)) +} + +// ErrorMessageGT applies the GT predicate on the "error_message" field. +func ErrorMessageGT(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGT(FieldErrorMessage, v)) +} + +// ErrorMessageGTE applies the GTE predicate on the "error_message" field. +func ErrorMessageGTE(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldGTE(FieldErrorMessage, v)) +} + +// ErrorMessageLT applies the LT predicate on the "error_message" field. +func ErrorMessageLT(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLT(FieldErrorMessage, v)) +} + +// ErrorMessageLTE applies the LTE predicate on the "error_message" field. +func ErrorMessageLTE(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldLTE(FieldErrorMessage, v)) +} + +// ErrorMessageContains applies the Contains predicate on the "error_message" field. +func ErrorMessageContains(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldContains(FieldErrorMessage, v)) +} + +// ErrorMessageHasPrefix applies the HasPrefix predicate on the "error_message" field. +func ErrorMessageHasPrefix(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldHasPrefix(FieldErrorMessage, v)) +} + +// ErrorMessageHasSuffix applies the HasSuffix predicate on the "error_message" field. +func ErrorMessageHasSuffix(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldHasSuffix(FieldErrorMessage, v)) +} + +// ErrorMessageIsNil applies the IsNil predicate on the "error_message" field. +func ErrorMessageIsNil() predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldIsNull(FieldErrorMessage)) +} + +// ErrorMessageNotNil applies the NotNil predicate on the "error_message" field. +func ErrorMessageNotNil() predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldNotNull(FieldErrorMessage)) +} + +// ErrorMessageEqualFold applies the EqualFold predicate on the "error_message" field. +func ErrorMessageEqualFold(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldEqualFold(FieldErrorMessage, v)) +} + +// ErrorMessageContainsFold applies the ContainsFold predicate on the "error_message" field. +func ErrorMessageContainsFold(v string) predicate.ChatMessage { + return predicate.ChatMessage(sql.FieldContainsFold(FieldErrorMessage, v)) +} + +// HasSession applies the HasEdge predicate on the "session" edge. +func HasSession() predicate.ChatMessage { + return predicate.ChatMessage(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, SessionTable, SessionColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasSessionWith applies the HasEdge predicate on the "session" edge with a given conditions (other predicates). +func HasSessionWith(preds ...predicate.ChatSession) predicate.ChatMessage { + return predicate.ChatMessage(func(s *sql.Selector) { + step := newSessionStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// HasUser applies the HasEdge predicate on the "user" edge. +func HasUser() predicate.ChatMessage { + return predicate.ChatMessage(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, UserTable, UserColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasUserWith applies the HasEdge predicate on the "user" edge with a given conditions (other predicates). +func HasUserWith(preds ...predicate.User) predicate.ChatMessage { + return predicate.ChatMessage(func(s *sql.Selector) { + step := newUserStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// HasUsageLog applies the HasEdge predicate on the "usage_log" edge. +func HasUsageLog() predicate.ChatMessage { + return predicate.ChatMessage(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, UsageLogTable, UsageLogColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasUsageLogWith applies the HasEdge predicate on the "usage_log" edge with a given conditions (other predicates). +func HasUsageLogWith(preds ...predicate.UsageLog) predicate.ChatMessage { + return predicate.ChatMessage(func(s *sql.Selector) { + step := newUsageLogStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// And groups predicates with the AND operator between them. +func And(predicates ...predicate.ChatMessage) predicate.ChatMessage { + return predicate.ChatMessage(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.ChatMessage) predicate.ChatMessage { + return predicate.ChatMessage(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.ChatMessage) predicate.ChatMessage { + return predicate.ChatMessage(sql.NotPredicates(p)) +} diff --git a/backend/ent/chatmessage_create.go b/backend/ent/chatmessage_create.go new file mode 100644 index 00000000000..2b00a07999e --- /dev/null +++ b/backend/ent/chatmessage_create.go @@ -0,0 +1,1307 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/ent/usagelog" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// ChatMessageCreate is the builder for creating a ChatMessage entity. +type ChatMessageCreate struct { + config + mutation *ChatMessageMutation + hooks []Hook + conflict []sql.ConflictOption +} + +// SetCreatedAt sets the "created_at" field. +func (_c *ChatMessageCreate) SetCreatedAt(v time.Time) *ChatMessageCreate { + _c.mutation.SetCreatedAt(v) + return _c +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_c *ChatMessageCreate) SetNillableCreatedAt(v *time.Time) *ChatMessageCreate { + if v != nil { + _c.SetCreatedAt(*v) + } + return _c +} + +// SetUpdatedAt sets the "updated_at" field. +func (_c *ChatMessageCreate) SetUpdatedAt(v time.Time) *ChatMessageCreate { + _c.mutation.SetUpdatedAt(v) + return _c +} + +// SetNillableUpdatedAt sets the "updated_at" field if the given value is not nil. +func (_c *ChatMessageCreate) SetNillableUpdatedAt(v *time.Time) *ChatMessageCreate { + if v != nil { + _c.SetUpdatedAt(*v) + } + return _c +} + +// SetSessionID sets the "session_id" field. +func (_c *ChatMessageCreate) SetSessionID(v int64) *ChatMessageCreate { + _c.mutation.SetSessionID(v) + return _c +} + +// SetUserID sets the "user_id" field. +func (_c *ChatMessageCreate) SetUserID(v int64) *ChatMessageCreate { + _c.mutation.SetUserID(v) + return _c +} + +// SetRole sets the "role" field. +func (_c *ChatMessageCreate) SetRole(v string) *ChatMessageCreate { + _c.mutation.SetRole(v) + return _c +} + +// SetContent sets the "content" field. +func (_c *ChatMessageCreate) SetContent(v string) *ChatMessageCreate { + _c.mutation.SetContent(v) + return _c +} + +// SetNillableContent sets the "content" field if the given value is not nil. +func (_c *ChatMessageCreate) SetNillableContent(v *string) *ChatMessageCreate { + if v != nil { + _c.SetContent(*v) + } + return _c +} + +// SetStatus sets the "status" field. +func (_c *ChatMessageCreate) SetStatus(v string) *ChatMessageCreate { + _c.mutation.SetStatus(v) + return _c +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_c *ChatMessageCreate) SetNillableStatus(v *string) *ChatMessageCreate { + if v != nil { + _c.SetStatus(*v) + } + return _c +} + +// SetModel sets the "model" field. +func (_c *ChatMessageCreate) SetModel(v string) *ChatMessageCreate { + _c.mutation.SetModel(v) + return _c +} + +// SetNillableModel sets the "model" field if the given value is not nil. +func (_c *ChatMessageCreate) SetNillableModel(v *string) *ChatMessageCreate { + if v != nil { + _c.SetModel(*v) + } + return _c +} + +// SetDurationMs sets the "duration_ms" field. +func (_c *ChatMessageCreate) SetDurationMs(v int) *ChatMessageCreate { + _c.mutation.SetDurationMs(v) + return _c +} + +// SetNillableDurationMs sets the "duration_ms" field if the given value is not nil. +func (_c *ChatMessageCreate) SetNillableDurationMs(v *int) *ChatMessageCreate { + if v != nil { + _c.SetDurationMs(*v) + } + return _c +} + +// SetUsageLogID sets the "usage_log_id" field. +func (_c *ChatMessageCreate) SetUsageLogID(v int64) *ChatMessageCreate { + _c.mutation.SetUsageLogID(v) + return _c +} + +// SetNillableUsageLogID sets the "usage_log_id" field if the given value is not nil. +func (_c *ChatMessageCreate) SetNillableUsageLogID(v *int64) *ChatMessageCreate { + if v != nil { + _c.SetUsageLogID(*v) + } + return _c +} + +// SetActualCost sets the "actual_cost" field. +func (_c *ChatMessageCreate) SetActualCost(v float64) *ChatMessageCreate { + _c.mutation.SetActualCost(v) + return _c +} + +// SetNillableActualCost sets the "actual_cost" field if the given value is not nil. +func (_c *ChatMessageCreate) SetNillableActualCost(v *float64) *ChatMessageCreate { + if v != nil { + _c.SetActualCost(*v) + } + return _c +} + +// SetErrorMessage sets the "error_message" field. +func (_c *ChatMessageCreate) SetErrorMessage(v string) *ChatMessageCreate { + _c.mutation.SetErrorMessage(v) + return _c +} + +// SetNillableErrorMessage sets the "error_message" field if the given value is not nil. +func (_c *ChatMessageCreate) SetNillableErrorMessage(v *string) *ChatMessageCreate { + if v != nil { + _c.SetErrorMessage(*v) + } + return _c +} + +// SetSession sets the "session" edge to the ChatSession entity. +func (_c *ChatMessageCreate) SetSession(v *ChatSession) *ChatMessageCreate { + return _c.SetSessionID(v.ID) +} + +// SetUser sets the "user" edge to the User entity. +func (_c *ChatMessageCreate) SetUser(v *User) *ChatMessageCreate { + return _c.SetUserID(v.ID) +} + +// SetUsageLog sets the "usage_log" edge to the UsageLog entity. +func (_c *ChatMessageCreate) SetUsageLog(v *UsageLog) *ChatMessageCreate { + return _c.SetUsageLogID(v.ID) +} + +// Mutation returns the ChatMessageMutation object of the builder. +func (_c *ChatMessageCreate) Mutation() *ChatMessageMutation { + return _c.mutation +} + +// Save creates the ChatMessage in the database. +func (_c *ChatMessageCreate) Save(ctx context.Context) (*ChatMessage, error) { + _c.defaults() + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *ChatMessageCreate) SaveX(ctx context.Context) *ChatMessage { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *ChatMessageCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *ChatMessageCreate) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_c *ChatMessageCreate) defaults() { + if _, ok := _c.mutation.CreatedAt(); !ok { + v := chatmessage.DefaultCreatedAt() + _c.mutation.SetCreatedAt(v) + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + v := chatmessage.DefaultUpdatedAt() + _c.mutation.SetUpdatedAt(v) + } + if _, ok := _c.mutation.Content(); !ok { + v := chatmessage.DefaultContent + _c.mutation.SetContent(v) + } + if _, ok := _c.mutation.Status(); !ok { + v := chatmessage.DefaultStatus + _c.mutation.SetStatus(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_c *ChatMessageCreate) check() error { + if _, ok := _c.mutation.CreatedAt(); !ok { + return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "ChatMessage.created_at"`)} + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + return &ValidationError{Name: "updated_at", err: errors.New(`ent: missing required field "ChatMessage.updated_at"`)} + } + if _, ok := _c.mutation.SessionID(); !ok { + return &ValidationError{Name: "session_id", err: errors.New(`ent: missing required field "ChatMessage.session_id"`)} + } + if _, ok := _c.mutation.UserID(); !ok { + return &ValidationError{Name: "user_id", err: errors.New(`ent: missing required field "ChatMessage.user_id"`)} + } + if _, ok := _c.mutation.Role(); !ok { + return &ValidationError{Name: "role", err: errors.New(`ent: missing required field "ChatMessage.role"`)} + } + if v, ok := _c.mutation.Role(); ok { + if err := chatmessage.RoleValidator(v); err != nil { + return &ValidationError{Name: "role", err: fmt.Errorf(`ent: validator failed for field "ChatMessage.role": %w`, err)} + } + } + if _, ok := _c.mutation.Content(); !ok { + return &ValidationError{Name: "content", err: errors.New(`ent: missing required field "ChatMessage.content"`)} + } + if _, ok := _c.mutation.Status(); !ok { + return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "ChatMessage.status"`)} + } + if v, ok := _c.mutation.Status(); ok { + if err := chatmessage.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "ChatMessage.status": %w`, err)} + } + } + if v, ok := _c.mutation.Model(); ok { + if err := chatmessage.ModelValidator(v); err != nil { + return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "ChatMessage.model": %w`, err)} + } + } + if len(_c.mutation.SessionIDs()) == 0 { + return &ValidationError{Name: "session", err: errors.New(`ent: missing required edge "ChatMessage.session"`)} + } + if len(_c.mutation.UserIDs()) == 0 { + return &ValidationError{Name: "user", err: errors.New(`ent: missing required edge "ChatMessage.user"`)} + } + return nil +} + +func (_c *ChatMessageCreate) sqlSave(ctx context.Context) (*ChatMessage, error) { + if err := _c.check(); err != nil { + return nil, err + } + _node, _spec := _c.createSpec() + if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + id := _spec.ID.Value.(int64) + _node.ID = int64(id) + _c.mutation.id = &_node.ID + _c.mutation.done = true + return _node, nil +} + +func (_c *ChatMessageCreate) createSpec() (*ChatMessage, *sqlgraph.CreateSpec) { + var ( + _node = &ChatMessage{config: _c.config} + _spec = sqlgraph.NewCreateSpec(chatmessage.Table, sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64)) + ) + _spec.OnConflict = _c.conflict + if value, ok := _c.mutation.CreatedAt(); ok { + _spec.SetField(chatmessage.FieldCreatedAt, field.TypeTime, value) + _node.CreatedAt = value + } + if value, ok := _c.mutation.UpdatedAt(); ok { + _spec.SetField(chatmessage.FieldUpdatedAt, field.TypeTime, value) + _node.UpdatedAt = value + } + if value, ok := _c.mutation.Role(); ok { + _spec.SetField(chatmessage.FieldRole, field.TypeString, value) + _node.Role = value + } + if value, ok := _c.mutation.Content(); ok { + _spec.SetField(chatmessage.FieldContent, field.TypeString, value) + _node.Content = value + } + if value, ok := _c.mutation.Status(); ok { + _spec.SetField(chatmessage.FieldStatus, field.TypeString, value) + _node.Status = value + } + if value, ok := _c.mutation.Model(); ok { + _spec.SetField(chatmessage.FieldModel, field.TypeString, value) + _node.Model = &value + } + if value, ok := _c.mutation.DurationMs(); ok { + _spec.SetField(chatmessage.FieldDurationMs, field.TypeInt, value) + _node.DurationMs = &value + } + if value, ok := _c.mutation.ActualCost(); ok { + _spec.SetField(chatmessage.FieldActualCost, field.TypeFloat64, value) + _node.ActualCost = &value + } + if value, ok := _c.mutation.ErrorMessage(); ok { + _spec.SetField(chatmessage.FieldErrorMessage, field.TypeString, value) + _node.ErrorMessage = &value + } + if nodes := _c.mutation.SessionIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.SessionTable, + Columns: []string{chatmessage.SessionColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _node.SessionID = nodes[0] + _spec.Edges = append(_spec.Edges, edge) + } + if nodes := _c.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.UserTable, + Columns: []string{chatmessage.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _node.UserID = nodes[0] + _spec.Edges = append(_spec.Edges, edge) + } + if nodes := _c.mutation.UsageLogIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.UsageLogTable, + Columns: []string{chatmessage.UsageLogColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(usagelog.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _node.UsageLogID = &nodes[0] + _spec.Edges = append(_spec.Edges, edge) + } + return _node, _spec +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.ChatMessage.Create(). +// SetCreatedAt(v). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.ChatMessageUpsert) { +// SetCreatedAt(v+v). +// }). +// Exec(ctx) +func (_c *ChatMessageCreate) OnConflict(opts ...sql.ConflictOption) *ChatMessageUpsertOne { + _c.conflict = opts + return &ChatMessageUpsertOne{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.ChatMessage.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *ChatMessageCreate) OnConflictColumns(columns ...string) *ChatMessageUpsertOne { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &ChatMessageUpsertOne{ + create: _c, + } +} + +type ( + // ChatMessageUpsertOne is the builder for "upsert"-ing + // one ChatMessage node. + ChatMessageUpsertOne struct { + create *ChatMessageCreate + } + + // ChatMessageUpsert is the "OnConflict" setter. + ChatMessageUpsert struct { + *sql.UpdateSet + } +) + +// SetUpdatedAt sets the "updated_at" field. +func (u *ChatMessageUpsert) SetUpdatedAt(v time.Time) *ChatMessageUpsert { + u.Set(chatmessage.FieldUpdatedAt, v) + return u +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateUpdatedAt() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldUpdatedAt) + return u +} + +// SetSessionID sets the "session_id" field. +func (u *ChatMessageUpsert) SetSessionID(v int64) *ChatMessageUpsert { + u.Set(chatmessage.FieldSessionID, v) + return u +} + +// UpdateSessionID sets the "session_id" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateSessionID() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldSessionID) + return u +} + +// SetUserID sets the "user_id" field. +func (u *ChatMessageUpsert) SetUserID(v int64) *ChatMessageUpsert { + u.Set(chatmessage.FieldUserID, v) + return u +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateUserID() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldUserID) + return u +} + +// SetRole sets the "role" field. +func (u *ChatMessageUpsert) SetRole(v string) *ChatMessageUpsert { + u.Set(chatmessage.FieldRole, v) + return u +} + +// UpdateRole sets the "role" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateRole() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldRole) + return u +} + +// SetContent sets the "content" field. +func (u *ChatMessageUpsert) SetContent(v string) *ChatMessageUpsert { + u.Set(chatmessage.FieldContent, v) + return u +} + +// UpdateContent sets the "content" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateContent() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldContent) + return u +} + +// SetStatus sets the "status" field. +func (u *ChatMessageUpsert) SetStatus(v string) *ChatMessageUpsert { + u.Set(chatmessage.FieldStatus, v) + return u +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateStatus() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldStatus) + return u +} + +// SetModel sets the "model" field. +func (u *ChatMessageUpsert) SetModel(v string) *ChatMessageUpsert { + u.Set(chatmessage.FieldModel, v) + return u +} + +// UpdateModel sets the "model" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateModel() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldModel) + return u +} + +// ClearModel clears the value of the "model" field. +func (u *ChatMessageUpsert) ClearModel() *ChatMessageUpsert { + u.SetNull(chatmessage.FieldModel) + return u +} + +// SetDurationMs sets the "duration_ms" field. +func (u *ChatMessageUpsert) SetDurationMs(v int) *ChatMessageUpsert { + u.Set(chatmessage.FieldDurationMs, v) + return u +} + +// UpdateDurationMs sets the "duration_ms" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateDurationMs() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldDurationMs) + return u +} + +// AddDurationMs adds v to the "duration_ms" field. +func (u *ChatMessageUpsert) AddDurationMs(v int) *ChatMessageUpsert { + u.Add(chatmessage.FieldDurationMs, v) + return u +} + +// ClearDurationMs clears the value of the "duration_ms" field. +func (u *ChatMessageUpsert) ClearDurationMs() *ChatMessageUpsert { + u.SetNull(chatmessage.FieldDurationMs) + return u +} + +// SetUsageLogID sets the "usage_log_id" field. +func (u *ChatMessageUpsert) SetUsageLogID(v int64) *ChatMessageUpsert { + u.Set(chatmessage.FieldUsageLogID, v) + return u +} + +// UpdateUsageLogID sets the "usage_log_id" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateUsageLogID() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldUsageLogID) + return u +} + +// ClearUsageLogID clears the value of the "usage_log_id" field. +func (u *ChatMessageUpsert) ClearUsageLogID() *ChatMessageUpsert { + u.SetNull(chatmessage.FieldUsageLogID) + return u +} + +// SetActualCost sets the "actual_cost" field. +func (u *ChatMessageUpsert) SetActualCost(v float64) *ChatMessageUpsert { + u.Set(chatmessage.FieldActualCost, v) + return u +} + +// UpdateActualCost sets the "actual_cost" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateActualCost() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldActualCost) + return u +} + +// AddActualCost adds v to the "actual_cost" field. +func (u *ChatMessageUpsert) AddActualCost(v float64) *ChatMessageUpsert { + u.Add(chatmessage.FieldActualCost, v) + return u +} + +// ClearActualCost clears the value of the "actual_cost" field. +func (u *ChatMessageUpsert) ClearActualCost() *ChatMessageUpsert { + u.SetNull(chatmessage.FieldActualCost) + return u +} + +// SetErrorMessage sets the "error_message" field. +func (u *ChatMessageUpsert) SetErrorMessage(v string) *ChatMessageUpsert { + u.Set(chatmessage.FieldErrorMessage, v) + return u +} + +// UpdateErrorMessage sets the "error_message" field to the value that was provided on create. +func (u *ChatMessageUpsert) UpdateErrorMessage() *ChatMessageUpsert { + u.SetExcluded(chatmessage.FieldErrorMessage) + return u +} + +// ClearErrorMessage clears the value of the "error_message" field. +func (u *ChatMessageUpsert) ClearErrorMessage() *ChatMessageUpsert { + u.SetNull(chatmessage.FieldErrorMessage) + return u +} + +// UpdateNewValues updates the mutable fields using the new values that were set on create. +// Using this option is equivalent to using: +// +// client.ChatMessage.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *ChatMessageUpsertOne) UpdateNewValues() *ChatMessageUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + if _, exists := u.create.mutation.CreatedAt(); exists { + s.SetIgnore(chatmessage.FieldCreatedAt) + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.ChatMessage.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *ChatMessageUpsertOne) Ignore() *ChatMessageUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *ChatMessageUpsertOne) DoNothing() *ChatMessageUpsertOne { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the ChatMessageCreate.OnConflict +// documentation for more info. +func (u *ChatMessageUpsertOne) Update(set func(*ChatMessageUpsert)) *ChatMessageUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&ChatMessageUpsert{UpdateSet: update}) + })) + return u +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *ChatMessageUpsertOne) SetUpdatedAt(v time.Time) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateUpdatedAt() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateUpdatedAt() + }) +} + +// SetSessionID sets the "session_id" field. +func (u *ChatMessageUpsertOne) SetSessionID(v int64) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetSessionID(v) + }) +} + +// UpdateSessionID sets the "session_id" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateSessionID() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateSessionID() + }) +} + +// SetUserID sets the "user_id" field. +func (u *ChatMessageUpsertOne) SetUserID(v int64) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetUserID(v) + }) +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateUserID() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateUserID() + }) +} + +// SetRole sets the "role" field. +func (u *ChatMessageUpsertOne) SetRole(v string) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetRole(v) + }) +} + +// UpdateRole sets the "role" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateRole() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateRole() + }) +} + +// SetContent sets the "content" field. +func (u *ChatMessageUpsertOne) SetContent(v string) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetContent(v) + }) +} + +// UpdateContent sets the "content" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateContent() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateContent() + }) +} + +// SetStatus sets the "status" field. +func (u *ChatMessageUpsertOne) SetStatus(v string) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateStatus() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateStatus() + }) +} + +// SetModel sets the "model" field. +func (u *ChatMessageUpsertOne) SetModel(v string) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetModel(v) + }) +} + +// UpdateModel sets the "model" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateModel() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateModel() + }) +} + +// ClearModel clears the value of the "model" field. +func (u *ChatMessageUpsertOne) ClearModel() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.ClearModel() + }) +} + +// SetDurationMs sets the "duration_ms" field. +func (u *ChatMessageUpsertOne) SetDurationMs(v int) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetDurationMs(v) + }) +} + +// AddDurationMs adds v to the "duration_ms" field. +func (u *ChatMessageUpsertOne) AddDurationMs(v int) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.AddDurationMs(v) + }) +} + +// UpdateDurationMs sets the "duration_ms" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateDurationMs() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateDurationMs() + }) +} + +// ClearDurationMs clears the value of the "duration_ms" field. +func (u *ChatMessageUpsertOne) ClearDurationMs() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.ClearDurationMs() + }) +} + +// SetUsageLogID sets the "usage_log_id" field. +func (u *ChatMessageUpsertOne) SetUsageLogID(v int64) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetUsageLogID(v) + }) +} + +// UpdateUsageLogID sets the "usage_log_id" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateUsageLogID() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateUsageLogID() + }) +} + +// ClearUsageLogID clears the value of the "usage_log_id" field. +func (u *ChatMessageUpsertOne) ClearUsageLogID() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.ClearUsageLogID() + }) +} + +// SetActualCost sets the "actual_cost" field. +func (u *ChatMessageUpsertOne) SetActualCost(v float64) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetActualCost(v) + }) +} + +// AddActualCost adds v to the "actual_cost" field. +func (u *ChatMessageUpsertOne) AddActualCost(v float64) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.AddActualCost(v) + }) +} + +// UpdateActualCost sets the "actual_cost" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateActualCost() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateActualCost() + }) +} + +// ClearActualCost clears the value of the "actual_cost" field. +func (u *ChatMessageUpsertOne) ClearActualCost() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.ClearActualCost() + }) +} + +// SetErrorMessage sets the "error_message" field. +func (u *ChatMessageUpsertOne) SetErrorMessage(v string) *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.SetErrorMessage(v) + }) +} + +// UpdateErrorMessage sets the "error_message" field to the value that was provided on create. +func (u *ChatMessageUpsertOne) UpdateErrorMessage() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateErrorMessage() + }) +} + +// ClearErrorMessage clears the value of the "error_message" field. +func (u *ChatMessageUpsertOne) ClearErrorMessage() *ChatMessageUpsertOne { + return u.Update(func(s *ChatMessageUpsert) { + s.ClearErrorMessage() + }) +} + +// Exec executes the query. +func (u *ChatMessageUpsertOne) Exec(ctx context.Context) error { + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for ChatMessageCreate.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *ChatMessageUpsertOne) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} + +// Exec executes the UPSERT query and returns the inserted/updated ID. +func (u *ChatMessageUpsertOne) ID(ctx context.Context) (id int64, err error) { + node, err := u.create.Save(ctx) + if err != nil { + return id, err + } + return node.ID, nil +} + +// IDX is like ID, but panics if an error occurs. +func (u *ChatMessageUpsertOne) IDX(ctx context.Context) int64 { + id, err := u.ID(ctx) + if err != nil { + panic(err) + } + return id +} + +// ChatMessageCreateBulk is the builder for creating many ChatMessage entities in bulk. +type ChatMessageCreateBulk struct { + config + err error + builders []*ChatMessageCreate + conflict []sql.ConflictOption +} + +// Save creates the ChatMessage entities in the database. +func (_c *ChatMessageCreateBulk) Save(ctx context.Context) ([]*ChatMessage, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*ChatMessage, len(_c.builders)) + mutators := make([]Mutator, len(_c.builders)) + for i := range _c.builders { + func(i int, root context.Context) { + builder := _c.builders[i] + builder.defaults() + var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { + mutation, ok := m.(*ChatMessageMutation) + if !ok { + return nil, fmt.Errorf("unexpected mutation type %T", m) + } + if err := builder.check(); err != nil { + return nil, err + } + builder.mutation = mutation + var err error + nodes[i], specs[i] = builder.createSpec() + if i < len(mutators)-1 { + _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation) + } else { + spec := &sqlgraph.BatchCreateSpec{Nodes: specs} + spec.OnConflict = _c.conflict + // Invoke the actual operation on the latest mutation in the chain. + if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + } + } + if err != nil { + return nil, err + } + mutation.id = &nodes[i].ID + if specs[i].ID.Value != nil { + id := specs[i].ID.Value.(int64) + nodes[i].ID = int64(id) + } + mutation.done = true + return nodes[i], nil + }) + for i := len(builder.hooks) - 1; i >= 0; i-- { + mut = builder.hooks[i](mut) + } + mutators[i] = mut + }(i, ctx) + } + if len(mutators) > 0 { + if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil { + return nil, err + } + } + return nodes, nil +} + +// SaveX is like Save, but panics if an error occurs. +func (_c *ChatMessageCreateBulk) SaveX(ctx context.Context) []*ChatMessage { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *ChatMessageCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *ChatMessageCreateBulk) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.ChatMessage.CreateBulk(builders...). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.ChatMessageUpsert) { +// SetCreatedAt(v+v). +// }). +// Exec(ctx) +func (_c *ChatMessageCreateBulk) OnConflict(opts ...sql.ConflictOption) *ChatMessageUpsertBulk { + _c.conflict = opts + return &ChatMessageUpsertBulk{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.ChatMessage.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *ChatMessageCreateBulk) OnConflictColumns(columns ...string) *ChatMessageUpsertBulk { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &ChatMessageUpsertBulk{ + create: _c, + } +} + +// ChatMessageUpsertBulk is the builder for "upsert"-ing +// a bulk of ChatMessage nodes. +type ChatMessageUpsertBulk struct { + create *ChatMessageCreateBulk +} + +// UpdateNewValues updates the mutable fields using the new values that +// were set on create. Using this option is equivalent to using: +// +// client.ChatMessage.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *ChatMessageUpsertBulk) UpdateNewValues() *ChatMessageUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + for _, b := range u.create.builders { + if _, exists := b.mutation.CreatedAt(); exists { + s.SetIgnore(chatmessage.FieldCreatedAt) + } + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.ChatMessage.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *ChatMessageUpsertBulk) Ignore() *ChatMessageUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *ChatMessageUpsertBulk) DoNothing() *ChatMessageUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the ChatMessageCreateBulk.OnConflict +// documentation for more info. +func (u *ChatMessageUpsertBulk) Update(set func(*ChatMessageUpsert)) *ChatMessageUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&ChatMessageUpsert{UpdateSet: update}) + })) + return u +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *ChatMessageUpsertBulk) SetUpdatedAt(v time.Time) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateUpdatedAt() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateUpdatedAt() + }) +} + +// SetSessionID sets the "session_id" field. +func (u *ChatMessageUpsertBulk) SetSessionID(v int64) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetSessionID(v) + }) +} + +// UpdateSessionID sets the "session_id" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateSessionID() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateSessionID() + }) +} + +// SetUserID sets the "user_id" field. +func (u *ChatMessageUpsertBulk) SetUserID(v int64) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetUserID(v) + }) +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateUserID() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateUserID() + }) +} + +// SetRole sets the "role" field. +func (u *ChatMessageUpsertBulk) SetRole(v string) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetRole(v) + }) +} + +// UpdateRole sets the "role" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateRole() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateRole() + }) +} + +// SetContent sets the "content" field. +func (u *ChatMessageUpsertBulk) SetContent(v string) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetContent(v) + }) +} + +// UpdateContent sets the "content" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateContent() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateContent() + }) +} + +// SetStatus sets the "status" field. +func (u *ChatMessageUpsertBulk) SetStatus(v string) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateStatus() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateStatus() + }) +} + +// SetModel sets the "model" field. +func (u *ChatMessageUpsertBulk) SetModel(v string) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetModel(v) + }) +} + +// UpdateModel sets the "model" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateModel() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateModel() + }) +} + +// ClearModel clears the value of the "model" field. +func (u *ChatMessageUpsertBulk) ClearModel() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.ClearModel() + }) +} + +// SetDurationMs sets the "duration_ms" field. +func (u *ChatMessageUpsertBulk) SetDurationMs(v int) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetDurationMs(v) + }) +} + +// AddDurationMs adds v to the "duration_ms" field. +func (u *ChatMessageUpsertBulk) AddDurationMs(v int) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.AddDurationMs(v) + }) +} + +// UpdateDurationMs sets the "duration_ms" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateDurationMs() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateDurationMs() + }) +} + +// ClearDurationMs clears the value of the "duration_ms" field. +func (u *ChatMessageUpsertBulk) ClearDurationMs() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.ClearDurationMs() + }) +} + +// SetUsageLogID sets the "usage_log_id" field. +func (u *ChatMessageUpsertBulk) SetUsageLogID(v int64) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetUsageLogID(v) + }) +} + +// UpdateUsageLogID sets the "usage_log_id" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateUsageLogID() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateUsageLogID() + }) +} + +// ClearUsageLogID clears the value of the "usage_log_id" field. +func (u *ChatMessageUpsertBulk) ClearUsageLogID() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.ClearUsageLogID() + }) +} + +// SetActualCost sets the "actual_cost" field. +func (u *ChatMessageUpsertBulk) SetActualCost(v float64) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetActualCost(v) + }) +} + +// AddActualCost adds v to the "actual_cost" field. +func (u *ChatMessageUpsertBulk) AddActualCost(v float64) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.AddActualCost(v) + }) +} + +// UpdateActualCost sets the "actual_cost" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateActualCost() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateActualCost() + }) +} + +// ClearActualCost clears the value of the "actual_cost" field. +func (u *ChatMessageUpsertBulk) ClearActualCost() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.ClearActualCost() + }) +} + +// SetErrorMessage sets the "error_message" field. +func (u *ChatMessageUpsertBulk) SetErrorMessage(v string) *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.SetErrorMessage(v) + }) +} + +// UpdateErrorMessage sets the "error_message" field to the value that was provided on create. +func (u *ChatMessageUpsertBulk) UpdateErrorMessage() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.UpdateErrorMessage() + }) +} + +// ClearErrorMessage clears the value of the "error_message" field. +func (u *ChatMessageUpsertBulk) ClearErrorMessage() *ChatMessageUpsertBulk { + return u.Update(func(s *ChatMessageUpsert) { + s.ClearErrorMessage() + }) +} + +// Exec executes the query. +func (u *ChatMessageUpsertBulk) Exec(ctx context.Context) error { + if u.create.err != nil { + return u.create.err + } + for i, b := range u.create.builders { + if len(b.conflict) != 0 { + return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the ChatMessageCreateBulk instead", i) + } + } + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for ChatMessageCreateBulk.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *ChatMessageUpsertBulk) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/chatmessage_delete.go b/backend/ent/chatmessage_delete.go new file mode 100644 index 00000000000..644ceba22d0 --- /dev/null +++ b/backend/ent/chatmessage_delete.go @@ -0,0 +1,88 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/predicate" +) + +// ChatMessageDelete is the builder for deleting a ChatMessage entity. +type ChatMessageDelete struct { + config + hooks []Hook + mutation *ChatMessageMutation +} + +// Where appends a list predicates to the ChatMessageDelete builder. +func (_d *ChatMessageDelete) Where(ps ...predicate.ChatMessage) *ChatMessageDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *ChatMessageDelete) Exec(ctx context.Context) (int, error) { + return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *ChatMessageDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *ChatMessageDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(chatmessage.Table, sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64)) + if ps := _d.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec) + if err != nil && sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + _d.mutation.done = true + return affected, err +} + +// ChatMessageDeleteOne is the builder for deleting a single ChatMessage entity. +type ChatMessageDeleteOne struct { + _d *ChatMessageDelete +} + +// Where appends a list predicates to the ChatMessageDelete builder. +func (_d *ChatMessageDeleteOne) Where(ps ...predicate.ChatMessage) *ChatMessageDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *ChatMessageDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{chatmessage.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *ChatMessageDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/chatmessage_query.go b/backend/ent/chatmessage_query.go new file mode 100644 index 00000000000..9b7b80f65dd --- /dev/null +++ b/backend/ent/chatmessage_query.go @@ -0,0 +1,796 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "fmt" + "math" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/usagelog" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// ChatMessageQuery is the builder for querying ChatMessage entities. +type ChatMessageQuery struct { + config + ctx *QueryContext + order []chatmessage.OrderOption + inters []Interceptor + predicates []predicate.ChatMessage + withSession *ChatSessionQuery + withUser *UserQuery + withUsageLog *UsageLogQuery + modifiers []func(*sql.Selector) + // intermediate query (i.e. traversal path). + sql *sql.Selector + path func(context.Context) (*sql.Selector, error) +} + +// Where adds a new predicate for the ChatMessageQuery builder. +func (_q *ChatMessageQuery) Where(ps ...predicate.ChatMessage) *ChatMessageQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *ChatMessageQuery) Limit(limit int) *ChatMessageQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *ChatMessageQuery) Offset(offset int) *ChatMessageQuery { + _q.ctx.Offset = &offset + return _q +} + +// Unique configures the query builder to filter duplicate records on query. +// By default, unique is set to true, and can be disabled using this method. +func (_q *ChatMessageQuery) Unique(unique bool) *ChatMessageQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *ChatMessageQuery) Order(o ...chatmessage.OrderOption) *ChatMessageQuery { + _q.order = append(_q.order, o...) + return _q +} + +// QuerySession chains the current query on the "session" edge. +func (_q *ChatMessageQuery) QuerySession() *ChatSessionQuery { + query := (&ChatSessionClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(chatmessage.Table, chatmessage.FieldID, selector), + sqlgraph.To(chatsession.Table, chatsession.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, chatmessage.SessionTable, chatmessage.SessionColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// QueryUser chains the current query on the "user" edge. +func (_q *ChatMessageQuery) QueryUser() *UserQuery { + query := (&UserClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(chatmessage.Table, chatmessage.FieldID, selector), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, chatmessage.UserTable, chatmessage.UserColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// QueryUsageLog chains the current query on the "usage_log" edge. +func (_q *ChatMessageQuery) QueryUsageLog() *UsageLogQuery { + query := (&UsageLogClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(chatmessage.Table, chatmessage.FieldID, selector), + sqlgraph.To(usagelog.Table, usagelog.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, chatmessage.UsageLogTable, chatmessage.UsageLogColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// First returns the first ChatMessage entity from the query. +// Returns a *NotFoundError when no ChatMessage was found. +func (_q *ChatMessageQuery) First(ctx context.Context) (*ChatMessage, error) { + nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &NotFoundError{chatmessage.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *ChatMessageQuery) FirstX(ctx context.Context) *ChatMessage { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first ChatMessage ID from the query. +// Returns a *NotFoundError when no ChatMessage ID was found. +func (_q *ChatMessageQuery) FirstID(ctx context.Context) (id int64, err error) { + var ids []int64 + if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { + return + } + if len(ids) == 0 { + err = &NotFoundError{chatmessage.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *ChatMessageQuery) FirstIDX(ctx context.Context) int64 { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single ChatMessage entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one ChatMessage entity is found. +// Returns a *NotFoundError when no ChatMessage entities are found. +func (_q *ChatMessageQuery) Only(ctx context.Context) (*ChatMessage, error) { + nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) + if err != nil { + return nil, err + } + switch len(nodes) { + case 1: + return nodes[0], nil + case 0: + return nil, &NotFoundError{chatmessage.Label} + default: + return nil, &NotSingularError{chatmessage.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *ChatMessageQuery) OnlyX(ctx context.Context) *ChatMessage { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only ChatMessage ID in the query. +// Returns a *NotSingularError when more than one ChatMessage ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *ChatMessageQuery) OnlyID(ctx context.Context) (id int64, err error) { + var ids []int64 + if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { + return + } + switch len(ids) { + case 1: + id = ids[0] + case 0: + err = &NotFoundError{chatmessage.Label} + default: + err = &NotSingularError{chatmessage.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *ChatMessageQuery) OnlyIDX(ctx context.Context) int64 { + id, err := _q.OnlyID(ctx) + if err != nil { + panic(err) + } + return id +} + +// All executes the query and returns a list of ChatMessages. +func (_q *ChatMessageQuery) All(ctx context.Context) ([]*ChatMessage, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*ChatMessage, *ChatMessageQuery]() + return withInterceptors[[]*ChatMessage](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *ChatMessageQuery) AllX(ctx context.Context) []*ChatMessage { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of ChatMessage IDs. +func (_q *ChatMessageQuery) IDs(ctx context.Context) (ids []int64, err error) { + if _q.ctx.Unique == nil && _q.path != nil { + _q.Unique(true) + } + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs) + if err = _q.Select(chatmessage.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *ChatMessageQuery) IDsX(ctx context.Context) []int64 { + ids, err := _q.IDs(ctx) + if err != nil { + panic(err) + } + return ids +} + +// Count returns the count of the given query. +func (_q *ChatMessageQuery) Count(ctx context.Context) (int, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) + if err := _q.prepareQuery(ctx); err != nil { + return 0, err + } + return withInterceptors[int](ctx, _q, querierCount[*ChatMessageQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *ChatMessageQuery) CountX(ctx context.Context) int { + count, err := _q.Count(ctx) + if err != nil { + panic(err) + } + return count +} + +// Exist returns true if the query has elements in the graph. +func (_q *ChatMessageQuery) Exist(ctx context.Context) (bool, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) + switch _, err := _q.FirstID(ctx); { + case IsNotFound(err): + return false, nil + case err != nil: + return false, fmt.Errorf("ent: check existence: %w", err) + default: + return true, nil + } +} + +// ExistX is like Exist, but panics if an error occurs. +func (_q *ChatMessageQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the ChatMessageQuery builder, including all associated steps. It can be +// used to prepare common query builders and use them differently after the clone is made. +func (_q *ChatMessageQuery) Clone() *ChatMessageQuery { + if _q == nil { + return nil + } + return &ChatMessageQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]chatmessage.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.ChatMessage{}, _q.predicates...), + withSession: _q.withSession.Clone(), + withUser: _q.withUser.Clone(), + withUsageLog: _q.withUsageLog.Clone(), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + } +} + +// WithSession tells the query-builder to eager-load the nodes that are connected to +// the "session" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *ChatMessageQuery) WithSession(opts ...func(*ChatSessionQuery)) *ChatMessageQuery { + query := (&ChatSessionClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withSession = query + return _q +} + +// WithUser tells the query-builder to eager-load the nodes that are connected to +// the "user" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *ChatMessageQuery) WithUser(opts ...func(*UserQuery)) *ChatMessageQuery { + query := (&UserClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withUser = query + return _q +} + +// WithUsageLog tells the query-builder to eager-load the nodes that are connected to +// the "usage_log" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *ChatMessageQuery) WithUsageLog(opts ...func(*UsageLogQuery)) *ChatMessageQuery { + query := (&UsageLogClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withUsageLog = query + return _q +} + +// GroupBy is used to group vertices by one or more fields/columns. +// It is often used with aggregate functions, like: count, max, mean, min, sum. +// +// Example: +// +// var v []struct { +// CreatedAt time.Time `json:"created_at,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.ChatMessage.Query(). +// GroupBy(chatmessage.FieldCreatedAt). +// Aggregate(ent.Count()). +// Scan(ctx, &v) +func (_q *ChatMessageQuery) GroupBy(field string, fields ...string) *ChatMessageGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &ChatMessageGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = chatmessage.Label + grbuild.scan = grbuild.Scan + return grbuild +} + +// Select allows the selection one or more fields/columns for the given query, +// instead of selecting all fields in the entity. +// +// Example: +// +// var v []struct { +// CreatedAt time.Time `json:"created_at,omitempty"` +// } +// +// client.ChatMessage.Query(). +// Select(chatmessage.FieldCreatedAt). +// Scan(ctx, &v) +func (_q *ChatMessageQuery) Select(fields ...string) *ChatMessageSelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &ChatMessageSelect{ChatMessageQuery: _q} + sbuild.label = chatmessage.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a ChatMessageSelect configured with the given aggregations. +func (_q *ChatMessageQuery) Aggregate(fns ...AggregateFunc) *ChatMessageSelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *ChatMessageQuery) prepareQuery(ctx context.Context) error { + for _, inter := range _q.inters { + if inter == nil { + return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)") + } + if trv, ok := inter.(Traverser); ok { + if err := trv.Traverse(ctx, _q); err != nil { + return err + } + } + } + for _, f := range _q.ctx.Fields { + if !chatmessage.ValidColumn(f) { + return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + } + if _q.path != nil { + prev, err := _q.path(ctx) + if err != nil { + return err + } + _q.sql = prev + } + return nil +} + +func (_q *ChatMessageQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*ChatMessage, error) { + var ( + nodes = []*ChatMessage{} + _spec = _q.querySpec() + loadedTypes = [3]bool{ + _q.withSession != nil, + _q.withUser != nil, + _q.withUsageLog != nil, + } + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*ChatMessage).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &ChatMessage{config: _q.config} + nodes = append(nodes, node) + node.Edges.loadedTypes = loadedTypes + return node.assignValues(columns, values) + } + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + for i := range hooks { + hooks[i](ctx, _spec) + } + if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil { + return nil, err + } + if len(nodes) == 0 { + return nodes, nil + } + if query := _q.withSession; query != nil { + if err := _q.loadSession(ctx, query, nodes, nil, + func(n *ChatMessage, e *ChatSession) { n.Edges.Session = e }); err != nil { + return nil, err + } + } + if query := _q.withUser; query != nil { + if err := _q.loadUser(ctx, query, nodes, nil, + func(n *ChatMessage, e *User) { n.Edges.User = e }); err != nil { + return nil, err + } + } + if query := _q.withUsageLog; query != nil { + if err := _q.loadUsageLog(ctx, query, nodes, nil, + func(n *ChatMessage, e *UsageLog) { n.Edges.UsageLog = e }); err != nil { + return nil, err + } + } + return nodes, nil +} + +func (_q *ChatMessageQuery) loadSession(ctx context.Context, query *ChatSessionQuery, nodes []*ChatMessage, init func(*ChatMessage), assign func(*ChatMessage, *ChatSession)) error { + ids := make([]int64, 0, len(nodes)) + nodeids := make(map[int64][]*ChatMessage) + for i := range nodes { + fk := nodes[i].SessionID + if _, ok := nodeids[fk]; !ok { + ids = append(ids, fk) + } + nodeids[fk] = append(nodeids[fk], nodes[i]) + } + if len(ids) == 0 { + return nil + } + query.Where(chatsession.IDIn(ids...)) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + nodes, ok := nodeids[n.ID] + if !ok { + return fmt.Errorf(`unexpected foreign-key "session_id" returned %v`, n.ID) + } + for i := range nodes { + assign(nodes[i], n) + } + } + return nil +} +func (_q *ChatMessageQuery) loadUser(ctx context.Context, query *UserQuery, nodes []*ChatMessage, init func(*ChatMessage), assign func(*ChatMessage, *User)) error { + ids := make([]int64, 0, len(nodes)) + nodeids := make(map[int64][]*ChatMessage) + for i := range nodes { + fk := nodes[i].UserID + if _, ok := nodeids[fk]; !ok { + ids = append(ids, fk) + } + nodeids[fk] = append(nodeids[fk], nodes[i]) + } + if len(ids) == 0 { + return nil + } + query.Where(user.IDIn(ids...)) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + nodes, ok := nodeids[n.ID] + if !ok { + return fmt.Errorf(`unexpected foreign-key "user_id" returned %v`, n.ID) + } + for i := range nodes { + assign(nodes[i], n) + } + } + return nil +} +func (_q *ChatMessageQuery) loadUsageLog(ctx context.Context, query *UsageLogQuery, nodes []*ChatMessage, init func(*ChatMessage), assign func(*ChatMessage, *UsageLog)) error { + ids := make([]int64, 0, len(nodes)) + nodeids := make(map[int64][]*ChatMessage) + for i := range nodes { + if nodes[i].UsageLogID == nil { + continue + } + fk := *nodes[i].UsageLogID + if _, ok := nodeids[fk]; !ok { + ids = append(ids, fk) + } + nodeids[fk] = append(nodeids[fk], nodes[i]) + } + if len(ids) == 0 { + return nil + } + query.Where(usagelog.IDIn(ids...)) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + nodes, ok := nodeids[n.ID] + if !ok { + return fmt.Errorf(`unexpected foreign-key "usage_log_id" returned %v`, n.ID) + } + for i := range nodes { + assign(nodes[i], n) + } + } + return nil +} + +func (_q *ChatMessageQuery) sqlCount(ctx context.Context) (int, error) { + _spec := _q.querySpec() + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + _spec.Node.Columns = _q.ctx.Fields + if len(_q.ctx.Fields) > 0 { + _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique + } + return sqlgraph.CountNodes(ctx, _q.driver, _spec) +} + +func (_q *ChatMessageQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(chatmessage.Table, chatmessage.Columns, sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64)) + _spec.From = _q.sql + if unique := _q.ctx.Unique; unique != nil { + _spec.Unique = *unique + } else if _q.path != nil { + _spec.Unique = true + } + if fields := _q.ctx.Fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, chatmessage.FieldID) + for i := range fields { + if fields[i] != chatmessage.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) + } + } + if _q.withSession != nil { + _spec.Node.AddColumnOnce(chatmessage.FieldSessionID) + } + if _q.withUser != nil { + _spec.Node.AddColumnOnce(chatmessage.FieldUserID) + } + if _q.withUsageLog != nil { + _spec.Node.AddColumnOnce(chatmessage.FieldUsageLogID) + } + } + if ps := _q.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if limit := _q.ctx.Limit; limit != nil { + _spec.Limit = *limit + } + if offset := _q.ctx.Offset; offset != nil { + _spec.Offset = *offset + } + if ps := _q.order; len(ps) > 0 { + _spec.Order = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + return _spec +} + +func (_q *ChatMessageQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(chatmessage.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = chatmessage.Columns + } + selector := builder.Select(t1.Columns(columns...)...).From(t1) + if _q.sql != nil { + selector = _q.sql + selector.Select(selector.Columns(columns...)...) + } + if _q.ctx.Unique != nil && *_q.ctx.Unique { + selector.Distinct() + } + for _, m := range _q.modifiers { + m(selector) + } + for _, p := range _q.predicates { + p(selector) + } + for _, p := range _q.order { + p(selector) + } + if offset := _q.ctx.Offset; offset != nil { + // limit is mandatory for offset clause. We start + // with default value, and override it below if needed. + selector.Offset(*offset).Limit(math.MaxInt32) + } + if limit := _q.ctx.Limit; limit != nil { + selector.Limit(*limit) + } + return selector +} + +// ForUpdate locks the selected rows against concurrent updates, and prevent them from being +// updated, deleted or "selected ... for update" by other sessions, until the transaction is +// either committed or rolled-back. +func (_q *ChatMessageQuery) ForUpdate(opts ...sql.LockOption) *ChatMessageQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForUpdate(opts...) + }) + return _q +} + +// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock +// on any rows that are read. Other sessions can read the rows, but cannot modify them +// until your transaction commits. +func (_q *ChatMessageQuery) ForShare(opts ...sql.LockOption) *ChatMessageQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + +// ChatMessageGroupBy is the group-by builder for ChatMessage entities. +type ChatMessageGroupBy struct { + selector + build *ChatMessageQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *ChatMessageGroupBy) Aggregate(fns ...AggregateFunc) *ChatMessageGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *ChatMessageGroupBy) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) + if err := _g.build.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*ChatMessageQuery, *ChatMessageGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *ChatMessageGroupBy) sqlScan(ctx context.Context, root *ChatMessageQuery, v any) error { + selector := root.sqlQuery(ctx).Select() + aggregation := make([]string, 0, len(_g.fns)) + for _, fn := range _g.fns { + aggregation = append(aggregation, fn(selector)) + } + if len(selector.SelectedColumns()) == 0 { + columns := make([]string, 0, len(*_g.flds)+len(_g.fns)) + for _, f := range *_g.flds { + columns = append(columns, selector.C(f)) + } + columns = append(columns, aggregation...) + selector.Select(columns...) + } + selector.GroupBy(selector.Columns(*_g.flds...)...) + if err := selector.Err(); err != nil { + return err + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _g.build.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// ChatMessageSelect is the builder for selecting fields of ChatMessage entities. +type ChatMessageSelect struct { + *ChatMessageQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *ChatMessageSelect) Aggregate(fns ...AggregateFunc) *ChatMessageSelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *ChatMessageSelect) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) + if err := _s.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*ChatMessageQuery, *ChatMessageSelect](ctx, _s.ChatMessageQuery, _s, _s.inters, v) +} + +func (_s *ChatMessageSelect) sqlScan(ctx context.Context, root *ChatMessageQuery, v any) error { + selector := root.sqlQuery(ctx) + aggregation := make([]string, 0, len(_s.fns)) + for _, fn := range _s.fns { + aggregation = append(aggregation, fn(selector)) + } + switch n := len(*_s.selector.flds); { + case n == 0 && len(aggregation) > 0: + selector.Select(aggregation...) + case n != 0 && len(aggregation) > 0: + selector.AppendSelect(aggregation...) + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _s.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} diff --git a/backend/ent/chatmessage_update.go b/backend/ent/chatmessage_update.go new file mode 100644 index 00000000000..d8071141576 --- /dev/null +++ b/backend/ent/chatmessage_update.go @@ -0,0 +1,959 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/usagelog" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// ChatMessageUpdate is the builder for updating ChatMessage entities. +type ChatMessageUpdate struct { + config + hooks []Hook + mutation *ChatMessageMutation +} + +// Where appends a list predicates to the ChatMessageUpdate builder. +func (_u *ChatMessageUpdate) Where(ps ...predicate.ChatMessage) *ChatMessageUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *ChatMessageUpdate) SetUpdatedAt(v time.Time) *ChatMessageUpdate { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// SetSessionID sets the "session_id" field. +func (_u *ChatMessageUpdate) SetSessionID(v int64) *ChatMessageUpdate { + _u.mutation.SetSessionID(v) + return _u +} + +// SetNillableSessionID sets the "session_id" field if the given value is not nil. +func (_u *ChatMessageUpdate) SetNillableSessionID(v *int64) *ChatMessageUpdate { + if v != nil { + _u.SetSessionID(*v) + } + return _u +} + +// SetUserID sets the "user_id" field. +func (_u *ChatMessageUpdate) SetUserID(v int64) *ChatMessageUpdate { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *ChatMessageUpdate) SetNillableUserID(v *int64) *ChatMessageUpdate { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetRole sets the "role" field. +func (_u *ChatMessageUpdate) SetRole(v string) *ChatMessageUpdate { + _u.mutation.SetRole(v) + return _u +} + +// SetNillableRole sets the "role" field if the given value is not nil. +func (_u *ChatMessageUpdate) SetNillableRole(v *string) *ChatMessageUpdate { + if v != nil { + _u.SetRole(*v) + } + return _u +} + +// SetContent sets the "content" field. +func (_u *ChatMessageUpdate) SetContent(v string) *ChatMessageUpdate { + _u.mutation.SetContent(v) + return _u +} + +// SetNillableContent sets the "content" field if the given value is not nil. +func (_u *ChatMessageUpdate) SetNillableContent(v *string) *ChatMessageUpdate { + if v != nil { + _u.SetContent(*v) + } + return _u +} + +// SetStatus sets the "status" field. +func (_u *ChatMessageUpdate) SetStatus(v string) *ChatMessageUpdate { + _u.mutation.SetStatus(v) + return _u +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_u *ChatMessageUpdate) SetNillableStatus(v *string) *ChatMessageUpdate { + if v != nil { + _u.SetStatus(*v) + } + return _u +} + +// SetModel sets the "model" field. +func (_u *ChatMessageUpdate) SetModel(v string) *ChatMessageUpdate { + _u.mutation.SetModel(v) + return _u +} + +// SetNillableModel sets the "model" field if the given value is not nil. +func (_u *ChatMessageUpdate) SetNillableModel(v *string) *ChatMessageUpdate { + if v != nil { + _u.SetModel(*v) + } + return _u +} + +// ClearModel clears the value of the "model" field. +func (_u *ChatMessageUpdate) ClearModel() *ChatMessageUpdate { + _u.mutation.ClearModel() + return _u +} + +// SetDurationMs sets the "duration_ms" field. +func (_u *ChatMessageUpdate) SetDurationMs(v int) *ChatMessageUpdate { + _u.mutation.ResetDurationMs() + _u.mutation.SetDurationMs(v) + return _u +} + +// SetNillableDurationMs sets the "duration_ms" field if the given value is not nil. +func (_u *ChatMessageUpdate) SetNillableDurationMs(v *int) *ChatMessageUpdate { + if v != nil { + _u.SetDurationMs(*v) + } + return _u +} + +// AddDurationMs adds value to the "duration_ms" field. +func (_u *ChatMessageUpdate) AddDurationMs(v int) *ChatMessageUpdate { + _u.mutation.AddDurationMs(v) + return _u +} + +// ClearDurationMs clears the value of the "duration_ms" field. +func (_u *ChatMessageUpdate) ClearDurationMs() *ChatMessageUpdate { + _u.mutation.ClearDurationMs() + return _u +} + +// SetUsageLogID sets the "usage_log_id" field. +func (_u *ChatMessageUpdate) SetUsageLogID(v int64) *ChatMessageUpdate { + _u.mutation.SetUsageLogID(v) + return _u +} + +// SetNillableUsageLogID sets the "usage_log_id" field if the given value is not nil. +func (_u *ChatMessageUpdate) SetNillableUsageLogID(v *int64) *ChatMessageUpdate { + if v != nil { + _u.SetUsageLogID(*v) + } + return _u +} + +// ClearUsageLogID clears the value of the "usage_log_id" field. +func (_u *ChatMessageUpdate) ClearUsageLogID() *ChatMessageUpdate { + _u.mutation.ClearUsageLogID() + return _u +} + +// SetActualCost sets the "actual_cost" field. +func (_u *ChatMessageUpdate) SetActualCost(v float64) *ChatMessageUpdate { + _u.mutation.ResetActualCost() + _u.mutation.SetActualCost(v) + return _u +} + +// SetNillableActualCost sets the "actual_cost" field if the given value is not nil. +func (_u *ChatMessageUpdate) SetNillableActualCost(v *float64) *ChatMessageUpdate { + if v != nil { + _u.SetActualCost(*v) + } + return _u +} + +// AddActualCost adds value to the "actual_cost" field. +func (_u *ChatMessageUpdate) AddActualCost(v float64) *ChatMessageUpdate { + _u.mutation.AddActualCost(v) + return _u +} + +// ClearActualCost clears the value of the "actual_cost" field. +func (_u *ChatMessageUpdate) ClearActualCost() *ChatMessageUpdate { + _u.mutation.ClearActualCost() + return _u +} + +// SetErrorMessage sets the "error_message" field. +func (_u *ChatMessageUpdate) SetErrorMessage(v string) *ChatMessageUpdate { + _u.mutation.SetErrorMessage(v) + return _u +} + +// SetNillableErrorMessage sets the "error_message" field if the given value is not nil. +func (_u *ChatMessageUpdate) SetNillableErrorMessage(v *string) *ChatMessageUpdate { + if v != nil { + _u.SetErrorMessage(*v) + } + return _u +} + +// ClearErrorMessage clears the value of the "error_message" field. +func (_u *ChatMessageUpdate) ClearErrorMessage() *ChatMessageUpdate { + _u.mutation.ClearErrorMessage() + return _u +} + +// SetSession sets the "session" edge to the ChatSession entity. +func (_u *ChatMessageUpdate) SetSession(v *ChatSession) *ChatMessageUpdate { + return _u.SetSessionID(v.ID) +} + +// SetUser sets the "user" edge to the User entity. +func (_u *ChatMessageUpdate) SetUser(v *User) *ChatMessageUpdate { + return _u.SetUserID(v.ID) +} + +// SetUsageLog sets the "usage_log" edge to the UsageLog entity. +func (_u *ChatMessageUpdate) SetUsageLog(v *UsageLog) *ChatMessageUpdate { + return _u.SetUsageLogID(v.ID) +} + +// Mutation returns the ChatMessageMutation object of the builder. +func (_u *ChatMessageUpdate) Mutation() *ChatMessageMutation { + return _u.mutation +} + +// ClearSession clears the "session" edge to the ChatSession entity. +func (_u *ChatMessageUpdate) ClearSession() *ChatMessageUpdate { + _u.mutation.ClearSession() + return _u +} + +// ClearUser clears the "user" edge to the User entity. +func (_u *ChatMessageUpdate) ClearUser() *ChatMessageUpdate { + _u.mutation.ClearUser() + return _u +} + +// ClearUsageLog clears the "usage_log" edge to the UsageLog entity. +func (_u *ChatMessageUpdate) ClearUsageLog() *ChatMessageUpdate { + _u.mutation.ClearUsageLog() + return _u +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *ChatMessageUpdate) Save(ctx context.Context) (int, error) { + _u.defaults() + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *ChatMessageUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *ChatMessageUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *ChatMessageUpdate) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_u *ChatMessageUpdate) defaults() { + if _, ok := _u.mutation.UpdatedAt(); !ok { + v := chatmessage.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *ChatMessageUpdate) check() error { + if v, ok := _u.mutation.Role(); ok { + if err := chatmessage.RoleValidator(v); err != nil { + return &ValidationError{Name: "role", err: fmt.Errorf(`ent: validator failed for field "ChatMessage.role": %w`, err)} + } + } + if v, ok := _u.mutation.Status(); ok { + if err := chatmessage.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "ChatMessage.status": %w`, err)} + } + } + if v, ok := _u.mutation.Model(); ok { + if err := chatmessage.ModelValidator(v); err != nil { + return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "ChatMessage.model": %w`, err)} + } + } + if _u.mutation.SessionCleared() && len(_u.mutation.SessionIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "ChatMessage.session"`) + } + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "ChatMessage.user"`) + } + return nil +} + +func (_u *ChatMessageUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(chatmessage.Table, chatmessage.Columns, sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64)) + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(chatmessage.FieldUpdatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.Role(); ok { + _spec.SetField(chatmessage.FieldRole, field.TypeString, value) + } + if value, ok := _u.mutation.Content(); ok { + _spec.SetField(chatmessage.FieldContent, field.TypeString, value) + } + if value, ok := _u.mutation.Status(); ok { + _spec.SetField(chatmessage.FieldStatus, field.TypeString, value) + } + if value, ok := _u.mutation.Model(); ok { + _spec.SetField(chatmessage.FieldModel, field.TypeString, value) + } + if _u.mutation.ModelCleared() { + _spec.ClearField(chatmessage.FieldModel, field.TypeString) + } + if value, ok := _u.mutation.DurationMs(); ok { + _spec.SetField(chatmessage.FieldDurationMs, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedDurationMs(); ok { + _spec.AddField(chatmessage.FieldDurationMs, field.TypeInt, value) + } + if _u.mutation.DurationMsCleared() { + _spec.ClearField(chatmessage.FieldDurationMs, field.TypeInt) + } + if value, ok := _u.mutation.ActualCost(); ok { + _spec.SetField(chatmessage.FieldActualCost, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedActualCost(); ok { + _spec.AddField(chatmessage.FieldActualCost, field.TypeFloat64, value) + } + if _u.mutation.ActualCostCleared() { + _spec.ClearField(chatmessage.FieldActualCost, field.TypeFloat64) + } + if value, ok := _u.mutation.ErrorMessage(); ok { + _spec.SetField(chatmessage.FieldErrorMessage, field.TypeString, value) + } + if _u.mutation.ErrorMessageCleared() { + _spec.ClearField(chatmessage.FieldErrorMessage, field.TypeString) + } + if _u.mutation.SessionCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.SessionTable, + Columns: []string{chatmessage.SessionColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.SessionIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.SessionTable, + Columns: []string{chatmessage.SessionColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.UserCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.UserTable, + Columns: []string{chatmessage.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.UserTable, + Columns: []string{chatmessage.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.UsageLogCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.UsageLogTable, + Columns: []string{chatmessage.UsageLogColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(usagelog.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.UsageLogIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.UsageLogTable, + Columns: []string{chatmessage.UsageLogColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(usagelog.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{chatmessage.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// ChatMessageUpdateOne is the builder for updating a single ChatMessage entity. +type ChatMessageUpdateOne struct { + config + fields []string + hooks []Hook + mutation *ChatMessageMutation +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *ChatMessageUpdateOne) SetUpdatedAt(v time.Time) *ChatMessageUpdateOne { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// SetSessionID sets the "session_id" field. +func (_u *ChatMessageUpdateOne) SetSessionID(v int64) *ChatMessageUpdateOne { + _u.mutation.SetSessionID(v) + return _u +} + +// SetNillableSessionID sets the "session_id" field if the given value is not nil. +func (_u *ChatMessageUpdateOne) SetNillableSessionID(v *int64) *ChatMessageUpdateOne { + if v != nil { + _u.SetSessionID(*v) + } + return _u +} + +// SetUserID sets the "user_id" field. +func (_u *ChatMessageUpdateOne) SetUserID(v int64) *ChatMessageUpdateOne { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *ChatMessageUpdateOne) SetNillableUserID(v *int64) *ChatMessageUpdateOne { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetRole sets the "role" field. +func (_u *ChatMessageUpdateOne) SetRole(v string) *ChatMessageUpdateOne { + _u.mutation.SetRole(v) + return _u +} + +// SetNillableRole sets the "role" field if the given value is not nil. +func (_u *ChatMessageUpdateOne) SetNillableRole(v *string) *ChatMessageUpdateOne { + if v != nil { + _u.SetRole(*v) + } + return _u +} + +// SetContent sets the "content" field. +func (_u *ChatMessageUpdateOne) SetContent(v string) *ChatMessageUpdateOne { + _u.mutation.SetContent(v) + return _u +} + +// SetNillableContent sets the "content" field if the given value is not nil. +func (_u *ChatMessageUpdateOne) SetNillableContent(v *string) *ChatMessageUpdateOne { + if v != nil { + _u.SetContent(*v) + } + return _u +} + +// SetStatus sets the "status" field. +func (_u *ChatMessageUpdateOne) SetStatus(v string) *ChatMessageUpdateOne { + _u.mutation.SetStatus(v) + return _u +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_u *ChatMessageUpdateOne) SetNillableStatus(v *string) *ChatMessageUpdateOne { + if v != nil { + _u.SetStatus(*v) + } + return _u +} + +// SetModel sets the "model" field. +func (_u *ChatMessageUpdateOne) SetModel(v string) *ChatMessageUpdateOne { + _u.mutation.SetModel(v) + return _u +} + +// SetNillableModel sets the "model" field if the given value is not nil. +func (_u *ChatMessageUpdateOne) SetNillableModel(v *string) *ChatMessageUpdateOne { + if v != nil { + _u.SetModel(*v) + } + return _u +} + +// ClearModel clears the value of the "model" field. +func (_u *ChatMessageUpdateOne) ClearModel() *ChatMessageUpdateOne { + _u.mutation.ClearModel() + return _u +} + +// SetDurationMs sets the "duration_ms" field. +func (_u *ChatMessageUpdateOne) SetDurationMs(v int) *ChatMessageUpdateOne { + _u.mutation.ResetDurationMs() + _u.mutation.SetDurationMs(v) + return _u +} + +// SetNillableDurationMs sets the "duration_ms" field if the given value is not nil. +func (_u *ChatMessageUpdateOne) SetNillableDurationMs(v *int) *ChatMessageUpdateOne { + if v != nil { + _u.SetDurationMs(*v) + } + return _u +} + +// AddDurationMs adds value to the "duration_ms" field. +func (_u *ChatMessageUpdateOne) AddDurationMs(v int) *ChatMessageUpdateOne { + _u.mutation.AddDurationMs(v) + return _u +} + +// ClearDurationMs clears the value of the "duration_ms" field. +func (_u *ChatMessageUpdateOne) ClearDurationMs() *ChatMessageUpdateOne { + _u.mutation.ClearDurationMs() + return _u +} + +// SetUsageLogID sets the "usage_log_id" field. +func (_u *ChatMessageUpdateOne) SetUsageLogID(v int64) *ChatMessageUpdateOne { + _u.mutation.SetUsageLogID(v) + return _u +} + +// SetNillableUsageLogID sets the "usage_log_id" field if the given value is not nil. +func (_u *ChatMessageUpdateOne) SetNillableUsageLogID(v *int64) *ChatMessageUpdateOne { + if v != nil { + _u.SetUsageLogID(*v) + } + return _u +} + +// ClearUsageLogID clears the value of the "usage_log_id" field. +func (_u *ChatMessageUpdateOne) ClearUsageLogID() *ChatMessageUpdateOne { + _u.mutation.ClearUsageLogID() + return _u +} + +// SetActualCost sets the "actual_cost" field. +func (_u *ChatMessageUpdateOne) SetActualCost(v float64) *ChatMessageUpdateOne { + _u.mutation.ResetActualCost() + _u.mutation.SetActualCost(v) + return _u +} + +// SetNillableActualCost sets the "actual_cost" field if the given value is not nil. +func (_u *ChatMessageUpdateOne) SetNillableActualCost(v *float64) *ChatMessageUpdateOne { + if v != nil { + _u.SetActualCost(*v) + } + return _u +} + +// AddActualCost adds value to the "actual_cost" field. +func (_u *ChatMessageUpdateOne) AddActualCost(v float64) *ChatMessageUpdateOne { + _u.mutation.AddActualCost(v) + return _u +} + +// ClearActualCost clears the value of the "actual_cost" field. +func (_u *ChatMessageUpdateOne) ClearActualCost() *ChatMessageUpdateOne { + _u.mutation.ClearActualCost() + return _u +} + +// SetErrorMessage sets the "error_message" field. +func (_u *ChatMessageUpdateOne) SetErrorMessage(v string) *ChatMessageUpdateOne { + _u.mutation.SetErrorMessage(v) + return _u +} + +// SetNillableErrorMessage sets the "error_message" field if the given value is not nil. +func (_u *ChatMessageUpdateOne) SetNillableErrorMessage(v *string) *ChatMessageUpdateOne { + if v != nil { + _u.SetErrorMessage(*v) + } + return _u +} + +// ClearErrorMessage clears the value of the "error_message" field. +func (_u *ChatMessageUpdateOne) ClearErrorMessage() *ChatMessageUpdateOne { + _u.mutation.ClearErrorMessage() + return _u +} + +// SetSession sets the "session" edge to the ChatSession entity. +func (_u *ChatMessageUpdateOne) SetSession(v *ChatSession) *ChatMessageUpdateOne { + return _u.SetSessionID(v.ID) +} + +// SetUser sets the "user" edge to the User entity. +func (_u *ChatMessageUpdateOne) SetUser(v *User) *ChatMessageUpdateOne { + return _u.SetUserID(v.ID) +} + +// SetUsageLog sets the "usage_log" edge to the UsageLog entity. +func (_u *ChatMessageUpdateOne) SetUsageLog(v *UsageLog) *ChatMessageUpdateOne { + return _u.SetUsageLogID(v.ID) +} + +// Mutation returns the ChatMessageMutation object of the builder. +func (_u *ChatMessageUpdateOne) Mutation() *ChatMessageMutation { + return _u.mutation +} + +// ClearSession clears the "session" edge to the ChatSession entity. +func (_u *ChatMessageUpdateOne) ClearSession() *ChatMessageUpdateOne { + _u.mutation.ClearSession() + return _u +} + +// ClearUser clears the "user" edge to the User entity. +func (_u *ChatMessageUpdateOne) ClearUser() *ChatMessageUpdateOne { + _u.mutation.ClearUser() + return _u +} + +// ClearUsageLog clears the "usage_log" edge to the UsageLog entity. +func (_u *ChatMessageUpdateOne) ClearUsageLog() *ChatMessageUpdateOne { + _u.mutation.ClearUsageLog() + return _u +} + +// Where appends a list predicates to the ChatMessageUpdate builder. +func (_u *ChatMessageUpdateOne) Where(ps ...predicate.ChatMessage) *ChatMessageUpdateOne { + _u.mutation.Where(ps...) + return _u +} + +// Select allows selecting one or more fields (columns) of the returned entity. +// The default is selecting all fields defined in the entity schema. +func (_u *ChatMessageUpdateOne) Select(field string, fields ...string) *ChatMessageUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated ChatMessage entity. +func (_u *ChatMessageUpdateOne) Save(ctx context.Context) (*ChatMessage, error) { + _u.defaults() + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *ChatMessageUpdateOne) SaveX(ctx context.Context) *ChatMessage { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *ChatMessageUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *ChatMessageUpdateOne) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_u *ChatMessageUpdateOne) defaults() { + if _, ok := _u.mutation.UpdatedAt(); !ok { + v := chatmessage.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } +} + +// check runs all checks and user-defined validators on the builder. +func (_u *ChatMessageUpdateOne) check() error { + if v, ok := _u.mutation.Role(); ok { + if err := chatmessage.RoleValidator(v); err != nil { + return &ValidationError{Name: "role", err: fmt.Errorf(`ent: validator failed for field "ChatMessage.role": %w`, err)} + } + } + if v, ok := _u.mutation.Status(); ok { + if err := chatmessage.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "ChatMessage.status": %w`, err)} + } + } + if v, ok := _u.mutation.Model(); ok { + if err := chatmessage.ModelValidator(v); err != nil { + return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "ChatMessage.model": %w`, err)} + } + } + if _u.mutation.SessionCleared() && len(_u.mutation.SessionIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "ChatMessage.session"`) + } + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "ChatMessage.user"`) + } + return nil +} + +func (_u *ChatMessageUpdateOne) sqlSave(ctx context.Context) (_node *ChatMessage, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(chatmessage.Table, chatmessage.Columns, sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "ChatMessage.id" for update`)} + } + _spec.Node.ID.Value = id + if fields := _u.fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, chatmessage.FieldID) + for _, f := range fields { + if !chatmessage.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + if f != chatmessage.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, f) + } + } + } + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(chatmessage.FieldUpdatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.Role(); ok { + _spec.SetField(chatmessage.FieldRole, field.TypeString, value) + } + if value, ok := _u.mutation.Content(); ok { + _spec.SetField(chatmessage.FieldContent, field.TypeString, value) + } + if value, ok := _u.mutation.Status(); ok { + _spec.SetField(chatmessage.FieldStatus, field.TypeString, value) + } + if value, ok := _u.mutation.Model(); ok { + _spec.SetField(chatmessage.FieldModel, field.TypeString, value) + } + if _u.mutation.ModelCleared() { + _spec.ClearField(chatmessage.FieldModel, field.TypeString) + } + if value, ok := _u.mutation.DurationMs(); ok { + _spec.SetField(chatmessage.FieldDurationMs, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedDurationMs(); ok { + _spec.AddField(chatmessage.FieldDurationMs, field.TypeInt, value) + } + if _u.mutation.DurationMsCleared() { + _spec.ClearField(chatmessage.FieldDurationMs, field.TypeInt) + } + if value, ok := _u.mutation.ActualCost(); ok { + _spec.SetField(chatmessage.FieldActualCost, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedActualCost(); ok { + _spec.AddField(chatmessage.FieldActualCost, field.TypeFloat64, value) + } + if _u.mutation.ActualCostCleared() { + _spec.ClearField(chatmessage.FieldActualCost, field.TypeFloat64) + } + if value, ok := _u.mutation.ErrorMessage(); ok { + _spec.SetField(chatmessage.FieldErrorMessage, field.TypeString, value) + } + if _u.mutation.ErrorMessageCleared() { + _spec.ClearField(chatmessage.FieldErrorMessage, field.TypeString) + } + if _u.mutation.SessionCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.SessionTable, + Columns: []string{chatmessage.SessionColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.SessionIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.SessionTable, + Columns: []string{chatmessage.SessionColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.UserCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.UserTable, + Columns: []string{chatmessage.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.UserTable, + Columns: []string{chatmessage.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.UsageLogCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.UsageLogTable, + Columns: []string{chatmessage.UsageLogColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(usagelog.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.UsageLogIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatmessage.UsageLogTable, + Columns: []string{chatmessage.UsageLogColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(usagelog.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + _node = &ChatMessage{config: _u.config} + _spec.Assign = _node.assignValues + _spec.ScanValues = _node.scanValues + if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{chatmessage.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + _u.mutation.done = true + return _node, nil +} diff --git a/backend/ent/chatsession.go b/backend/ent/chatsession.go new file mode 100644 index 00000000000..32d7298636f --- /dev/null +++ b/backend/ent/chatsession.go @@ -0,0 +1,261 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "fmt" + "strings" + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// ChatSession is the model entity for the ChatSession schema. +type ChatSession struct { + config `json:"-"` + // ID of the ent. + ID int64 `json:"id,omitempty"` + // CreatedAt holds the value of the "created_at" field. + CreatedAt time.Time `json:"created_at,omitempty"` + // UpdatedAt holds the value of the "updated_at" field. + UpdatedAt time.Time `json:"updated_at,omitempty"` + // DeletedAt holds the value of the "deleted_at" field. + DeletedAt *time.Time `json:"deleted_at,omitempty"` + // UserID holds the value of the "user_id" field. + UserID int64 `json:"user_id,omitempty"` + // APIKeyID holds the value of the "api_key_id" field. + APIKeyID int64 `json:"api_key_id,omitempty"` + // Title holds the value of the "title" field. + Title string `json:"title,omitempty"` + // Model holds the value of the "model" field. + Model string `json:"model,omitempty"` + // Status holds the value of the "status" field. + Status string `json:"status,omitempty"` + // ExpiresAt holds the value of the "expires_at" field. + ExpiresAt time.Time `json:"expires_at,omitempty"` + // Edges holds the relations/edges for other nodes in the graph. + // The values are being populated by the ChatSessionQuery when eager-loading is set. + Edges ChatSessionEdges `json:"edges"` + selectValues sql.SelectValues +} + +// ChatSessionEdges holds the relations/edges for other nodes in the graph. +type ChatSessionEdges struct { + // User holds the value of the user edge. + User *User `json:"user,omitempty"` + // APIKey holds the value of the api_key edge. + APIKey *APIKey `json:"api_key,omitempty"` + // Messages holds the value of the messages edge. + Messages []*ChatMessage `json:"messages,omitempty"` + // loadedTypes holds the information for reporting if a + // type was loaded (or requested) in eager-loading or not. + loadedTypes [3]bool +} + +// UserOrErr returns the User value or an error if the edge +// was not loaded in eager-loading, or loaded but was not found. +func (e ChatSessionEdges) UserOrErr() (*User, error) { + if e.User != nil { + return e.User, nil + } else if e.loadedTypes[0] { + return nil, &NotFoundError{label: user.Label} + } + return nil, &NotLoadedError{edge: "user"} +} + +// APIKeyOrErr returns the APIKey value or an error if the edge +// was not loaded in eager-loading, or loaded but was not found. +func (e ChatSessionEdges) APIKeyOrErr() (*APIKey, error) { + if e.APIKey != nil { + return e.APIKey, nil + } else if e.loadedTypes[1] { + return nil, &NotFoundError{label: apikey.Label} + } + return nil, &NotLoadedError{edge: "api_key"} +} + +// MessagesOrErr returns the Messages value or an error if the edge +// was not loaded in eager-loading. +func (e ChatSessionEdges) MessagesOrErr() ([]*ChatMessage, error) { + if e.loadedTypes[2] { + return e.Messages, nil + } + return nil, &NotLoadedError{edge: "messages"} +} + +// scanValues returns the types for scanning values from sql.Rows. +func (*ChatSession) scanValues(columns []string) ([]any, error) { + values := make([]any, len(columns)) + for i := range columns { + switch columns[i] { + case chatsession.FieldID, chatsession.FieldUserID, chatsession.FieldAPIKeyID: + values[i] = new(sql.NullInt64) + case chatsession.FieldTitle, chatsession.FieldModel, chatsession.FieldStatus: + values[i] = new(sql.NullString) + case chatsession.FieldCreatedAt, chatsession.FieldUpdatedAt, chatsession.FieldDeletedAt, chatsession.FieldExpiresAt: + values[i] = new(sql.NullTime) + default: + values[i] = new(sql.UnknownType) + } + } + return values, nil +} + +// assignValues assigns the values that were returned from sql.Rows (after scanning) +// to the ChatSession fields. +func (_m *ChatSession) assignValues(columns []string, values []any) error { + if m, n := len(values), len(columns); m < n { + return fmt.Errorf("mismatch number of scan values: %d != %d", m, n) + } + for i := range columns { + switch columns[i] { + case chatsession.FieldID: + value, ok := values[i].(*sql.NullInt64) + if !ok { + return fmt.Errorf("unexpected type %T for field id", value) + } + _m.ID = int64(value.Int64) + case chatsession.FieldCreatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field created_at", values[i]) + } else if value.Valid { + _m.CreatedAt = value.Time + } + case chatsession.FieldUpdatedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field updated_at", values[i]) + } else if value.Valid { + _m.UpdatedAt = value.Time + } + case chatsession.FieldDeletedAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field deleted_at", values[i]) + } else if value.Valid { + _m.DeletedAt = new(time.Time) + *_m.DeletedAt = value.Time + } + case chatsession.FieldUserID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field user_id", values[i]) + } else if value.Valid { + _m.UserID = value.Int64 + } + case chatsession.FieldAPIKeyID: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field api_key_id", values[i]) + } else if value.Valid { + _m.APIKeyID = value.Int64 + } + case chatsession.FieldTitle: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field title", values[i]) + } else if value.Valid { + _m.Title = value.String + } + case chatsession.FieldModel: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field model", values[i]) + } else if value.Valid { + _m.Model = value.String + } + case chatsession.FieldStatus: + if value, ok := values[i].(*sql.NullString); !ok { + return fmt.Errorf("unexpected type %T for field status", values[i]) + } else if value.Valid { + _m.Status = value.String + } + case chatsession.FieldExpiresAt: + if value, ok := values[i].(*sql.NullTime); !ok { + return fmt.Errorf("unexpected type %T for field expires_at", values[i]) + } else if value.Valid { + _m.ExpiresAt = value.Time + } + default: + _m.selectValues.Set(columns[i], values[i]) + } + } + return nil +} + +// Value returns the ent.Value that was dynamically selected and assigned to the ChatSession. +// This includes values selected through modifiers, order, etc. +func (_m *ChatSession) Value(name string) (ent.Value, error) { + return _m.selectValues.Get(name) +} + +// QueryUser queries the "user" edge of the ChatSession entity. +func (_m *ChatSession) QueryUser() *UserQuery { + return NewChatSessionClient(_m.config).QueryUser(_m) +} + +// QueryAPIKey queries the "api_key" edge of the ChatSession entity. +func (_m *ChatSession) QueryAPIKey() *APIKeyQuery { + return NewChatSessionClient(_m.config).QueryAPIKey(_m) +} + +// QueryMessages queries the "messages" edge of the ChatSession entity. +func (_m *ChatSession) QueryMessages() *ChatMessageQuery { + return NewChatSessionClient(_m.config).QueryMessages(_m) +} + +// Update returns a builder for updating this ChatSession. +// Note that you need to call ChatSession.Unwrap() before calling this method if this ChatSession +// was returned from a transaction, and the transaction was committed or rolled back. +func (_m *ChatSession) Update() *ChatSessionUpdateOne { + return NewChatSessionClient(_m.config).UpdateOne(_m) +} + +// Unwrap unwraps the ChatSession entity that was returned from a transaction after it was closed, +// so that all future queries will be executed through the driver which created the transaction. +func (_m *ChatSession) Unwrap() *ChatSession { + _tx, ok := _m.config.driver.(*txDriver) + if !ok { + panic("ent: ChatSession is not a transactional entity") + } + _m.config.driver = _tx.drv + return _m +} + +// String implements the fmt.Stringer. +func (_m *ChatSession) String() string { + var builder strings.Builder + builder.WriteString("ChatSession(") + builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID)) + builder.WriteString("created_at=") + builder.WriteString(_m.CreatedAt.Format(time.ANSIC)) + builder.WriteString(", ") + builder.WriteString("updated_at=") + builder.WriteString(_m.UpdatedAt.Format(time.ANSIC)) + builder.WriteString(", ") + if v := _m.DeletedAt; v != nil { + builder.WriteString("deleted_at=") + builder.WriteString(v.Format(time.ANSIC)) + } + builder.WriteString(", ") + builder.WriteString("user_id=") + builder.WriteString(fmt.Sprintf("%v", _m.UserID)) + builder.WriteString(", ") + builder.WriteString("api_key_id=") + builder.WriteString(fmt.Sprintf("%v", _m.APIKeyID)) + builder.WriteString(", ") + builder.WriteString("title=") + builder.WriteString(_m.Title) + builder.WriteString(", ") + builder.WriteString("model=") + builder.WriteString(_m.Model) + builder.WriteString(", ") + builder.WriteString("status=") + builder.WriteString(_m.Status) + builder.WriteString(", ") + builder.WriteString("expires_at=") + builder.WriteString(_m.ExpiresAt.Format(time.ANSIC)) + builder.WriteByte(')') + return builder.String() +} + +// ChatSessions is a parsable slice of ChatSession. +type ChatSessions []*ChatSession diff --git a/backend/ent/chatsession/chatsession.go b/backend/ent/chatsession/chatsession.go new file mode 100644 index 00000000000..e089d68f6e2 --- /dev/null +++ b/backend/ent/chatsession/chatsession.go @@ -0,0 +1,217 @@ +// Code generated by ent, DO NOT EDIT. + +package chatsession + +import ( + "time" + + "entgo.io/ent" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" +) + +const ( + // Label holds the string label denoting the chatsession type in the database. + Label = "chat_session" + // FieldID holds the string denoting the id field in the database. + FieldID = "id" + // FieldCreatedAt holds the string denoting the created_at field in the database. + FieldCreatedAt = "created_at" + // FieldUpdatedAt holds the string denoting the updated_at field in the database. + FieldUpdatedAt = "updated_at" + // FieldDeletedAt holds the string denoting the deleted_at field in the database. + FieldDeletedAt = "deleted_at" + // FieldUserID holds the string denoting the user_id field in the database. + FieldUserID = "user_id" + // FieldAPIKeyID holds the string denoting the api_key_id field in the database. + FieldAPIKeyID = "api_key_id" + // FieldTitle holds the string denoting the title field in the database. + FieldTitle = "title" + // FieldModel holds the string denoting the model field in the database. + FieldModel = "model" + // FieldStatus holds the string denoting the status field in the database. + FieldStatus = "status" + // FieldExpiresAt holds the string denoting the expires_at field in the database. + FieldExpiresAt = "expires_at" + // EdgeUser holds the string denoting the user edge name in mutations. + EdgeUser = "user" + // EdgeAPIKey holds the string denoting the api_key edge name in mutations. + EdgeAPIKey = "api_key" + // EdgeMessages holds the string denoting the messages edge name in mutations. + EdgeMessages = "messages" + // Table holds the table name of the chatsession in the database. + Table = "chat_sessions" + // UserTable is the table that holds the user relation/edge. + UserTable = "chat_sessions" + // UserInverseTable is the table name for the User entity. + // It exists in this package in order to avoid circular dependency with the "user" package. + UserInverseTable = "users" + // UserColumn is the table column denoting the user relation/edge. + UserColumn = "user_id" + // APIKeyTable is the table that holds the api_key relation/edge. + APIKeyTable = "chat_sessions" + // APIKeyInverseTable is the table name for the APIKey entity. + // It exists in this package in order to avoid circular dependency with the "apikey" package. + APIKeyInverseTable = "api_keys" + // APIKeyColumn is the table column denoting the api_key relation/edge. + APIKeyColumn = "api_key_id" + // MessagesTable is the table that holds the messages relation/edge. + MessagesTable = "chat_messages" + // MessagesInverseTable is the table name for the ChatMessage entity. + // It exists in this package in order to avoid circular dependency with the "chatmessage" package. + MessagesInverseTable = "chat_messages" + // MessagesColumn is the table column denoting the messages relation/edge. + MessagesColumn = "session_id" +) + +// Columns holds all SQL columns for chatsession fields. +var Columns = []string{ + FieldID, + FieldCreatedAt, + FieldUpdatedAt, + FieldDeletedAt, + FieldUserID, + FieldAPIKeyID, + FieldTitle, + FieldModel, + FieldStatus, + FieldExpiresAt, +} + +// ValidColumn reports if the column name is valid (part of the table columns). +func ValidColumn(column string) bool { + for i := range Columns { + if column == Columns[i] { + return true + } + } + return false +} + +// Note that the variables below are initialized by the runtime +// package on the initialization of the application. Therefore, +// it should be imported in the main as follows: +// +// import _ "github.com/Wei-Shaw/sub2api/ent/runtime" +var ( + Hooks [1]ent.Hook + Interceptors [1]ent.Interceptor + // DefaultCreatedAt holds the default value on creation for the "created_at" field. + DefaultCreatedAt func() time.Time + // DefaultUpdatedAt holds the default value on creation for the "updated_at" field. + DefaultUpdatedAt func() time.Time + // UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field. + UpdateDefaultUpdatedAt func() time.Time + // TitleValidator is a validator for the "title" field. It is called by the builders before save. + TitleValidator func(string) error + // ModelValidator is a validator for the "model" field. It is called by the builders before save. + ModelValidator func(string) error + // DefaultStatus holds the default value on creation for the "status" field. + DefaultStatus string + // StatusValidator is a validator for the "status" field. It is called by the builders before save. + StatusValidator func(string) error + // DefaultExpiresAt holds the default value on creation for the "expires_at" field. + DefaultExpiresAt func() time.Time +) + +// OrderOption defines the ordering options for the ChatSession queries. +type OrderOption func(*sql.Selector) + +// ByID orders the results by the id field. +func ByID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldID, opts...).ToFunc() +} + +// ByCreatedAt orders the results by the created_at field. +func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldCreatedAt, opts...).ToFunc() +} + +// ByUpdatedAt orders the results by the updated_at field. +func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc() +} + +// ByDeletedAt orders the results by the deleted_at field. +func ByDeletedAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldDeletedAt, opts...).ToFunc() +} + +// ByUserID orders the results by the user_id field. +func ByUserID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldUserID, opts...).ToFunc() +} + +// ByAPIKeyID orders the results by the api_key_id field. +func ByAPIKeyID(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAPIKeyID, opts...).ToFunc() +} + +// ByTitle orders the results by the title field. +func ByTitle(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldTitle, opts...).ToFunc() +} + +// ByModel orders the results by the model field. +func ByModel(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldModel, opts...).ToFunc() +} + +// ByStatus orders the results by the status field. +func ByStatus(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldStatus, opts...).ToFunc() +} + +// ByExpiresAt orders the results by the expires_at field. +func ByExpiresAt(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldExpiresAt, opts...).ToFunc() +} + +// ByUserField orders the results by user field. +func ByUserField(field string, opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newUserStep(), sql.OrderByField(field, opts...)) + } +} + +// ByAPIKeyField orders the results by api_key field. +func ByAPIKeyField(field string, opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newAPIKeyStep(), sql.OrderByField(field, opts...)) + } +} + +// ByMessagesCount orders the results by messages count. +func ByMessagesCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newMessagesStep(), opts...) + } +} + +// ByMessages orders the results by messages terms. +func ByMessages(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newMessagesStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} +func newUserStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(UserInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, UserTable, UserColumn), + ) +} +func newAPIKeyStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(APIKeyInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, APIKeyTable, APIKeyColumn), + ) +} +func newMessagesStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(MessagesInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, MessagesTable, MessagesColumn), + ) +} diff --git a/backend/ent/chatsession/where.go b/backend/ent/chatsession/where.go new file mode 100644 index 00000000000..30bbb33b58c --- /dev/null +++ b/backend/ent/chatsession/where.go @@ -0,0 +1,590 @@ +// Code generated by ent, DO NOT EDIT. + +package chatsession + +import ( + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "github.com/Wei-Shaw/sub2api/ent/predicate" +) + +// ID filters vertices based on their ID field. +func ID(id int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldID, id)) +} + +// IDEQ applies the EQ predicate on the ID field. +func IDEQ(id int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldID, id)) +} + +// IDNEQ applies the NEQ predicate on the ID field. +func IDNEQ(id int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNEQ(FieldID, id)) +} + +// IDIn applies the In predicate on the ID field. +func IDIn(ids ...int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldIn(FieldID, ids...)) +} + +// IDNotIn applies the NotIn predicate on the ID field. +func IDNotIn(ids ...int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotIn(FieldID, ids...)) +} + +// IDGT applies the GT predicate on the ID field. +func IDGT(id int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGT(FieldID, id)) +} + +// IDGTE applies the GTE predicate on the ID field. +func IDGTE(id int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGTE(FieldID, id)) +} + +// IDLT applies the LT predicate on the ID field. +func IDLT(id int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLT(FieldID, id)) +} + +// IDLTE applies the LTE predicate on the ID field. +func IDLTE(id int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLTE(FieldID, id)) +} + +// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ. +func CreatedAt(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldCreatedAt, v)) +} + +// UpdatedAt applies equality check predicate on the "updated_at" field. It's identical to UpdatedAtEQ. +func UpdatedAt(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// DeletedAt applies equality check predicate on the "deleted_at" field. It's identical to DeletedAtEQ. +func DeletedAt(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldDeletedAt, v)) +} + +// UserID applies equality check predicate on the "user_id" field. It's identical to UserIDEQ. +func UserID(v int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldUserID, v)) +} + +// APIKeyID applies equality check predicate on the "api_key_id" field. It's identical to APIKeyIDEQ. +func APIKeyID(v int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldAPIKeyID, v)) +} + +// Title applies equality check predicate on the "title" field. It's identical to TitleEQ. +func Title(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldTitle, v)) +} + +// Model applies equality check predicate on the "model" field. It's identical to ModelEQ. +func Model(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldModel, v)) +} + +// Status applies equality check predicate on the "status" field. It's identical to StatusEQ. +func Status(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldStatus, v)) +} + +// ExpiresAt applies equality check predicate on the "expires_at" field. It's identical to ExpiresAtEQ. +func ExpiresAt(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldExpiresAt, v)) +} + +// CreatedAtEQ applies the EQ predicate on the "created_at" field. +func CreatedAtEQ(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldCreatedAt, v)) +} + +// CreatedAtNEQ applies the NEQ predicate on the "created_at" field. +func CreatedAtNEQ(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNEQ(FieldCreatedAt, v)) +} + +// CreatedAtIn applies the In predicate on the "created_at" field. +func CreatedAtIn(vs ...time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldIn(FieldCreatedAt, vs...)) +} + +// CreatedAtNotIn applies the NotIn predicate on the "created_at" field. +func CreatedAtNotIn(vs ...time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotIn(FieldCreatedAt, vs...)) +} + +// CreatedAtGT applies the GT predicate on the "created_at" field. +func CreatedAtGT(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGT(FieldCreatedAt, v)) +} + +// CreatedAtGTE applies the GTE predicate on the "created_at" field. +func CreatedAtGTE(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGTE(FieldCreatedAt, v)) +} + +// CreatedAtLT applies the LT predicate on the "created_at" field. +func CreatedAtLT(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLT(FieldCreatedAt, v)) +} + +// CreatedAtLTE applies the LTE predicate on the "created_at" field. +func CreatedAtLTE(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLTE(FieldCreatedAt, v)) +} + +// UpdatedAtEQ applies the EQ predicate on the "updated_at" field. +func UpdatedAtEQ(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field. +func UpdatedAtNEQ(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNEQ(FieldUpdatedAt, v)) +} + +// UpdatedAtIn applies the In predicate on the "updated_at" field. +func UpdatedAtIn(vs ...time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field. +func UpdatedAtNotIn(vs ...time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotIn(FieldUpdatedAt, vs...)) +} + +// UpdatedAtGT applies the GT predicate on the "updated_at" field. +func UpdatedAtGT(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGT(FieldUpdatedAt, v)) +} + +// UpdatedAtGTE applies the GTE predicate on the "updated_at" field. +func UpdatedAtGTE(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGTE(FieldUpdatedAt, v)) +} + +// UpdatedAtLT applies the LT predicate on the "updated_at" field. +func UpdatedAtLT(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLT(FieldUpdatedAt, v)) +} + +// UpdatedAtLTE applies the LTE predicate on the "updated_at" field. +func UpdatedAtLTE(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLTE(FieldUpdatedAt, v)) +} + +// DeletedAtEQ applies the EQ predicate on the "deleted_at" field. +func DeletedAtEQ(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldDeletedAt, v)) +} + +// DeletedAtNEQ applies the NEQ predicate on the "deleted_at" field. +func DeletedAtNEQ(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNEQ(FieldDeletedAt, v)) +} + +// DeletedAtIn applies the In predicate on the "deleted_at" field. +func DeletedAtIn(vs ...time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldIn(FieldDeletedAt, vs...)) +} + +// DeletedAtNotIn applies the NotIn predicate on the "deleted_at" field. +func DeletedAtNotIn(vs ...time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotIn(FieldDeletedAt, vs...)) +} + +// DeletedAtGT applies the GT predicate on the "deleted_at" field. +func DeletedAtGT(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGT(FieldDeletedAt, v)) +} + +// DeletedAtGTE applies the GTE predicate on the "deleted_at" field. +func DeletedAtGTE(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGTE(FieldDeletedAt, v)) +} + +// DeletedAtLT applies the LT predicate on the "deleted_at" field. +func DeletedAtLT(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLT(FieldDeletedAt, v)) +} + +// DeletedAtLTE applies the LTE predicate on the "deleted_at" field. +func DeletedAtLTE(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLTE(FieldDeletedAt, v)) +} + +// DeletedAtIsNil applies the IsNil predicate on the "deleted_at" field. +func DeletedAtIsNil() predicate.ChatSession { + return predicate.ChatSession(sql.FieldIsNull(FieldDeletedAt)) +} + +// DeletedAtNotNil applies the NotNil predicate on the "deleted_at" field. +func DeletedAtNotNil() predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotNull(FieldDeletedAt)) +} + +// UserIDEQ applies the EQ predicate on the "user_id" field. +func UserIDEQ(v int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldUserID, v)) +} + +// UserIDNEQ applies the NEQ predicate on the "user_id" field. +func UserIDNEQ(v int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNEQ(FieldUserID, v)) +} + +// UserIDIn applies the In predicate on the "user_id" field. +func UserIDIn(vs ...int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldIn(FieldUserID, vs...)) +} + +// UserIDNotIn applies the NotIn predicate on the "user_id" field. +func UserIDNotIn(vs ...int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotIn(FieldUserID, vs...)) +} + +// APIKeyIDEQ applies the EQ predicate on the "api_key_id" field. +func APIKeyIDEQ(v int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldAPIKeyID, v)) +} + +// APIKeyIDNEQ applies the NEQ predicate on the "api_key_id" field. +func APIKeyIDNEQ(v int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNEQ(FieldAPIKeyID, v)) +} + +// APIKeyIDIn applies the In predicate on the "api_key_id" field. +func APIKeyIDIn(vs ...int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldIn(FieldAPIKeyID, vs...)) +} + +// APIKeyIDNotIn applies the NotIn predicate on the "api_key_id" field. +func APIKeyIDNotIn(vs ...int64) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotIn(FieldAPIKeyID, vs...)) +} + +// TitleEQ applies the EQ predicate on the "title" field. +func TitleEQ(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldTitle, v)) +} + +// TitleNEQ applies the NEQ predicate on the "title" field. +func TitleNEQ(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNEQ(FieldTitle, v)) +} + +// TitleIn applies the In predicate on the "title" field. +func TitleIn(vs ...string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldIn(FieldTitle, vs...)) +} + +// TitleNotIn applies the NotIn predicate on the "title" field. +func TitleNotIn(vs ...string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotIn(FieldTitle, vs...)) +} + +// TitleGT applies the GT predicate on the "title" field. +func TitleGT(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGT(FieldTitle, v)) +} + +// TitleGTE applies the GTE predicate on the "title" field. +func TitleGTE(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGTE(FieldTitle, v)) +} + +// TitleLT applies the LT predicate on the "title" field. +func TitleLT(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLT(FieldTitle, v)) +} + +// TitleLTE applies the LTE predicate on the "title" field. +func TitleLTE(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLTE(FieldTitle, v)) +} + +// TitleContains applies the Contains predicate on the "title" field. +func TitleContains(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldContains(FieldTitle, v)) +} + +// TitleHasPrefix applies the HasPrefix predicate on the "title" field. +func TitleHasPrefix(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldHasPrefix(FieldTitle, v)) +} + +// TitleHasSuffix applies the HasSuffix predicate on the "title" field. +func TitleHasSuffix(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldHasSuffix(FieldTitle, v)) +} + +// TitleEqualFold applies the EqualFold predicate on the "title" field. +func TitleEqualFold(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEqualFold(FieldTitle, v)) +} + +// TitleContainsFold applies the ContainsFold predicate on the "title" field. +func TitleContainsFold(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldContainsFold(FieldTitle, v)) +} + +// ModelEQ applies the EQ predicate on the "model" field. +func ModelEQ(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldModel, v)) +} + +// ModelNEQ applies the NEQ predicate on the "model" field. +func ModelNEQ(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNEQ(FieldModel, v)) +} + +// ModelIn applies the In predicate on the "model" field. +func ModelIn(vs ...string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldIn(FieldModel, vs...)) +} + +// ModelNotIn applies the NotIn predicate on the "model" field. +func ModelNotIn(vs ...string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotIn(FieldModel, vs...)) +} + +// ModelGT applies the GT predicate on the "model" field. +func ModelGT(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGT(FieldModel, v)) +} + +// ModelGTE applies the GTE predicate on the "model" field. +func ModelGTE(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGTE(FieldModel, v)) +} + +// ModelLT applies the LT predicate on the "model" field. +func ModelLT(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLT(FieldModel, v)) +} + +// ModelLTE applies the LTE predicate on the "model" field. +func ModelLTE(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLTE(FieldModel, v)) +} + +// ModelContains applies the Contains predicate on the "model" field. +func ModelContains(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldContains(FieldModel, v)) +} + +// ModelHasPrefix applies the HasPrefix predicate on the "model" field. +func ModelHasPrefix(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldHasPrefix(FieldModel, v)) +} + +// ModelHasSuffix applies the HasSuffix predicate on the "model" field. +func ModelHasSuffix(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldHasSuffix(FieldModel, v)) +} + +// ModelEqualFold applies the EqualFold predicate on the "model" field. +func ModelEqualFold(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEqualFold(FieldModel, v)) +} + +// ModelContainsFold applies the ContainsFold predicate on the "model" field. +func ModelContainsFold(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldContainsFold(FieldModel, v)) +} + +// StatusEQ applies the EQ predicate on the "status" field. +func StatusEQ(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldStatus, v)) +} + +// StatusNEQ applies the NEQ predicate on the "status" field. +func StatusNEQ(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNEQ(FieldStatus, v)) +} + +// StatusIn applies the In predicate on the "status" field. +func StatusIn(vs ...string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldIn(FieldStatus, vs...)) +} + +// StatusNotIn applies the NotIn predicate on the "status" field. +func StatusNotIn(vs ...string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotIn(FieldStatus, vs...)) +} + +// StatusGT applies the GT predicate on the "status" field. +func StatusGT(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGT(FieldStatus, v)) +} + +// StatusGTE applies the GTE predicate on the "status" field. +func StatusGTE(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGTE(FieldStatus, v)) +} + +// StatusLT applies the LT predicate on the "status" field. +func StatusLT(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLT(FieldStatus, v)) +} + +// StatusLTE applies the LTE predicate on the "status" field. +func StatusLTE(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLTE(FieldStatus, v)) +} + +// StatusContains applies the Contains predicate on the "status" field. +func StatusContains(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldContains(FieldStatus, v)) +} + +// StatusHasPrefix applies the HasPrefix predicate on the "status" field. +func StatusHasPrefix(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldHasPrefix(FieldStatus, v)) +} + +// StatusHasSuffix applies the HasSuffix predicate on the "status" field. +func StatusHasSuffix(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldHasSuffix(FieldStatus, v)) +} + +// StatusEqualFold applies the EqualFold predicate on the "status" field. +func StatusEqualFold(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEqualFold(FieldStatus, v)) +} + +// StatusContainsFold applies the ContainsFold predicate on the "status" field. +func StatusContainsFold(v string) predicate.ChatSession { + return predicate.ChatSession(sql.FieldContainsFold(FieldStatus, v)) +} + +// ExpiresAtEQ applies the EQ predicate on the "expires_at" field. +func ExpiresAtEQ(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldEQ(FieldExpiresAt, v)) +} + +// ExpiresAtNEQ applies the NEQ predicate on the "expires_at" field. +func ExpiresAtNEQ(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNEQ(FieldExpiresAt, v)) +} + +// ExpiresAtIn applies the In predicate on the "expires_at" field. +func ExpiresAtIn(vs ...time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtNotIn applies the NotIn predicate on the "expires_at" field. +func ExpiresAtNotIn(vs ...time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldNotIn(FieldExpiresAt, vs...)) +} + +// ExpiresAtGT applies the GT predicate on the "expires_at" field. +func ExpiresAtGT(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGT(FieldExpiresAt, v)) +} + +// ExpiresAtGTE applies the GTE predicate on the "expires_at" field. +func ExpiresAtGTE(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldGTE(FieldExpiresAt, v)) +} + +// ExpiresAtLT applies the LT predicate on the "expires_at" field. +func ExpiresAtLT(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLT(FieldExpiresAt, v)) +} + +// ExpiresAtLTE applies the LTE predicate on the "expires_at" field. +func ExpiresAtLTE(v time.Time) predicate.ChatSession { + return predicate.ChatSession(sql.FieldLTE(FieldExpiresAt, v)) +} + +// HasUser applies the HasEdge predicate on the "user" edge. +func HasUser() predicate.ChatSession { + return predicate.ChatSession(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, UserTable, UserColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasUserWith applies the HasEdge predicate on the "user" edge with a given conditions (other predicates). +func HasUserWith(preds ...predicate.User) predicate.ChatSession { + return predicate.ChatSession(func(s *sql.Selector) { + step := newUserStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// HasAPIKey applies the HasEdge predicate on the "api_key" edge. +func HasAPIKey() predicate.ChatSession { + return predicate.ChatSession(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, APIKeyTable, APIKeyColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasAPIKeyWith applies the HasEdge predicate on the "api_key" edge with a given conditions (other predicates). +func HasAPIKeyWith(preds ...predicate.APIKey) predicate.ChatSession { + return predicate.ChatSession(func(s *sql.Selector) { + step := newAPIKeyStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// HasMessages applies the HasEdge predicate on the "messages" edge. +func HasMessages() predicate.ChatSession { + return predicate.ChatSession(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, MessagesTable, MessagesColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasMessagesWith applies the HasEdge predicate on the "messages" edge with a given conditions (other predicates). +func HasMessagesWith(preds ...predicate.ChatMessage) predicate.ChatSession { + return predicate.ChatSession(func(s *sql.Selector) { + step := newMessagesStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// And groups predicates with the AND operator between them. +func And(predicates ...predicate.ChatSession) predicate.ChatSession { + return predicate.ChatSession(sql.AndPredicates(predicates...)) +} + +// Or groups predicates with the OR operator between them. +func Or(predicates ...predicate.ChatSession) predicate.ChatSession { + return predicate.ChatSession(sql.OrPredicates(predicates...)) +} + +// Not applies the not operator on the given predicate. +func Not(p predicate.ChatSession) predicate.ChatSession { + return predicate.ChatSession(sql.NotPredicates(p)) +} diff --git a/backend/ent/chatsession_create.go b/backend/ent/chatsession_create.go new file mode 100644 index 00000000000..c57fe5f70a7 --- /dev/null +++ b/backend/ent/chatsession_create.go @@ -0,0 +1,1033 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// ChatSessionCreate is the builder for creating a ChatSession entity. +type ChatSessionCreate struct { + config + mutation *ChatSessionMutation + hooks []Hook + conflict []sql.ConflictOption +} + +// SetCreatedAt sets the "created_at" field. +func (_c *ChatSessionCreate) SetCreatedAt(v time.Time) *ChatSessionCreate { + _c.mutation.SetCreatedAt(v) + return _c +} + +// SetNillableCreatedAt sets the "created_at" field if the given value is not nil. +func (_c *ChatSessionCreate) SetNillableCreatedAt(v *time.Time) *ChatSessionCreate { + if v != nil { + _c.SetCreatedAt(*v) + } + return _c +} + +// SetUpdatedAt sets the "updated_at" field. +func (_c *ChatSessionCreate) SetUpdatedAt(v time.Time) *ChatSessionCreate { + _c.mutation.SetUpdatedAt(v) + return _c +} + +// SetNillableUpdatedAt sets the "updated_at" field if the given value is not nil. +func (_c *ChatSessionCreate) SetNillableUpdatedAt(v *time.Time) *ChatSessionCreate { + if v != nil { + _c.SetUpdatedAt(*v) + } + return _c +} + +// SetDeletedAt sets the "deleted_at" field. +func (_c *ChatSessionCreate) SetDeletedAt(v time.Time) *ChatSessionCreate { + _c.mutation.SetDeletedAt(v) + return _c +} + +// SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil. +func (_c *ChatSessionCreate) SetNillableDeletedAt(v *time.Time) *ChatSessionCreate { + if v != nil { + _c.SetDeletedAt(*v) + } + return _c +} + +// SetUserID sets the "user_id" field. +func (_c *ChatSessionCreate) SetUserID(v int64) *ChatSessionCreate { + _c.mutation.SetUserID(v) + return _c +} + +// SetAPIKeyID sets the "api_key_id" field. +func (_c *ChatSessionCreate) SetAPIKeyID(v int64) *ChatSessionCreate { + _c.mutation.SetAPIKeyID(v) + return _c +} + +// SetTitle sets the "title" field. +func (_c *ChatSessionCreate) SetTitle(v string) *ChatSessionCreate { + _c.mutation.SetTitle(v) + return _c +} + +// SetModel sets the "model" field. +func (_c *ChatSessionCreate) SetModel(v string) *ChatSessionCreate { + _c.mutation.SetModel(v) + return _c +} + +// SetStatus sets the "status" field. +func (_c *ChatSessionCreate) SetStatus(v string) *ChatSessionCreate { + _c.mutation.SetStatus(v) + return _c +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_c *ChatSessionCreate) SetNillableStatus(v *string) *ChatSessionCreate { + if v != nil { + _c.SetStatus(*v) + } + return _c +} + +// SetExpiresAt sets the "expires_at" field. +func (_c *ChatSessionCreate) SetExpiresAt(v time.Time) *ChatSessionCreate { + _c.mutation.SetExpiresAt(v) + return _c +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_c *ChatSessionCreate) SetNillableExpiresAt(v *time.Time) *ChatSessionCreate { + if v != nil { + _c.SetExpiresAt(*v) + } + return _c +} + +// SetUser sets the "user" edge to the User entity. +func (_c *ChatSessionCreate) SetUser(v *User) *ChatSessionCreate { + return _c.SetUserID(v.ID) +} + +// SetAPIKey sets the "api_key" edge to the APIKey entity. +func (_c *ChatSessionCreate) SetAPIKey(v *APIKey) *ChatSessionCreate { + return _c.SetAPIKeyID(v.ID) +} + +// AddMessageIDs adds the "messages" edge to the ChatMessage entity by IDs. +func (_c *ChatSessionCreate) AddMessageIDs(ids ...int64) *ChatSessionCreate { + _c.mutation.AddMessageIDs(ids...) + return _c +} + +// AddMessages adds the "messages" edges to the ChatMessage entity. +func (_c *ChatSessionCreate) AddMessages(v ...*ChatMessage) *ChatSessionCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddMessageIDs(ids...) +} + +// Mutation returns the ChatSessionMutation object of the builder. +func (_c *ChatSessionCreate) Mutation() *ChatSessionMutation { + return _c.mutation +} + +// Save creates the ChatSession in the database. +func (_c *ChatSessionCreate) Save(ctx context.Context) (*ChatSession, error) { + if err := _c.defaults(); err != nil { + return nil, err + } + return withHooks(ctx, _c.sqlSave, _c.mutation, _c.hooks) +} + +// SaveX calls Save and panics if Save returns an error. +func (_c *ChatSessionCreate) SaveX(ctx context.Context) *ChatSession { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *ChatSessionCreate) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *ChatSessionCreate) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_c *ChatSessionCreate) defaults() error { + if _, ok := _c.mutation.CreatedAt(); !ok { + if chatsession.DefaultCreatedAt == nil { + return fmt.Errorf("ent: uninitialized chatsession.DefaultCreatedAt (forgotten import ent/runtime?)") + } + v := chatsession.DefaultCreatedAt() + _c.mutation.SetCreatedAt(v) + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + if chatsession.DefaultUpdatedAt == nil { + return fmt.Errorf("ent: uninitialized chatsession.DefaultUpdatedAt (forgotten import ent/runtime?)") + } + v := chatsession.DefaultUpdatedAt() + _c.mutation.SetUpdatedAt(v) + } + if _, ok := _c.mutation.Status(); !ok { + v := chatsession.DefaultStatus + _c.mutation.SetStatus(v) + } + if _, ok := _c.mutation.ExpiresAt(); !ok { + if chatsession.DefaultExpiresAt == nil { + return fmt.Errorf("ent: uninitialized chatsession.DefaultExpiresAt (forgotten import ent/runtime?)") + } + v := chatsession.DefaultExpiresAt() + _c.mutation.SetExpiresAt(v) + } + return nil +} + +// check runs all checks and user-defined validators on the builder. +func (_c *ChatSessionCreate) check() error { + if _, ok := _c.mutation.CreatedAt(); !ok { + return &ValidationError{Name: "created_at", err: errors.New(`ent: missing required field "ChatSession.created_at"`)} + } + if _, ok := _c.mutation.UpdatedAt(); !ok { + return &ValidationError{Name: "updated_at", err: errors.New(`ent: missing required field "ChatSession.updated_at"`)} + } + if _, ok := _c.mutation.UserID(); !ok { + return &ValidationError{Name: "user_id", err: errors.New(`ent: missing required field "ChatSession.user_id"`)} + } + if _, ok := _c.mutation.APIKeyID(); !ok { + return &ValidationError{Name: "api_key_id", err: errors.New(`ent: missing required field "ChatSession.api_key_id"`)} + } + if _, ok := _c.mutation.Title(); !ok { + return &ValidationError{Name: "title", err: errors.New(`ent: missing required field "ChatSession.title"`)} + } + if v, ok := _c.mutation.Title(); ok { + if err := chatsession.TitleValidator(v); err != nil { + return &ValidationError{Name: "title", err: fmt.Errorf(`ent: validator failed for field "ChatSession.title": %w`, err)} + } + } + if _, ok := _c.mutation.Model(); !ok { + return &ValidationError{Name: "model", err: errors.New(`ent: missing required field "ChatSession.model"`)} + } + if v, ok := _c.mutation.Model(); ok { + if err := chatsession.ModelValidator(v); err != nil { + return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "ChatSession.model": %w`, err)} + } + } + if _, ok := _c.mutation.Status(); !ok { + return &ValidationError{Name: "status", err: errors.New(`ent: missing required field "ChatSession.status"`)} + } + if v, ok := _c.mutation.Status(); ok { + if err := chatsession.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "ChatSession.status": %w`, err)} + } + } + if _, ok := _c.mutation.ExpiresAt(); !ok { + return &ValidationError{Name: "expires_at", err: errors.New(`ent: missing required field "ChatSession.expires_at"`)} + } + if len(_c.mutation.UserIDs()) == 0 { + return &ValidationError{Name: "user", err: errors.New(`ent: missing required edge "ChatSession.user"`)} + } + if len(_c.mutation.APIKeyIDs()) == 0 { + return &ValidationError{Name: "api_key", err: errors.New(`ent: missing required edge "ChatSession.api_key"`)} + } + return nil +} + +func (_c *ChatSessionCreate) sqlSave(ctx context.Context) (*ChatSession, error) { + if err := _c.check(); err != nil { + return nil, err + } + _node, _spec := _c.createSpec() + if err := sqlgraph.CreateNode(ctx, _c.driver, _spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + id := _spec.ID.Value.(int64) + _node.ID = int64(id) + _c.mutation.id = &_node.ID + _c.mutation.done = true + return _node, nil +} + +func (_c *ChatSessionCreate) createSpec() (*ChatSession, *sqlgraph.CreateSpec) { + var ( + _node = &ChatSession{config: _c.config} + _spec = sqlgraph.NewCreateSpec(chatsession.Table, sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64)) + ) + _spec.OnConflict = _c.conflict + if value, ok := _c.mutation.CreatedAt(); ok { + _spec.SetField(chatsession.FieldCreatedAt, field.TypeTime, value) + _node.CreatedAt = value + } + if value, ok := _c.mutation.UpdatedAt(); ok { + _spec.SetField(chatsession.FieldUpdatedAt, field.TypeTime, value) + _node.UpdatedAt = value + } + if value, ok := _c.mutation.DeletedAt(); ok { + _spec.SetField(chatsession.FieldDeletedAt, field.TypeTime, value) + _node.DeletedAt = &value + } + if value, ok := _c.mutation.Title(); ok { + _spec.SetField(chatsession.FieldTitle, field.TypeString, value) + _node.Title = value + } + if value, ok := _c.mutation.Model(); ok { + _spec.SetField(chatsession.FieldModel, field.TypeString, value) + _node.Model = value + } + if value, ok := _c.mutation.Status(); ok { + _spec.SetField(chatsession.FieldStatus, field.TypeString, value) + _node.Status = value + } + if value, ok := _c.mutation.ExpiresAt(); ok { + _spec.SetField(chatsession.FieldExpiresAt, field.TypeTime, value) + _node.ExpiresAt = value + } + if nodes := _c.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatsession.UserTable, + Columns: []string{chatsession.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _node.UserID = nodes[0] + _spec.Edges = append(_spec.Edges, edge) + } + if nodes := _c.mutation.APIKeyIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatsession.APIKeyTable, + Columns: []string{chatsession.APIKeyColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(apikey.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _node.APIKeyID = nodes[0] + _spec.Edges = append(_spec.Edges, edge) + } + if nodes := _c.mutation.MessagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: chatsession.MessagesTable, + Columns: []string{chatsession.MessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } + return _node, _spec +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.ChatSession.Create(). +// SetCreatedAt(v). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.ChatSessionUpsert) { +// SetCreatedAt(v+v). +// }). +// Exec(ctx) +func (_c *ChatSessionCreate) OnConflict(opts ...sql.ConflictOption) *ChatSessionUpsertOne { + _c.conflict = opts + return &ChatSessionUpsertOne{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.ChatSession.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *ChatSessionCreate) OnConflictColumns(columns ...string) *ChatSessionUpsertOne { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &ChatSessionUpsertOne{ + create: _c, + } +} + +type ( + // ChatSessionUpsertOne is the builder for "upsert"-ing + // one ChatSession node. + ChatSessionUpsertOne struct { + create *ChatSessionCreate + } + + // ChatSessionUpsert is the "OnConflict" setter. + ChatSessionUpsert struct { + *sql.UpdateSet + } +) + +// SetUpdatedAt sets the "updated_at" field. +func (u *ChatSessionUpsert) SetUpdatedAt(v time.Time) *ChatSessionUpsert { + u.Set(chatsession.FieldUpdatedAt, v) + return u +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *ChatSessionUpsert) UpdateUpdatedAt() *ChatSessionUpsert { + u.SetExcluded(chatsession.FieldUpdatedAt) + return u +} + +// SetDeletedAt sets the "deleted_at" field. +func (u *ChatSessionUpsert) SetDeletedAt(v time.Time) *ChatSessionUpsert { + u.Set(chatsession.FieldDeletedAt, v) + return u +} + +// UpdateDeletedAt sets the "deleted_at" field to the value that was provided on create. +func (u *ChatSessionUpsert) UpdateDeletedAt() *ChatSessionUpsert { + u.SetExcluded(chatsession.FieldDeletedAt) + return u +} + +// ClearDeletedAt clears the value of the "deleted_at" field. +func (u *ChatSessionUpsert) ClearDeletedAt() *ChatSessionUpsert { + u.SetNull(chatsession.FieldDeletedAt) + return u +} + +// SetUserID sets the "user_id" field. +func (u *ChatSessionUpsert) SetUserID(v int64) *ChatSessionUpsert { + u.Set(chatsession.FieldUserID, v) + return u +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *ChatSessionUpsert) UpdateUserID() *ChatSessionUpsert { + u.SetExcluded(chatsession.FieldUserID) + return u +} + +// SetAPIKeyID sets the "api_key_id" field. +func (u *ChatSessionUpsert) SetAPIKeyID(v int64) *ChatSessionUpsert { + u.Set(chatsession.FieldAPIKeyID, v) + return u +} + +// UpdateAPIKeyID sets the "api_key_id" field to the value that was provided on create. +func (u *ChatSessionUpsert) UpdateAPIKeyID() *ChatSessionUpsert { + u.SetExcluded(chatsession.FieldAPIKeyID) + return u +} + +// SetTitle sets the "title" field. +func (u *ChatSessionUpsert) SetTitle(v string) *ChatSessionUpsert { + u.Set(chatsession.FieldTitle, v) + return u +} + +// UpdateTitle sets the "title" field to the value that was provided on create. +func (u *ChatSessionUpsert) UpdateTitle() *ChatSessionUpsert { + u.SetExcluded(chatsession.FieldTitle) + return u +} + +// SetModel sets the "model" field. +func (u *ChatSessionUpsert) SetModel(v string) *ChatSessionUpsert { + u.Set(chatsession.FieldModel, v) + return u +} + +// UpdateModel sets the "model" field to the value that was provided on create. +func (u *ChatSessionUpsert) UpdateModel() *ChatSessionUpsert { + u.SetExcluded(chatsession.FieldModel) + return u +} + +// SetStatus sets the "status" field. +func (u *ChatSessionUpsert) SetStatus(v string) *ChatSessionUpsert { + u.Set(chatsession.FieldStatus, v) + return u +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *ChatSessionUpsert) UpdateStatus() *ChatSessionUpsert { + u.SetExcluded(chatsession.FieldStatus) + return u +} + +// SetExpiresAt sets the "expires_at" field. +func (u *ChatSessionUpsert) SetExpiresAt(v time.Time) *ChatSessionUpsert { + u.Set(chatsession.FieldExpiresAt, v) + return u +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *ChatSessionUpsert) UpdateExpiresAt() *ChatSessionUpsert { + u.SetExcluded(chatsession.FieldExpiresAt) + return u +} + +// UpdateNewValues updates the mutable fields using the new values that were set on create. +// Using this option is equivalent to using: +// +// client.ChatSession.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *ChatSessionUpsertOne) UpdateNewValues() *ChatSessionUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + if _, exists := u.create.mutation.CreatedAt(); exists { + s.SetIgnore(chatsession.FieldCreatedAt) + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.ChatSession.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *ChatSessionUpsertOne) Ignore() *ChatSessionUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *ChatSessionUpsertOne) DoNothing() *ChatSessionUpsertOne { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the ChatSessionCreate.OnConflict +// documentation for more info. +func (u *ChatSessionUpsertOne) Update(set func(*ChatSessionUpsert)) *ChatSessionUpsertOne { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&ChatSessionUpsert{UpdateSet: update}) + })) + return u +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *ChatSessionUpsertOne) SetUpdatedAt(v time.Time) *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *ChatSessionUpsertOne) UpdateUpdatedAt() *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateUpdatedAt() + }) +} + +// SetDeletedAt sets the "deleted_at" field. +func (u *ChatSessionUpsertOne) SetDeletedAt(v time.Time) *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.SetDeletedAt(v) + }) +} + +// UpdateDeletedAt sets the "deleted_at" field to the value that was provided on create. +func (u *ChatSessionUpsertOne) UpdateDeletedAt() *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateDeletedAt() + }) +} + +// ClearDeletedAt clears the value of the "deleted_at" field. +func (u *ChatSessionUpsertOne) ClearDeletedAt() *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.ClearDeletedAt() + }) +} + +// SetUserID sets the "user_id" field. +func (u *ChatSessionUpsertOne) SetUserID(v int64) *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.SetUserID(v) + }) +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *ChatSessionUpsertOne) UpdateUserID() *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateUserID() + }) +} + +// SetAPIKeyID sets the "api_key_id" field. +func (u *ChatSessionUpsertOne) SetAPIKeyID(v int64) *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.SetAPIKeyID(v) + }) +} + +// UpdateAPIKeyID sets the "api_key_id" field to the value that was provided on create. +func (u *ChatSessionUpsertOne) UpdateAPIKeyID() *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateAPIKeyID() + }) +} + +// SetTitle sets the "title" field. +func (u *ChatSessionUpsertOne) SetTitle(v string) *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.SetTitle(v) + }) +} + +// UpdateTitle sets the "title" field to the value that was provided on create. +func (u *ChatSessionUpsertOne) UpdateTitle() *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateTitle() + }) +} + +// SetModel sets the "model" field. +func (u *ChatSessionUpsertOne) SetModel(v string) *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.SetModel(v) + }) +} + +// UpdateModel sets the "model" field to the value that was provided on create. +func (u *ChatSessionUpsertOne) UpdateModel() *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateModel() + }) +} + +// SetStatus sets the "status" field. +func (u *ChatSessionUpsertOne) SetStatus(v string) *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *ChatSessionUpsertOne) UpdateStatus() *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateStatus() + }) +} + +// SetExpiresAt sets the "expires_at" field. +func (u *ChatSessionUpsertOne) SetExpiresAt(v time.Time) *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.SetExpiresAt(v) + }) +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *ChatSessionUpsertOne) UpdateExpiresAt() *ChatSessionUpsertOne { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateExpiresAt() + }) +} + +// Exec executes the query. +func (u *ChatSessionUpsertOne) Exec(ctx context.Context) error { + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for ChatSessionCreate.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *ChatSessionUpsertOne) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} + +// Exec executes the UPSERT query and returns the inserted/updated ID. +func (u *ChatSessionUpsertOne) ID(ctx context.Context) (id int64, err error) { + node, err := u.create.Save(ctx) + if err != nil { + return id, err + } + return node.ID, nil +} + +// IDX is like ID, but panics if an error occurs. +func (u *ChatSessionUpsertOne) IDX(ctx context.Context) int64 { + id, err := u.ID(ctx) + if err != nil { + panic(err) + } + return id +} + +// ChatSessionCreateBulk is the builder for creating many ChatSession entities in bulk. +type ChatSessionCreateBulk struct { + config + err error + builders []*ChatSessionCreate + conflict []sql.ConflictOption +} + +// Save creates the ChatSession entities in the database. +func (_c *ChatSessionCreateBulk) Save(ctx context.Context) ([]*ChatSession, error) { + if _c.err != nil { + return nil, _c.err + } + specs := make([]*sqlgraph.CreateSpec, len(_c.builders)) + nodes := make([]*ChatSession, len(_c.builders)) + mutators := make([]Mutator, len(_c.builders)) + for i := range _c.builders { + func(i int, root context.Context) { + builder := _c.builders[i] + builder.defaults() + var mut Mutator = MutateFunc(func(ctx context.Context, m Mutation) (Value, error) { + mutation, ok := m.(*ChatSessionMutation) + if !ok { + return nil, fmt.Errorf("unexpected mutation type %T", m) + } + if err := builder.check(); err != nil { + return nil, err + } + builder.mutation = mutation + var err error + nodes[i], specs[i] = builder.createSpec() + if i < len(mutators)-1 { + _, err = mutators[i+1].Mutate(root, _c.builders[i+1].mutation) + } else { + spec := &sqlgraph.BatchCreateSpec{Nodes: specs} + spec.OnConflict = _c.conflict + // Invoke the actual operation on the latest mutation in the chain. + if err = sqlgraph.BatchCreate(ctx, _c.driver, spec); err != nil { + if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + } + } + if err != nil { + return nil, err + } + mutation.id = &nodes[i].ID + if specs[i].ID.Value != nil { + id := specs[i].ID.Value.(int64) + nodes[i].ID = int64(id) + } + mutation.done = true + return nodes[i], nil + }) + for i := len(builder.hooks) - 1; i >= 0; i-- { + mut = builder.hooks[i](mut) + } + mutators[i] = mut + }(i, ctx) + } + if len(mutators) > 0 { + if _, err := mutators[0].Mutate(ctx, _c.builders[0].mutation); err != nil { + return nil, err + } + } + return nodes, nil +} + +// SaveX is like Save, but panics if an error occurs. +func (_c *ChatSessionCreateBulk) SaveX(ctx context.Context) []*ChatSession { + v, err := _c.Save(ctx) + if err != nil { + panic(err) + } + return v +} + +// Exec executes the query. +func (_c *ChatSessionCreateBulk) Exec(ctx context.Context) error { + _, err := _c.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_c *ChatSessionCreateBulk) ExecX(ctx context.Context) { + if err := _c.Exec(ctx); err != nil { + panic(err) + } +} + +// OnConflict allows configuring the `ON CONFLICT` / `ON DUPLICATE KEY` clause +// of the `INSERT` statement. For example: +// +// client.ChatSession.CreateBulk(builders...). +// OnConflict( +// // Update the row with the new values +// // the was proposed for insertion. +// sql.ResolveWithNewValues(), +// ). +// // Override some of the fields with custom +// // update values. +// Update(func(u *ent.ChatSessionUpsert) { +// SetCreatedAt(v+v). +// }). +// Exec(ctx) +func (_c *ChatSessionCreateBulk) OnConflict(opts ...sql.ConflictOption) *ChatSessionUpsertBulk { + _c.conflict = opts + return &ChatSessionUpsertBulk{ + create: _c, + } +} + +// OnConflictColumns calls `OnConflict` and configures the columns +// as conflict target. Using this option is equivalent to using: +// +// client.ChatSession.Create(). +// OnConflict(sql.ConflictColumns(columns...)). +// Exec(ctx) +func (_c *ChatSessionCreateBulk) OnConflictColumns(columns ...string) *ChatSessionUpsertBulk { + _c.conflict = append(_c.conflict, sql.ConflictColumns(columns...)) + return &ChatSessionUpsertBulk{ + create: _c, + } +} + +// ChatSessionUpsertBulk is the builder for "upsert"-ing +// a bulk of ChatSession nodes. +type ChatSessionUpsertBulk struct { + create *ChatSessionCreateBulk +} + +// UpdateNewValues updates the mutable fields using the new values that +// were set on create. Using this option is equivalent to using: +// +// client.ChatSession.Create(). +// OnConflict( +// sql.ResolveWithNewValues(), +// ). +// Exec(ctx) +func (u *ChatSessionUpsertBulk) UpdateNewValues() *ChatSessionUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithNewValues()) + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(s *sql.UpdateSet) { + for _, b := range u.create.builders { + if _, exists := b.mutation.CreatedAt(); exists { + s.SetIgnore(chatsession.FieldCreatedAt) + } + } + })) + return u +} + +// Ignore sets each column to itself in case of conflict. +// Using this option is equivalent to using: +// +// client.ChatSession.Create(). +// OnConflict(sql.ResolveWithIgnore()). +// Exec(ctx) +func (u *ChatSessionUpsertBulk) Ignore() *ChatSessionUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWithIgnore()) + return u +} + +// DoNothing configures the conflict_action to `DO NOTHING`. +// Supported only by SQLite and PostgreSQL. +func (u *ChatSessionUpsertBulk) DoNothing() *ChatSessionUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.DoNothing()) + return u +} + +// Update allows overriding fields `UPDATE` values. See the ChatSessionCreateBulk.OnConflict +// documentation for more info. +func (u *ChatSessionUpsertBulk) Update(set func(*ChatSessionUpsert)) *ChatSessionUpsertBulk { + u.create.conflict = append(u.create.conflict, sql.ResolveWith(func(update *sql.UpdateSet) { + set(&ChatSessionUpsert{UpdateSet: update}) + })) + return u +} + +// SetUpdatedAt sets the "updated_at" field. +func (u *ChatSessionUpsertBulk) SetUpdatedAt(v time.Time) *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.SetUpdatedAt(v) + }) +} + +// UpdateUpdatedAt sets the "updated_at" field to the value that was provided on create. +func (u *ChatSessionUpsertBulk) UpdateUpdatedAt() *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateUpdatedAt() + }) +} + +// SetDeletedAt sets the "deleted_at" field. +func (u *ChatSessionUpsertBulk) SetDeletedAt(v time.Time) *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.SetDeletedAt(v) + }) +} + +// UpdateDeletedAt sets the "deleted_at" field to the value that was provided on create. +func (u *ChatSessionUpsertBulk) UpdateDeletedAt() *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateDeletedAt() + }) +} + +// ClearDeletedAt clears the value of the "deleted_at" field. +func (u *ChatSessionUpsertBulk) ClearDeletedAt() *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.ClearDeletedAt() + }) +} + +// SetUserID sets the "user_id" field. +func (u *ChatSessionUpsertBulk) SetUserID(v int64) *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.SetUserID(v) + }) +} + +// UpdateUserID sets the "user_id" field to the value that was provided on create. +func (u *ChatSessionUpsertBulk) UpdateUserID() *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateUserID() + }) +} + +// SetAPIKeyID sets the "api_key_id" field. +func (u *ChatSessionUpsertBulk) SetAPIKeyID(v int64) *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.SetAPIKeyID(v) + }) +} + +// UpdateAPIKeyID sets the "api_key_id" field to the value that was provided on create. +func (u *ChatSessionUpsertBulk) UpdateAPIKeyID() *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateAPIKeyID() + }) +} + +// SetTitle sets the "title" field. +func (u *ChatSessionUpsertBulk) SetTitle(v string) *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.SetTitle(v) + }) +} + +// UpdateTitle sets the "title" field to the value that was provided on create. +func (u *ChatSessionUpsertBulk) UpdateTitle() *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateTitle() + }) +} + +// SetModel sets the "model" field. +func (u *ChatSessionUpsertBulk) SetModel(v string) *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.SetModel(v) + }) +} + +// UpdateModel sets the "model" field to the value that was provided on create. +func (u *ChatSessionUpsertBulk) UpdateModel() *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateModel() + }) +} + +// SetStatus sets the "status" field. +func (u *ChatSessionUpsertBulk) SetStatus(v string) *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.SetStatus(v) + }) +} + +// UpdateStatus sets the "status" field to the value that was provided on create. +func (u *ChatSessionUpsertBulk) UpdateStatus() *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateStatus() + }) +} + +// SetExpiresAt sets the "expires_at" field. +func (u *ChatSessionUpsertBulk) SetExpiresAt(v time.Time) *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.SetExpiresAt(v) + }) +} + +// UpdateExpiresAt sets the "expires_at" field to the value that was provided on create. +func (u *ChatSessionUpsertBulk) UpdateExpiresAt() *ChatSessionUpsertBulk { + return u.Update(func(s *ChatSessionUpsert) { + s.UpdateExpiresAt() + }) +} + +// Exec executes the query. +func (u *ChatSessionUpsertBulk) Exec(ctx context.Context) error { + if u.create.err != nil { + return u.create.err + } + for i, b := range u.create.builders { + if len(b.conflict) != 0 { + return fmt.Errorf("ent: OnConflict was set for builder %d. Set it on the ChatSessionCreateBulk instead", i) + } + } + if len(u.create.conflict) == 0 { + return errors.New("ent: missing options for ChatSessionCreateBulk.OnConflict") + } + return u.create.Exec(ctx) +} + +// ExecX is like Exec, but panics if an error occurs. +func (u *ChatSessionUpsertBulk) ExecX(ctx context.Context) { + if err := u.create.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/chatsession_delete.go b/backend/ent/chatsession_delete.go new file mode 100644 index 00000000000..91c825db38d --- /dev/null +++ b/backend/ent/chatsession_delete.go @@ -0,0 +1,88 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/ent/predicate" +) + +// ChatSessionDelete is the builder for deleting a ChatSession entity. +type ChatSessionDelete struct { + config + hooks []Hook + mutation *ChatSessionMutation +} + +// Where appends a list predicates to the ChatSessionDelete builder. +func (_d *ChatSessionDelete) Where(ps ...predicate.ChatSession) *ChatSessionDelete { + _d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query and returns how many vertices were deleted. +func (_d *ChatSessionDelete) Exec(ctx context.Context) (int, error) { + return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks) +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *ChatSessionDelete) ExecX(ctx context.Context) int { + n, err := _d.Exec(ctx) + if err != nil { + panic(err) + } + return n +} + +func (_d *ChatSessionDelete) sqlExec(ctx context.Context) (int, error) { + _spec := sqlgraph.NewDeleteSpec(chatsession.Table, sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64)) + if ps := _d.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec) + if err != nil && sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + _d.mutation.done = true + return affected, err +} + +// ChatSessionDeleteOne is the builder for deleting a single ChatSession entity. +type ChatSessionDeleteOne struct { + _d *ChatSessionDelete +} + +// Where appends a list predicates to the ChatSessionDelete builder. +func (_d *ChatSessionDeleteOne) Where(ps ...predicate.ChatSession) *ChatSessionDeleteOne { + _d._d.mutation.Where(ps...) + return _d +} + +// Exec executes the deletion query. +func (_d *ChatSessionDeleteOne) Exec(ctx context.Context) error { + n, err := _d._d.Exec(ctx) + switch { + case err != nil: + return err + case n == 0: + return &NotFoundError{chatsession.Label} + default: + return nil + } +} + +// ExecX is like Exec, but panics if an error occurs. +func (_d *ChatSessionDeleteOne) ExecX(ctx context.Context) { + if err := _d.Exec(ctx); err != nil { + panic(err) + } +} diff --git a/backend/ent/chatsession_query.go b/backend/ent/chatsession_query.go new file mode 100644 index 00000000000..82c45d0cab6 --- /dev/null +++ b/backend/ent/chatsession_query.go @@ -0,0 +1,793 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "database/sql/driver" + "fmt" + "math" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// ChatSessionQuery is the builder for querying ChatSession entities. +type ChatSessionQuery struct { + config + ctx *QueryContext + order []chatsession.OrderOption + inters []Interceptor + predicates []predicate.ChatSession + withUser *UserQuery + withAPIKey *APIKeyQuery + withMessages *ChatMessageQuery + modifiers []func(*sql.Selector) + // intermediate query (i.e. traversal path). + sql *sql.Selector + path func(context.Context) (*sql.Selector, error) +} + +// Where adds a new predicate for the ChatSessionQuery builder. +func (_q *ChatSessionQuery) Where(ps ...predicate.ChatSession) *ChatSessionQuery { + _q.predicates = append(_q.predicates, ps...) + return _q +} + +// Limit the number of records to be returned by this query. +func (_q *ChatSessionQuery) Limit(limit int) *ChatSessionQuery { + _q.ctx.Limit = &limit + return _q +} + +// Offset to start from. +func (_q *ChatSessionQuery) Offset(offset int) *ChatSessionQuery { + _q.ctx.Offset = &offset + return _q +} + +// Unique configures the query builder to filter duplicate records on query. +// By default, unique is set to true, and can be disabled using this method. +func (_q *ChatSessionQuery) Unique(unique bool) *ChatSessionQuery { + _q.ctx.Unique = &unique + return _q +} + +// Order specifies how the records should be ordered. +func (_q *ChatSessionQuery) Order(o ...chatsession.OrderOption) *ChatSessionQuery { + _q.order = append(_q.order, o...) + return _q +} + +// QueryUser chains the current query on the "user" edge. +func (_q *ChatSessionQuery) QueryUser() *UserQuery { + query := (&UserClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(chatsession.Table, chatsession.FieldID, selector), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, chatsession.UserTable, chatsession.UserColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// QueryAPIKey chains the current query on the "api_key" edge. +func (_q *ChatSessionQuery) QueryAPIKey() *APIKeyQuery { + query := (&APIKeyClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(chatsession.Table, chatsession.FieldID, selector), + sqlgraph.To(apikey.Table, apikey.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, chatsession.APIKeyTable, chatsession.APIKeyColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// QueryMessages chains the current query on the "messages" edge. +func (_q *ChatSessionQuery) QueryMessages() *ChatMessageQuery { + query := (&ChatMessageClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(chatsession.Table, chatsession.FieldID, selector), + sqlgraph.To(chatmessage.Table, chatmessage.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, chatsession.MessagesTable, chatsession.MessagesColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// First returns the first ChatSession entity from the query. +// Returns a *NotFoundError when no ChatSession was found. +func (_q *ChatSessionQuery) First(ctx context.Context) (*ChatSession, error) { + nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst)) + if err != nil { + return nil, err + } + if len(nodes) == 0 { + return nil, &NotFoundError{chatsession.Label} + } + return nodes[0], nil +} + +// FirstX is like First, but panics if an error occurs. +func (_q *ChatSessionQuery) FirstX(ctx context.Context) *ChatSession { + node, err := _q.First(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return node +} + +// FirstID returns the first ChatSession ID from the query. +// Returns a *NotFoundError when no ChatSession ID was found. +func (_q *ChatSessionQuery) FirstID(ctx context.Context) (id int64, err error) { + var ids []int64 + if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil { + return + } + if len(ids) == 0 { + err = &NotFoundError{chatsession.Label} + return + } + return ids[0], nil +} + +// FirstIDX is like FirstID, but panics if an error occurs. +func (_q *ChatSessionQuery) FirstIDX(ctx context.Context) int64 { + id, err := _q.FirstID(ctx) + if err != nil && !IsNotFound(err) { + panic(err) + } + return id +} + +// Only returns a single ChatSession entity found by the query, ensuring it only returns one. +// Returns a *NotSingularError when more than one ChatSession entity is found. +// Returns a *NotFoundError when no ChatSession entities are found. +func (_q *ChatSessionQuery) Only(ctx context.Context) (*ChatSession, error) { + nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly)) + if err != nil { + return nil, err + } + switch len(nodes) { + case 1: + return nodes[0], nil + case 0: + return nil, &NotFoundError{chatsession.Label} + default: + return nil, &NotSingularError{chatsession.Label} + } +} + +// OnlyX is like Only, but panics if an error occurs. +func (_q *ChatSessionQuery) OnlyX(ctx context.Context) *ChatSession { + node, err := _q.Only(ctx) + if err != nil { + panic(err) + } + return node +} + +// OnlyID is like Only, but returns the only ChatSession ID in the query. +// Returns a *NotSingularError when more than one ChatSession ID is found. +// Returns a *NotFoundError when no entities are found. +func (_q *ChatSessionQuery) OnlyID(ctx context.Context) (id int64, err error) { + var ids []int64 + if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil { + return + } + switch len(ids) { + case 1: + id = ids[0] + case 0: + err = &NotFoundError{chatsession.Label} + default: + err = &NotSingularError{chatsession.Label} + } + return +} + +// OnlyIDX is like OnlyID, but panics if an error occurs. +func (_q *ChatSessionQuery) OnlyIDX(ctx context.Context) int64 { + id, err := _q.OnlyID(ctx) + if err != nil { + panic(err) + } + return id +} + +// All executes the query and returns a list of ChatSessions. +func (_q *ChatSessionQuery) All(ctx context.Context) ([]*ChatSession, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll) + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + qr := querierAll[[]*ChatSession, *ChatSessionQuery]() + return withInterceptors[[]*ChatSession](ctx, _q, qr, _q.inters) +} + +// AllX is like All, but panics if an error occurs. +func (_q *ChatSessionQuery) AllX(ctx context.Context) []*ChatSession { + nodes, err := _q.All(ctx) + if err != nil { + panic(err) + } + return nodes +} + +// IDs executes the query and returns a list of ChatSession IDs. +func (_q *ChatSessionQuery) IDs(ctx context.Context) (ids []int64, err error) { + if _q.ctx.Unique == nil && _q.path != nil { + _q.Unique(true) + } + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs) + if err = _q.Select(chatsession.FieldID).Scan(ctx, &ids); err != nil { + return nil, err + } + return ids, nil +} + +// IDsX is like IDs, but panics if an error occurs. +func (_q *ChatSessionQuery) IDsX(ctx context.Context) []int64 { + ids, err := _q.IDs(ctx) + if err != nil { + panic(err) + } + return ids +} + +// Count returns the count of the given query. +func (_q *ChatSessionQuery) Count(ctx context.Context) (int, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount) + if err := _q.prepareQuery(ctx); err != nil { + return 0, err + } + return withInterceptors[int](ctx, _q, querierCount[*ChatSessionQuery](), _q.inters) +} + +// CountX is like Count, but panics if an error occurs. +func (_q *ChatSessionQuery) CountX(ctx context.Context) int { + count, err := _q.Count(ctx) + if err != nil { + panic(err) + } + return count +} + +// Exist returns true if the query has elements in the graph. +func (_q *ChatSessionQuery) Exist(ctx context.Context) (bool, error) { + ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist) + switch _, err := _q.FirstID(ctx); { + case IsNotFound(err): + return false, nil + case err != nil: + return false, fmt.Errorf("ent: check existence: %w", err) + default: + return true, nil + } +} + +// ExistX is like Exist, but panics if an error occurs. +func (_q *ChatSessionQuery) ExistX(ctx context.Context) bool { + exist, err := _q.Exist(ctx) + if err != nil { + panic(err) + } + return exist +} + +// Clone returns a duplicate of the ChatSessionQuery builder, including all associated steps. It can be +// used to prepare common query builders and use them differently after the clone is made. +func (_q *ChatSessionQuery) Clone() *ChatSessionQuery { + if _q == nil { + return nil + } + return &ChatSessionQuery{ + config: _q.config, + ctx: _q.ctx.Clone(), + order: append([]chatsession.OrderOption{}, _q.order...), + inters: append([]Interceptor{}, _q.inters...), + predicates: append([]predicate.ChatSession{}, _q.predicates...), + withUser: _q.withUser.Clone(), + withAPIKey: _q.withAPIKey.Clone(), + withMessages: _q.withMessages.Clone(), + // clone intermediate query. + sql: _q.sql.Clone(), + path: _q.path, + } +} + +// WithUser tells the query-builder to eager-load the nodes that are connected to +// the "user" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *ChatSessionQuery) WithUser(opts ...func(*UserQuery)) *ChatSessionQuery { + query := (&UserClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withUser = query + return _q +} + +// WithAPIKey tells the query-builder to eager-load the nodes that are connected to +// the "api_key" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *ChatSessionQuery) WithAPIKey(opts ...func(*APIKeyQuery)) *ChatSessionQuery { + query := (&APIKeyClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withAPIKey = query + return _q +} + +// WithMessages tells the query-builder to eager-load the nodes that are connected to +// the "messages" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *ChatSessionQuery) WithMessages(opts ...func(*ChatMessageQuery)) *ChatSessionQuery { + query := (&ChatMessageClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withMessages = query + return _q +} + +// GroupBy is used to group vertices by one or more fields/columns. +// It is often used with aggregate functions, like: count, max, mean, min, sum. +// +// Example: +// +// var v []struct { +// CreatedAt time.Time `json:"created_at,omitempty"` +// Count int `json:"count,omitempty"` +// } +// +// client.ChatSession.Query(). +// GroupBy(chatsession.FieldCreatedAt). +// Aggregate(ent.Count()). +// Scan(ctx, &v) +func (_q *ChatSessionQuery) GroupBy(field string, fields ...string) *ChatSessionGroupBy { + _q.ctx.Fields = append([]string{field}, fields...) + grbuild := &ChatSessionGroupBy{build: _q} + grbuild.flds = &_q.ctx.Fields + grbuild.label = chatsession.Label + grbuild.scan = grbuild.Scan + return grbuild +} + +// Select allows the selection one or more fields/columns for the given query, +// instead of selecting all fields in the entity. +// +// Example: +// +// var v []struct { +// CreatedAt time.Time `json:"created_at,omitempty"` +// } +// +// client.ChatSession.Query(). +// Select(chatsession.FieldCreatedAt). +// Scan(ctx, &v) +func (_q *ChatSessionQuery) Select(fields ...string) *ChatSessionSelect { + _q.ctx.Fields = append(_q.ctx.Fields, fields...) + sbuild := &ChatSessionSelect{ChatSessionQuery: _q} + sbuild.label = chatsession.Label + sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan + return sbuild +} + +// Aggregate returns a ChatSessionSelect configured with the given aggregations. +func (_q *ChatSessionQuery) Aggregate(fns ...AggregateFunc) *ChatSessionSelect { + return _q.Select().Aggregate(fns...) +} + +func (_q *ChatSessionQuery) prepareQuery(ctx context.Context) error { + for _, inter := range _q.inters { + if inter == nil { + return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)") + } + if trv, ok := inter.(Traverser); ok { + if err := trv.Traverse(ctx, _q); err != nil { + return err + } + } + } + for _, f := range _q.ctx.Fields { + if !chatsession.ValidColumn(f) { + return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + } + if _q.path != nil { + prev, err := _q.path(ctx) + if err != nil { + return err + } + _q.sql = prev + } + return nil +} + +func (_q *ChatSessionQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*ChatSession, error) { + var ( + nodes = []*ChatSession{} + _spec = _q.querySpec() + loadedTypes = [3]bool{ + _q.withUser != nil, + _q.withAPIKey != nil, + _q.withMessages != nil, + } + ) + _spec.ScanValues = func(columns []string) ([]any, error) { + return (*ChatSession).scanValues(nil, columns) + } + _spec.Assign = func(columns []string, values []any) error { + node := &ChatSession{config: _q.config} + nodes = append(nodes, node) + node.Edges.loadedTypes = loadedTypes + return node.assignValues(columns, values) + } + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + for i := range hooks { + hooks[i](ctx, _spec) + } + if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil { + return nil, err + } + if len(nodes) == 0 { + return nodes, nil + } + if query := _q.withUser; query != nil { + if err := _q.loadUser(ctx, query, nodes, nil, + func(n *ChatSession, e *User) { n.Edges.User = e }); err != nil { + return nil, err + } + } + if query := _q.withAPIKey; query != nil { + if err := _q.loadAPIKey(ctx, query, nodes, nil, + func(n *ChatSession, e *APIKey) { n.Edges.APIKey = e }); err != nil { + return nil, err + } + } + if query := _q.withMessages; query != nil { + if err := _q.loadMessages(ctx, query, nodes, + func(n *ChatSession) { n.Edges.Messages = []*ChatMessage{} }, + func(n *ChatSession, e *ChatMessage) { n.Edges.Messages = append(n.Edges.Messages, e) }); err != nil { + return nil, err + } + } + return nodes, nil +} + +func (_q *ChatSessionQuery) loadUser(ctx context.Context, query *UserQuery, nodes []*ChatSession, init func(*ChatSession), assign func(*ChatSession, *User)) error { + ids := make([]int64, 0, len(nodes)) + nodeids := make(map[int64][]*ChatSession) + for i := range nodes { + fk := nodes[i].UserID + if _, ok := nodeids[fk]; !ok { + ids = append(ids, fk) + } + nodeids[fk] = append(nodeids[fk], nodes[i]) + } + if len(ids) == 0 { + return nil + } + query.Where(user.IDIn(ids...)) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + nodes, ok := nodeids[n.ID] + if !ok { + return fmt.Errorf(`unexpected foreign-key "user_id" returned %v`, n.ID) + } + for i := range nodes { + assign(nodes[i], n) + } + } + return nil +} +func (_q *ChatSessionQuery) loadAPIKey(ctx context.Context, query *APIKeyQuery, nodes []*ChatSession, init func(*ChatSession), assign func(*ChatSession, *APIKey)) error { + ids := make([]int64, 0, len(nodes)) + nodeids := make(map[int64][]*ChatSession) + for i := range nodes { + fk := nodes[i].APIKeyID + if _, ok := nodeids[fk]; !ok { + ids = append(ids, fk) + } + nodeids[fk] = append(nodeids[fk], nodes[i]) + } + if len(ids) == 0 { + return nil + } + query.Where(apikey.IDIn(ids...)) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + nodes, ok := nodeids[n.ID] + if !ok { + return fmt.Errorf(`unexpected foreign-key "api_key_id" returned %v`, n.ID) + } + for i := range nodes { + assign(nodes[i], n) + } + } + return nil +} +func (_q *ChatSessionQuery) loadMessages(ctx context.Context, query *ChatMessageQuery, nodes []*ChatSession, init func(*ChatSession), assign func(*ChatSession, *ChatMessage)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[int64]*ChatSession) + for i := range nodes { + fks = append(fks, nodes[i].ID) + nodeids[nodes[i].ID] = nodes[i] + if init != nil { + init(nodes[i]) + } + } + if len(query.ctx.Fields) > 0 { + query.ctx.AppendFieldOnce(chatmessage.FieldSessionID) + } + query.Where(predicate.ChatMessage(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(chatsession.MessagesColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.SessionID + node, ok := nodeids[fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "session_id" returned %v for node %v`, fk, n.ID) + } + assign(node, n) + } + return nil +} + +func (_q *ChatSessionQuery) sqlCount(ctx context.Context) (int, error) { + _spec := _q.querySpec() + if len(_q.modifiers) > 0 { + _spec.Modifiers = _q.modifiers + } + _spec.Node.Columns = _q.ctx.Fields + if len(_q.ctx.Fields) > 0 { + _spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique + } + return sqlgraph.CountNodes(ctx, _q.driver, _spec) +} + +func (_q *ChatSessionQuery) querySpec() *sqlgraph.QuerySpec { + _spec := sqlgraph.NewQuerySpec(chatsession.Table, chatsession.Columns, sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64)) + _spec.From = _q.sql + if unique := _q.ctx.Unique; unique != nil { + _spec.Unique = *unique + } else if _q.path != nil { + _spec.Unique = true + } + if fields := _q.ctx.Fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, chatsession.FieldID) + for i := range fields { + if fields[i] != chatsession.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, fields[i]) + } + } + if _q.withUser != nil { + _spec.Node.AddColumnOnce(chatsession.FieldUserID) + } + if _q.withAPIKey != nil { + _spec.Node.AddColumnOnce(chatsession.FieldAPIKeyID) + } + } + if ps := _q.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if limit := _q.ctx.Limit; limit != nil { + _spec.Limit = *limit + } + if offset := _q.ctx.Offset; offset != nil { + _spec.Offset = *offset + } + if ps := _q.order; len(ps) > 0 { + _spec.Order = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + return _spec +} + +func (_q *ChatSessionQuery) sqlQuery(ctx context.Context) *sql.Selector { + builder := sql.Dialect(_q.driver.Dialect()) + t1 := builder.Table(chatsession.Table) + columns := _q.ctx.Fields + if len(columns) == 0 { + columns = chatsession.Columns + } + selector := builder.Select(t1.Columns(columns...)...).From(t1) + if _q.sql != nil { + selector = _q.sql + selector.Select(selector.Columns(columns...)...) + } + if _q.ctx.Unique != nil && *_q.ctx.Unique { + selector.Distinct() + } + for _, m := range _q.modifiers { + m(selector) + } + for _, p := range _q.predicates { + p(selector) + } + for _, p := range _q.order { + p(selector) + } + if offset := _q.ctx.Offset; offset != nil { + // limit is mandatory for offset clause. We start + // with default value, and override it below if needed. + selector.Offset(*offset).Limit(math.MaxInt32) + } + if limit := _q.ctx.Limit; limit != nil { + selector.Limit(*limit) + } + return selector +} + +// ForUpdate locks the selected rows against concurrent updates, and prevent them from being +// updated, deleted or "selected ... for update" by other sessions, until the transaction is +// either committed or rolled-back. +func (_q *ChatSessionQuery) ForUpdate(opts ...sql.LockOption) *ChatSessionQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForUpdate(opts...) + }) + return _q +} + +// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock +// on any rows that are read. Other sessions can read the rows, but cannot modify them +// until your transaction commits. +func (_q *ChatSessionQuery) ForShare(opts ...sql.LockOption) *ChatSessionQuery { + if _q.driver.Dialect() == dialect.Postgres { + _q.Unique(false) + } + _q.modifiers = append(_q.modifiers, func(s *sql.Selector) { + s.ForShare(opts...) + }) + return _q +} + +// ChatSessionGroupBy is the group-by builder for ChatSession entities. +type ChatSessionGroupBy struct { + selector + build *ChatSessionQuery +} + +// Aggregate adds the given aggregation functions to the group-by query. +func (_g *ChatSessionGroupBy) Aggregate(fns ...AggregateFunc) *ChatSessionGroupBy { + _g.fns = append(_g.fns, fns...) + return _g +} + +// Scan applies the selector query and scans the result into the given value. +func (_g *ChatSessionGroupBy) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy) + if err := _g.build.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*ChatSessionQuery, *ChatSessionGroupBy](ctx, _g.build, _g, _g.build.inters, v) +} + +func (_g *ChatSessionGroupBy) sqlScan(ctx context.Context, root *ChatSessionQuery, v any) error { + selector := root.sqlQuery(ctx).Select() + aggregation := make([]string, 0, len(_g.fns)) + for _, fn := range _g.fns { + aggregation = append(aggregation, fn(selector)) + } + if len(selector.SelectedColumns()) == 0 { + columns := make([]string, 0, len(*_g.flds)+len(_g.fns)) + for _, f := range *_g.flds { + columns = append(columns, selector.C(f)) + } + columns = append(columns, aggregation...) + selector.Select(columns...) + } + selector.GroupBy(selector.Columns(*_g.flds...)...) + if err := selector.Err(); err != nil { + return err + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _g.build.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} + +// ChatSessionSelect is the builder for selecting fields of ChatSession entities. +type ChatSessionSelect struct { + *ChatSessionQuery + selector +} + +// Aggregate adds the given aggregation functions to the selector query. +func (_s *ChatSessionSelect) Aggregate(fns ...AggregateFunc) *ChatSessionSelect { + _s.fns = append(_s.fns, fns...) + return _s +} + +// Scan applies the selector query and scans the result into the given value. +func (_s *ChatSessionSelect) Scan(ctx context.Context, v any) error { + ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect) + if err := _s.prepareQuery(ctx); err != nil { + return err + } + return scanWithInterceptors[*ChatSessionQuery, *ChatSessionSelect](ctx, _s.ChatSessionQuery, _s, _s.inters, v) +} + +func (_s *ChatSessionSelect) sqlScan(ctx context.Context, root *ChatSessionQuery, v any) error { + selector := root.sqlQuery(ctx) + aggregation := make([]string, 0, len(_s.fns)) + for _, fn := range _s.fns { + aggregation = append(aggregation, fn(selector)) + } + switch n := len(*_s.selector.flds); { + case n == 0 && len(aggregation) > 0: + selector.Select(aggregation...) + case n != 0 && len(aggregation) > 0: + selector.AppendSelect(aggregation...) + } + rows := &sql.Rows{} + query, args := selector.Query() + if err := _s.driver.Query(ctx, query, args, rows); err != nil { + return err + } + defer rows.Close() + return sql.ScanSlice(rows, v) +} diff --git a/backend/ent/chatsession_update.go b/backend/ent/chatsession_update.go new file mode 100644 index 00000000000..f84e964bc28 --- /dev/null +++ b/backend/ent/chatsession_update.go @@ -0,0 +1,851 @@ +// Code generated by ent, DO NOT EDIT. + +package ent + +import ( + "context" + "errors" + "fmt" + "time" + + "entgo.io/ent/dialect/sql" + "entgo.io/ent/dialect/sql/sqlgraph" + "entgo.io/ent/schema/field" + "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/ent/predicate" + "github.com/Wei-Shaw/sub2api/ent/user" +) + +// ChatSessionUpdate is the builder for updating ChatSession entities. +type ChatSessionUpdate struct { + config + hooks []Hook + mutation *ChatSessionMutation +} + +// Where appends a list predicates to the ChatSessionUpdate builder. +func (_u *ChatSessionUpdate) Where(ps ...predicate.ChatSession) *ChatSessionUpdate { + _u.mutation.Where(ps...) + return _u +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *ChatSessionUpdate) SetUpdatedAt(v time.Time) *ChatSessionUpdate { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// SetDeletedAt sets the "deleted_at" field. +func (_u *ChatSessionUpdate) SetDeletedAt(v time.Time) *ChatSessionUpdate { + _u.mutation.SetDeletedAt(v) + return _u +} + +// SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil. +func (_u *ChatSessionUpdate) SetNillableDeletedAt(v *time.Time) *ChatSessionUpdate { + if v != nil { + _u.SetDeletedAt(*v) + } + return _u +} + +// ClearDeletedAt clears the value of the "deleted_at" field. +func (_u *ChatSessionUpdate) ClearDeletedAt() *ChatSessionUpdate { + _u.mutation.ClearDeletedAt() + return _u +} + +// SetUserID sets the "user_id" field. +func (_u *ChatSessionUpdate) SetUserID(v int64) *ChatSessionUpdate { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *ChatSessionUpdate) SetNillableUserID(v *int64) *ChatSessionUpdate { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetAPIKeyID sets the "api_key_id" field. +func (_u *ChatSessionUpdate) SetAPIKeyID(v int64) *ChatSessionUpdate { + _u.mutation.SetAPIKeyID(v) + return _u +} + +// SetNillableAPIKeyID sets the "api_key_id" field if the given value is not nil. +func (_u *ChatSessionUpdate) SetNillableAPIKeyID(v *int64) *ChatSessionUpdate { + if v != nil { + _u.SetAPIKeyID(*v) + } + return _u +} + +// SetTitle sets the "title" field. +func (_u *ChatSessionUpdate) SetTitle(v string) *ChatSessionUpdate { + _u.mutation.SetTitle(v) + return _u +} + +// SetNillableTitle sets the "title" field if the given value is not nil. +func (_u *ChatSessionUpdate) SetNillableTitle(v *string) *ChatSessionUpdate { + if v != nil { + _u.SetTitle(*v) + } + return _u +} + +// SetModel sets the "model" field. +func (_u *ChatSessionUpdate) SetModel(v string) *ChatSessionUpdate { + _u.mutation.SetModel(v) + return _u +} + +// SetNillableModel sets the "model" field if the given value is not nil. +func (_u *ChatSessionUpdate) SetNillableModel(v *string) *ChatSessionUpdate { + if v != nil { + _u.SetModel(*v) + } + return _u +} + +// SetStatus sets the "status" field. +func (_u *ChatSessionUpdate) SetStatus(v string) *ChatSessionUpdate { + _u.mutation.SetStatus(v) + return _u +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_u *ChatSessionUpdate) SetNillableStatus(v *string) *ChatSessionUpdate { + if v != nil { + _u.SetStatus(*v) + } + return _u +} + +// SetExpiresAt sets the "expires_at" field. +func (_u *ChatSessionUpdate) SetExpiresAt(v time.Time) *ChatSessionUpdate { + _u.mutation.SetExpiresAt(v) + return _u +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_u *ChatSessionUpdate) SetNillableExpiresAt(v *time.Time) *ChatSessionUpdate { + if v != nil { + _u.SetExpiresAt(*v) + } + return _u +} + +// SetUser sets the "user" edge to the User entity. +func (_u *ChatSessionUpdate) SetUser(v *User) *ChatSessionUpdate { + return _u.SetUserID(v.ID) +} + +// SetAPIKey sets the "api_key" edge to the APIKey entity. +func (_u *ChatSessionUpdate) SetAPIKey(v *APIKey) *ChatSessionUpdate { + return _u.SetAPIKeyID(v.ID) +} + +// AddMessageIDs adds the "messages" edge to the ChatMessage entity by IDs. +func (_u *ChatSessionUpdate) AddMessageIDs(ids ...int64) *ChatSessionUpdate { + _u.mutation.AddMessageIDs(ids...) + return _u +} + +// AddMessages adds the "messages" edges to the ChatMessage entity. +func (_u *ChatSessionUpdate) AddMessages(v ...*ChatMessage) *ChatSessionUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddMessageIDs(ids...) +} + +// Mutation returns the ChatSessionMutation object of the builder. +func (_u *ChatSessionUpdate) Mutation() *ChatSessionMutation { + return _u.mutation +} + +// ClearUser clears the "user" edge to the User entity. +func (_u *ChatSessionUpdate) ClearUser() *ChatSessionUpdate { + _u.mutation.ClearUser() + return _u +} + +// ClearAPIKey clears the "api_key" edge to the APIKey entity. +func (_u *ChatSessionUpdate) ClearAPIKey() *ChatSessionUpdate { + _u.mutation.ClearAPIKey() + return _u +} + +// ClearMessages clears all "messages" edges to the ChatMessage entity. +func (_u *ChatSessionUpdate) ClearMessages() *ChatSessionUpdate { + _u.mutation.ClearMessages() + return _u +} + +// RemoveMessageIDs removes the "messages" edge to ChatMessage entities by IDs. +func (_u *ChatSessionUpdate) RemoveMessageIDs(ids ...int64) *ChatSessionUpdate { + _u.mutation.RemoveMessageIDs(ids...) + return _u +} + +// RemoveMessages removes "messages" edges to ChatMessage entities. +func (_u *ChatSessionUpdate) RemoveMessages(v ...*ChatMessage) *ChatSessionUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveMessageIDs(ids...) +} + +// Save executes the query and returns the number of nodes affected by the update operation. +func (_u *ChatSessionUpdate) Save(ctx context.Context) (int, error) { + if err := _u.defaults(); err != nil { + return 0, err + } + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *ChatSessionUpdate) SaveX(ctx context.Context) int { + affected, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return affected +} + +// Exec executes the query. +func (_u *ChatSessionUpdate) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *ChatSessionUpdate) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_u *ChatSessionUpdate) defaults() error { + if _, ok := _u.mutation.UpdatedAt(); !ok { + if chatsession.UpdateDefaultUpdatedAt == nil { + return fmt.Errorf("ent: uninitialized chatsession.UpdateDefaultUpdatedAt (forgotten import ent/runtime?)") + } + v := chatsession.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } + return nil +} + +// check runs all checks and user-defined validators on the builder. +func (_u *ChatSessionUpdate) check() error { + if v, ok := _u.mutation.Title(); ok { + if err := chatsession.TitleValidator(v); err != nil { + return &ValidationError{Name: "title", err: fmt.Errorf(`ent: validator failed for field "ChatSession.title": %w`, err)} + } + } + if v, ok := _u.mutation.Model(); ok { + if err := chatsession.ModelValidator(v); err != nil { + return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "ChatSession.model": %w`, err)} + } + } + if v, ok := _u.mutation.Status(); ok { + if err := chatsession.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "ChatSession.status": %w`, err)} + } + } + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "ChatSession.user"`) + } + if _u.mutation.APIKeyCleared() && len(_u.mutation.APIKeyIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "ChatSession.api_key"`) + } + return nil +} + +func (_u *ChatSessionUpdate) sqlSave(ctx context.Context) (_node int, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(chatsession.Table, chatsession.Columns, sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64)) + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(chatsession.FieldUpdatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.DeletedAt(); ok { + _spec.SetField(chatsession.FieldDeletedAt, field.TypeTime, value) + } + if _u.mutation.DeletedAtCleared() { + _spec.ClearField(chatsession.FieldDeletedAt, field.TypeTime) + } + if value, ok := _u.mutation.Title(); ok { + _spec.SetField(chatsession.FieldTitle, field.TypeString, value) + } + if value, ok := _u.mutation.Model(); ok { + _spec.SetField(chatsession.FieldModel, field.TypeString, value) + } + if value, ok := _u.mutation.Status(); ok { + _spec.SetField(chatsession.FieldStatus, field.TypeString, value) + } + if value, ok := _u.mutation.ExpiresAt(); ok { + _spec.SetField(chatsession.FieldExpiresAt, field.TypeTime, value) + } + if _u.mutation.UserCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatsession.UserTable, + Columns: []string{chatsession.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatsession.UserTable, + Columns: []string{chatsession.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.APIKeyCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatsession.APIKeyTable, + Columns: []string{chatsession.APIKeyColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(apikey.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.APIKeyIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatsession.APIKeyTable, + Columns: []string{chatsession.APIKeyColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(apikey.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.MessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: chatsession.MessagesTable, + Columns: []string{chatsession.MessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedMessagesIDs(); len(nodes) > 0 && !_u.mutation.MessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: chatsession.MessagesTable, + Columns: []string{chatsession.MessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.MessagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: chatsession.MessagesTable, + Columns: []string{chatsession.MessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{chatsession.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return 0, err + } + _u.mutation.done = true + return _node, nil +} + +// ChatSessionUpdateOne is the builder for updating a single ChatSession entity. +type ChatSessionUpdateOne struct { + config + fields []string + hooks []Hook + mutation *ChatSessionMutation +} + +// SetUpdatedAt sets the "updated_at" field. +func (_u *ChatSessionUpdateOne) SetUpdatedAt(v time.Time) *ChatSessionUpdateOne { + _u.mutation.SetUpdatedAt(v) + return _u +} + +// SetDeletedAt sets the "deleted_at" field. +func (_u *ChatSessionUpdateOne) SetDeletedAt(v time.Time) *ChatSessionUpdateOne { + _u.mutation.SetDeletedAt(v) + return _u +} + +// SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil. +func (_u *ChatSessionUpdateOne) SetNillableDeletedAt(v *time.Time) *ChatSessionUpdateOne { + if v != nil { + _u.SetDeletedAt(*v) + } + return _u +} + +// ClearDeletedAt clears the value of the "deleted_at" field. +func (_u *ChatSessionUpdateOne) ClearDeletedAt() *ChatSessionUpdateOne { + _u.mutation.ClearDeletedAt() + return _u +} + +// SetUserID sets the "user_id" field. +func (_u *ChatSessionUpdateOne) SetUserID(v int64) *ChatSessionUpdateOne { + _u.mutation.SetUserID(v) + return _u +} + +// SetNillableUserID sets the "user_id" field if the given value is not nil. +func (_u *ChatSessionUpdateOne) SetNillableUserID(v *int64) *ChatSessionUpdateOne { + if v != nil { + _u.SetUserID(*v) + } + return _u +} + +// SetAPIKeyID sets the "api_key_id" field. +func (_u *ChatSessionUpdateOne) SetAPIKeyID(v int64) *ChatSessionUpdateOne { + _u.mutation.SetAPIKeyID(v) + return _u +} + +// SetNillableAPIKeyID sets the "api_key_id" field if the given value is not nil. +func (_u *ChatSessionUpdateOne) SetNillableAPIKeyID(v *int64) *ChatSessionUpdateOne { + if v != nil { + _u.SetAPIKeyID(*v) + } + return _u +} + +// SetTitle sets the "title" field. +func (_u *ChatSessionUpdateOne) SetTitle(v string) *ChatSessionUpdateOne { + _u.mutation.SetTitle(v) + return _u +} + +// SetNillableTitle sets the "title" field if the given value is not nil. +func (_u *ChatSessionUpdateOne) SetNillableTitle(v *string) *ChatSessionUpdateOne { + if v != nil { + _u.SetTitle(*v) + } + return _u +} + +// SetModel sets the "model" field. +func (_u *ChatSessionUpdateOne) SetModel(v string) *ChatSessionUpdateOne { + _u.mutation.SetModel(v) + return _u +} + +// SetNillableModel sets the "model" field if the given value is not nil. +func (_u *ChatSessionUpdateOne) SetNillableModel(v *string) *ChatSessionUpdateOne { + if v != nil { + _u.SetModel(*v) + } + return _u +} + +// SetStatus sets the "status" field. +func (_u *ChatSessionUpdateOne) SetStatus(v string) *ChatSessionUpdateOne { + _u.mutation.SetStatus(v) + return _u +} + +// SetNillableStatus sets the "status" field if the given value is not nil. +func (_u *ChatSessionUpdateOne) SetNillableStatus(v *string) *ChatSessionUpdateOne { + if v != nil { + _u.SetStatus(*v) + } + return _u +} + +// SetExpiresAt sets the "expires_at" field. +func (_u *ChatSessionUpdateOne) SetExpiresAt(v time.Time) *ChatSessionUpdateOne { + _u.mutation.SetExpiresAt(v) + return _u +} + +// SetNillableExpiresAt sets the "expires_at" field if the given value is not nil. +func (_u *ChatSessionUpdateOne) SetNillableExpiresAt(v *time.Time) *ChatSessionUpdateOne { + if v != nil { + _u.SetExpiresAt(*v) + } + return _u +} + +// SetUser sets the "user" edge to the User entity. +func (_u *ChatSessionUpdateOne) SetUser(v *User) *ChatSessionUpdateOne { + return _u.SetUserID(v.ID) +} + +// SetAPIKey sets the "api_key" edge to the APIKey entity. +func (_u *ChatSessionUpdateOne) SetAPIKey(v *APIKey) *ChatSessionUpdateOne { + return _u.SetAPIKeyID(v.ID) +} + +// AddMessageIDs adds the "messages" edge to the ChatMessage entity by IDs. +func (_u *ChatSessionUpdateOne) AddMessageIDs(ids ...int64) *ChatSessionUpdateOne { + _u.mutation.AddMessageIDs(ids...) + return _u +} + +// AddMessages adds the "messages" edges to the ChatMessage entity. +func (_u *ChatSessionUpdateOne) AddMessages(v ...*ChatMessage) *ChatSessionUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddMessageIDs(ids...) +} + +// Mutation returns the ChatSessionMutation object of the builder. +func (_u *ChatSessionUpdateOne) Mutation() *ChatSessionMutation { + return _u.mutation +} + +// ClearUser clears the "user" edge to the User entity. +func (_u *ChatSessionUpdateOne) ClearUser() *ChatSessionUpdateOne { + _u.mutation.ClearUser() + return _u +} + +// ClearAPIKey clears the "api_key" edge to the APIKey entity. +func (_u *ChatSessionUpdateOne) ClearAPIKey() *ChatSessionUpdateOne { + _u.mutation.ClearAPIKey() + return _u +} + +// ClearMessages clears all "messages" edges to the ChatMessage entity. +func (_u *ChatSessionUpdateOne) ClearMessages() *ChatSessionUpdateOne { + _u.mutation.ClearMessages() + return _u +} + +// RemoveMessageIDs removes the "messages" edge to ChatMessage entities by IDs. +func (_u *ChatSessionUpdateOne) RemoveMessageIDs(ids ...int64) *ChatSessionUpdateOne { + _u.mutation.RemoveMessageIDs(ids...) + return _u +} + +// RemoveMessages removes "messages" edges to ChatMessage entities. +func (_u *ChatSessionUpdateOne) RemoveMessages(v ...*ChatMessage) *ChatSessionUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveMessageIDs(ids...) +} + +// Where appends a list predicates to the ChatSessionUpdate builder. +func (_u *ChatSessionUpdateOne) Where(ps ...predicate.ChatSession) *ChatSessionUpdateOne { + _u.mutation.Where(ps...) + return _u +} + +// Select allows selecting one or more fields (columns) of the returned entity. +// The default is selecting all fields defined in the entity schema. +func (_u *ChatSessionUpdateOne) Select(field string, fields ...string) *ChatSessionUpdateOne { + _u.fields = append([]string{field}, fields...) + return _u +} + +// Save executes the query and returns the updated ChatSession entity. +func (_u *ChatSessionUpdateOne) Save(ctx context.Context) (*ChatSession, error) { + if err := _u.defaults(); err != nil { + return nil, err + } + return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) +} + +// SaveX is like Save, but panics if an error occurs. +func (_u *ChatSessionUpdateOne) SaveX(ctx context.Context) *ChatSession { + node, err := _u.Save(ctx) + if err != nil { + panic(err) + } + return node +} + +// Exec executes the query on the entity. +func (_u *ChatSessionUpdateOne) Exec(ctx context.Context) error { + _, err := _u.Save(ctx) + return err +} + +// ExecX is like Exec, but panics if an error occurs. +func (_u *ChatSessionUpdateOne) ExecX(ctx context.Context) { + if err := _u.Exec(ctx); err != nil { + panic(err) + } +} + +// defaults sets the default values of the builder before save. +func (_u *ChatSessionUpdateOne) defaults() error { + if _, ok := _u.mutation.UpdatedAt(); !ok { + if chatsession.UpdateDefaultUpdatedAt == nil { + return fmt.Errorf("ent: uninitialized chatsession.UpdateDefaultUpdatedAt (forgotten import ent/runtime?)") + } + v := chatsession.UpdateDefaultUpdatedAt() + _u.mutation.SetUpdatedAt(v) + } + return nil +} + +// check runs all checks and user-defined validators on the builder. +func (_u *ChatSessionUpdateOne) check() error { + if v, ok := _u.mutation.Title(); ok { + if err := chatsession.TitleValidator(v); err != nil { + return &ValidationError{Name: "title", err: fmt.Errorf(`ent: validator failed for field "ChatSession.title": %w`, err)} + } + } + if v, ok := _u.mutation.Model(); ok { + if err := chatsession.ModelValidator(v); err != nil { + return &ValidationError{Name: "model", err: fmt.Errorf(`ent: validator failed for field "ChatSession.model": %w`, err)} + } + } + if v, ok := _u.mutation.Status(); ok { + if err := chatsession.StatusValidator(v); err != nil { + return &ValidationError{Name: "status", err: fmt.Errorf(`ent: validator failed for field "ChatSession.status": %w`, err)} + } + } + if _u.mutation.UserCleared() && len(_u.mutation.UserIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "ChatSession.user"`) + } + if _u.mutation.APIKeyCleared() && len(_u.mutation.APIKeyIDs()) > 0 { + return errors.New(`ent: clearing a required unique edge "ChatSession.api_key"`) + } + return nil +} + +func (_u *ChatSessionUpdateOne) sqlSave(ctx context.Context) (_node *ChatSession, err error) { + if err := _u.check(); err != nil { + return _node, err + } + _spec := sqlgraph.NewUpdateSpec(chatsession.Table, chatsession.Columns, sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64)) + id, ok := _u.mutation.ID() + if !ok { + return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "ChatSession.id" for update`)} + } + _spec.Node.ID.Value = id + if fields := _u.fields; len(fields) > 0 { + _spec.Node.Columns = make([]string, 0, len(fields)) + _spec.Node.Columns = append(_spec.Node.Columns, chatsession.FieldID) + for _, f := range fields { + if !chatsession.ValidColumn(f) { + return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)} + } + if f != chatsession.FieldID { + _spec.Node.Columns = append(_spec.Node.Columns, f) + } + } + } + if ps := _u.mutation.predicates; len(ps) > 0 { + _spec.Predicate = func(selector *sql.Selector) { + for i := range ps { + ps[i](selector) + } + } + } + if value, ok := _u.mutation.UpdatedAt(); ok { + _spec.SetField(chatsession.FieldUpdatedAt, field.TypeTime, value) + } + if value, ok := _u.mutation.DeletedAt(); ok { + _spec.SetField(chatsession.FieldDeletedAt, field.TypeTime, value) + } + if _u.mutation.DeletedAtCleared() { + _spec.ClearField(chatsession.FieldDeletedAt, field.TypeTime) + } + if value, ok := _u.mutation.Title(); ok { + _spec.SetField(chatsession.FieldTitle, field.TypeString, value) + } + if value, ok := _u.mutation.Model(); ok { + _spec.SetField(chatsession.FieldModel, field.TypeString, value) + } + if value, ok := _u.mutation.Status(); ok { + _spec.SetField(chatsession.FieldStatus, field.TypeString, value) + } + if value, ok := _u.mutation.ExpiresAt(); ok { + _spec.SetField(chatsession.FieldExpiresAt, field.TypeTime, value) + } + if _u.mutation.UserCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatsession.UserTable, + Columns: []string{chatsession.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.UserIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatsession.UserTable, + Columns: []string{chatsession.UserColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.APIKeyCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatsession.APIKeyTable, + Columns: []string{chatsession.APIKeyColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(apikey.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.APIKeyIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.M2O, + Inverse: true, + Table: chatsession.APIKeyTable, + Columns: []string{chatsession.APIKeyColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(apikey.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.MessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: chatsession.MessagesTable, + Columns: []string{chatsession.MessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedMessagesIDs(); len(nodes) > 0 && !_u.mutation.MessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: chatsession.MessagesTable, + Columns: []string{chatsession.MessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.MessagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: chatsession.MessagesTable, + Columns: []string{chatsession.MessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + _node = &ChatSession{config: _u.config} + _spec.Assign = _node.assignValues + _spec.ScanValues = _node.scanValues + if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil { + if _, ok := err.(*sqlgraph.NotFoundError); ok { + err = &NotFoundError{chatsession.Label} + } else if sqlgraph.IsConstraintError(err) { + err = &ConstraintError{msg: err.Error(), wrap: err} + } + return nil, err + } + _u.mutation.done = true + return _node, nil +} diff --git a/backend/ent/client.go b/backend/ent/client.go index df20ddfa341..1c2f9914442 100644 --- a/backend/ent/client.go +++ b/backend/ent/client.go @@ -26,6 +26,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" "github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/idempotencyrecord" @@ -80,6 +82,10 @@ type Client struct { ChannelMonitorHistory *ChannelMonitorHistoryClient // ChannelMonitorRequestTemplate is the client for interacting with the ChannelMonitorRequestTemplate builders. ChannelMonitorRequestTemplate *ChannelMonitorRequestTemplateClient + // ChatMessage is the client for interacting with the ChatMessage builders. + ChatMessage *ChatMessageClient + // ChatSession is the client for interacting with the ChatSession builders. + ChatSession *ChatSessionClient // ErrorPassthroughRule is the client for interacting with the ErrorPassthroughRule builders. ErrorPassthroughRule *ErrorPassthroughRuleClient // Group is the client for interacting with the Group builders. @@ -148,6 +154,8 @@ func (c *Client) init() { c.ChannelMonitorDailyRollup = NewChannelMonitorDailyRollupClient(c.config) c.ChannelMonitorHistory = NewChannelMonitorHistoryClient(c.config) c.ChannelMonitorRequestTemplate = NewChannelMonitorRequestTemplateClient(c.config) + c.ChatMessage = NewChatMessageClient(c.config) + c.ChatSession = NewChatSessionClient(c.config) c.ErrorPassthroughRule = NewErrorPassthroughRuleClient(c.config) c.Group = NewGroupClient(c.config) c.IdempotencyRecord = NewIdempotencyRecordClient(c.config) @@ -274,6 +282,8 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) { ChannelMonitorDailyRollup: NewChannelMonitorDailyRollupClient(cfg), ChannelMonitorHistory: NewChannelMonitorHistoryClient(cfg), ChannelMonitorRequestTemplate: NewChannelMonitorRequestTemplateClient(cfg), + ChatMessage: NewChatMessageClient(cfg), + ChatSession: NewChatSessionClient(cfg), ErrorPassthroughRule: NewErrorPassthroughRuleClient(cfg), Group: NewGroupClient(cfg), IdempotencyRecord: NewIdempotencyRecordClient(cfg), @@ -327,6 +337,8 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error) ChannelMonitorDailyRollup: NewChannelMonitorDailyRollupClient(cfg), ChannelMonitorHistory: NewChannelMonitorHistoryClient(cfg), ChannelMonitorRequestTemplate: NewChannelMonitorRequestTemplateClient(cfg), + ChatMessage: NewChatMessageClient(cfg), + ChatSession: NewChatSessionClient(cfg), ErrorPassthroughRule: NewErrorPassthroughRuleClient(cfg), Group: NewGroupClient(cfg), IdempotencyRecord: NewIdempotencyRecordClient(cfg), @@ -382,12 +394,13 @@ func (c *Client) Use(hooks ...Hook) { c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead, c.AuthIdentity, c.AuthIdentityChannel, c.ChannelMonitor, c.ChannelMonitorDailyRollup, c.ChannelMonitorHistory, - c.ChannelMonitorRequestTemplate, c.ErrorPassthroughRule, c.Group, - c.IdempotencyRecord, c.IdentityAdoptionDecision, c.PaymentAuditLog, - c.PaymentOrder, c.PaymentProviderInstance, c.PendingAuthSession, c.PromoCode, - c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, - c.SubscriptionPlan, c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, - c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, + c.ChannelMonitorRequestTemplate, c.ChatMessage, c.ChatSession, + c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, + c.IdentityAdoptionDecision, c.PaymentAuditLog, c.PaymentOrder, + c.PaymentProviderInstance, c.PendingAuthSession, c.PromoCode, c.PromoCodeUsage, + c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, c.SubscriptionPlan, + c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User, + c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription, } { n.Use(hooks...) @@ -401,12 +414,13 @@ func (c *Client) Intercept(interceptors ...Interceptor) { c.APIKey, c.Account, c.AccountGroup, c.Announcement, c.AnnouncementRead, c.AuthIdentity, c.AuthIdentityChannel, c.ChannelMonitor, c.ChannelMonitorDailyRollup, c.ChannelMonitorHistory, - c.ChannelMonitorRequestTemplate, c.ErrorPassthroughRule, c.Group, - c.IdempotencyRecord, c.IdentityAdoptionDecision, c.PaymentAuditLog, - c.PaymentOrder, c.PaymentProviderInstance, c.PendingAuthSession, c.PromoCode, - c.PromoCodeUsage, c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, - c.SubscriptionPlan, c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, - c.User, c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, + c.ChannelMonitorRequestTemplate, c.ChatMessage, c.ChatSession, + c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord, + c.IdentityAdoptionDecision, c.PaymentAuditLog, c.PaymentOrder, + c.PaymentProviderInstance, c.PendingAuthSession, c.PromoCode, c.PromoCodeUsage, + c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, c.SubscriptionPlan, + c.TLSFingerprintProfile, c.UsageCleanupTask, c.UsageLog, c.User, + c.UserAllowedGroup, c.UserAttributeDefinition, c.UserAttributeValue, c.UserSubscription, } { n.Intercept(interceptors...) @@ -438,6 +452,10 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) { return c.ChannelMonitorHistory.mutate(ctx, m) case *ChannelMonitorRequestTemplateMutation: return c.ChannelMonitorRequestTemplate.mutate(ctx, m) + case *ChatMessageMutation: + return c.ChatMessage.mutate(ctx, m) + case *ChatSessionMutation: + return c.ChatSession.mutate(ctx, m) case *ErrorPassthroughRuleMutation: return c.ErrorPassthroughRule.mutate(ctx, m) case *GroupMutation: @@ -645,6 +663,22 @@ func (c *APIKeyClient) QueryUsageLogs(_m *APIKey) *UsageLogQuery { return query } +// QueryChatSessions queries the chat_sessions edge of a APIKey. +func (c *APIKeyClient) QueryChatSessions(_m *APIKey) *ChatSessionQuery { + query := (&ChatSessionClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(apikey.Table, apikey.FieldID, id), + sqlgraph.To(chatsession.Table, chatsession.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, apikey.ChatSessionsTable, apikey.ChatSessionsColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + // Hooks returns the client hooks. func (c *APIKeyClient) Hooks() []Hook { hooks := c.hooks.APIKey @@ -2259,6 +2293,370 @@ func (c *ChannelMonitorRequestTemplateClient) mutate(ctx context.Context, m *Cha } } +// ChatMessageClient is a client for the ChatMessage schema. +type ChatMessageClient struct { + config +} + +// NewChatMessageClient returns a client for the ChatMessage from the given config. +func NewChatMessageClient(c config) *ChatMessageClient { + return &ChatMessageClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `chatmessage.Hooks(f(g(h())))`. +func (c *ChatMessageClient) Use(hooks ...Hook) { + c.hooks.ChatMessage = append(c.hooks.ChatMessage, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `chatmessage.Intercept(f(g(h())))`. +func (c *ChatMessageClient) Intercept(interceptors ...Interceptor) { + c.inters.ChatMessage = append(c.inters.ChatMessage, interceptors...) +} + +// Create returns a builder for creating a ChatMessage entity. +func (c *ChatMessageClient) Create() *ChatMessageCreate { + mutation := newChatMessageMutation(c.config, OpCreate) + return &ChatMessageCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of ChatMessage entities. +func (c *ChatMessageClient) CreateBulk(builders ...*ChatMessageCreate) *ChatMessageCreateBulk { + return &ChatMessageCreateBulk{config: c.config, builders: builders} +} + +// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates +// a builder and applies setFunc on it. +func (c *ChatMessageClient) MapCreateBulk(slice any, setFunc func(*ChatMessageCreate, int)) *ChatMessageCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &ChatMessageCreateBulk{err: fmt.Errorf("calling to ChatMessageClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*ChatMessageCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &ChatMessageCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for ChatMessage. +func (c *ChatMessageClient) Update() *ChatMessageUpdate { + mutation := newChatMessageMutation(c.config, OpUpdate) + return &ChatMessageUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *ChatMessageClient) UpdateOne(_m *ChatMessage) *ChatMessageUpdateOne { + mutation := newChatMessageMutation(c.config, OpUpdateOne, withChatMessage(_m)) + return &ChatMessageUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *ChatMessageClient) UpdateOneID(id int64) *ChatMessageUpdateOne { + mutation := newChatMessageMutation(c.config, OpUpdateOne, withChatMessageID(id)) + return &ChatMessageUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for ChatMessage. +func (c *ChatMessageClient) Delete() *ChatMessageDelete { + mutation := newChatMessageMutation(c.config, OpDelete) + return &ChatMessageDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *ChatMessageClient) DeleteOne(_m *ChatMessage) *ChatMessageDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *ChatMessageClient) DeleteOneID(id int64) *ChatMessageDeleteOne { + builder := c.Delete().Where(chatmessage.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &ChatMessageDeleteOne{builder} +} + +// Query returns a query builder for ChatMessage. +func (c *ChatMessageClient) Query() *ChatMessageQuery { + return &ChatMessageQuery{ + config: c.config, + ctx: &QueryContext{Type: TypeChatMessage}, + inters: c.Interceptors(), + } +} + +// Get returns a ChatMessage entity by its id. +func (c *ChatMessageClient) Get(ctx context.Context, id int64) (*ChatMessage, error) { + return c.Query().Where(chatmessage.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *ChatMessageClient) GetX(ctx context.Context, id int64) *ChatMessage { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// QuerySession queries the session edge of a ChatMessage. +func (c *ChatMessageClient) QuerySession(_m *ChatMessage) *ChatSessionQuery { + query := (&ChatSessionClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(chatmessage.Table, chatmessage.FieldID, id), + sqlgraph.To(chatsession.Table, chatsession.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, chatmessage.SessionTable, chatmessage.SessionColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// QueryUser queries the user edge of a ChatMessage. +func (c *ChatMessageClient) QueryUser(_m *ChatMessage) *UserQuery { + query := (&UserClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(chatmessage.Table, chatmessage.FieldID, id), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, chatmessage.UserTable, chatmessage.UserColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// QueryUsageLog queries the usage_log edge of a ChatMessage. +func (c *ChatMessageClient) QueryUsageLog(_m *ChatMessage) *UsageLogQuery { + query := (&UsageLogClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(chatmessage.Table, chatmessage.FieldID, id), + sqlgraph.To(usagelog.Table, usagelog.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, chatmessage.UsageLogTable, chatmessage.UsageLogColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// Hooks returns the client hooks. +func (c *ChatMessageClient) Hooks() []Hook { + return c.hooks.ChatMessage +} + +// Interceptors returns the client interceptors. +func (c *ChatMessageClient) Interceptors() []Interceptor { + return c.inters.ChatMessage +} + +func (c *ChatMessageClient) mutate(ctx context.Context, m *ChatMessageMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&ChatMessageCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&ChatMessageUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&ChatMessageUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&ChatMessageDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("ent: unknown ChatMessage mutation op: %q", m.Op()) + } +} + +// ChatSessionClient is a client for the ChatSession schema. +type ChatSessionClient struct { + config +} + +// NewChatSessionClient returns a client for the ChatSession from the given config. +func NewChatSessionClient(c config) *ChatSessionClient { + return &ChatSessionClient{config: c} +} + +// Use adds a list of mutation hooks to the hooks stack. +// A call to `Use(f, g, h)` equals to `chatsession.Hooks(f(g(h())))`. +func (c *ChatSessionClient) Use(hooks ...Hook) { + c.hooks.ChatSession = append(c.hooks.ChatSession, hooks...) +} + +// Intercept adds a list of query interceptors to the interceptors stack. +// A call to `Intercept(f, g, h)` equals to `chatsession.Intercept(f(g(h())))`. +func (c *ChatSessionClient) Intercept(interceptors ...Interceptor) { + c.inters.ChatSession = append(c.inters.ChatSession, interceptors...) +} + +// Create returns a builder for creating a ChatSession entity. +func (c *ChatSessionClient) Create() *ChatSessionCreate { + mutation := newChatSessionMutation(c.config, OpCreate) + return &ChatSessionCreate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// CreateBulk returns a builder for creating a bulk of ChatSession entities. +func (c *ChatSessionClient) CreateBulk(builders ...*ChatSessionCreate) *ChatSessionCreateBulk { + return &ChatSessionCreateBulk{config: c.config, builders: builders} +} + +// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates +// a builder and applies setFunc on it. +func (c *ChatSessionClient) MapCreateBulk(slice any, setFunc func(*ChatSessionCreate, int)) *ChatSessionCreateBulk { + rv := reflect.ValueOf(slice) + if rv.Kind() != reflect.Slice { + return &ChatSessionCreateBulk{err: fmt.Errorf("calling to ChatSessionClient.MapCreateBulk with wrong type %T, need slice", slice)} + } + builders := make([]*ChatSessionCreate, rv.Len()) + for i := 0; i < rv.Len(); i++ { + builders[i] = c.Create() + setFunc(builders[i], i) + } + return &ChatSessionCreateBulk{config: c.config, builders: builders} +} + +// Update returns an update builder for ChatSession. +func (c *ChatSessionClient) Update() *ChatSessionUpdate { + mutation := newChatSessionMutation(c.config, OpUpdate) + return &ChatSessionUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOne returns an update builder for the given entity. +func (c *ChatSessionClient) UpdateOne(_m *ChatSession) *ChatSessionUpdateOne { + mutation := newChatSessionMutation(c.config, OpUpdateOne, withChatSession(_m)) + return &ChatSessionUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// UpdateOneID returns an update builder for the given id. +func (c *ChatSessionClient) UpdateOneID(id int64) *ChatSessionUpdateOne { + mutation := newChatSessionMutation(c.config, OpUpdateOne, withChatSessionID(id)) + return &ChatSessionUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// Delete returns a delete builder for ChatSession. +func (c *ChatSessionClient) Delete() *ChatSessionDelete { + mutation := newChatSessionMutation(c.config, OpDelete) + return &ChatSessionDelete{config: c.config, hooks: c.Hooks(), mutation: mutation} +} + +// DeleteOne returns a builder for deleting the given entity. +func (c *ChatSessionClient) DeleteOne(_m *ChatSession) *ChatSessionDeleteOne { + return c.DeleteOneID(_m.ID) +} + +// DeleteOneID returns a builder for deleting the given entity by its id. +func (c *ChatSessionClient) DeleteOneID(id int64) *ChatSessionDeleteOne { + builder := c.Delete().Where(chatsession.ID(id)) + builder.mutation.id = &id + builder.mutation.op = OpDeleteOne + return &ChatSessionDeleteOne{builder} +} + +// Query returns a query builder for ChatSession. +func (c *ChatSessionClient) Query() *ChatSessionQuery { + return &ChatSessionQuery{ + config: c.config, + ctx: &QueryContext{Type: TypeChatSession}, + inters: c.Interceptors(), + } +} + +// Get returns a ChatSession entity by its id. +func (c *ChatSessionClient) Get(ctx context.Context, id int64) (*ChatSession, error) { + return c.Query().Where(chatsession.ID(id)).Only(ctx) +} + +// GetX is like Get, but panics if an error occurs. +func (c *ChatSessionClient) GetX(ctx context.Context, id int64) *ChatSession { + obj, err := c.Get(ctx, id) + if err != nil { + panic(err) + } + return obj +} + +// QueryUser queries the user edge of a ChatSession. +func (c *ChatSessionClient) QueryUser(_m *ChatSession) *UserQuery { + query := (&UserClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(chatsession.Table, chatsession.FieldID, id), + sqlgraph.To(user.Table, user.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, chatsession.UserTable, chatsession.UserColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// QueryAPIKey queries the api_key edge of a ChatSession. +func (c *ChatSessionClient) QueryAPIKey(_m *ChatSession) *APIKeyQuery { + query := (&APIKeyClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(chatsession.Table, chatsession.FieldID, id), + sqlgraph.To(apikey.Table, apikey.FieldID), + sqlgraph.Edge(sqlgraph.M2O, true, chatsession.APIKeyTable, chatsession.APIKeyColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// QueryMessages queries the messages edge of a ChatSession. +func (c *ChatSessionClient) QueryMessages(_m *ChatSession) *ChatMessageQuery { + query := (&ChatMessageClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(chatsession.Table, chatsession.FieldID, id), + sqlgraph.To(chatmessage.Table, chatmessage.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, chatsession.MessagesTable, chatsession.MessagesColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// Hooks returns the client hooks. +func (c *ChatSessionClient) Hooks() []Hook { + hooks := c.hooks.ChatSession + return append(hooks[:len(hooks):len(hooks)], chatsession.Hooks[:]...) +} + +// Interceptors returns the client interceptors. +func (c *ChatSessionClient) Interceptors() []Interceptor { + inters := c.inters.ChatSession + return append(inters[:len(inters):len(inters)], chatsession.Interceptors[:]...) +} + +func (c *ChatSessionClient) mutate(ctx context.Context, m *ChatSessionMutation) (Value, error) { + switch m.Op() { + case OpCreate: + return (&ChatSessionCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdate: + return (&ChatSessionUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpUpdateOne: + return (&ChatSessionUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx) + case OpDelete, OpDeleteOne: + return (&ChatSessionDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx) + default: + return nil, fmt.Errorf("ent: unknown ChatSession mutation op: %q", m.Op()) + } +} + // ErrorPassthroughRuleClient is a client for the ErrorPassthroughRule schema. type ErrorPassthroughRuleClient struct { config @@ -5016,6 +5414,22 @@ func (c *UsageLogClient) QuerySubscription(_m *UsageLog) *UserSubscriptionQuery return query } +// QueryChatMessages queries the chat_messages edge of a UsageLog. +func (c *UsageLogClient) QueryChatMessages(_m *UsageLog) *ChatMessageQuery { + query := (&ChatMessageClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(usagelog.Table, usagelog.FieldID, id), + sqlgraph.To(chatmessage.Table, chatmessage.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, usagelog.ChatMessagesTable, usagelog.ChatMessagesColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + // Hooks returns the client hooks. func (c *UsageLogClient) Hooks() []Hook { return c.hooks.UsageLog @@ -5261,6 +5675,38 @@ func (c *UserClient) QueryUsageLogs(_m *User) *UsageLogQuery { return query } +// QueryChatSessions queries the chat_sessions edge of a User. +func (c *UserClient) QueryChatSessions(_m *User) *ChatSessionQuery { + query := (&ChatSessionClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(user.Table, user.FieldID, id), + sqlgraph.To(chatsession.Table, chatsession.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.ChatSessionsTable, user.ChatSessionsColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + +// QueryChatMessages queries the chat_messages edge of a User. +func (c *UserClient) QueryChatMessages(_m *User) *ChatMessageQuery { + query := (&ChatMessageClient{config: c.config}).Query() + query.path = func(context.Context) (fromV *sql.Selector, _ error) { + id := _m.ID + step := sqlgraph.NewStep( + sqlgraph.From(user.Table, user.FieldID, id), + sqlgraph.To(chatmessage.Table, chatmessage.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.ChatMessagesTable, user.ChatMessagesColumn), + ) + fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step) + return fromV, nil + } + return query +} + // QueryAttributeValues queries the attribute_values edge of a User. func (c *UserClient) QueryAttributeValues(_m *User) *UserAttributeValueQuery { query := (&UserAttributeValueClient{config: c.config}).Query() @@ -6020,22 +6466,24 @@ type ( hooks struct { APIKey, Account, AccountGroup, Announcement, AnnouncementRead, AuthIdentity, AuthIdentityChannel, ChannelMonitor, ChannelMonitorDailyRollup, - ChannelMonitorHistory, ChannelMonitorRequestTemplate, ErrorPassthroughRule, - Group, IdempotencyRecord, IdentityAdoptionDecision, PaymentAuditLog, - PaymentOrder, PaymentProviderInstance, PendingAuthSession, PromoCode, - PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan, - TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup, - UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Hook + ChannelMonitorHistory, ChannelMonitorRequestTemplate, ChatMessage, ChatSession, + ErrorPassthroughRule, Group, IdempotencyRecord, IdentityAdoptionDecision, + PaymentAuditLog, PaymentOrder, PaymentProviderInstance, PendingAuthSession, + PromoCode, PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, + SubscriptionPlan, TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, + UserAllowedGroup, UserAttributeDefinition, UserAttributeValue, + UserSubscription []ent.Hook } inters struct { APIKey, Account, AccountGroup, Announcement, AnnouncementRead, AuthIdentity, AuthIdentityChannel, ChannelMonitor, ChannelMonitorDailyRollup, - ChannelMonitorHistory, ChannelMonitorRequestTemplate, ErrorPassthroughRule, - Group, IdempotencyRecord, IdentityAdoptionDecision, PaymentAuditLog, - PaymentOrder, PaymentProviderInstance, PendingAuthSession, PromoCode, - PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan, - TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup, - UserAttributeDefinition, UserAttributeValue, UserSubscription []ent.Interceptor + ChannelMonitorHistory, ChannelMonitorRequestTemplate, ChatMessage, ChatSession, + ErrorPassthroughRule, Group, IdempotencyRecord, IdentityAdoptionDecision, + PaymentAuditLog, PaymentOrder, PaymentProviderInstance, PendingAuthSession, + PromoCode, PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, + SubscriptionPlan, TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, + UserAllowedGroup, UserAttributeDefinition, UserAttributeValue, + UserSubscription []ent.Interceptor } ) diff --git a/backend/ent/ent.go b/backend/ent/ent.go index c9fcc314e95..bb09aa07586 100644 --- a/backend/ent/ent.go +++ b/backend/ent/ent.go @@ -23,6 +23,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" "github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/idempotencyrecord" @@ -117,6 +119,8 @@ func checkColumn(t, c string) error { channelmonitordailyrollup.Table: channelmonitordailyrollup.ValidColumn, channelmonitorhistory.Table: channelmonitorhistory.ValidColumn, channelmonitorrequesttemplate.Table: channelmonitorrequesttemplate.ValidColumn, + chatmessage.Table: chatmessage.ValidColumn, + chatsession.Table: chatsession.ValidColumn, errorpassthroughrule.Table: errorpassthroughrule.ValidColumn, group.Table: group.ValidColumn, idempotencyrecord.Table: idempotencyrecord.ValidColumn, diff --git a/backend/ent/group.go b/backend/ent/group.go index 5d9ae2ed203..a4f52c73295 100644 --- a/backend/ent/group.go +++ b/backend/ent/group.go @@ -47,6 +47,12 @@ type Group struct { MonthlyLimitUsd *float64 `json:"monthly_limit_usd,omitempty"` // DefaultValidityDays holds the value of the "default_validity_days" field. DefaultValidityDays int `json:"default_validity_days,omitempty"` + // 是否允许该分组使用图片生成能力 + AllowImageGeneration bool `json:"allow_image_generation,omitempty"` + // 图片生成是否使用独立倍率;false 表示共享分组有效倍率 + ImageRateIndependent bool `json:"image_rate_independent,omitempty"` + // 图片生成独立倍率,仅 image_rate_independent=true 时生效 + ImageRateMultiplier float64 `json:"image_rate_multiplier,omitempty"` // ImagePrice1k holds the value of the "image_price_1k" field. ImagePrice1k *float64 `json:"image_price_1k,omitempty"` // ImagePrice2k holds the value of the "image_price_2k" field. @@ -189,9 +195,9 @@ func (*Group) scanValues(columns []string) ([]any, error) { switch columns[i] { case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig: values[i] = new([]byte) - case group.FieldIsExclusive, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: + case group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldImageRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet: values[i] = new(sql.NullBool) - case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k: + case group.FieldRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k: values[i] = new(sql.NullFloat64) case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit: values[i] = new(sql.NullInt64) @@ -309,6 +315,24 @@ func (_m *Group) assignValues(columns []string, values []any) error { } else if value.Valid { _m.DefaultValidityDays = int(value.Int64) } + case group.FieldAllowImageGeneration: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field allow_image_generation", values[i]) + } else if value.Valid { + _m.AllowImageGeneration = value.Bool + } + case group.FieldImageRateIndependent: + if value, ok := values[i].(*sql.NullBool); !ok { + return fmt.Errorf("unexpected type %T for field image_rate_independent", values[i]) + } else if value.Valid { + _m.ImageRateIndependent = value.Bool + } + case group.FieldImageRateMultiplier: + if value, ok := values[i].(*sql.NullFloat64); !ok { + return fmt.Errorf("unexpected type %T for field image_rate_multiplier", values[i]) + } else if value.Valid { + _m.ImageRateMultiplier = value.Float64 + } case group.FieldImagePrice1k: if value, ok := values[i].(*sql.NullFloat64); !ok { return fmt.Errorf("unexpected type %T for field image_price_1k", values[i]) @@ -550,6 +574,15 @@ func (_m *Group) String() string { builder.WriteString("default_validity_days=") builder.WriteString(fmt.Sprintf("%v", _m.DefaultValidityDays)) builder.WriteString(", ") + builder.WriteString("allow_image_generation=") + builder.WriteString(fmt.Sprintf("%v", _m.AllowImageGeneration)) + builder.WriteString(", ") + builder.WriteString("image_rate_independent=") + builder.WriteString(fmt.Sprintf("%v", _m.ImageRateIndependent)) + builder.WriteString(", ") + builder.WriteString("image_rate_multiplier=") + builder.WriteString(fmt.Sprintf("%v", _m.ImageRateMultiplier)) + builder.WriteString(", ") if v := _m.ImagePrice1k; v != nil { builder.WriteString("image_price_1k=") builder.WriteString(fmt.Sprintf("%v", *v)) diff --git a/backend/ent/group/group.go b/backend/ent/group/group.go index 24bd9c13d63..4e9ba6b601f 100644 --- a/backend/ent/group/group.go +++ b/backend/ent/group/group.go @@ -44,6 +44,12 @@ const ( FieldMonthlyLimitUsd = "monthly_limit_usd" // FieldDefaultValidityDays holds the string denoting the default_validity_days field in the database. FieldDefaultValidityDays = "default_validity_days" + // FieldAllowImageGeneration holds the string denoting the allow_image_generation field in the database. + FieldAllowImageGeneration = "allow_image_generation" + // FieldImageRateIndependent holds the string denoting the image_rate_independent field in the database. + FieldImageRateIndependent = "image_rate_independent" + // FieldImageRateMultiplier holds the string denoting the image_rate_multiplier field in the database. + FieldImageRateMultiplier = "image_rate_multiplier" // FieldImagePrice1k holds the string denoting the image_price_1k field in the database. FieldImagePrice1k = "image_price_1k" // FieldImagePrice2k holds the string denoting the image_price_2k field in the database. @@ -167,6 +173,9 @@ var Columns = []string{ FieldWeeklyLimitUsd, FieldMonthlyLimitUsd, FieldDefaultValidityDays, + FieldAllowImageGeneration, + FieldImageRateIndependent, + FieldImageRateMultiplier, FieldImagePrice1k, FieldImagePrice2k, FieldImagePrice4k, @@ -239,6 +248,12 @@ var ( SubscriptionTypeValidator func(string) error // DefaultDefaultValidityDays holds the default value on creation for the "default_validity_days" field. DefaultDefaultValidityDays int + // DefaultAllowImageGeneration holds the default value on creation for the "allow_image_generation" field. + DefaultAllowImageGeneration bool + // DefaultImageRateIndependent holds the default value on creation for the "image_rate_independent" field. + DefaultImageRateIndependent bool + // DefaultImageRateMultiplier holds the default value on creation for the "image_rate_multiplier" field. + DefaultImageRateMultiplier float64 // DefaultClaudeCodeOnly holds the default value on creation for the "claude_code_only" field. DefaultClaudeCodeOnly bool // DefaultModelRoutingEnabled holds the default value on creation for the "model_routing_enabled" field. @@ -343,6 +358,21 @@ func ByDefaultValidityDays(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldDefaultValidityDays, opts...).ToFunc() } +// ByAllowImageGeneration orders the results by the allow_image_generation field. +func ByAllowImageGeneration(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldAllowImageGeneration, opts...).ToFunc() +} + +// ByImageRateIndependent orders the results by the image_rate_independent field. +func ByImageRateIndependent(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldImageRateIndependent, opts...).ToFunc() +} + +// ByImageRateMultiplier orders the results by the image_rate_multiplier field. +func ByImageRateMultiplier(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldImageRateMultiplier, opts...).ToFunc() +} + // ByImagePrice1k orders the results by the image_price_1k field. func ByImagePrice1k(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldImagePrice1k, opts...).ToFunc() diff --git a/backend/ent/group/where.go b/backend/ent/group/where.go index 2814d130f13..d3223a92577 100644 --- a/backend/ent/group/where.go +++ b/backend/ent/group/where.go @@ -125,6 +125,21 @@ func DefaultValidityDays(v int) predicate.Group { return predicate.Group(sql.FieldEQ(FieldDefaultValidityDays, v)) } +// AllowImageGeneration applies equality check predicate on the "allow_image_generation" field. It's identical to AllowImageGenerationEQ. +func AllowImageGeneration(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAllowImageGeneration, v)) +} + +// ImageRateIndependent applies equality check predicate on the "image_rate_independent" field. It's identical to ImageRateIndependentEQ. +func ImageRateIndependent(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldImageRateIndependent, v)) +} + +// ImageRateMultiplier applies equality check predicate on the "image_rate_multiplier" field. It's identical to ImageRateMultiplierEQ. +func ImageRateMultiplier(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldImageRateMultiplier, v)) +} + // ImagePrice1k applies equality check predicate on the "image_price_1k" field. It's identical to ImagePrice1kEQ. func ImagePrice1k(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldImagePrice1k, v)) @@ -900,6 +915,66 @@ func DefaultValidityDaysLTE(v int) predicate.Group { return predicate.Group(sql.FieldLTE(FieldDefaultValidityDays, v)) } +// AllowImageGenerationEQ applies the EQ predicate on the "allow_image_generation" field. +func AllowImageGenerationEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldAllowImageGeneration, v)) +} + +// AllowImageGenerationNEQ applies the NEQ predicate on the "allow_image_generation" field. +func AllowImageGenerationNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldAllowImageGeneration, v)) +} + +// ImageRateIndependentEQ applies the EQ predicate on the "image_rate_independent" field. +func ImageRateIndependentEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldImageRateIndependent, v)) +} + +// ImageRateIndependentNEQ applies the NEQ predicate on the "image_rate_independent" field. +func ImageRateIndependentNEQ(v bool) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldImageRateIndependent, v)) +} + +// ImageRateMultiplierEQ applies the EQ predicate on the "image_rate_multiplier" field. +func ImageRateMultiplierEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldEQ(FieldImageRateMultiplier, v)) +} + +// ImageRateMultiplierNEQ applies the NEQ predicate on the "image_rate_multiplier" field. +func ImageRateMultiplierNEQ(v float64) predicate.Group { + return predicate.Group(sql.FieldNEQ(FieldImageRateMultiplier, v)) +} + +// ImageRateMultiplierIn applies the In predicate on the "image_rate_multiplier" field. +func ImageRateMultiplierIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldIn(FieldImageRateMultiplier, vs...)) +} + +// ImageRateMultiplierNotIn applies the NotIn predicate on the "image_rate_multiplier" field. +func ImageRateMultiplierNotIn(vs ...float64) predicate.Group { + return predicate.Group(sql.FieldNotIn(FieldImageRateMultiplier, vs...)) +} + +// ImageRateMultiplierGT applies the GT predicate on the "image_rate_multiplier" field. +func ImageRateMultiplierGT(v float64) predicate.Group { + return predicate.Group(sql.FieldGT(FieldImageRateMultiplier, v)) +} + +// ImageRateMultiplierGTE applies the GTE predicate on the "image_rate_multiplier" field. +func ImageRateMultiplierGTE(v float64) predicate.Group { + return predicate.Group(sql.FieldGTE(FieldImageRateMultiplier, v)) +} + +// ImageRateMultiplierLT applies the LT predicate on the "image_rate_multiplier" field. +func ImageRateMultiplierLT(v float64) predicate.Group { + return predicate.Group(sql.FieldLT(FieldImageRateMultiplier, v)) +} + +// ImageRateMultiplierLTE applies the LTE predicate on the "image_rate_multiplier" field. +func ImageRateMultiplierLTE(v float64) predicate.Group { + return predicate.Group(sql.FieldLTE(FieldImageRateMultiplier, v)) +} + // ImagePrice1kEQ applies the EQ predicate on the "image_price_1k" field. func ImagePrice1kEQ(v float64) predicate.Group { return predicate.Group(sql.FieldEQ(FieldImagePrice1k, v)) diff --git a/backend/ent/group_create.go b/backend/ent/group_create.go index 20ea0a0fe71..44b905bd04b 100644 --- a/backend/ent/group_create.go +++ b/backend/ent/group_create.go @@ -217,6 +217,48 @@ func (_c *GroupCreate) SetNillableDefaultValidityDays(v *int) *GroupCreate { return _c } +// SetAllowImageGeneration sets the "allow_image_generation" field. +func (_c *GroupCreate) SetAllowImageGeneration(v bool) *GroupCreate { + _c.mutation.SetAllowImageGeneration(v) + return _c +} + +// SetNillableAllowImageGeneration sets the "allow_image_generation" field if the given value is not nil. +func (_c *GroupCreate) SetNillableAllowImageGeneration(v *bool) *GroupCreate { + if v != nil { + _c.SetAllowImageGeneration(*v) + } + return _c +} + +// SetImageRateIndependent sets the "image_rate_independent" field. +func (_c *GroupCreate) SetImageRateIndependent(v bool) *GroupCreate { + _c.mutation.SetImageRateIndependent(v) + return _c +} + +// SetNillableImageRateIndependent sets the "image_rate_independent" field if the given value is not nil. +func (_c *GroupCreate) SetNillableImageRateIndependent(v *bool) *GroupCreate { + if v != nil { + _c.SetImageRateIndependent(*v) + } + return _c +} + +// SetImageRateMultiplier sets the "image_rate_multiplier" field. +func (_c *GroupCreate) SetImageRateMultiplier(v float64) *GroupCreate { + _c.mutation.SetImageRateMultiplier(v) + return _c +} + +// SetNillableImageRateMultiplier sets the "image_rate_multiplier" field if the given value is not nil. +func (_c *GroupCreate) SetNillableImageRateMultiplier(v *float64) *GroupCreate { + if v != nil { + _c.SetImageRateMultiplier(*v) + } + return _c +} + // SetImagePrice1k sets the "image_price_1k" field. func (_c *GroupCreate) SetImagePrice1k(v float64) *GroupCreate { _c.mutation.SetImagePrice1k(v) @@ -604,6 +646,18 @@ func (_c *GroupCreate) defaults() error { v := group.DefaultDefaultValidityDays _c.mutation.SetDefaultValidityDays(v) } + if _, ok := _c.mutation.AllowImageGeneration(); !ok { + v := group.DefaultAllowImageGeneration + _c.mutation.SetAllowImageGeneration(v) + } + if _, ok := _c.mutation.ImageRateIndependent(); !ok { + v := group.DefaultImageRateIndependent + _c.mutation.SetImageRateIndependent(v) + } + if _, ok := _c.mutation.ImageRateMultiplier(); !ok { + v := group.DefaultImageRateMultiplier + _c.mutation.SetImageRateMultiplier(v) + } if _, ok := _c.mutation.ClaudeCodeOnly(); !ok { v := group.DefaultClaudeCodeOnly _c.mutation.SetClaudeCodeOnly(v) @@ -700,6 +754,15 @@ func (_c *GroupCreate) check() error { if _, ok := _c.mutation.DefaultValidityDays(); !ok { return &ValidationError{Name: "default_validity_days", err: errors.New(`ent: missing required field "Group.default_validity_days"`)} } + if _, ok := _c.mutation.AllowImageGeneration(); !ok { + return &ValidationError{Name: "allow_image_generation", err: errors.New(`ent: missing required field "Group.allow_image_generation"`)} + } + if _, ok := _c.mutation.ImageRateIndependent(); !ok { + return &ValidationError{Name: "image_rate_independent", err: errors.New(`ent: missing required field "Group.image_rate_independent"`)} + } + if _, ok := _c.mutation.ImageRateMultiplier(); !ok { + return &ValidationError{Name: "image_rate_multiplier", err: errors.New(`ent: missing required field "Group.image_rate_multiplier"`)} + } if _, ok := _c.mutation.ClaudeCodeOnly(); !ok { return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)} } @@ -821,6 +884,18 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) { _spec.SetField(group.FieldDefaultValidityDays, field.TypeInt, value) _node.DefaultValidityDays = value } + if value, ok := _c.mutation.AllowImageGeneration(); ok { + _spec.SetField(group.FieldAllowImageGeneration, field.TypeBool, value) + _node.AllowImageGeneration = value + } + if value, ok := _c.mutation.ImageRateIndependent(); ok { + _spec.SetField(group.FieldImageRateIndependent, field.TypeBool, value) + _node.ImageRateIndependent = value + } + if value, ok := _c.mutation.ImageRateMultiplier(); ok { + _spec.SetField(group.FieldImageRateMultiplier, field.TypeFloat64, value) + _node.ImageRateMultiplier = value + } if value, ok := _c.mutation.ImagePrice1k(); ok { _spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value) _node.ImagePrice1k = &value @@ -1261,6 +1336,48 @@ func (u *GroupUpsert) AddDefaultValidityDays(v int) *GroupUpsert { return u } +// SetAllowImageGeneration sets the "allow_image_generation" field. +func (u *GroupUpsert) SetAllowImageGeneration(v bool) *GroupUpsert { + u.Set(group.FieldAllowImageGeneration, v) + return u +} + +// UpdateAllowImageGeneration sets the "allow_image_generation" field to the value that was provided on create. +func (u *GroupUpsert) UpdateAllowImageGeneration() *GroupUpsert { + u.SetExcluded(group.FieldAllowImageGeneration) + return u +} + +// SetImageRateIndependent sets the "image_rate_independent" field. +func (u *GroupUpsert) SetImageRateIndependent(v bool) *GroupUpsert { + u.Set(group.FieldImageRateIndependent, v) + return u +} + +// UpdateImageRateIndependent sets the "image_rate_independent" field to the value that was provided on create. +func (u *GroupUpsert) UpdateImageRateIndependent() *GroupUpsert { + u.SetExcluded(group.FieldImageRateIndependent) + return u +} + +// SetImageRateMultiplier sets the "image_rate_multiplier" field. +func (u *GroupUpsert) SetImageRateMultiplier(v float64) *GroupUpsert { + u.Set(group.FieldImageRateMultiplier, v) + return u +} + +// UpdateImageRateMultiplier sets the "image_rate_multiplier" field to the value that was provided on create. +func (u *GroupUpsert) UpdateImageRateMultiplier() *GroupUpsert { + u.SetExcluded(group.FieldImageRateMultiplier) + return u +} + +// AddImageRateMultiplier adds v to the "image_rate_multiplier" field. +func (u *GroupUpsert) AddImageRateMultiplier(v float64) *GroupUpsert { + u.Add(group.FieldImageRateMultiplier, v) + return u +} + // SetImagePrice1k sets the "image_price_1k" field. func (u *GroupUpsert) SetImagePrice1k(v float64) *GroupUpsert { u.Set(group.FieldImagePrice1k, v) @@ -1840,6 +1957,55 @@ func (u *GroupUpsertOne) UpdateDefaultValidityDays() *GroupUpsertOne { }) } +// SetAllowImageGeneration sets the "allow_image_generation" field. +func (u *GroupUpsertOne) SetAllowImageGeneration(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetAllowImageGeneration(v) + }) +} + +// UpdateAllowImageGeneration sets the "allow_image_generation" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateAllowImageGeneration() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateAllowImageGeneration() + }) +} + +// SetImageRateIndependent sets the "image_rate_independent" field. +func (u *GroupUpsertOne) SetImageRateIndependent(v bool) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetImageRateIndependent(v) + }) +} + +// UpdateImageRateIndependent sets the "image_rate_independent" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateImageRateIndependent() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateImageRateIndependent() + }) +} + +// SetImageRateMultiplier sets the "image_rate_multiplier" field. +func (u *GroupUpsertOne) SetImageRateMultiplier(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.SetImageRateMultiplier(v) + }) +} + +// AddImageRateMultiplier adds v to the "image_rate_multiplier" field. +func (u *GroupUpsertOne) AddImageRateMultiplier(v float64) *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.AddImageRateMultiplier(v) + }) +} + +// UpdateImageRateMultiplier sets the "image_rate_multiplier" field to the value that was provided on create. +func (u *GroupUpsertOne) UpdateImageRateMultiplier() *GroupUpsertOne { + return u.Update(func(s *GroupUpsert) { + s.UpdateImageRateMultiplier() + }) +} + // SetImagePrice1k sets the "image_price_1k" field. func (u *GroupUpsertOne) SetImagePrice1k(v float64) *GroupUpsertOne { return u.Update(func(s *GroupUpsert) { @@ -2632,6 +2798,55 @@ func (u *GroupUpsertBulk) UpdateDefaultValidityDays() *GroupUpsertBulk { }) } +// SetAllowImageGeneration sets the "allow_image_generation" field. +func (u *GroupUpsertBulk) SetAllowImageGeneration(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetAllowImageGeneration(v) + }) +} + +// UpdateAllowImageGeneration sets the "allow_image_generation" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateAllowImageGeneration() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateAllowImageGeneration() + }) +} + +// SetImageRateIndependent sets the "image_rate_independent" field. +func (u *GroupUpsertBulk) SetImageRateIndependent(v bool) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetImageRateIndependent(v) + }) +} + +// UpdateImageRateIndependent sets the "image_rate_independent" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateImageRateIndependent() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateImageRateIndependent() + }) +} + +// SetImageRateMultiplier sets the "image_rate_multiplier" field. +func (u *GroupUpsertBulk) SetImageRateMultiplier(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.SetImageRateMultiplier(v) + }) +} + +// AddImageRateMultiplier adds v to the "image_rate_multiplier" field. +func (u *GroupUpsertBulk) AddImageRateMultiplier(v float64) *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.AddImageRateMultiplier(v) + }) +} + +// UpdateImageRateMultiplier sets the "image_rate_multiplier" field to the value that was provided on create. +func (u *GroupUpsertBulk) UpdateImageRateMultiplier() *GroupUpsertBulk { + return u.Update(func(s *GroupUpsert) { + s.UpdateImageRateMultiplier() + }) +} + // SetImagePrice1k sets the "image_price_1k" field. func (u *GroupUpsertBulk) SetImagePrice1k(v float64) *GroupUpsertBulk { return u.Update(func(s *GroupUpsert) { diff --git a/backend/ent/group_update.go b/backend/ent/group_update.go index cc14f897d62..fe55982c3f4 100644 --- a/backend/ent/group_update.go +++ b/backend/ent/group_update.go @@ -275,6 +275,55 @@ func (_u *GroupUpdate) AddDefaultValidityDays(v int) *GroupUpdate { return _u } +// SetAllowImageGeneration sets the "allow_image_generation" field. +func (_u *GroupUpdate) SetAllowImageGeneration(v bool) *GroupUpdate { + _u.mutation.SetAllowImageGeneration(v) + return _u +} + +// SetNillableAllowImageGeneration sets the "allow_image_generation" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableAllowImageGeneration(v *bool) *GroupUpdate { + if v != nil { + _u.SetAllowImageGeneration(*v) + } + return _u +} + +// SetImageRateIndependent sets the "image_rate_independent" field. +func (_u *GroupUpdate) SetImageRateIndependent(v bool) *GroupUpdate { + _u.mutation.SetImageRateIndependent(v) + return _u +} + +// SetNillableImageRateIndependent sets the "image_rate_independent" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableImageRateIndependent(v *bool) *GroupUpdate { + if v != nil { + _u.SetImageRateIndependent(*v) + } + return _u +} + +// SetImageRateMultiplier sets the "image_rate_multiplier" field. +func (_u *GroupUpdate) SetImageRateMultiplier(v float64) *GroupUpdate { + _u.mutation.ResetImageRateMultiplier() + _u.mutation.SetImageRateMultiplier(v) + return _u +} + +// SetNillableImageRateMultiplier sets the "image_rate_multiplier" field if the given value is not nil. +func (_u *GroupUpdate) SetNillableImageRateMultiplier(v *float64) *GroupUpdate { + if v != nil { + _u.SetImageRateMultiplier(*v) + } + return _u +} + +// AddImageRateMultiplier adds value to the "image_rate_multiplier" field. +func (_u *GroupUpdate) AddImageRateMultiplier(v float64) *GroupUpdate { + _u.mutation.AddImageRateMultiplier(v) + return _u +} + // SetImagePrice1k sets the "image_price_1k" field. func (_u *GroupUpdate) SetImagePrice1k(v float64) *GroupUpdate { _u.mutation.ResetImagePrice1k() @@ -962,6 +1011,18 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) { if value, ok := _u.mutation.AddedDefaultValidityDays(); ok { _spec.AddField(group.FieldDefaultValidityDays, field.TypeInt, value) } + if value, ok := _u.mutation.AllowImageGeneration(); ok { + _spec.SetField(group.FieldAllowImageGeneration, field.TypeBool, value) + } + if value, ok := _u.mutation.ImageRateIndependent(); ok { + _spec.SetField(group.FieldImageRateIndependent, field.TypeBool, value) + } + if value, ok := _u.mutation.ImageRateMultiplier(); ok { + _spec.SetField(group.FieldImageRateMultiplier, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedImageRateMultiplier(); ok { + _spec.AddField(group.FieldImageRateMultiplier, field.TypeFloat64, value) + } if value, ok := _u.mutation.ImagePrice1k(); ok { _spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value) } @@ -1610,6 +1671,55 @@ func (_u *GroupUpdateOne) AddDefaultValidityDays(v int) *GroupUpdateOne { return _u } +// SetAllowImageGeneration sets the "allow_image_generation" field. +func (_u *GroupUpdateOne) SetAllowImageGeneration(v bool) *GroupUpdateOne { + _u.mutation.SetAllowImageGeneration(v) + return _u +} + +// SetNillableAllowImageGeneration sets the "allow_image_generation" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableAllowImageGeneration(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetAllowImageGeneration(*v) + } + return _u +} + +// SetImageRateIndependent sets the "image_rate_independent" field. +func (_u *GroupUpdateOne) SetImageRateIndependent(v bool) *GroupUpdateOne { + _u.mutation.SetImageRateIndependent(v) + return _u +} + +// SetNillableImageRateIndependent sets the "image_rate_independent" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableImageRateIndependent(v *bool) *GroupUpdateOne { + if v != nil { + _u.SetImageRateIndependent(*v) + } + return _u +} + +// SetImageRateMultiplier sets the "image_rate_multiplier" field. +func (_u *GroupUpdateOne) SetImageRateMultiplier(v float64) *GroupUpdateOne { + _u.mutation.ResetImageRateMultiplier() + _u.mutation.SetImageRateMultiplier(v) + return _u +} + +// SetNillableImageRateMultiplier sets the "image_rate_multiplier" field if the given value is not nil. +func (_u *GroupUpdateOne) SetNillableImageRateMultiplier(v *float64) *GroupUpdateOne { + if v != nil { + _u.SetImageRateMultiplier(*v) + } + return _u +} + +// AddImageRateMultiplier adds value to the "image_rate_multiplier" field. +func (_u *GroupUpdateOne) AddImageRateMultiplier(v float64) *GroupUpdateOne { + _u.mutation.AddImageRateMultiplier(v) + return _u +} + // SetImagePrice1k sets the "image_price_1k" field. func (_u *GroupUpdateOne) SetImagePrice1k(v float64) *GroupUpdateOne { _u.mutation.ResetImagePrice1k() @@ -2327,6 +2437,18 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error) if value, ok := _u.mutation.AddedDefaultValidityDays(); ok { _spec.AddField(group.FieldDefaultValidityDays, field.TypeInt, value) } + if value, ok := _u.mutation.AllowImageGeneration(); ok { + _spec.SetField(group.FieldAllowImageGeneration, field.TypeBool, value) + } + if value, ok := _u.mutation.ImageRateIndependent(); ok { + _spec.SetField(group.FieldImageRateIndependent, field.TypeBool, value) + } + if value, ok := _u.mutation.ImageRateMultiplier(); ok { + _spec.SetField(group.FieldImageRateMultiplier, field.TypeFloat64, value) + } + if value, ok := _u.mutation.AddedImageRateMultiplier(); ok { + _spec.AddField(group.FieldImageRateMultiplier, field.TypeFloat64, value) + } if value, ok := _u.mutation.ImagePrice1k(); ok { _spec.SetField(group.FieldImagePrice1k, field.TypeFloat64, value) } diff --git a/backend/ent/hook/hook.go b/backend/ent/hook/hook.go index 414eba242c6..7c7eaeb9ed0 100644 --- a/backend/ent/hook/hook.go +++ b/backend/ent/hook/hook.go @@ -141,6 +141,30 @@ func (f ChannelMonitorRequestTemplateFunc) Mutate(ctx context.Context, m ent.Mut return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.ChannelMonitorRequestTemplateMutation", m) } +// The ChatMessageFunc type is an adapter to allow the use of ordinary +// function as ChatMessage mutator. +type ChatMessageFunc func(context.Context, *ent.ChatMessageMutation) (ent.Value, error) + +// Mutate calls f(ctx, m). +func (f ChatMessageFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { + if mv, ok := m.(*ent.ChatMessageMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.ChatMessageMutation", m) +} + +// The ChatSessionFunc type is an adapter to allow the use of ordinary +// function as ChatSession mutator. +type ChatSessionFunc func(context.Context, *ent.ChatSessionMutation) (ent.Value, error) + +// Mutate calls f(ctx, m). +func (f ChatSessionFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) { + if mv, ok := m.(*ent.ChatSessionMutation); ok { + return f(ctx, mv) + } + return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.ChatSessionMutation", m) +} + // The ErrorPassthroughRuleFunc type is an adapter to allow the use of ordinary // function as ErrorPassthroughRule mutator. type ErrorPassthroughRuleFunc func(context.Context, *ent.ErrorPassthroughRuleMutation) (ent.Value, error) diff --git a/backend/ent/intercept/intercept.go b/backend/ent/intercept/intercept.go index 95b68e097a3..709ea578092 100644 --- a/backend/ent/intercept/intercept.go +++ b/backend/ent/intercept/intercept.go @@ -19,6 +19,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" "github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/idempotencyrecord" @@ -398,6 +400,60 @@ func (f TraverseChannelMonitorRequestTemplate) Traverse(ctx context.Context, q e return fmt.Errorf("unexpected query type %T. expect *ent.ChannelMonitorRequestTemplateQuery", q) } +// The ChatMessageFunc type is an adapter to allow the use of ordinary function as a Querier. +type ChatMessageFunc func(context.Context, *ent.ChatMessageQuery) (ent.Value, error) + +// Query calls f(ctx, q). +func (f ChatMessageFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) { + if q, ok := q.(*ent.ChatMessageQuery); ok { + return f(ctx, q) + } + return nil, fmt.Errorf("unexpected query type %T. expect *ent.ChatMessageQuery", q) +} + +// The TraverseChatMessage type is an adapter to allow the use of ordinary function as Traverser. +type TraverseChatMessage func(context.Context, *ent.ChatMessageQuery) error + +// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline. +func (f TraverseChatMessage) Intercept(next ent.Querier) ent.Querier { + return next +} + +// Traverse calls f(ctx, q). +func (f TraverseChatMessage) Traverse(ctx context.Context, q ent.Query) error { + if q, ok := q.(*ent.ChatMessageQuery); ok { + return f(ctx, q) + } + return fmt.Errorf("unexpected query type %T. expect *ent.ChatMessageQuery", q) +} + +// The ChatSessionFunc type is an adapter to allow the use of ordinary function as a Querier. +type ChatSessionFunc func(context.Context, *ent.ChatSessionQuery) (ent.Value, error) + +// Query calls f(ctx, q). +func (f ChatSessionFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) { + if q, ok := q.(*ent.ChatSessionQuery); ok { + return f(ctx, q) + } + return nil, fmt.Errorf("unexpected query type %T. expect *ent.ChatSessionQuery", q) +} + +// The TraverseChatSession type is an adapter to allow the use of ordinary function as Traverser. +type TraverseChatSession func(context.Context, *ent.ChatSessionQuery) error + +// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline. +func (f TraverseChatSession) Intercept(next ent.Querier) ent.Querier { + return next +} + +// Traverse calls f(ctx, q). +func (f TraverseChatSession) Traverse(ctx context.Context, q ent.Query) error { + if q, ok := q.(*ent.ChatSessionQuery); ok { + return f(ctx, q) + } + return fmt.Errorf("unexpected query type %T. expect *ent.ChatSessionQuery", q) +} + // The ErrorPassthroughRuleFunc type is an adapter to allow the use of ordinary function as a Querier. type ErrorPassthroughRuleFunc func(context.Context, *ent.ErrorPassthroughRuleQuery) (ent.Value, error) @@ -1044,6 +1100,10 @@ func NewQuery(q ent.Query) (Query, error) { return &query[*ent.ChannelMonitorHistoryQuery, predicate.ChannelMonitorHistory, channelmonitorhistory.OrderOption]{typ: ent.TypeChannelMonitorHistory, tq: q}, nil case *ent.ChannelMonitorRequestTemplateQuery: return &query[*ent.ChannelMonitorRequestTemplateQuery, predicate.ChannelMonitorRequestTemplate, channelmonitorrequesttemplate.OrderOption]{typ: ent.TypeChannelMonitorRequestTemplate, tq: q}, nil + case *ent.ChatMessageQuery: + return &query[*ent.ChatMessageQuery, predicate.ChatMessage, chatmessage.OrderOption]{typ: ent.TypeChatMessage, tq: q}, nil + case *ent.ChatSessionQuery: + return &query[*ent.ChatSessionQuery, predicate.ChatSession, chatsession.OrderOption]{typ: ent.TypeChatSession, tq: q}, nil case *ent.ErrorPassthroughRuleQuery: return &query[*ent.ErrorPassthroughRuleQuery, predicate.ErrorPassthroughRule, errorpassthroughrule.OrderOption]{typ: ent.TypeErrorPassthroughRule, tq: q}, nil case *ent.GroupQuery: diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index 178ae170846..db663454bf4 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -584,6 +584,130 @@ var ( }, }, } + // ChatMessagesColumns holds the columns for the "chat_messages" table. + ChatMessagesColumns = []*schema.Column{ + {Name: "id", Type: field.TypeInt64, Increment: true}, + {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: "role", Type: field.TypeString, Size: 20}, + {Name: "content", Type: field.TypeString, Default: "", SchemaType: map[string]string{"postgres": "text"}}, + {Name: "status", Type: field.TypeString, Size: 20, Default: "completed"}, + {Name: "model", Type: field.TypeString, Nullable: true, Size: 100}, + {Name: "duration_ms", Type: field.TypeInt, Nullable: true}, + {Name: "actual_cost", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,10)"}}, + {Name: "error_message", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}}, + {Name: "session_id", Type: field.TypeInt64}, + {Name: "usage_log_id", Type: field.TypeInt64, Nullable: true}, + {Name: "user_id", Type: field.TypeInt64}, + } + // ChatMessagesTable holds the schema information for the "chat_messages" table. + ChatMessagesTable = &schema.Table{ + Name: "chat_messages", + Columns: ChatMessagesColumns, + PrimaryKey: []*schema.Column{ChatMessagesColumns[0]}, + ForeignKeys: []*schema.ForeignKey{ + { + Symbol: "chat_messages_chat_sessions_messages", + Columns: []*schema.Column{ChatMessagesColumns[10]}, + RefColumns: []*schema.Column{ChatSessionsColumns[0]}, + OnDelete: schema.NoAction, + }, + { + Symbol: "chat_messages_usage_logs_chat_messages", + Columns: []*schema.Column{ChatMessagesColumns[11]}, + RefColumns: []*schema.Column{UsageLogsColumns[0]}, + OnDelete: schema.SetNull, + }, + { + Symbol: "chat_messages_users_chat_messages", + Columns: []*schema.Column{ChatMessagesColumns[12]}, + RefColumns: []*schema.Column{UsersColumns[0]}, + OnDelete: schema.NoAction, + }, + }, + Indexes: []*schema.Index{ + { + Name: "chatmessage_session_id_created_at", + Unique: false, + Columns: []*schema.Column{ChatMessagesColumns[10], ChatMessagesColumns[1]}, + }, + { + Name: "chatmessage_user_id_created_at", + Unique: false, + Columns: []*schema.Column{ChatMessagesColumns[12], ChatMessagesColumns[1]}, + }, + { + Name: "chatmessage_usage_log_id", + Unique: false, + Columns: []*schema.Column{ChatMessagesColumns[11]}, + }, + { + Name: "chatmessage_status", + Unique: false, + Columns: []*schema.Column{ChatMessagesColumns[5]}, + }, + }, + } + // ChatSessionsColumns holds the columns for the "chat_sessions" table. + ChatSessionsColumns = []*schema.Column{ + {Name: "id", Type: field.TypeInt64, Increment: true}, + {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: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "title", Type: field.TypeString, Size: 160}, + {Name: "model", Type: field.TypeString, Size: 100}, + {Name: "status", Type: field.TypeString, Size: 20, Default: "active"}, + {Name: "expires_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}}, + {Name: "api_key_id", Type: field.TypeInt64}, + {Name: "user_id", Type: field.TypeInt64}, + } + // ChatSessionsTable holds the schema information for the "chat_sessions" table. + ChatSessionsTable = &schema.Table{ + Name: "chat_sessions", + Columns: ChatSessionsColumns, + PrimaryKey: []*schema.Column{ChatSessionsColumns[0]}, + ForeignKeys: []*schema.ForeignKey{ + { + Symbol: "chat_sessions_api_keys_chat_sessions", + Columns: []*schema.Column{ChatSessionsColumns[8]}, + RefColumns: []*schema.Column{APIKeysColumns[0]}, + OnDelete: schema.NoAction, + }, + { + Symbol: "chat_sessions_users_chat_sessions", + Columns: []*schema.Column{ChatSessionsColumns[9]}, + RefColumns: []*schema.Column{UsersColumns[0]}, + OnDelete: schema.NoAction, + }, + }, + Indexes: []*schema.Index{ + { + Name: "chatsession_user_id_updated_at", + Unique: false, + Columns: []*schema.Column{ChatSessionsColumns[9], ChatSessionsColumns[2]}, + }, + { + Name: "chatsession_user_id_expires_at", + Unique: false, + Columns: []*schema.Column{ChatSessionsColumns[9], ChatSessionsColumns[7]}, + }, + { + Name: "chatsession_user_id_deleted_at", + Unique: false, + Columns: []*schema.Column{ChatSessionsColumns[9], ChatSessionsColumns[3]}, + }, + { + Name: "chatsession_api_key_id", + Unique: false, + Columns: []*schema.Column{ChatSessionsColumns[8]}, + }, + { + Name: "chatsession_status", + Unique: false, + Columns: []*schema.Column{ChatSessionsColumns[6]}, + }, + }, + } // ErrorPassthroughRulesColumns holds the columns for the "error_passthrough_rules" table. ErrorPassthroughRulesColumns = []*schema.Column{ {Name: "id", Type: field.TypeInt64, Increment: true}, @@ -638,6 +762,9 @@ var ( {Name: "weekly_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "monthly_limit_usd", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "default_validity_days", Type: field.TypeInt, Default: 30}, + {Name: "allow_image_generation", Type: field.TypeBool, Default: false}, + {Name: "image_rate_independent", Type: field.TypeBool, Default: false}, + {Name: "image_rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}}, {Name: "image_price_1k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "image_price_2k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, {Name: "image_price_4k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}}, @@ -690,7 +817,7 @@ var ( { Name: "group_sort_order", Unique: false, - Columns: []*schema.Column{GroupsColumns[25]}, + Columns: []*schema.Column{GroupsColumns[28]}, }, }, } @@ -1689,6 +1816,8 @@ var ( ChannelMonitorDailyRollupsTable, ChannelMonitorHistoriesTable, ChannelMonitorRequestTemplatesTable, + ChatMessagesTable, + ChatSessionsTable, ErrorPassthroughRulesTable, GroupsTable, IdempotencyRecordsTable, @@ -1761,6 +1890,17 @@ func init() { ChannelMonitorRequestTemplatesTable.Annotation = &entsql.Annotation{ Table: "channel_monitor_request_templates", } + ChatMessagesTable.ForeignKeys[0].RefTable = ChatSessionsTable + ChatMessagesTable.ForeignKeys[1].RefTable = UsageLogsTable + ChatMessagesTable.ForeignKeys[2].RefTable = UsersTable + ChatMessagesTable.Annotation = &entsql.Annotation{ + Table: "chat_messages", + } + ChatSessionsTable.ForeignKeys[0].RefTable = APIKeysTable + ChatSessionsTable.ForeignKeys[1].RefTable = UsersTable + ChatSessionsTable.Annotation = &entsql.Annotation{ + Table: "chat_sessions", + } ErrorPassthroughRulesTable.Annotation = &entsql.Annotation{ Table: "error_passthrough_rules", } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index d616e4ae12f..fd87e76ad4b 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -23,6 +23,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" "github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/idempotencyrecord" @@ -70,6 +72,8 @@ const ( TypeChannelMonitorDailyRollup = "ChannelMonitorDailyRollup" TypeChannelMonitorHistory = "ChannelMonitorHistory" TypeChannelMonitorRequestTemplate = "ChannelMonitorRequestTemplate" + TypeChatMessage = "ChatMessage" + TypeChatSession = "ChatSession" TypeErrorPassthroughRule = "ErrorPassthroughRule" TypeGroup = "Group" TypeIdempotencyRecord = "IdempotencyRecord" @@ -98,51 +102,54 @@ const ( // APIKeyMutation represents an operation that mutates the APIKey nodes in the graph. type APIKeyMutation struct { config - op Op - typ string - id *int64 - created_at *time.Time - updated_at *time.Time - deleted_at *time.Time - key *string - name *string - status *string - last_used_at *time.Time - ip_whitelist *[]string - appendip_whitelist []string - ip_blacklist *[]string - appendip_blacklist []string - quota *float64 - addquota *float64 - quota_used *float64 - addquota_used *float64 - expires_at *time.Time - rate_limit_5h *float64 - addrate_limit_5h *float64 - rate_limit_1d *float64 - addrate_limit_1d *float64 - rate_limit_7d *float64 - addrate_limit_7d *float64 - usage_5h *float64 - addusage_5h *float64 - usage_1d *float64 - addusage_1d *float64 - usage_7d *float64 - addusage_7d *float64 - window_5h_start *time.Time - window_1d_start *time.Time - window_7d_start *time.Time - clearedFields map[string]struct{} - user *int64 - cleareduser bool - group *int64 - clearedgroup bool - usage_logs map[int64]struct{} - removedusage_logs map[int64]struct{} - clearedusage_logs bool - done bool - oldValue func(context.Context) (*APIKey, error) - predicates []predicate.APIKey + op Op + typ string + id *int64 + created_at *time.Time + updated_at *time.Time + deleted_at *time.Time + key *string + name *string + status *string + last_used_at *time.Time + ip_whitelist *[]string + appendip_whitelist []string + ip_blacklist *[]string + appendip_blacklist []string + quota *float64 + addquota *float64 + quota_used *float64 + addquota_used *float64 + expires_at *time.Time + rate_limit_5h *float64 + addrate_limit_5h *float64 + rate_limit_1d *float64 + addrate_limit_1d *float64 + rate_limit_7d *float64 + addrate_limit_7d *float64 + usage_5h *float64 + addusage_5h *float64 + usage_1d *float64 + addusage_1d *float64 + usage_7d *float64 + addusage_7d *float64 + window_5h_start *time.Time + window_1d_start *time.Time + window_7d_start *time.Time + clearedFields map[string]struct{} + user *int64 + cleareduser bool + group *int64 + clearedgroup bool + usage_logs map[int64]struct{} + removedusage_logs map[int64]struct{} + clearedusage_logs bool + chat_sessions map[int64]struct{} + removedchat_sessions map[int64]struct{} + clearedchat_sessions bool + done bool + oldValue func(context.Context) (*APIKey, error) + predicates []predicate.APIKey } var _ ent.Mutation = (*APIKeyMutation)(nil) @@ -1488,6 +1495,60 @@ func (m *APIKeyMutation) ResetUsageLogs() { m.removedusage_logs = nil } +// AddChatSessionIDs adds the "chat_sessions" edge to the ChatSession entity by ids. +func (m *APIKeyMutation) AddChatSessionIDs(ids ...int64) { + if m.chat_sessions == nil { + m.chat_sessions = make(map[int64]struct{}) + } + for i := range ids { + m.chat_sessions[ids[i]] = struct{}{} + } +} + +// ClearChatSessions clears the "chat_sessions" edge to the ChatSession entity. +func (m *APIKeyMutation) ClearChatSessions() { + m.clearedchat_sessions = true +} + +// ChatSessionsCleared reports if the "chat_sessions" edge to the ChatSession entity was cleared. +func (m *APIKeyMutation) ChatSessionsCleared() bool { + return m.clearedchat_sessions +} + +// RemoveChatSessionIDs removes the "chat_sessions" edge to the ChatSession entity by IDs. +func (m *APIKeyMutation) RemoveChatSessionIDs(ids ...int64) { + if m.removedchat_sessions == nil { + m.removedchat_sessions = make(map[int64]struct{}) + } + for i := range ids { + delete(m.chat_sessions, ids[i]) + m.removedchat_sessions[ids[i]] = struct{}{} + } +} + +// RemovedChatSessions returns the removed IDs of the "chat_sessions" edge to the ChatSession entity. +func (m *APIKeyMutation) RemovedChatSessionsIDs() (ids []int64) { + for id := range m.removedchat_sessions { + ids = append(ids, id) + } + return +} + +// ChatSessionsIDs returns the "chat_sessions" edge IDs in the mutation. +func (m *APIKeyMutation) ChatSessionsIDs() (ids []int64) { + for id := range m.chat_sessions { + ids = append(ids, id) + } + return +} + +// ResetChatSessions resets all changes to the "chat_sessions" edge. +func (m *APIKeyMutation) ResetChatSessions() { + m.chat_sessions = nil + m.clearedchat_sessions = false + m.removedchat_sessions = nil +} + // Where appends a list predicates to the APIKeyMutation builder. func (m *APIKeyMutation) Where(ps ...predicate.APIKey) { m.predicates = append(m.predicates, ps...) @@ -2151,7 +2212,7 @@ func (m *APIKeyMutation) ResetField(name string) error { // AddedEdges returns all edge names that were set/added in this mutation. func (m *APIKeyMutation) AddedEdges() []string { - edges := make([]string, 0, 3) + edges := make([]string, 0, 4) if m.user != nil { edges = append(edges, apikey.EdgeUser) } @@ -2161,6 +2222,9 @@ func (m *APIKeyMutation) AddedEdges() []string { if m.usage_logs != nil { edges = append(edges, apikey.EdgeUsageLogs) } + if m.chat_sessions != nil { + edges = append(edges, apikey.EdgeChatSessions) + } return edges } @@ -2182,16 +2246,25 @@ func (m *APIKeyMutation) AddedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case apikey.EdgeChatSessions: + ids := make([]ent.Value, 0, len(m.chat_sessions)) + for id := range m.chat_sessions { + ids = append(ids, id) + } + return ids } return nil } // RemovedEdges returns all edge names that were removed in this mutation. func (m *APIKeyMutation) RemovedEdges() []string { - edges := make([]string, 0, 3) + edges := make([]string, 0, 4) if m.removedusage_logs != nil { edges = append(edges, apikey.EdgeUsageLogs) } + if m.removedchat_sessions != nil { + edges = append(edges, apikey.EdgeChatSessions) + } return edges } @@ -2205,13 +2278,19 @@ func (m *APIKeyMutation) RemovedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case apikey.EdgeChatSessions: + ids := make([]ent.Value, 0, len(m.removedchat_sessions)) + for id := range m.removedchat_sessions { + ids = append(ids, id) + } + return ids } return nil } // ClearedEdges returns all edge names that were cleared in this mutation. func (m *APIKeyMutation) ClearedEdges() []string { - edges := make([]string, 0, 3) + edges := make([]string, 0, 4) if m.cleareduser { edges = append(edges, apikey.EdgeUser) } @@ -2221,6 +2300,9 @@ func (m *APIKeyMutation) ClearedEdges() []string { if m.clearedusage_logs { edges = append(edges, apikey.EdgeUsageLogs) } + if m.clearedchat_sessions { + edges = append(edges, apikey.EdgeChatSessions) + } return edges } @@ -2234,6 +2316,8 @@ func (m *APIKeyMutation) EdgeCleared(name string) bool { return m.clearedgroup case apikey.EdgeUsageLogs: return m.clearedusage_logs + case apikey.EdgeChatSessions: + return m.clearedchat_sessions } return false } @@ -2265,6 +2349,9 @@ func (m *APIKeyMutation) ResetEdge(name string) error { case apikey.EdgeUsageLogs: m.ResetUsageLogs() return nil + case apikey.EdgeChatSessions: + m.ResetChatSessions() + return nil } return fmt.Errorf("unknown APIKey edge %s", name) } @@ -12972,113 +13059,2266 @@ func (m *ChannelMonitorRequestTemplateMutation) SetBodyOverride(value map[string m.body_override = &value } -// BodyOverride returns the value of the "body_override" field in the mutation. -func (m *ChannelMonitorRequestTemplateMutation) BodyOverride() (r map[string]interface{}, exists bool) { - v := m.body_override - if v == nil { - return - } - return *v, true +// BodyOverride returns the value of the "body_override" field in the mutation. +func (m *ChannelMonitorRequestTemplateMutation) BodyOverride() (r map[string]interface{}, exists bool) { + v := m.body_override + if v == nil { + return + } + return *v, true +} + +// OldBodyOverride returns the old "body_override" field's value of the ChannelMonitorRequestTemplate entity. +// If the ChannelMonitorRequestTemplate 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 *ChannelMonitorRequestTemplateMutation) OldBodyOverride(ctx context.Context) (v map[string]interface{}, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldBodyOverride is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldBodyOverride requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldBodyOverride: %w", err) + } + return oldValue.BodyOverride, nil +} + +// ClearBodyOverride clears the value of the "body_override" field. +func (m *ChannelMonitorRequestTemplateMutation) ClearBodyOverride() { + m.body_override = nil + m.clearedFields[channelmonitorrequesttemplate.FieldBodyOverride] = struct{}{} +} + +// BodyOverrideCleared returns if the "body_override" field was cleared in this mutation. +func (m *ChannelMonitorRequestTemplateMutation) BodyOverrideCleared() bool { + _, ok := m.clearedFields[channelmonitorrequesttemplate.FieldBodyOverride] + return ok +} + +// ResetBodyOverride resets all changes to the "body_override" field. +func (m *ChannelMonitorRequestTemplateMutation) ResetBodyOverride() { + m.body_override = nil + delete(m.clearedFields, channelmonitorrequesttemplate.FieldBodyOverride) +} + +// AddMonitorIDs adds the "monitors" edge to the ChannelMonitor entity by ids. +func (m *ChannelMonitorRequestTemplateMutation) AddMonitorIDs(ids ...int64) { + if m.monitors == nil { + m.monitors = make(map[int64]struct{}) + } + for i := range ids { + m.monitors[ids[i]] = struct{}{} + } +} + +// ClearMonitors clears the "monitors" edge to the ChannelMonitor entity. +func (m *ChannelMonitorRequestTemplateMutation) ClearMonitors() { + m.clearedmonitors = true +} + +// MonitorsCleared reports if the "monitors" edge to the ChannelMonitor entity was cleared. +func (m *ChannelMonitorRequestTemplateMutation) MonitorsCleared() bool { + return m.clearedmonitors +} + +// RemoveMonitorIDs removes the "monitors" edge to the ChannelMonitor entity by IDs. +func (m *ChannelMonitorRequestTemplateMutation) RemoveMonitorIDs(ids ...int64) { + if m.removedmonitors == nil { + m.removedmonitors = make(map[int64]struct{}) + } + for i := range ids { + delete(m.monitors, ids[i]) + m.removedmonitors[ids[i]] = struct{}{} + } +} + +// RemovedMonitors returns the removed IDs of the "monitors" edge to the ChannelMonitor entity. +func (m *ChannelMonitorRequestTemplateMutation) RemovedMonitorsIDs() (ids []int64) { + for id := range m.removedmonitors { + ids = append(ids, id) + } + return +} + +// MonitorsIDs returns the "monitors" edge IDs in the mutation. +func (m *ChannelMonitorRequestTemplateMutation) MonitorsIDs() (ids []int64) { + for id := range m.monitors { + ids = append(ids, id) + } + return +} + +// ResetMonitors resets all changes to the "monitors" edge. +func (m *ChannelMonitorRequestTemplateMutation) ResetMonitors() { + m.monitors = nil + m.clearedmonitors = false + m.removedmonitors = nil +} + +// Where appends a list predicates to the ChannelMonitorRequestTemplateMutation builder. +func (m *ChannelMonitorRequestTemplateMutation) Where(ps ...predicate.ChannelMonitorRequestTemplate) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the ChannelMonitorRequestTemplateMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *ChannelMonitorRequestTemplateMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.ChannelMonitorRequestTemplate, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *ChannelMonitorRequestTemplateMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *ChannelMonitorRequestTemplateMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (ChannelMonitorRequestTemplate). +func (m *ChannelMonitorRequestTemplateMutation) Type() string { + return m.typ +} + +// Fields returns all fields that were changed during this mutation. Note that in +// order to get all numeric fields that were incremented/decremented, call +// AddedFields(). +func (m *ChannelMonitorRequestTemplateMutation) Fields() []string { + fields := make([]string, 0, 8) + if m.created_at != nil { + fields = append(fields, channelmonitorrequesttemplate.FieldCreatedAt) + } + if m.updated_at != nil { + fields = append(fields, channelmonitorrequesttemplate.FieldUpdatedAt) + } + if m.name != nil { + fields = append(fields, channelmonitorrequesttemplate.FieldName) + } + if m.provider != nil { + fields = append(fields, channelmonitorrequesttemplate.FieldProvider) + } + if m.description != nil { + fields = append(fields, channelmonitorrequesttemplate.FieldDescription) + } + if m.extra_headers != nil { + fields = append(fields, channelmonitorrequesttemplate.FieldExtraHeaders) + } + if m.body_override_mode != nil { + fields = append(fields, channelmonitorrequesttemplate.FieldBodyOverrideMode) + } + if m.body_override != nil { + fields = append(fields, channelmonitorrequesttemplate.FieldBodyOverride) + } + return fields +} + +// Field returns the value of a field with the given name. The second boolean +// return value indicates that this field was not set, or was not defined in the +// schema. +func (m *ChannelMonitorRequestTemplateMutation) Field(name string) (ent.Value, bool) { + switch name { + case channelmonitorrequesttemplate.FieldCreatedAt: + return m.CreatedAt() + case channelmonitorrequesttemplate.FieldUpdatedAt: + return m.UpdatedAt() + case channelmonitorrequesttemplate.FieldName: + return m.Name() + case channelmonitorrequesttemplate.FieldProvider: + return m.Provider() + case channelmonitorrequesttemplate.FieldDescription: + return m.Description() + case channelmonitorrequesttemplate.FieldExtraHeaders: + return m.ExtraHeaders() + case channelmonitorrequesttemplate.FieldBodyOverrideMode: + return m.BodyOverrideMode() + case channelmonitorrequesttemplate.FieldBodyOverride: + return m.BodyOverride() + } + return nil, false +} + +// OldField returns the old value of the field from the database. An error is +// returned if the mutation operation is not UpdateOne, or the query to the +// database failed. +func (m *ChannelMonitorRequestTemplateMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case channelmonitorrequesttemplate.FieldCreatedAt: + return m.OldCreatedAt(ctx) + case channelmonitorrequesttemplate.FieldUpdatedAt: + return m.OldUpdatedAt(ctx) + case channelmonitorrequesttemplate.FieldName: + return m.OldName(ctx) + case channelmonitorrequesttemplate.FieldProvider: + return m.OldProvider(ctx) + case channelmonitorrequesttemplate.FieldDescription: + return m.OldDescription(ctx) + case channelmonitorrequesttemplate.FieldExtraHeaders: + return m.OldExtraHeaders(ctx) + case channelmonitorrequesttemplate.FieldBodyOverrideMode: + return m.OldBodyOverrideMode(ctx) + case channelmonitorrequesttemplate.FieldBodyOverride: + return m.OldBodyOverride(ctx) + } + return nil, fmt.Errorf("unknown ChannelMonitorRequestTemplate field %s", name) +} + +// SetField sets the value of a field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *ChannelMonitorRequestTemplateMutation) SetField(name string, value ent.Value) error { + switch name { + case channelmonitorrequesttemplate.FieldCreatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCreatedAt(v) + return nil + case channelmonitorrequesttemplate.FieldUpdatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpdatedAt(v) + return nil + case channelmonitorrequesttemplate.FieldName: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetName(v) + return nil + case channelmonitorrequesttemplate.FieldProvider: + v, ok := value.(channelmonitorrequesttemplate.Provider) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetProvider(v) + return nil + case channelmonitorrequesttemplate.FieldDescription: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetDescription(v) + return nil + case channelmonitorrequesttemplate.FieldExtraHeaders: + v, ok := value.(map[string]string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetExtraHeaders(v) + return nil + case channelmonitorrequesttemplate.FieldBodyOverrideMode: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBodyOverrideMode(v) + return nil + case channelmonitorrequesttemplate.FieldBodyOverride: + v, ok := value.(map[string]interface{}) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetBodyOverride(v) + return nil + } + return fmt.Errorf("unknown ChannelMonitorRequestTemplate field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *ChannelMonitorRequestTemplateMutation) AddedFields() []string { + return nil +} + +// AddedField returns the numeric value that was incremented/decremented on a field +// with the given name. The second boolean return value indicates that this field +// was not set, or was not defined in the schema. +func (m *ChannelMonitorRequestTemplateMutation) AddedField(name string) (ent.Value, bool) { + return nil, false +} + +// AddField adds the value to the field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *ChannelMonitorRequestTemplateMutation) AddField(name string, value ent.Value) error { + switch name { + } + return fmt.Errorf("unknown ChannelMonitorRequestTemplate numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *ChannelMonitorRequestTemplateMutation) ClearedFields() []string { + var fields []string + if m.FieldCleared(channelmonitorrequesttemplate.FieldDescription) { + fields = append(fields, channelmonitorrequesttemplate.FieldDescription) + } + if m.FieldCleared(channelmonitorrequesttemplate.FieldBodyOverride) { + fields = append(fields, channelmonitorrequesttemplate.FieldBodyOverride) + } + return fields +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *ChannelMonitorRequestTemplateMutation) FieldCleared(name string) bool { + _, ok := m.clearedFields[name] + return ok +} + +// ClearField clears the value of the field with the given name. It returns an +// error if the field is not defined in the schema. +func (m *ChannelMonitorRequestTemplateMutation) ClearField(name string) error { + switch name { + case channelmonitorrequesttemplate.FieldDescription: + m.ClearDescription() + return nil + case channelmonitorrequesttemplate.FieldBodyOverride: + m.ClearBodyOverride() + return nil + } + return fmt.Errorf("unknown ChannelMonitorRequestTemplate nullable field %s", name) +} + +// ResetField resets all changes in the mutation for the field with the given name. +// It returns an error if the field is not defined in the schema. +func (m *ChannelMonitorRequestTemplateMutation) ResetField(name string) error { + switch name { + case channelmonitorrequesttemplate.FieldCreatedAt: + m.ResetCreatedAt() + return nil + case channelmonitorrequesttemplate.FieldUpdatedAt: + m.ResetUpdatedAt() + return nil + case channelmonitorrequesttemplate.FieldName: + m.ResetName() + return nil + case channelmonitorrequesttemplate.FieldProvider: + m.ResetProvider() + return nil + case channelmonitorrequesttemplate.FieldDescription: + m.ResetDescription() + return nil + case channelmonitorrequesttemplate.FieldExtraHeaders: + m.ResetExtraHeaders() + return nil + case channelmonitorrequesttemplate.FieldBodyOverrideMode: + m.ResetBodyOverrideMode() + return nil + case channelmonitorrequesttemplate.FieldBodyOverride: + m.ResetBodyOverride() + return nil + } + return fmt.Errorf("unknown ChannelMonitorRequestTemplate field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *ChannelMonitorRequestTemplateMutation) AddedEdges() []string { + edges := make([]string, 0, 1) + if m.monitors != nil { + edges = append(edges, channelmonitorrequesttemplate.EdgeMonitors) + } + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *ChannelMonitorRequestTemplateMutation) AddedIDs(name string) []ent.Value { + switch name { + case channelmonitorrequesttemplate.EdgeMonitors: + ids := make([]ent.Value, 0, len(m.monitors)) + for id := range m.monitors { + ids = append(ids, id) + } + return ids + } + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *ChannelMonitorRequestTemplateMutation) RemovedEdges() []string { + edges := make([]string, 0, 1) + if m.removedmonitors != nil { + edges = append(edges, channelmonitorrequesttemplate.EdgeMonitors) + } + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *ChannelMonitorRequestTemplateMutation) RemovedIDs(name string) []ent.Value { + switch name { + case channelmonitorrequesttemplate.EdgeMonitors: + ids := make([]ent.Value, 0, len(m.removedmonitors)) + for id := range m.removedmonitors { + ids = append(ids, id) + } + return ids + } + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *ChannelMonitorRequestTemplateMutation) ClearedEdges() []string { + edges := make([]string, 0, 1) + if m.clearedmonitors { + edges = append(edges, channelmonitorrequesttemplate.EdgeMonitors) + } + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *ChannelMonitorRequestTemplateMutation) EdgeCleared(name string) bool { + switch name { + case channelmonitorrequesttemplate.EdgeMonitors: + return m.clearedmonitors + } + return false +} + +// ClearEdge clears the value of the edge with the given name. It returns an error +// if that edge is not defined in the schema. +func (m *ChannelMonitorRequestTemplateMutation) ClearEdge(name string) error { + switch name { + } + return fmt.Errorf("unknown ChannelMonitorRequestTemplate unique edge %s", name) +} + +// ResetEdge resets all changes to the edge with the given name in this mutation. +// It returns an error if the edge is not defined in the schema. +func (m *ChannelMonitorRequestTemplateMutation) ResetEdge(name string) error { + switch name { + case channelmonitorrequesttemplate.EdgeMonitors: + m.ResetMonitors() + return nil + } + return fmt.Errorf("unknown ChannelMonitorRequestTemplate edge %s", name) +} + +// ChatMessageMutation represents an operation that mutates the ChatMessage nodes in the graph. +type ChatMessageMutation struct { + config + op Op + typ string + id *int64 + created_at *time.Time + updated_at *time.Time + role *string + content *string + status *string + model *string + duration_ms *int + addduration_ms *int + actual_cost *float64 + addactual_cost *float64 + error_message *string + clearedFields map[string]struct{} + session *int64 + clearedsession bool + user *int64 + cleareduser bool + usage_log *int64 + clearedusage_log bool + done bool + oldValue func(context.Context) (*ChatMessage, error) + predicates []predicate.ChatMessage +} + +var _ ent.Mutation = (*ChatMessageMutation)(nil) + +// chatmessageOption allows management of the mutation configuration using functional options. +type chatmessageOption func(*ChatMessageMutation) + +// newChatMessageMutation creates new mutation for the ChatMessage entity. +func newChatMessageMutation(c config, op Op, opts ...chatmessageOption) *ChatMessageMutation { + m := &ChatMessageMutation{ + config: c, + op: op, + typ: TypeChatMessage, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withChatMessageID sets the ID field of the mutation. +func withChatMessageID(id int64) chatmessageOption { + return func(m *ChatMessageMutation) { + var ( + err error + once sync.Once + value *ChatMessage + ) + m.oldValue = func(ctx context.Context) (*ChatMessage, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().ChatMessage.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withChatMessage sets the old ChatMessage of the mutation. +func withChatMessage(node *ChatMessage) chatmessageOption { + return func(m *ChatMessageMutation) { + m.oldValue = func(context.Context) (*ChatMessage, error) { + return node, nil + } + m.id = &node.ID + } +} + +// Client returns a new `ent.Client` from the mutation. If the mutation was +// executed in a transaction (ent.Tx), a transactional client is returned. +func (m ChatMessageMutation) Client() *Client { + client := &Client{config: m.config} + client.init() + return client +} + +// Tx returns an `ent.Tx` for mutations that were executed in transactions; +// it returns an error otherwise. +func (m ChatMessageMutation) Tx() (*Tx, error) { + if _, ok := m.driver.(*txDriver); !ok { + return nil, errors.New("ent: mutation is not running in a transaction") + } + tx := &Tx{config: m.config} + tx.init() + return tx, nil +} + +// ID returns the ID value in the mutation. Note that the ID is only available +// if it was provided to the builder or after it was returned from the database. +func (m *ChatMessageMutation) ID() (id int64, exists bool) { + if m.id == nil { + return + } + return *m.id, true +} + +// IDs queries the database and returns the entity ids that match the mutation's predicate. +// That means, if the mutation is applied within a transaction with an isolation level such +// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated +// or updated by the mutation. +func (m *ChatMessageMutation) IDs(ctx context.Context) ([]int64, error) { + switch { + case m.op.Is(OpUpdateOne | OpDeleteOne): + id, exists := m.ID() + if exists { + return []int64{id}, nil + } + fallthrough + case m.op.Is(OpUpdate | OpDelete): + return m.Client().ChatMessage.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetCreatedAt sets the "created_at" field. +func (m *ChatMessageMutation) SetCreatedAt(t time.Time) { + m.created_at = &t +} + +// CreatedAt returns the value of the "created_at" field in the mutation. +func (m *ChatMessageMutation) CreatedAt() (r time.Time, exists bool) { + v := m.created_at + if v == nil { + return + } + return *v, true +} + +// OldCreatedAt returns the old "created_at" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCreatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) + } + return oldValue.CreatedAt, nil +} + +// ResetCreatedAt resets all changes to the "created_at" field. +func (m *ChatMessageMutation) ResetCreatedAt() { + m.created_at = nil +} + +// SetUpdatedAt sets the "updated_at" field. +func (m *ChatMessageMutation) SetUpdatedAt(t time.Time) { + m.updated_at = &t +} + +// UpdatedAt returns the value of the "updated_at" field in the mutation. +func (m *ChatMessageMutation) UpdatedAt() (r time.Time, exists bool) { + v := m.updated_at + if v == nil { + return + } + return *v, true +} + +// OldUpdatedAt returns the old "updated_at" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldUpdatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpdatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpdatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpdatedAt: %w", err) + } + return oldValue.UpdatedAt, nil +} + +// ResetUpdatedAt resets all changes to the "updated_at" field. +func (m *ChatMessageMutation) ResetUpdatedAt() { + m.updated_at = nil +} + +// SetSessionID sets the "session_id" field. +func (m *ChatMessageMutation) SetSessionID(i int64) { + m.session = &i +} + +// SessionID returns the value of the "session_id" field in the mutation. +func (m *ChatMessageMutation) SessionID() (r int64, exists bool) { + v := m.session + if v == nil { + return + } + return *v, true +} + +// OldSessionID returns the old "session_id" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldSessionID(ctx context.Context) (v int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldSessionID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldSessionID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldSessionID: %w", err) + } + return oldValue.SessionID, nil +} + +// ResetSessionID resets all changes to the "session_id" field. +func (m *ChatMessageMutation) ResetSessionID() { + m.session = nil +} + +// SetUserID sets the "user_id" field. +func (m *ChatMessageMutation) SetUserID(i int64) { + m.user = &i +} + +// UserID returns the value of the "user_id" field in the mutation. +func (m *ChatMessageMutation) UserID() (r int64, exists bool) { + v := m.user + if v == nil { + return + } + return *v, true +} + +// OldUserID returns the old "user_id" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldUserID(ctx context.Context) (v int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserID: %w", err) + } + return oldValue.UserID, nil +} + +// ResetUserID resets all changes to the "user_id" field. +func (m *ChatMessageMutation) ResetUserID() { + m.user = nil +} + +// SetRole sets the "role" field. +func (m *ChatMessageMutation) SetRole(s string) { + m.role = &s +} + +// Role returns the value of the "role" field in the mutation. +func (m *ChatMessageMutation) Role() (r string, exists bool) { + v := m.role + if v == nil { + return + } + return *v, true +} + +// OldRole returns the old "role" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldRole(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldRole is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldRole requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldRole: %w", err) + } + return oldValue.Role, nil +} + +// ResetRole resets all changes to the "role" field. +func (m *ChatMessageMutation) ResetRole() { + m.role = nil +} + +// SetContent sets the "content" field. +func (m *ChatMessageMutation) SetContent(s string) { + m.content = &s +} + +// Content returns the value of the "content" field in the mutation. +func (m *ChatMessageMutation) Content() (r string, exists bool) { + v := m.content + if v == nil { + return + } + return *v, true +} + +// OldContent returns the old "content" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldContent(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldContent is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldContent requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldContent: %w", err) + } + return oldValue.Content, nil +} + +// ResetContent resets all changes to the "content" field. +func (m *ChatMessageMutation) ResetContent() { + m.content = nil +} + +// SetStatus sets the "status" field. +func (m *ChatMessageMutation) SetStatus(s string) { + m.status = &s +} + +// Status returns the value of the "status" field in the mutation. +func (m *ChatMessageMutation) Status() (r string, exists bool) { + v := m.status + if v == nil { + return + } + return *v, true +} + +// OldStatus returns the old "status" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldStatus(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldStatus is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldStatus requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldStatus: %w", err) + } + return oldValue.Status, nil +} + +// ResetStatus resets all changes to the "status" field. +func (m *ChatMessageMutation) ResetStatus() { + m.status = nil +} + +// SetModel sets the "model" field. +func (m *ChatMessageMutation) SetModel(s string) { + m.model = &s +} + +// Model returns the value of the "model" field in the mutation. +func (m *ChatMessageMutation) Model() (r string, exists bool) { + v := m.model + if v == nil { + return + } + return *v, true +} + +// OldModel returns the old "model" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldModel(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldModel is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldModel requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldModel: %w", err) + } + return oldValue.Model, nil +} + +// ClearModel clears the value of the "model" field. +func (m *ChatMessageMutation) ClearModel() { + m.model = nil + m.clearedFields[chatmessage.FieldModel] = struct{}{} +} + +// ModelCleared returns if the "model" field was cleared in this mutation. +func (m *ChatMessageMutation) ModelCleared() bool { + _, ok := m.clearedFields[chatmessage.FieldModel] + return ok +} + +// ResetModel resets all changes to the "model" field. +func (m *ChatMessageMutation) ResetModel() { + m.model = nil + delete(m.clearedFields, chatmessage.FieldModel) +} + +// SetDurationMs sets the "duration_ms" field. +func (m *ChatMessageMutation) SetDurationMs(i int) { + m.duration_ms = &i + m.addduration_ms = nil +} + +// DurationMs returns the value of the "duration_ms" field in the mutation. +func (m *ChatMessageMutation) DurationMs() (r int, exists bool) { + v := m.duration_ms + if v == nil { + return + } + return *v, true +} + +// OldDurationMs returns the old "duration_ms" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldDurationMs(ctx context.Context) (v *int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldDurationMs is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldDurationMs requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldDurationMs: %w", err) + } + return oldValue.DurationMs, nil +} + +// AddDurationMs adds i to the "duration_ms" field. +func (m *ChatMessageMutation) AddDurationMs(i int) { + if m.addduration_ms != nil { + *m.addduration_ms += i + } else { + m.addduration_ms = &i + } +} + +// AddedDurationMs returns the value that was added to the "duration_ms" field in this mutation. +func (m *ChatMessageMutation) AddedDurationMs() (r int, exists bool) { + v := m.addduration_ms + if v == nil { + return + } + return *v, true +} + +// ClearDurationMs clears the value of the "duration_ms" field. +func (m *ChatMessageMutation) ClearDurationMs() { + m.duration_ms = nil + m.addduration_ms = nil + m.clearedFields[chatmessage.FieldDurationMs] = struct{}{} +} + +// DurationMsCleared returns if the "duration_ms" field was cleared in this mutation. +func (m *ChatMessageMutation) DurationMsCleared() bool { + _, ok := m.clearedFields[chatmessage.FieldDurationMs] + return ok +} + +// ResetDurationMs resets all changes to the "duration_ms" field. +func (m *ChatMessageMutation) ResetDurationMs() { + m.duration_ms = nil + m.addduration_ms = nil + delete(m.clearedFields, chatmessage.FieldDurationMs) +} + +// SetUsageLogID sets the "usage_log_id" field. +func (m *ChatMessageMutation) SetUsageLogID(i int64) { + m.usage_log = &i +} + +// UsageLogID returns the value of the "usage_log_id" field in the mutation. +func (m *ChatMessageMutation) UsageLogID() (r int64, exists bool) { + v := m.usage_log + if v == nil { + return + } + return *v, true +} + +// OldUsageLogID returns the old "usage_log_id" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldUsageLogID(ctx context.Context) (v *int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUsageLogID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUsageLogID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUsageLogID: %w", err) + } + return oldValue.UsageLogID, nil +} + +// ClearUsageLogID clears the value of the "usage_log_id" field. +func (m *ChatMessageMutation) ClearUsageLogID() { + m.usage_log = nil + m.clearedFields[chatmessage.FieldUsageLogID] = struct{}{} +} + +// UsageLogIDCleared returns if the "usage_log_id" field was cleared in this mutation. +func (m *ChatMessageMutation) UsageLogIDCleared() bool { + _, ok := m.clearedFields[chatmessage.FieldUsageLogID] + return ok +} + +// ResetUsageLogID resets all changes to the "usage_log_id" field. +func (m *ChatMessageMutation) ResetUsageLogID() { + m.usage_log = nil + delete(m.clearedFields, chatmessage.FieldUsageLogID) +} + +// SetActualCost sets the "actual_cost" field. +func (m *ChatMessageMutation) SetActualCost(f float64) { + m.actual_cost = &f + m.addactual_cost = nil +} + +// ActualCost returns the value of the "actual_cost" field in the mutation. +func (m *ChatMessageMutation) ActualCost() (r float64, exists bool) { + v := m.actual_cost + if v == nil { + return + } + return *v, true +} + +// OldActualCost returns the old "actual_cost" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldActualCost(ctx context.Context) (v *float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldActualCost is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldActualCost requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldActualCost: %w", err) + } + return oldValue.ActualCost, nil +} + +// AddActualCost adds f to the "actual_cost" field. +func (m *ChatMessageMutation) AddActualCost(f float64) { + if m.addactual_cost != nil { + *m.addactual_cost += f + } else { + m.addactual_cost = &f + } +} + +// AddedActualCost returns the value that was added to the "actual_cost" field in this mutation. +func (m *ChatMessageMutation) AddedActualCost() (r float64, exists bool) { + v := m.addactual_cost + if v == nil { + return + } + return *v, true +} + +// ClearActualCost clears the value of the "actual_cost" field. +func (m *ChatMessageMutation) ClearActualCost() { + m.actual_cost = nil + m.addactual_cost = nil + m.clearedFields[chatmessage.FieldActualCost] = struct{}{} +} + +// ActualCostCleared returns if the "actual_cost" field was cleared in this mutation. +func (m *ChatMessageMutation) ActualCostCleared() bool { + _, ok := m.clearedFields[chatmessage.FieldActualCost] + return ok +} + +// ResetActualCost resets all changes to the "actual_cost" field. +func (m *ChatMessageMutation) ResetActualCost() { + m.actual_cost = nil + m.addactual_cost = nil + delete(m.clearedFields, chatmessage.FieldActualCost) +} + +// SetErrorMessage sets the "error_message" field. +func (m *ChatMessageMutation) SetErrorMessage(s string) { + m.error_message = &s +} + +// ErrorMessage returns the value of the "error_message" field in the mutation. +func (m *ChatMessageMutation) ErrorMessage() (r string, exists bool) { + v := m.error_message + if v == nil { + return + } + return *v, true +} + +// OldErrorMessage returns the old "error_message" field's value of the ChatMessage entity. +// If the ChatMessage 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 *ChatMessageMutation) OldErrorMessage(ctx context.Context) (v *string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldErrorMessage is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldErrorMessage requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldErrorMessage: %w", err) + } + return oldValue.ErrorMessage, nil +} + +// ClearErrorMessage clears the value of the "error_message" field. +func (m *ChatMessageMutation) ClearErrorMessage() { + m.error_message = nil + m.clearedFields[chatmessage.FieldErrorMessage] = struct{}{} +} + +// ErrorMessageCleared returns if the "error_message" field was cleared in this mutation. +func (m *ChatMessageMutation) ErrorMessageCleared() bool { + _, ok := m.clearedFields[chatmessage.FieldErrorMessage] + return ok +} + +// ResetErrorMessage resets all changes to the "error_message" field. +func (m *ChatMessageMutation) ResetErrorMessage() { + m.error_message = nil + delete(m.clearedFields, chatmessage.FieldErrorMessage) +} + +// ClearSession clears the "session" edge to the ChatSession entity. +func (m *ChatMessageMutation) ClearSession() { + m.clearedsession = true + m.clearedFields[chatmessage.FieldSessionID] = struct{}{} +} + +// SessionCleared reports if the "session" edge to the ChatSession entity was cleared. +func (m *ChatMessageMutation) SessionCleared() bool { + return m.clearedsession +} + +// SessionIDs returns the "session" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// SessionID instead. It exists only for internal usage by the builders. +func (m *ChatMessageMutation) SessionIDs() (ids []int64) { + if id := m.session; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetSession resets all changes to the "session" edge. +func (m *ChatMessageMutation) ResetSession() { + m.session = nil + m.clearedsession = false +} + +// ClearUser clears the "user" edge to the User entity. +func (m *ChatMessageMutation) ClearUser() { + m.cleareduser = true + m.clearedFields[chatmessage.FieldUserID] = struct{}{} +} + +// UserCleared reports if the "user" edge to the User entity was cleared. +func (m *ChatMessageMutation) UserCleared() bool { + return m.cleareduser +} + +// UserIDs returns the "user" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// UserID instead. It exists only for internal usage by the builders. +func (m *ChatMessageMutation) UserIDs() (ids []int64) { + if id := m.user; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetUser resets all changes to the "user" edge. +func (m *ChatMessageMutation) ResetUser() { + m.user = nil + m.cleareduser = false +} + +// ClearUsageLog clears the "usage_log" edge to the UsageLog entity. +func (m *ChatMessageMutation) ClearUsageLog() { + m.clearedusage_log = true + m.clearedFields[chatmessage.FieldUsageLogID] = struct{}{} +} + +// UsageLogCleared reports if the "usage_log" edge to the UsageLog entity was cleared. +func (m *ChatMessageMutation) UsageLogCleared() bool { + return m.UsageLogIDCleared() || m.clearedusage_log +} + +// UsageLogIDs returns the "usage_log" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// UsageLogID instead. It exists only for internal usage by the builders. +func (m *ChatMessageMutation) UsageLogIDs() (ids []int64) { + if id := m.usage_log; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetUsageLog resets all changes to the "usage_log" edge. +func (m *ChatMessageMutation) ResetUsageLog() { + m.usage_log = nil + m.clearedusage_log = false +} + +// Where appends a list predicates to the ChatMessageMutation builder. +func (m *ChatMessageMutation) Where(ps ...predicate.ChatMessage) { + m.predicates = append(m.predicates, ps...) +} + +// WhereP appends storage-level predicates to the ChatMessageMutation builder. Using this method, +// users can use type-assertion to append predicates that do not depend on any generated package. +func (m *ChatMessageMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.ChatMessage, len(ps)) + for i := range ps { + p[i] = ps[i] + } + m.Where(p...) +} + +// Op returns the operation name. +func (m *ChatMessageMutation) Op() Op { + return m.op +} + +// SetOp allows setting the mutation operation. +func (m *ChatMessageMutation) SetOp(op Op) { + m.op = op +} + +// Type returns the node type of this mutation (ChatMessage). +func (m *ChatMessageMutation) Type() string { + return m.typ +} + +// Fields returns all fields that were changed during this mutation. Note that in +// order to get all numeric fields that were incremented/decremented, call +// AddedFields(). +func (m *ChatMessageMutation) Fields() []string { + fields := make([]string, 0, 12) + if m.created_at != nil { + fields = append(fields, chatmessage.FieldCreatedAt) + } + if m.updated_at != nil { + fields = append(fields, chatmessage.FieldUpdatedAt) + } + if m.session != nil { + fields = append(fields, chatmessage.FieldSessionID) + } + if m.user != nil { + fields = append(fields, chatmessage.FieldUserID) + } + if m.role != nil { + fields = append(fields, chatmessage.FieldRole) + } + if m.content != nil { + fields = append(fields, chatmessage.FieldContent) + } + if m.status != nil { + fields = append(fields, chatmessage.FieldStatus) + } + if m.model != nil { + fields = append(fields, chatmessage.FieldModel) + } + if m.duration_ms != nil { + fields = append(fields, chatmessage.FieldDurationMs) + } + if m.usage_log != nil { + fields = append(fields, chatmessage.FieldUsageLogID) + } + if m.actual_cost != nil { + fields = append(fields, chatmessage.FieldActualCost) + } + if m.error_message != nil { + fields = append(fields, chatmessage.FieldErrorMessage) + } + return fields +} + +// Field returns the value of a field with the given name. The second boolean +// return value indicates that this field was not set, or was not defined in the +// schema. +func (m *ChatMessageMutation) Field(name string) (ent.Value, bool) { + switch name { + case chatmessage.FieldCreatedAt: + return m.CreatedAt() + case chatmessage.FieldUpdatedAt: + return m.UpdatedAt() + case chatmessage.FieldSessionID: + return m.SessionID() + case chatmessage.FieldUserID: + return m.UserID() + case chatmessage.FieldRole: + return m.Role() + case chatmessage.FieldContent: + return m.Content() + case chatmessage.FieldStatus: + return m.Status() + case chatmessage.FieldModel: + return m.Model() + case chatmessage.FieldDurationMs: + return m.DurationMs() + case chatmessage.FieldUsageLogID: + return m.UsageLogID() + case chatmessage.FieldActualCost: + return m.ActualCost() + case chatmessage.FieldErrorMessage: + return m.ErrorMessage() + } + return nil, false +} + +// OldField returns the old value of the field from the database. An error is +// returned if the mutation operation is not UpdateOne, or the query to the +// database failed. +func (m *ChatMessageMutation) OldField(ctx context.Context, name string) (ent.Value, error) { + switch name { + case chatmessage.FieldCreatedAt: + return m.OldCreatedAt(ctx) + case chatmessage.FieldUpdatedAt: + return m.OldUpdatedAt(ctx) + case chatmessage.FieldSessionID: + return m.OldSessionID(ctx) + case chatmessage.FieldUserID: + return m.OldUserID(ctx) + case chatmessage.FieldRole: + return m.OldRole(ctx) + case chatmessage.FieldContent: + return m.OldContent(ctx) + case chatmessage.FieldStatus: + return m.OldStatus(ctx) + case chatmessage.FieldModel: + return m.OldModel(ctx) + case chatmessage.FieldDurationMs: + return m.OldDurationMs(ctx) + case chatmessage.FieldUsageLogID: + return m.OldUsageLogID(ctx) + case chatmessage.FieldActualCost: + return m.OldActualCost(ctx) + case chatmessage.FieldErrorMessage: + return m.OldErrorMessage(ctx) + } + return nil, fmt.Errorf("unknown ChatMessage field %s", name) +} + +// SetField sets the value of a field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *ChatMessageMutation) SetField(name string, value ent.Value) error { + switch name { + case chatmessage.FieldCreatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetCreatedAt(v) + return nil + case chatmessage.FieldUpdatedAt: + v, ok := value.(time.Time) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUpdatedAt(v) + return nil + case chatmessage.FieldSessionID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetSessionID(v) + return nil + case chatmessage.FieldUserID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUserID(v) + return nil + case chatmessage.FieldRole: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetRole(v) + return nil + case chatmessage.FieldContent: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetContent(v) + return nil + case chatmessage.FieldStatus: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetStatus(v) + return nil + case chatmessage.FieldModel: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetModel(v) + return nil + case chatmessage.FieldDurationMs: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetDurationMs(v) + return nil + case chatmessage.FieldUsageLogID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetUsageLogID(v) + return nil + case chatmessage.FieldActualCost: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetActualCost(v) + return nil + case chatmessage.FieldErrorMessage: + v, ok := value.(string) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetErrorMessage(v) + return nil + } + return fmt.Errorf("unknown ChatMessage field %s", name) +} + +// AddedFields returns all numeric fields that were incremented/decremented during +// this mutation. +func (m *ChatMessageMutation) AddedFields() []string { + var fields []string + if m.addduration_ms != nil { + fields = append(fields, chatmessage.FieldDurationMs) + } + if m.addactual_cost != nil { + fields = append(fields, chatmessage.FieldActualCost) + } + return fields +} + +// AddedField returns the numeric value that was incremented/decremented on a field +// with the given name. The second boolean return value indicates that this field +// was not set, or was not defined in the schema. +func (m *ChatMessageMutation) AddedField(name string) (ent.Value, bool) { + switch name { + case chatmessage.FieldDurationMs: + return m.AddedDurationMs() + case chatmessage.FieldActualCost: + return m.AddedActualCost() + } + return nil, false +} + +// AddField adds the value to the field with the given name. It returns an error if +// the field is not defined in the schema, or if the type mismatched the field +// type. +func (m *ChatMessageMutation) AddField(name string, value ent.Value) error { + switch name { + case chatmessage.FieldDurationMs: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddDurationMs(v) + return nil + case chatmessage.FieldActualCost: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddActualCost(v) + return nil + } + return fmt.Errorf("unknown ChatMessage numeric field %s", name) +} + +// ClearedFields returns all nullable fields that were cleared during this +// mutation. +func (m *ChatMessageMutation) ClearedFields() []string { + var fields []string + if m.FieldCleared(chatmessage.FieldModel) { + fields = append(fields, chatmessage.FieldModel) + } + if m.FieldCleared(chatmessage.FieldDurationMs) { + fields = append(fields, chatmessage.FieldDurationMs) + } + if m.FieldCleared(chatmessage.FieldUsageLogID) { + fields = append(fields, chatmessage.FieldUsageLogID) + } + if m.FieldCleared(chatmessage.FieldActualCost) { + fields = append(fields, chatmessage.FieldActualCost) + } + if m.FieldCleared(chatmessage.FieldErrorMessage) { + fields = append(fields, chatmessage.FieldErrorMessage) + } + return fields +} + +// FieldCleared returns a boolean indicating if a field with the given name was +// cleared in this mutation. +func (m *ChatMessageMutation) FieldCleared(name string) bool { + _, ok := m.clearedFields[name] + return ok +} + +// ClearField clears the value of the field with the given name. It returns an +// error if the field is not defined in the schema. +func (m *ChatMessageMutation) ClearField(name string) error { + switch name { + case chatmessage.FieldModel: + m.ClearModel() + return nil + case chatmessage.FieldDurationMs: + m.ClearDurationMs() + return nil + case chatmessage.FieldUsageLogID: + m.ClearUsageLogID() + return nil + case chatmessage.FieldActualCost: + m.ClearActualCost() + return nil + case chatmessage.FieldErrorMessage: + m.ClearErrorMessage() + return nil + } + return fmt.Errorf("unknown ChatMessage nullable field %s", name) +} + +// ResetField resets all changes in the mutation for the field with the given name. +// It returns an error if the field is not defined in the schema. +func (m *ChatMessageMutation) ResetField(name string) error { + switch name { + case chatmessage.FieldCreatedAt: + m.ResetCreatedAt() + return nil + case chatmessage.FieldUpdatedAt: + m.ResetUpdatedAt() + return nil + case chatmessage.FieldSessionID: + m.ResetSessionID() + return nil + case chatmessage.FieldUserID: + m.ResetUserID() + return nil + case chatmessage.FieldRole: + m.ResetRole() + return nil + case chatmessage.FieldContent: + m.ResetContent() + return nil + case chatmessage.FieldStatus: + m.ResetStatus() + return nil + case chatmessage.FieldModel: + m.ResetModel() + return nil + case chatmessage.FieldDurationMs: + m.ResetDurationMs() + return nil + case chatmessage.FieldUsageLogID: + m.ResetUsageLogID() + return nil + case chatmessage.FieldActualCost: + m.ResetActualCost() + return nil + case chatmessage.FieldErrorMessage: + m.ResetErrorMessage() + return nil + } + return fmt.Errorf("unknown ChatMessage field %s", name) +} + +// AddedEdges returns all edge names that were set/added in this mutation. +func (m *ChatMessageMutation) AddedEdges() []string { + edges := make([]string, 0, 3) + if m.session != nil { + edges = append(edges, chatmessage.EdgeSession) + } + if m.user != nil { + edges = append(edges, chatmessage.EdgeUser) + } + if m.usage_log != nil { + edges = append(edges, chatmessage.EdgeUsageLog) + } + return edges +} + +// AddedIDs returns all IDs (to other nodes) that were added for the given edge +// name in this mutation. +func (m *ChatMessageMutation) AddedIDs(name string) []ent.Value { + switch name { + case chatmessage.EdgeSession: + if id := m.session; id != nil { + return []ent.Value{*id} + } + case chatmessage.EdgeUser: + if id := m.user; id != nil { + return []ent.Value{*id} + } + case chatmessage.EdgeUsageLog: + if id := m.usage_log; id != nil { + return []ent.Value{*id} + } + } + return nil +} + +// RemovedEdges returns all edge names that were removed in this mutation. +func (m *ChatMessageMutation) RemovedEdges() []string { + edges := make([]string, 0, 3) + return edges +} + +// RemovedIDs returns all IDs (to other nodes) that were removed for the edge with +// the given name in this mutation. +func (m *ChatMessageMutation) RemovedIDs(name string) []ent.Value { + return nil +} + +// ClearedEdges returns all edge names that were cleared in this mutation. +func (m *ChatMessageMutation) ClearedEdges() []string { + edges := make([]string, 0, 3) + if m.clearedsession { + edges = append(edges, chatmessage.EdgeSession) + } + if m.cleareduser { + edges = append(edges, chatmessage.EdgeUser) + } + if m.clearedusage_log { + edges = append(edges, chatmessage.EdgeUsageLog) + } + return edges +} + +// EdgeCleared returns a boolean which indicates if the edge with the given name +// was cleared in this mutation. +func (m *ChatMessageMutation) EdgeCleared(name string) bool { + switch name { + case chatmessage.EdgeSession: + return m.clearedsession + case chatmessage.EdgeUser: + return m.cleareduser + case chatmessage.EdgeUsageLog: + return m.clearedusage_log + } + return false +} + +// ClearEdge clears the value of the edge with the given name. It returns an error +// if that edge is not defined in the schema. +func (m *ChatMessageMutation) ClearEdge(name string) error { + switch name { + case chatmessage.EdgeSession: + m.ClearSession() + return nil + case chatmessage.EdgeUser: + m.ClearUser() + return nil + case chatmessage.EdgeUsageLog: + m.ClearUsageLog() + return nil + } + return fmt.Errorf("unknown ChatMessage unique edge %s", name) +} + +// ResetEdge resets all changes to the edge with the given name in this mutation. +// It returns an error if the edge is not defined in the schema. +func (m *ChatMessageMutation) ResetEdge(name string) error { + switch name { + case chatmessage.EdgeSession: + m.ResetSession() + return nil + case chatmessage.EdgeUser: + m.ResetUser() + return nil + case chatmessage.EdgeUsageLog: + m.ResetUsageLog() + return nil + } + return fmt.Errorf("unknown ChatMessage edge %s", name) +} + +// ChatSessionMutation represents an operation that mutates the ChatSession nodes in the graph. +type ChatSessionMutation struct { + config + op Op + typ string + id *int64 + created_at *time.Time + updated_at *time.Time + deleted_at *time.Time + title *string + model *string + status *string + expires_at *time.Time + clearedFields map[string]struct{} + user *int64 + cleareduser bool + api_key *int64 + clearedapi_key bool + messages map[int64]struct{} + removedmessages map[int64]struct{} + clearedmessages bool + done bool + oldValue func(context.Context) (*ChatSession, error) + predicates []predicate.ChatSession +} + +var _ ent.Mutation = (*ChatSessionMutation)(nil) + +// chatsessionOption allows management of the mutation configuration using functional options. +type chatsessionOption func(*ChatSessionMutation) + +// newChatSessionMutation creates new mutation for the ChatSession entity. +func newChatSessionMutation(c config, op Op, opts ...chatsessionOption) *ChatSessionMutation { + m := &ChatSessionMutation{ + config: c, + op: op, + typ: TypeChatSession, + clearedFields: make(map[string]struct{}), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withChatSessionID sets the ID field of the mutation. +func withChatSessionID(id int64) chatsessionOption { + return func(m *ChatSessionMutation) { + var ( + err error + once sync.Once + value *ChatSession + ) + m.oldValue = func(ctx context.Context) (*ChatSession, error) { + once.Do(func() { + if m.done { + err = errors.New("querying old values post mutation is not allowed") + } else { + value, err = m.Client().ChatSession.Get(ctx, id) + } + }) + return value, err + } + m.id = &id + } +} + +// withChatSession sets the old ChatSession of the mutation. +func withChatSession(node *ChatSession) chatsessionOption { + return func(m *ChatSessionMutation) { + m.oldValue = func(context.Context) (*ChatSession, error) { + return node, nil + } + m.id = &node.ID + } +} + +// Client returns a new `ent.Client` from the mutation. If the mutation was +// executed in a transaction (ent.Tx), a transactional client is returned. +func (m ChatSessionMutation) Client() *Client { + client := &Client{config: m.config} + client.init() + return client +} + +// Tx returns an `ent.Tx` for mutations that were executed in transactions; +// it returns an error otherwise. +func (m ChatSessionMutation) Tx() (*Tx, error) { + if _, ok := m.driver.(*txDriver); !ok { + return nil, errors.New("ent: mutation is not running in a transaction") + } + tx := &Tx{config: m.config} + tx.init() + return tx, nil +} + +// ID returns the ID value in the mutation. Note that the ID is only available +// if it was provided to the builder or after it was returned from the database. +func (m *ChatSessionMutation) ID() (id int64, exists bool) { + if m.id == nil { + return + } + return *m.id, true +} + +// IDs queries the database and returns the entity ids that match the mutation's predicate. +// That means, if the mutation is applied within a transaction with an isolation level such +// as sql.LevelSerializable, the returned ids match the ids of the rows that will be updated +// or updated by the mutation. +func (m *ChatSessionMutation) IDs(ctx context.Context) ([]int64, error) { + switch { + case m.op.Is(OpUpdateOne | OpDeleteOne): + id, exists := m.ID() + if exists { + return []int64{id}, nil + } + fallthrough + case m.op.Is(OpUpdate | OpDelete): + return m.Client().ChatSession.Query().Where(m.predicates...).IDs(ctx) + default: + return nil, fmt.Errorf("IDs is not allowed on %s operations", m.op) + } +} + +// SetCreatedAt sets the "created_at" field. +func (m *ChatSessionMutation) SetCreatedAt(t time.Time) { + m.created_at = &t +} + +// CreatedAt returns the value of the "created_at" field in the mutation. +func (m *ChatSessionMutation) CreatedAt() (r time.Time, exists bool) { + v := m.created_at + if v == nil { + return + } + return *v, true +} + +// OldCreatedAt returns the old "created_at" field's value of the ChatSession entity. +// If the ChatSession 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 *ChatSessionMutation) OldCreatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldCreatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldCreatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldCreatedAt: %w", err) + } + return oldValue.CreatedAt, nil +} + +// ResetCreatedAt resets all changes to the "created_at" field. +func (m *ChatSessionMutation) ResetCreatedAt() { + m.created_at = nil +} + +// SetUpdatedAt sets the "updated_at" field. +func (m *ChatSessionMutation) SetUpdatedAt(t time.Time) { + m.updated_at = &t +} + +// UpdatedAt returns the value of the "updated_at" field in the mutation. +func (m *ChatSessionMutation) UpdatedAt() (r time.Time, exists bool) { + v := m.updated_at + if v == nil { + return + } + return *v, true +} + +// OldUpdatedAt returns the old "updated_at" field's value of the ChatSession entity. +// If the ChatSession 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 *ChatSessionMutation) OldUpdatedAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUpdatedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUpdatedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUpdatedAt: %w", err) + } + return oldValue.UpdatedAt, nil +} + +// ResetUpdatedAt resets all changes to the "updated_at" field. +func (m *ChatSessionMutation) ResetUpdatedAt() { + m.updated_at = nil +} + +// SetDeletedAt sets the "deleted_at" field. +func (m *ChatSessionMutation) SetDeletedAt(t time.Time) { + m.deleted_at = &t +} + +// DeletedAt returns the value of the "deleted_at" field in the mutation. +func (m *ChatSessionMutation) DeletedAt() (r time.Time, exists bool) { + v := m.deleted_at + if v == nil { + return + } + return *v, true +} + +// OldDeletedAt returns the old "deleted_at" field's value of the ChatSession entity. +// If the ChatSession 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 *ChatSessionMutation) OldDeletedAt(ctx context.Context) (v *time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldDeletedAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldDeletedAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldDeletedAt: %w", err) + } + return oldValue.DeletedAt, nil +} + +// ClearDeletedAt clears the value of the "deleted_at" field. +func (m *ChatSessionMutation) ClearDeletedAt() { + m.deleted_at = nil + m.clearedFields[chatsession.FieldDeletedAt] = struct{}{} +} + +// DeletedAtCleared returns if the "deleted_at" field was cleared in this mutation. +func (m *ChatSessionMutation) DeletedAtCleared() bool { + _, ok := m.clearedFields[chatsession.FieldDeletedAt] + return ok +} + +// ResetDeletedAt resets all changes to the "deleted_at" field. +func (m *ChatSessionMutation) ResetDeletedAt() { + m.deleted_at = nil + delete(m.clearedFields, chatsession.FieldDeletedAt) +} + +// SetUserID sets the "user_id" field. +func (m *ChatSessionMutation) SetUserID(i int64) { + m.user = &i +} + +// UserID returns the value of the "user_id" field in the mutation. +func (m *ChatSessionMutation) UserID() (r int64, exists bool) { + v := m.user + if v == nil { + return + } + return *v, true +} + +// OldUserID returns the old "user_id" field's value of the ChatSession entity. +// If the ChatSession 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 *ChatSessionMutation) OldUserID(ctx context.Context) (v int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldUserID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldUserID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldUserID: %w", err) + } + return oldValue.UserID, nil +} + +// ResetUserID resets all changes to the "user_id" field. +func (m *ChatSessionMutation) ResetUserID() { + m.user = nil +} + +// SetAPIKeyID sets the "api_key_id" field. +func (m *ChatSessionMutation) SetAPIKeyID(i int64) { + m.api_key = &i +} + +// APIKeyID returns the value of the "api_key_id" field in the mutation. +func (m *ChatSessionMutation) APIKeyID() (r int64, exists bool) { + v := m.api_key + if v == nil { + return + } + return *v, true +} + +// OldAPIKeyID returns the old "api_key_id" field's value of the ChatSession entity. +// If the ChatSession 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 *ChatSessionMutation) OldAPIKeyID(ctx context.Context) (v int64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAPIKeyID is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAPIKeyID requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAPIKeyID: %w", err) + } + return oldValue.APIKeyID, nil +} + +// ResetAPIKeyID resets all changes to the "api_key_id" field. +func (m *ChatSessionMutation) ResetAPIKeyID() { + m.api_key = nil +} + +// SetTitle sets the "title" field. +func (m *ChatSessionMutation) SetTitle(s string) { + m.title = &s +} + +// Title returns the value of the "title" field in the mutation. +func (m *ChatSessionMutation) Title() (r string, exists bool) { + v := m.title + if v == nil { + return + } + return *v, true +} + +// OldTitle returns the old "title" field's value of the ChatSession entity. +// If the ChatSession 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 *ChatSessionMutation) OldTitle(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldTitle is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldTitle requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldTitle: %w", err) + } + return oldValue.Title, nil +} + +// ResetTitle resets all changes to the "title" field. +func (m *ChatSessionMutation) ResetTitle() { + m.title = nil +} + +// SetModel sets the "model" field. +func (m *ChatSessionMutation) SetModel(s string) { + m.model = &s +} + +// Model returns the value of the "model" field in the mutation. +func (m *ChatSessionMutation) Model() (r string, exists bool) { + v := m.model + if v == nil { + return + } + return *v, true +} + +// OldModel returns the old "model" field's value of the ChatSession entity. +// If the ChatSession 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 *ChatSessionMutation) OldModel(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldModel is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldModel requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldModel: %w", err) + } + return oldValue.Model, nil +} + +// ResetModel resets all changes to the "model" field. +func (m *ChatSessionMutation) ResetModel() { + m.model = nil +} + +// SetStatus sets the "status" field. +func (m *ChatSessionMutation) SetStatus(s string) { + m.status = &s +} + +// Status returns the value of the "status" field in the mutation. +func (m *ChatSessionMutation) Status() (r string, exists bool) { + v := m.status + if v == nil { + return + } + return *v, true +} + +// OldStatus returns the old "status" field's value of the ChatSession entity. +// If the ChatSession 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 *ChatSessionMutation) OldStatus(ctx context.Context) (v string, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldStatus is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldStatus requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldStatus: %w", err) + } + return oldValue.Status, nil +} + +// ResetStatus resets all changes to the "status" field. +func (m *ChatSessionMutation) ResetStatus() { + m.status = nil +} + +// SetExpiresAt sets the "expires_at" field. +func (m *ChatSessionMutation) SetExpiresAt(t time.Time) { + m.expires_at = &t +} + +// ExpiresAt returns the value of the "expires_at" field in the mutation. +func (m *ChatSessionMutation) ExpiresAt() (r time.Time, exists bool) { + v := m.expires_at + if v == nil { + return + } + return *v, true +} + +// OldExpiresAt returns the old "expires_at" field's value of the ChatSession entity. +// If the ChatSession 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 *ChatSessionMutation) OldExpiresAt(ctx context.Context) (v time.Time, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldExpiresAt is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldExpiresAt requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldExpiresAt: %w", err) + } + return oldValue.ExpiresAt, nil +} + +// ResetExpiresAt resets all changes to the "expires_at" field. +func (m *ChatSessionMutation) ResetExpiresAt() { + m.expires_at = nil +} + +// ClearUser clears the "user" edge to the User entity. +func (m *ChatSessionMutation) ClearUser() { + m.cleareduser = true + m.clearedFields[chatsession.FieldUserID] = struct{}{} +} + +// UserCleared reports if the "user" edge to the User entity was cleared. +func (m *ChatSessionMutation) UserCleared() bool { + return m.cleareduser +} + +// UserIDs returns the "user" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// UserID instead. It exists only for internal usage by the builders. +func (m *ChatSessionMutation) UserIDs() (ids []int64) { + if id := m.user; id != nil { + ids = append(ids, *id) + } + return +} + +// ResetUser resets all changes to the "user" edge. +func (m *ChatSessionMutation) ResetUser() { + m.user = nil + m.cleareduser = false } -// OldBodyOverride returns the old "body_override" field's value of the ChannelMonitorRequestTemplate entity. -// If the ChannelMonitorRequestTemplate 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 *ChannelMonitorRequestTemplateMutation) OldBodyOverride(ctx context.Context) (v map[string]interface{}, err error) { - if !m.op.Is(OpUpdateOne) { - return v, errors.New("OldBodyOverride is only allowed on UpdateOne operations") - } - if m.id == nil || m.oldValue == nil { - return v, errors.New("OldBodyOverride requires an ID field in the mutation") - } - oldValue, err := m.oldValue(ctx) - if err != nil { - return v, fmt.Errorf("querying old value for OldBodyOverride: %w", err) - } - return oldValue.BodyOverride, nil +// ClearAPIKey clears the "api_key" edge to the APIKey entity. +func (m *ChatSessionMutation) ClearAPIKey() { + m.clearedapi_key = true + m.clearedFields[chatsession.FieldAPIKeyID] = struct{}{} } -// ClearBodyOverride clears the value of the "body_override" field. -func (m *ChannelMonitorRequestTemplateMutation) ClearBodyOverride() { - m.body_override = nil - m.clearedFields[channelmonitorrequesttemplate.FieldBodyOverride] = struct{}{} +// APIKeyCleared reports if the "api_key" edge to the APIKey entity was cleared. +func (m *ChatSessionMutation) APIKeyCleared() bool { + return m.clearedapi_key } -// BodyOverrideCleared returns if the "body_override" field was cleared in this mutation. -func (m *ChannelMonitorRequestTemplateMutation) BodyOverrideCleared() bool { - _, ok := m.clearedFields[channelmonitorrequesttemplate.FieldBodyOverride] - return ok +// APIKeyIDs returns the "api_key" edge IDs in the mutation. +// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use +// APIKeyID instead. It exists only for internal usage by the builders. +func (m *ChatSessionMutation) APIKeyIDs() (ids []int64) { + if id := m.api_key; id != nil { + ids = append(ids, *id) + } + return } -// ResetBodyOverride resets all changes to the "body_override" field. -func (m *ChannelMonitorRequestTemplateMutation) ResetBodyOverride() { - m.body_override = nil - delete(m.clearedFields, channelmonitorrequesttemplate.FieldBodyOverride) +// ResetAPIKey resets all changes to the "api_key" edge. +func (m *ChatSessionMutation) ResetAPIKey() { + m.api_key = nil + m.clearedapi_key = false } -// AddMonitorIDs adds the "monitors" edge to the ChannelMonitor entity by ids. -func (m *ChannelMonitorRequestTemplateMutation) AddMonitorIDs(ids ...int64) { - if m.monitors == nil { - m.monitors = make(map[int64]struct{}) +// AddMessageIDs adds the "messages" edge to the ChatMessage entity by ids. +func (m *ChatSessionMutation) AddMessageIDs(ids ...int64) { + if m.messages == nil { + m.messages = make(map[int64]struct{}) } for i := range ids { - m.monitors[ids[i]] = struct{}{} + m.messages[ids[i]] = struct{}{} } } -// ClearMonitors clears the "monitors" edge to the ChannelMonitor entity. -func (m *ChannelMonitorRequestTemplateMutation) ClearMonitors() { - m.clearedmonitors = true +// ClearMessages clears the "messages" edge to the ChatMessage entity. +func (m *ChatSessionMutation) ClearMessages() { + m.clearedmessages = true } -// MonitorsCleared reports if the "monitors" edge to the ChannelMonitor entity was cleared. -func (m *ChannelMonitorRequestTemplateMutation) MonitorsCleared() bool { - return m.clearedmonitors +// MessagesCleared reports if the "messages" edge to the ChatMessage entity was cleared. +func (m *ChatSessionMutation) MessagesCleared() bool { + return m.clearedmessages } -// RemoveMonitorIDs removes the "monitors" edge to the ChannelMonitor entity by IDs. -func (m *ChannelMonitorRequestTemplateMutation) RemoveMonitorIDs(ids ...int64) { - if m.removedmonitors == nil { - m.removedmonitors = make(map[int64]struct{}) +// RemoveMessageIDs removes the "messages" edge to the ChatMessage entity by IDs. +func (m *ChatSessionMutation) RemoveMessageIDs(ids ...int64) { + if m.removedmessages == nil { + m.removedmessages = make(map[int64]struct{}) } for i := range ids { - delete(m.monitors, ids[i]) - m.removedmonitors[ids[i]] = struct{}{} + delete(m.messages, ids[i]) + m.removedmessages[ids[i]] = struct{}{} } } -// RemovedMonitors returns the removed IDs of the "monitors" edge to the ChannelMonitor entity. -func (m *ChannelMonitorRequestTemplateMutation) RemovedMonitorsIDs() (ids []int64) { - for id := range m.removedmonitors { +// RemovedMessages returns the removed IDs of the "messages" edge to the ChatMessage entity. +func (m *ChatSessionMutation) RemovedMessagesIDs() (ids []int64) { + for id := range m.removedmessages { ids = append(ids, id) } return } -// MonitorsIDs returns the "monitors" edge IDs in the mutation. -func (m *ChannelMonitorRequestTemplateMutation) MonitorsIDs() (ids []int64) { - for id := range m.monitors { +// MessagesIDs returns the "messages" edge IDs in the mutation. +func (m *ChatSessionMutation) MessagesIDs() (ids []int64) { + for id := range m.messages { ids = append(ids, id) } return } -// ResetMonitors resets all changes to the "monitors" edge. -func (m *ChannelMonitorRequestTemplateMutation) ResetMonitors() { - m.monitors = nil - m.clearedmonitors = false - m.removedmonitors = nil +// ResetMessages resets all changes to the "messages" edge. +func (m *ChatSessionMutation) ResetMessages() { + m.messages = nil + m.clearedmessages = false + m.removedmessages = nil } -// Where appends a list predicates to the ChannelMonitorRequestTemplateMutation builder. -func (m *ChannelMonitorRequestTemplateMutation) Where(ps ...predicate.ChannelMonitorRequestTemplate) { +// Where appends a list predicates to the ChatSessionMutation builder. +func (m *ChatSessionMutation) Where(ps ...predicate.ChatSession) { m.predicates = append(m.predicates, ps...) } -// WhereP appends storage-level predicates to the ChannelMonitorRequestTemplateMutation builder. Using this method, +// WhereP appends storage-level predicates to the ChatSessionMutation builder. Using this method, // users can use type-assertion to append predicates that do not depend on any generated package. -func (m *ChannelMonitorRequestTemplateMutation) WhereP(ps ...func(*sql.Selector)) { - p := make([]predicate.ChannelMonitorRequestTemplate, len(ps)) +func (m *ChatSessionMutation) WhereP(ps ...func(*sql.Selector)) { + p := make([]predicate.ChatSession, len(ps)) for i := range ps { p[i] = ps[i] } @@ -13086,48 +15326,51 @@ func (m *ChannelMonitorRequestTemplateMutation) WhereP(ps ...func(*sql.Selector) } // Op returns the operation name. -func (m *ChannelMonitorRequestTemplateMutation) Op() Op { +func (m *ChatSessionMutation) Op() Op { return m.op } // SetOp allows setting the mutation operation. -func (m *ChannelMonitorRequestTemplateMutation) SetOp(op Op) { +func (m *ChatSessionMutation) SetOp(op Op) { m.op = op } -// Type returns the node type of this mutation (ChannelMonitorRequestTemplate). -func (m *ChannelMonitorRequestTemplateMutation) Type() string { +// Type returns the node type of this mutation (ChatSession). +func (m *ChatSessionMutation) Type() string { return m.typ } // Fields returns all fields that were changed during this mutation. Note that in // order to get all numeric fields that were incremented/decremented, call // AddedFields(). -func (m *ChannelMonitorRequestTemplateMutation) Fields() []string { - fields := make([]string, 0, 8) +func (m *ChatSessionMutation) Fields() []string { + fields := make([]string, 0, 9) if m.created_at != nil { - fields = append(fields, channelmonitorrequesttemplate.FieldCreatedAt) + fields = append(fields, chatsession.FieldCreatedAt) } if m.updated_at != nil { - fields = append(fields, channelmonitorrequesttemplate.FieldUpdatedAt) + fields = append(fields, chatsession.FieldUpdatedAt) } - if m.name != nil { - fields = append(fields, channelmonitorrequesttemplate.FieldName) + if m.deleted_at != nil { + fields = append(fields, chatsession.FieldDeletedAt) } - if m.provider != nil { - fields = append(fields, channelmonitorrequesttemplate.FieldProvider) + if m.user != nil { + fields = append(fields, chatsession.FieldUserID) } - if m.description != nil { - fields = append(fields, channelmonitorrequesttemplate.FieldDescription) + if m.api_key != nil { + fields = append(fields, chatsession.FieldAPIKeyID) } - if m.extra_headers != nil { - fields = append(fields, channelmonitorrequesttemplate.FieldExtraHeaders) + if m.title != nil { + fields = append(fields, chatsession.FieldTitle) } - if m.body_override_mode != nil { - fields = append(fields, channelmonitorrequesttemplate.FieldBodyOverrideMode) + if m.model != nil { + fields = append(fields, chatsession.FieldModel) } - if m.body_override != nil { - fields = append(fields, channelmonitorrequesttemplate.FieldBodyOverride) + if m.status != nil { + fields = append(fields, chatsession.FieldStatus) + } + if m.expires_at != nil { + fields = append(fields, chatsession.FieldExpiresAt) } return fields } @@ -13135,24 +15378,26 @@ func (m *ChannelMonitorRequestTemplateMutation) Fields() []string { // Field returns the value of a field with the given name. The second boolean // return value indicates that this field was not set, or was not defined in the // schema. -func (m *ChannelMonitorRequestTemplateMutation) Field(name string) (ent.Value, bool) { +func (m *ChatSessionMutation) Field(name string) (ent.Value, bool) { switch name { - case channelmonitorrequesttemplate.FieldCreatedAt: + case chatsession.FieldCreatedAt: return m.CreatedAt() - case channelmonitorrequesttemplate.FieldUpdatedAt: + case chatsession.FieldUpdatedAt: return m.UpdatedAt() - case channelmonitorrequesttemplate.FieldName: - return m.Name() - case channelmonitorrequesttemplate.FieldProvider: - return m.Provider() - case channelmonitorrequesttemplate.FieldDescription: - return m.Description() - case channelmonitorrequesttemplate.FieldExtraHeaders: - return m.ExtraHeaders() - case channelmonitorrequesttemplate.FieldBodyOverrideMode: - return m.BodyOverrideMode() - case channelmonitorrequesttemplate.FieldBodyOverride: - return m.BodyOverride() + case chatsession.FieldDeletedAt: + return m.DeletedAt() + case chatsession.FieldUserID: + return m.UserID() + case chatsession.FieldAPIKeyID: + return m.APIKeyID() + case chatsession.FieldTitle: + return m.Title() + case chatsession.FieldModel: + return m.Model() + case chatsession.FieldStatus: + return m.Status() + case chatsession.FieldExpiresAt: + return m.ExpiresAt() } return nil, false } @@ -13160,197 +15405,220 @@ func (m *ChannelMonitorRequestTemplateMutation) Field(name string) (ent.Value, b // OldField returns the old value of the field from the database. An error is // returned if the mutation operation is not UpdateOne, or the query to the // database failed. -func (m *ChannelMonitorRequestTemplateMutation) OldField(ctx context.Context, name string) (ent.Value, error) { +func (m *ChatSessionMutation) OldField(ctx context.Context, name string) (ent.Value, error) { switch name { - case channelmonitorrequesttemplate.FieldCreatedAt: + case chatsession.FieldCreatedAt: return m.OldCreatedAt(ctx) - case channelmonitorrequesttemplate.FieldUpdatedAt: + case chatsession.FieldUpdatedAt: return m.OldUpdatedAt(ctx) - case channelmonitorrequesttemplate.FieldName: - return m.OldName(ctx) - case channelmonitorrequesttemplate.FieldProvider: - return m.OldProvider(ctx) - case channelmonitorrequesttemplate.FieldDescription: - return m.OldDescription(ctx) - case channelmonitorrequesttemplate.FieldExtraHeaders: - return m.OldExtraHeaders(ctx) - case channelmonitorrequesttemplate.FieldBodyOverrideMode: - return m.OldBodyOverrideMode(ctx) - case channelmonitorrequesttemplate.FieldBodyOverride: - return m.OldBodyOverride(ctx) + case chatsession.FieldDeletedAt: + return m.OldDeletedAt(ctx) + case chatsession.FieldUserID: + return m.OldUserID(ctx) + case chatsession.FieldAPIKeyID: + return m.OldAPIKeyID(ctx) + case chatsession.FieldTitle: + return m.OldTitle(ctx) + case chatsession.FieldModel: + return m.OldModel(ctx) + case chatsession.FieldStatus: + return m.OldStatus(ctx) + case chatsession.FieldExpiresAt: + return m.OldExpiresAt(ctx) } - return nil, fmt.Errorf("unknown ChannelMonitorRequestTemplate field %s", name) + return nil, fmt.Errorf("unknown ChatSession field %s", name) } // SetField sets the value of a field with the given name. It returns an error if // the field is not defined in the schema, or if the type mismatched the field // type. -func (m *ChannelMonitorRequestTemplateMutation) SetField(name string, value ent.Value) error { +func (m *ChatSessionMutation) SetField(name string, value ent.Value) error { switch name { - case channelmonitorrequesttemplate.FieldCreatedAt: + case chatsession.FieldCreatedAt: v, ok := value.(time.Time) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } m.SetCreatedAt(v) return nil - case channelmonitorrequesttemplate.FieldUpdatedAt: + case chatsession.FieldUpdatedAt: v, ok := value.(time.Time) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } m.SetUpdatedAt(v) return nil - case channelmonitorrequesttemplate.FieldName: - v, ok := value.(string) + case chatsession.FieldDeletedAt: + v, ok := value.(time.Time) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } - m.SetName(v) + m.SetDeletedAt(v) return nil - case channelmonitorrequesttemplate.FieldProvider: - v, ok := value.(channelmonitorrequesttemplate.Provider) + case chatsession.FieldUserID: + v, ok := value.(int64) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } - m.SetProvider(v) + m.SetUserID(v) return nil - case channelmonitorrequesttemplate.FieldDescription: + case chatsession.FieldAPIKeyID: + v, ok := value.(int64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAPIKeyID(v) + return nil + case chatsession.FieldTitle: v, ok := value.(string) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } - m.SetDescription(v) + m.SetTitle(v) return nil - case channelmonitorrequesttemplate.FieldExtraHeaders: - v, ok := value.(map[string]string) + case chatsession.FieldModel: + v, ok := value.(string) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } - m.SetExtraHeaders(v) + m.SetModel(v) return nil - case channelmonitorrequesttemplate.FieldBodyOverrideMode: + case chatsession.FieldStatus: v, ok := value.(string) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } - m.SetBodyOverrideMode(v) + m.SetStatus(v) return nil - case channelmonitorrequesttemplate.FieldBodyOverride: - v, ok := value.(map[string]interface{}) + case chatsession.FieldExpiresAt: + v, ok := value.(time.Time) if !ok { return fmt.Errorf("unexpected type %T for field %s", value, name) } - m.SetBodyOverride(v) + m.SetExpiresAt(v) return nil } - return fmt.Errorf("unknown ChannelMonitorRequestTemplate field %s", name) + return fmt.Errorf("unknown ChatSession field %s", name) } // AddedFields returns all numeric fields that were incremented/decremented during // this mutation. -func (m *ChannelMonitorRequestTemplateMutation) AddedFields() []string { - return nil +func (m *ChatSessionMutation) AddedFields() []string { + var fields []string + return fields } // AddedField returns the numeric value that was incremented/decremented on a field // with the given name. The second boolean return value indicates that this field // was not set, or was not defined in the schema. -func (m *ChannelMonitorRequestTemplateMutation) AddedField(name string) (ent.Value, bool) { +func (m *ChatSessionMutation) AddedField(name string) (ent.Value, bool) { + switch name { + } return nil, false } // AddField adds the value to the field with the given name. It returns an error if // the field is not defined in the schema, or if the type mismatched the field // type. -func (m *ChannelMonitorRequestTemplateMutation) AddField(name string, value ent.Value) error { +func (m *ChatSessionMutation) AddField(name string, value ent.Value) error { switch name { } - return fmt.Errorf("unknown ChannelMonitorRequestTemplate numeric field %s", name) + return fmt.Errorf("unknown ChatSession numeric field %s", name) } // ClearedFields returns all nullable fields that were cleared during this // mutation. -func (m *ChannelMonitorRequestTemplateMutation) ClearedFields() []string { +func (m *ChatSessionMutation) ClearedFields() []string { var fields []string - if m.FieldCleared(channelmonitorrequesttemplate.FieldDescription) { - fields = append(fields, channelmonitorrequesttemplate.FieldDescription) - } - if m.FieldCleared(channelmonitorrequesttemplate.FieldBodyOverride) { - fields = append(fields, channelmonitorrequesttemplate.FieldBodyOverride) + if m.FieldCleared(chatsession.FieldDeletedAt) { + fields = append(fields, chatsession.FieldDeletedAt) } return fields } // FieldCleared returns a boolean indicating if a field with the given name was // cleared in this mutation. -func (m *ChannelMonitorRequestTemplateMutation) FieldCleared(name string) bool { +func (m *ChatSessionMutation) FieldCleared(name string) bool { _, ok := m.clearedFields[name] return ok } // ClearField clears the value of the field with the given name. It returns an // error if the field is not defined in the schema. -func (m *ChannelMonitorRequestTemplateMutation) ClearField(name string) error { +func (m *ChatSessionMutation) ClearField(name string) error { switch name { - case channelmonitorrequesttemplate.FieldDescription: - m.ClearDescription() - return nil - case channelmonitorrequesttemplate.FieldBodyOverride: - m.ClearBodyOverride() + case chatsession.FieldDeletedAt: + m.ClearDeletedAt() return nil } - return fmt.Errorf("unknown ChannelMonitorRequestTemplate nullable field %s", name) + return fmt.Errorf("unknown ChatSession nullable field %s", name) } // ResetField resets all changes in the mutation for the field with the given name. // It returns an error if the field is not defined in the schema. -func (m *ChannelMonitorRequestTemplateMutation) ResetField(name string) error { +func (m *ChatSessionMutation) ResetField(name string) error { switch name { - case channelmonitorrequesttemplate.FieldCreatedAt: + case chatsession.FieldCreatedAt: m.ResetCreatedAt() return nil - case channelmonitorrequesttemplate.FieldUpdatedAt: + case chatsession.FieldUpdatedAt: m.ResetUpdatedAt() return nil - case channelmonitorrequesttemplate.FieldName: - m.ResetName() + case chatsession.FieldDeletedAt: + m.ResetDeletedAt() return nil - case channelmonitorrequesttemplate.FieldProvider: - m.ResetProvider() + case chatsession.FieldUserID: + m.ResetUserID() return nil - case channelmonitorrequesttemplate.FieldDescription: - m.ResetDescription() + case chatsession.FieldAPIKeyID: + m.ResetAPIKeyID() return nil - case channelmonitorrequesttemplate.FieldExtraHeaders: - m.ResetExtraHeaders() + case chatsession.FieldTitle: + m.ResetTitle() return nil - case channelmonitorrequesttemplate.FieldBodyOverrideMode: - m.ResetBodyOverrideMode() + case chatsession.FieldModel: + m.ResetModel() return nil - case channelmonitorrequesttemplate.FieldBodyOverride: - m.ResetBodyOverride() + case chatsession.FieldStatus: + m.ResetStatus() + return nil + case chatsession.FieldExpiresAt: + m.ResetExpiresAt() return nil } - return fmt.Errorf("unknown ChannelMonitorRequestTemplate field %s", name) + return fmt.Errorf("unknown ChatSession field %s", name) } // AddedEdges returns all edge names that were set/added in this mutation. -func (m *ChannelMonitorRequestTemplateMutation) AddedEdges() []string { - edges := make([]string, 0, 1) - if m.monitors != nil { - edges = append(edges, channelmonitorrequesttemplate.EdgeMonitors) +func (m *ChatSessionMutation) AddedEdges() []string { + edges := make([]string, 0, 3) + if m.user != nil { + edges = append(edges, chatsession.EdgeUser) + } + if m.api_key != nil { + edges = append(edges, chatsession.EdgeAPIKey) + } + if m.messages != nil { + edges = append(edges, chatsession.EdgeMessages) } return edges } // AddedIDs returns all IDs (to other nodes) that were added for the given edge // name in this mutation. -func (m *ChannelMonitorRequestTemplateMutation) AddedIDs(name string) []ent.Value { +func (m *ChatSessionMutation) AddedIDs(name string) []ent.Value { switch name { - case channelmonitorrequesttemplate.EdgeMonitors: - ids := make([]ent.Value, 0, len(m.monitors)) - for id := range m.monitors { + case chatsession.EdgeUser: + if id := m.user; id != nil { + return []ent.Value{*id} + } + case chatsession.EdgeAPIKey: + if id := m.api_key; id != nil { + return []ent.Value{*id} + } + case chatsession.EdgeMessages: + ids := make([]ent.Value, 0, len(m.messages)) + for id := range m.messages { ids = append(ids, id) } return ids @@ -13359,21 +15627,21 @@ func (m *ChannelMonitorRequestTemplateMutation) AddedIDs(name string) []ent.Valu } // RemovedEdges returns all edge names that were removed in this mutation. -func (m *ChannelMonitorRequestTemplateMutation) RemovedEdges() []string { - edges := make([]string, 0, 1) - if m.removedmonitors != nil { - edges = append(edges, channelmonitorrequesttemplate.EdgeMonitors) +func (m *ChatSessionMutation) RemovedEdges() []string { + edges := make([]string, 0, 3) + if m.removedmessages != nil { + edges = append(edges, chatsession.EdgeMessages) } return edges } // RemovedIDs returns all IDs (to other nodes) that were removed for the edge with // the given name in this mutation. -func (m *ChannelMonitorRequestTemplateMutation) RemovedIDs(name string) []ent.Value { +func (m *ChatSessionMutation) RemovedIDs(name string) []ent.Value { switch name { - case channelmonitorrequesttemplate.EdgeMonitors: - ids := make([]ent.Value, 0, len(m.removedmonitors)) - for id := range m.removedmonitors { + case chatsession.EdgeMessages: + ids := make([]ent.Value, 0, len(m.removedmessages)) + for id := range m.removedmessages { ids = append(ids, id) } return ids @@ -13382,41 +15650,63 @@ func (m *ChannelMonitorRequestTemplateMutation) RemovedIDs(name string) []ent.Va } // ClearedEdges returns all edge names that were cleared in this mutation. -func (m *ChannelMonitorRequestTemplateMutation) ClearedEdges() []string { - edges := make([]string, 0, 1) - if m.clearedmonitors { - edges = append(edges, channelmonitorrequesttemplate.EdgeMonitors) +func (m *ChatSessionMutation) ClearedEdges() []string { + edges := make([]string, 0, 3) + if m.cleareduser { + edges = append(edges, chatsession.EdgeUser) + } + if m.clearedapi_key { + edges = append(edges, chatsession.EdgeAPIKey) + } + if m.clearedmessages { + edges = append(edges, chatsession.EdgeMessages) } return edges } // EdgeCleared returns a boolean which indicates if the edge with the given name // was cleared in this mutation. -func (m *ChannelMonitorRequestTemplateMutation) EdgeCleared(name string) bool { +func (m *ChatSessionMutation) EdgeCleared(name string) bool { switch name { - case channelmonitorrequesttemplate.EdgeMonitors: - return m.clearedmonitors + case chatsession.EdgeUser: + return m.cleareduser + case chatsession.EdgeAPIKey: + return m.clearedapi_key + case chatsession.EdgeMessages: + return m.clearedmessages } return false } // ClearEdge clears the value of the edge with the given name. It returns an error // if that edge is not defined in the schema. -func (m *ChannelMonitorRequestTemplateMutation) ClearEdge(name string) error { +func (m *ChatSessionMutation) ClearEdge(name string) error { switch name { + case chatsession.EdgeUser: + m.ClearUser() + return nil + case chatsession.EdgeAPIKey: + m.ClearAPIKey() + return nil } - return fmt.Errorf("unknown ChannelMonitorRequestTemplate unique edge %s", name) + return fmt.Errorf("unknown ChatSession unique edge %s", name) } // ResetEdge resets all changes to the edge with the given name in this mutation. // It returns an error if the edge is not defined in the schema. -func (m *ChannelMonitorRequestTemplateMutation) ResetEdge(name string) error { +func (m *ChatSessionMutation) ResetEdge(name string) error { switch name { - case channelmonitorrequesttemplate.EdgeMonitors: - m.ResetMonitors() + case chatsession.EdgeUser: + m.ResetUser() + return nil + case chatsession.EdgeAPIKey: + m.ResetAPIKey() + return nil + case chatsession.EdgeMessages: + m.ResetMessages() return nil } - return fmt.Errorf("unknown ChannelMonitorRequestTemplate edge %s", name) + return fmt.Errorf("unknown ChatSession edge %s", name) } // ErrorPassthroughRuleMutation represents an operation that mutates the ErrorPassthroughRule nodes in the graph. @@ -14764,6 +17054,10 @@ type GroupMutation struct { addmonthly_limit_usd *float64 default_validity_days *int adddefault_validity_days *int + allow_image_generation *bool + image_rate_independent *bool + image_rate_multiplier *float64 + addimage_rate_multiplier *float64 image_price_1k *float64 addimage_price_1k *float64 image_price_2k *float64 @@ -15583,6 +17877,134 @@ func (m *GroupMutation) ResetDefaultValidityDays() { m.adddefault_validity_days = nil } +// SetAllowImageGeneration sets the "allow_image_generation" field. +func (m *GroupMutation) SetAllowImageGeneration(b bool) { + m.allow_image_generation = &b +} + +// AllowImageGeneration returns the value of the "allow_image_generation" field in the mutation. +func (m *GroupMutation) AllowImageGeneration() (r bool, exists bool) { + v := m.allow_image_generation + if v == nil { + return + } + return *v, true +} + +// OldAllowImageGeneration returns the old "allow_image_generation" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldAllowImageGeneration(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldAllowImageGeneration is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldAllowImageGeneration requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldAllowImageGeneration: %w", err) + } + return oldValue.AllowImageGeneration, nil +} + +// ResetAllowImageGeneration resets all changes to the "allow_image_generation" field. +func (m *GroupMutation) ResetAllowImageGeneration() { + m.allow_image_generation = nil +} + +// SetImageRateIndependent sets the "image_rate_independent" field. +func (m *GroupMutation) SetImageRateIndependent(b bool) { + m.image_rate_independent = &b +} + +// ImageRateIndependent returns the value of the "image_rate_independent" field in the mutation. +func (m *GroupMutation) ImageRateIndependent() (r bool, exists bool) { + v := m.image_rate_independent + if v == nil { + return + } + return *v, true +} + +// OldImageRateIndependent returns the old "image_rate_independent" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldImageRateIndependent(ctx context.Context) (v bool, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImageRateIndependent is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImageRateIndependent requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImageRateIndependent: %w", err) + } + return oldValue.ImageRateIndependent, nil +} + +// ResetImageRateIndependent resets all changes to the "image_rate_independent" field. +func (m *GroupMutation) ResetImageRateIndependent() { + m.image_rate_independent = nil +} + +// SetImageRateMultiplier sets the "image_rate_multiplier" field. +func (m *GroupMutation) SetImageRateMultiplier(f float64) { + m.image_rate_multiplier = &f + m.addimage_rate_multiplier = nil +} + +// ImageRateMultiplier returns the value of the "image_rate_multiplier" field in the mutation. +func (m *GroupMutation) ImageRateMultiplier() (r float64, exists bool) { + v := m.image_rate_multiplier + if v == nil { + return + } + return *v, true +} + +// OldImageRateMultiplier returns the old "image_rate_multiplier" field's value of the Group entity. +// If the Group object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *GroupMutation) OldImageRateMultiplier(ctx context.Context) (v float64, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldImageRateMultiplier is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldImageRateMultiplier requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldImageRateMultiplier: %w", err) + } + return oldValue.ImageRateMultiplier, nil +} + +// AddImageRateMultiplier adds f to the "image_rate_multiplier" field. +func (m *GroupMutation) AddImageRateMultiplier(f float64) { + if m.addimage_rate_multiplier != nil { + *m.addimage_rate_multiplier += f + } else { + m.addimage_rate_multiplier = &f + } +} + +// AddedImageRateMultiplier returns the value that was added to the "image_rate_multiplier" field in this mutation. +func (m *GroupMutation) AddedImageRateMultiplier() (r float64, exists bool) { + v := m.addimage_rate_multiplier + if v == nil { + return + } + return *v, true +} + +// ResetImageRateMultiplier resets all changes to the "image_rate_multiplier" field. +func (m *GroupMutation) ResetImageRateMultiplier() { + m.image_rate_multiplier = nil + m.addimage_rate_multiplier = nil +} + // SetImagePrice1k sets the "image_price_1k" field. func (m *GroupMutation) SetImagePrice1k(f float64) { m.image_price_1k = &f @@ -16791,7 +19213,7 @@ func (m *GroupMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *GroupMutation) Fields() []string { - fields := make([]string, 0, 31) + fields := make([]string, 0, 34) if m.created_at != nil { fields = append(fields, group.FieldCreatedAt) } @@ -16834,6 +19256,15 @@ func (m *GroupMutation) Fields() []string { if m.default_validity_days != nil { fields = append(fields, group.FieldDefaultValidityDays) } + if m.allow_image_generation != nil { + fields = append(fields, group.FieldAllowImageGeneration) + } + if m.image_rate_independent != nil { + fields = append(fields, group.FieldImageRateIndependent) + } + if m.image_rate_multiplier != nil { + fields = append(fields, group.FieldImageRateMultiplier) + } if m.image_price_1k != nil { fields = append(fields, group.FieldImagePrice1k) } @@ -16921,6 +19352,12 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) { return m.MonthlyLimitUsd() case group.FieldDefaultValidityDays: return m.DefaultValidityDays() + case group.FieldAllowImageGeneration: + return m.AllowImageGeneration() + case group.FieldImageRateIndependent: + return m.ImageRateIndependent() + case group.FieldImageRateMultiplier: + return m.ImageRateMultiplier() case group.FieldImagePrice1k: return m.ImagePrice1k() case group.FieldImagePrice2k: @@ -16992,6 +19429,12 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e return m.OldMonthlyLimitUsd(ctx) case group.FieldDefaultValidityDays: return m.OldDefaultValidityDays(ctx) + case group.FieldAllowImageGeneration: + return m.OldAllowImageGeneration(ctx) + case group.FieldImageRateIndependent: + return m.OldImageRateIndependent(ctx) + case group.FieldImageRateMultiplier: + return m.OldImageRateMultiplier(ctx) case group.FieldImagePrice1k: return m.OldImagePrice1k(ctx) case group.FieldImagePrice2k: @@ -17133,6 +19576,27 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error { } m.SetDefaultValidityDays(v) return nil + case group.FieldAllowImageGeneration: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetAllowImageGeneration(v) + return nil + case group.FieldImageRateIndependent: + v, ok := value.(bool) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImageRateIndependent(v) + return nil + case group.FieldImageRateMultiplier: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetImageRateMultiplier(v) + return nil case group.FieldImagePrice1k: v, ok := value.(float64) if !ok { @@ -17275,6 +19739,9 @@ func (m *GroupMutation) AddedFields() []string { if m.adddefault_validity_days != nil { fields = append(fields, group.FieldDefaultValidityDays) } + if m.addimage_rate_multiplier != nil { + fields = append(fields, group.FieldImageRateMultiplier) + } if m.addimage_price_1k != nil { fields = append(fields, group.FieldImagePrice1k) } @@ -17314,6 +19781,8 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) { return m.AddedMonthlyLimitUsd() case group.FieldDefaultValidityDays: return m.AddedDefaultValidityDays() + case group.FieldImageRateMultiplier: + return m.AddedImageRateMultiplier() case group.FieldImagePrice1k: return m.AddedImagePrice1k() case group.FieldImagePrice2k: @@ -17372,6 +19841,13 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error { } m.AddDefaultValidityDays(v) return nil + case group.FieldImageRateMultiplier: + v, ok := value.(float64) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddImageRateMultiplier(v) + return nil case group.FieldImagePrice1k: v, ok := value.(float64) if !ok { @@ -17559,6 +20035,15 @@ func (m *GroupMutation) ResetField(name string) error { case group.FieldDefaultValidityDays: m.ResetDefaultValidityDays() return nil + case group.FieldAllowImageGeneration: + m.ResetAllowImageGeneration() + return nil + case group.FieldImageRateIndependent: + m.ResetImageRateIndependent() + return nil + case group.FieldImageRateMultiplier: + m.ResetImageRateMultiplier() + return nil case group.FieldImagePrice1k: m.ResetImagePrice1k() return nil @@ -34078,6 +36563,9 @@ type UsageLogMutation struct { clearedgroup bool subscription *int64 clearedsubscription bool + chat_messages map[int64]struct{} + removedchat_messages map[int64]struct{} + clearedchat_messages bool done bool oldValue func(context.Context) (*UsageLog, error) predicates []predicate.UsageLog @@ -36214,6 +38702,60 @@ func (m *UsageLogMutation) ResetSubscription() { m.clearedsubscription = false } +// AddChatMessageIDs adds the "chat_messages" edge to the ChatMessage entity by ids. +func (m *UsageLogMutation) AddChatMessageIDs(ids ...int64) { + if m.chat_messages == nil { + m.chat_messages = make(map[int64]struct{}) + } + for i := range ids { + m.chat_messages[ids[i]] = struct{}{} + } +} + +// ClearChatMessages clears the "chat_messages" edge to the ChatMessage entity. +func (m *UsageLogMutation) ClearChatMessages() { + m.clearedchat_messages = true +} + +// ChatMessagesCleared reports if the "chat_messages" edge to the ChatMessage entity was cleared. +func (m *UsageLogMutation) ChatMessagesCleared() bool { + return m.clearedchat_messages +} + +// RemoveChatMessageIDs removes the "chat_messages" edge to the ChatMessage entity by IDs. +func (m *UsageLogMutation) RemoveChatMessageIDs(ids ...int64) { + if m.removedchat_messages == nil { + m.removedchat_messages = make(map[int64]struct{}) + } + for i := range ids { + delete(m.chat_messages, ids[i]) + m.removedchat_messages[ids[i]] = struct{}{} + } +} + +// RemovedChatMessages returns the removed IDs of the "chat_messages" edge to the ChatMessage entity. +func (m *UsageLogMutation) RemovedChatMessagesIDs() (ids []int64) { + for id := range m.removedchat_messages { + ids = append(ids, id) + } + return +} + +// ChatMessagesIDs returns the "chat_messages" edge IDs in the mutation. +func (m *UsageLogMutation) ChatMessagesIDs() (ids []int64) { + for id := range m.chat_messages { + ids = append(ids, id) + } + return +} + +// ResetChatMessages resets all changes to the "chat_messages" edge. +func (m *UsageLogMutation) ResetChatMessages() { + m.chat_messages = nil + m.clearedchat_messages = false + m.removedchat_messages = nil +} + // Where appends a list predicates to the UsageLogMutation builder. func (m *UsageLogMutation) Where(ps ...predicate.UsageLog) { m.predicates = append(m.predicates, ps...) @@ -37277,7 +39819,7 @@ func (m *UsageLogMutation) ResetField(name string) error { // AddedEdges returns all edge names that were set/added in this mutation. func (m *UsageLogMutation) AddedEdges() []string { - edges := make([]string, 0, 5) + edges := make([]string, 0, 6) if m.user != nil { edges = append(edges, usagelog.EdgeUser) } @@ -37293,6 +39835,9 @@ func (m *UsageLogMutation) AddedEdges() []string { if m.subscription != nil { edges = append(edges, usagelog.EdgeSubscription) } + if m.chat_messages != nil { + edges = append(edges, usagelog.EdgeChatMessages) + } return edges } @@ -37320,25 +39865,42 @@ func (m *UsageLogMutation) AddedIDs(name string) []ent.Value { if id := m.subscription; id != nil { return []ent.Value{*id} } + case usagelog.EdgeChatMessages: + ids := make([]ent.Value, 0, len(m.chat_messages)) + for id := range m.chat_messages { + ids = append(ids, id) + } + return ids } return nil } // RemovedEdges returns all edge names that were removed in this mutation. func (m *UsageLogMutation) RemovedEdges() []string { - edges := make([]string, 0, 5) + edges := make([]string, 0, 6) + if m.removedchat_messages != nil { + edges = append(edges, usagelog.EdgeChatMessages) + } return edges } // RemovedIDs returns all IDs (to other nodes) that were removed for the edge with // the given name in this mutation. func (m *UsageLogMutation) RemovedIDs(name string) []ent.Value { + switch name { + case usagelog.EdgeChatMessages: + ids := make([]ent.Value, 0, len(m.removedchat_messages)) + for id := range m.removedchat_messages { + ids = append(ids, id) + } + return ids + } return nil } // ClearedEdges returns all edge names that were cleared in this mutation. func (m *UsageLogMutation) ClearedEdges() []string { - edges := make([]string, 0, 5) + edges := make([]string, 0, 6) if m.cleareduser { edges = append(edges, usagelog.EdgeUser) } @@ -37354,6 +39916,9 @@ func (m *UsageLogMutation) ClearedEdges() []string { if m.clearedsubscription { edges = append(edges, usagelog.EdgeSubscription) } + if m.clearedchat_messages { + edges = append(edges, usagelog.EdgeChatMessages) + } return edges } @@ -37371,6 +39936,8 @@ func (m *UsageLogMutation) EdgeCleared(name string) bool { return m.clearedgroup case usagelog.EdgeSubscription: return m.clearedsubscription + case usagelog.EdgeChatMessages: + return m.clearedchat_messages } return false } @@ -37417,6 +39984,9 @@ func (m *UsageLogMutation) ResetEdge(name string) error { case usagelog.EdgeSubscription: m.ResetSubscription() return nil + case usagelog.EdgeChatMessages: + m.ResetChatMessages() + return nil } return fmt.Errorf("unknown UsageLog edge %s", name) } @@ -37477,6 +40047,12 @@ type UserMutation struct { usage_logs map[int64]struct{} removedusage_logs map[int64]struct{} clearedusage_logs bool + chat_sessions map[int64]struct{} + removedchat_sessions map[int64]struct{} + clearedchat_sessions bool + chat_messages map[int64]struct{} + removedchat_messages map[int64]struct{} + clearedchat_messages bool attribute_values map[int64]struct{} removedattribute_values map[int64]struct{} clearedattribute_values bool @@ -38980,6 +41556,114 @@ func (m *UserMutation) ResetUsageLogs() { m.removedusage_logs = nil } +// AddChatSessionIDs adds the "chat_sessions" edge to the ChatSession entity by ids. +func (m *UserMutation) AddChatSessionIDs(ids ...int64) { + if m.chat_sessions == nil { + m.chat_sessions = make(map[int64]struct{}) + } + for i := range ids { + m.chat_sessions[ids[i]] = struct{}{} + } +} + +// ClearChatSessions clears the "chat_sessions" edge to the ChatSession entity. +func (m *UserMutation) ClearChatSessions() { + m.clearedchat_sessions = true +} + +// ChatSessionsCleared reports if the "chat_sessions" edge to the ChatSession entity was cleared. +func (m *UserMutation) ChatSessionsCleared() bool { + return m.clearedchat_sessions +} + +// RemoveChatSessionIDs removes the "chat_sessions" edge to the ChatSession entity by IDs. +func (m *UserMutation) RemoveChatSessionIDs(ids ...int64) { + if m.removedchat_sessions == nil { + m.removedchat_sessions = make(map[int64]struct{}) + } + for i := range ids { + delete(m.chat_sessions, ids[i]) + m.removedchat_sessions[ids[i]] = struct{}{} + } +} + +// RemovedChatSessions returns the removed IDs of the "chat_sessions" edge to the ChatSession entity. +func (m *UserMutation) RemovedChatSessionsIDs() (ids []int64) { + for id := range m.removedchat_sessions { + ids = append(ids, id) + } + return +} + +// ChatSessionsIDs returns the "chat_sessions" edge IDs in the mutation. +func (m *UserMutation) ChatSessionsIDs() (ids []int64) { + for id := range m.chat_sessions { + ids = append(ids, id) + } + return +} + +// ResetChatSessions resets all changes to the "chat_sessions" edge. +func (m *UserMutation) ResetChatSessions() { + m.chat_sessions = nil + m.clearedchat_sessions = false + m.removedchat_sessions = nil +} + +// AddChatMessageIDs adds the "chat_messages" edge to the ChatMessage entity by ids. +func (m *UserMutation) AddChatMessageIDs(ids ...int64) { + if m.chat_messages == nil { + m.chat_messages = make(map[int64]struct{}) + } + for i := range ids { + m.chat_messages[ids[i]] = struct{}{} + } +} + +// ClearChatMessages clears the "chat_messages" edge to the ChatMessage entity. +func (m *UserMutation) ClearChatMessages() { + m.clearedchat_messages = true +} + +// ChatMessagesCleared reports if the "chat_messages" edge to the ChatMessage entity was cleared. +func (m *UserMutation) ChatMessagesCleared() bool { + return m.clearedchat_messages +} + +// RemoveChatMessageIDs removes the "chat_messages" edge to the ChatMessage entity by IDs. +func (m *UserMutation) RemoveChatMessageIDs(ids ...int64) { + if m.removedchat_messages == nil { + m.removedchat_messages = make(map[int64]struct{}) + } + for i := range ids { + delete(m.chat_messages, ids[i]) + m.removedchat_messages[ids[i]] = struct{}{} + } +} + +// RemovedChatMessages returns the removed IDs of the "chat_messages" edge to the ChatMessage entity. +func (m *UserMutation) RemovedChatMessagesIDs() (ids []int64) { + for id := range m.removedchat_messages { + ids = append(ids, id) + } + return +} + +// ChatMessagesIDs returns the "chat_messages" edge IDs in the mutation. +func (m *UserMutation) ChatMessagesIDs() (ids []int64) { + for id := range m.chat_messages { + ids = append(ids, id) + } + return +} + +// ResetChatMessages resets all changes to the "chat_messages" edge. +func (m *UserMutation) ResetChatMessages() { + m.chat_messages = nil + m.clearedchat_messages = false + m.removedchat_messages = nil +} + // AddAttributeValueIDs adds the "attribute_values" edge to the UserAttributeValue entity by ids. func (m *UserMutation) AddAttributeValueIDs(ids ...int64) { if m.attribute_values == nil { @@ -39859,7 +42543,7 @@ func (m *UserMutation) ResetField(name string) error { // AddedEdges returns all edge names that were set/added in this mutation. func (m *UserMutation) AddedEdges() []string { - edges := make([]string, 0, 12) + edges := make([]string, 0, 14) if m.api_keys != nil { edges = append(edges, user.EdgeAPIKeys) } @@ -39881,6 +42565,12 @@ func (m *UserMutation) AddedEdges() []string { if m.usage_logs != nil { edges = append(edges, user.EdgeUsageLogs) } + if m.chat_sessions != nil { + edges = append(edges, user.EdgeChatSessions) + } + if m.chat_messages != nil { + edges = append(edges, user.EdgeChatMessages) + } if m.attribute_values != nil { edges = append(edges, user.EdgeAttributeValues) } @@ -39945,6 +42635,18 @@ func (m *UserMutation) AddedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case user.EdgeChatSessions: + ids := make([]ent.Value, 0, len(m.chat_sessions)) + for id := range m.chat_sessions { + ids = append(ids, id) + } + return ids + case user.EdgeChatMessages: + ids := make([]ent.Value, 0, len(m.chat_messages)) + for id := range m.chat_messages { + ids = append(ids, id) + } + return ids case user.EdgeAttributeValues: ids := make([]ent.Value, 0, len(m.attribute_values)) for id := range m.attribute_values { @@ -39981,7 +42683,7 @@ func (m *UserMutation) AddedIDs(name string) []ent.Value { // RemovedEdges returns all edge names that were removed in this mutation. func (m *UserMutation) RemovedEdges() []string { - edges := make([]string, 0, 12) + edges := make([]string, 0, 14) if m.removedapi_keys != nil { edges = append(edges, user.EdgeAPIKeys) } @@ -40003,6 +42705,12 @@ func (m *UserMutation) RemovedEdges() []string { if m.removedusage_logs != nil { edges = append(edges, user.EdgeUsageLogs) } + if m.removedchat_sessions != nil { + edges = append(edges, user.EdgeChatSessions) + } + if m.removedchat_messages != nil { + edges = append(edges, user.EdgeChatMessages) + } if m.removedattribute_values != nil { edges = append(edges, user.EdgeAttributeValues) } @@ -40067,6 +42775,18 @@ func (m *UserMutation) RemovedIDs(name string) []ent.Value { ids = append(ids, id) } return ids + case user.EdgeChatSessions: + ids := make([]ent.Value, 0, len(m.removedchat_sessions)) + for id := range m.removedchat_sessions { + ids = append(ids, id) + } + return ids + case user.EdgeChatMessages: + ids := make([]ent.Value, 0, len(m.removedchat_messages)) + for id := range m.removedchat_messages { + ids = append(ids, id) + } + return ids case user.EdgeAttributeValues: ids := make([]ent.Value, 0, len(m.removedattribute_values)) for id := range m.removedattribute_values { @@ -40103,7 +42823,7 @@ func (m *UserMutation) RemovedIDs(name string) []ent.Value { // ClearedEdges returns all edge names that were cleared in this mutation. func (m *UserMutation) ClearedEdges() []string { - edges := make([]string, 0, 12) + edges := make([]string, 0, 14) if m.clearedapi_keys { edges = append(edges, user.EdgeAPIKeys) } @@ -40125,6 +42845,12 @@ func (m *UserMutation) ClearedEdges() []string { if m.clearedusage_logs { edges = append(edges, user.EdgeUsageLogs) } + if m.clearedchat_sessions { + edges = append(edges, user.EdgeChatSessions) + } + if m.clearedchat_messages { + edges = append(edges, user.EdgeChatMessages) + } if m.clearedattribute_values { edges = append(edges, user.EdgeAttributeValues) } @@ -40161,6 +42887,10 @@ func (m *UserMutation) EdgeCleared(name string) bool { return m.clearedallowed_groups case user.EdgeUsageLogs: return m.clearedusage_logs + case user.EdgeChatSessions: + return m.clearedchat_sessions + case user.EdgeChatMessages: + return m.clearedchat_messages case user.EdgeAttributeValues: return m.clearedattribute_values case user.EdgePromoCodeUsages: @@ -40208,6 +42938,12 @@ func (m *UserMutation) ResetEdge(name string) error { case user.EdgeUsageLogs: m.ResetUsageLogs() return nil + case user.EdgeChatSessions: + m.ResetChatSessions() + return nil + case user.EdgeChatMessages: + m.ResetChatMessages() + return nil case user.EdgeAttributeValues: m.ResetAttributeValues() return nil diff --git a/backend/ent/predicate/predicate.go b/backend/ent/predicate/predicate.go index dc86471e793..ecebe4fb278 100644 --- a/backend/ent/predicate/predicate.go +++ b/backend/ent/predicate/predicate.go @@ -39,6 +39,12 @@ type ChannelMonitorHistory func(*sql.Selector) // ChannelMonitorRequestTemplate is the predicate function for channelmonitorrequesttemplate builders. type ChannelMonitorRequestTemplate func(*sql.Selector) +// ChatMessage is the predicate function for chatmessage builders. +type ChatMessage func(*sql.Selector) + +// ChatSession is the predicate function for chatsession builders. +type ChatSession func(*sql.Selector) + // ErrorPassthroughRule is the predicate function for errorpassthroughrule builders. type ErrorPassthroughRule func(*sql.Selector) diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 6b344a5582c..5e6c2b35694 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -16,6 +16,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup" "github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory" "github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/idempotencyrecord" @@ -677,6 +679,118 @@ func init() { channelmonitorrequesttemplate.DefaultBodyOverrideMode = channelmonitorrequesttemplateDescBodyOverrideMode.Default.(string) // channelmonitorrequesttemplate.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save. channelmonitorrequesttemplate.BodyOverrideModeValidator = channelmonitorrequesttemplateDescBodyOverrideMode.Validators[0].(func(string) error) + chatmessageMixin := schema.ChatMessage{}.Mixin() + chatmessageMixinFields0 := chatmessageMixin[0].Fields() + _ = chatmessageMixinFields0 + chatmessageFields := schema.ChatMessage{}.Fields() + _ = chatmessageFields + // chatmessageDescCreatedAt is the schema descriptor for created_at field. + chatmessageDescCreatedAt := chatmessageMixinFields0[0].Descriptor() + // chatmessage.DefaultCreatedAt holds the default value on creation for the created_at field. + chatmessage.DefaultCreatedAt = chatmessageDescCreatedAt.Default.(func() time.Time) + // chatmessageDescUpdatedAt is the schema descriptor for updated_at field. + chatmessageDescUpdatedAt := chatmessageMixinFields0[1].Descriptor() + // chatmessage.DefaultUpdatedAt holds the default value on creation for the updated_at field. + chatmessage.DefaultUpdatedAt = chatmessageDescUpdatedAt.Default.(func() time.Time) + // chatmessage.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. + chatmessage.UpdateDefaultUpdatedAt = chatmessageDescUpdatedAt.UpdateDefault.(func() time.Time) + // chatmessageDescRole is the schema descriptor for role field. + chatmessageDescRole := chatmessageFields[2].Descriptor() + // chatmessage.RoleValidator is a validator for the "role" field. It is called by the builders before save. + chatmessage.RoleValidator = func() func(string) error { + validators := chatmessageDescRole.Validators + fns := [...]func(string) error{ + validators[0].(func(string) error), + validators[1].(func(string) error), + } + return func(role string) error { + for _, fn := range fns { + if err := fn(role); err != nil { + return err + } + } + return nil + } + }() + // chatmessageDescContent is the schema descriptor for content field. + chatmessageDescContent := chatmessageFields[3].Descriptor() + // chatmessage.DefaultContent holds the default value on creation for the content field. + chatmessage.DefaultContent = chatmessageDescContent.Default.(string) + // chatmessageDescStatus is the schema descriptor for status field. + chatmessageDescStatus := chatmessageFields[4].Descriptor() + // chatmessage.DefaultStatus holds the default value on creation for the status field. + chatmessage.DefaultStatus = chatmessageDescStatus.Default.(string) + // chatmessage.StatusValidator is a validator for the "status" field. It is called by the builders before save. + chatmessage.StatusValidator = chatmessageDescStatus.Validators[0].(func(string) error) + // chatmessageDescModel is the schema descriptor for model field. + chatmessageDescModel := chatmessageFields[5].Descriptor() + // chatmessage.ModelValidator is a validator for the "model" field. It is called by the builders before save. + chatmessage.ModelValidator = chatmessageDescModel.Validators[0].(func(string) error) + chatsessionMixin := schema.ChatSession{}.Mixin() + chatsessionMixinHooks1 := chatsessionMixin[1].Hooks() + chatsession.Hooks[0] = chatsessionMixinHooks1[0] + chatsessionMixinInters1 := chatsessionMixin[1].Interceptors() + chatsession.Interceptors[0] = chatsessionMixinInters1[0] + chatsessionMixinFields0 := chatsessionMixin[0].Fields() + _ = chatsessionMixinFields0 + chatsessionFields := schema.ChatSession{}.Fields() + _ = chatsessionFields + // chatsessionDescCreatedAt is the schema descriptor for created_at field. + chatsessionDescCreatedAt := chatsessionMixinFields0[0].Descriptor() + // chatsession.DefaultCreatedAt holds the default value on creation for the created_at field. + chatsession.DefaultCreatedAt = chatsessionDescCreatedAt.Default.(func() time.Time) + // chatsessionDescUpdatedAt is the schema descriptor for updated_at field. + chatsessionDescUpdatedAt := chatsessionMixinFields0[1].Descriptor() + // chatsession.DefaultUpdatedAt holds the default value on creation for the updated_at field. + chatsession.DefaultUpdatedAt = chatsessionDescUpdatedAt.Default.(func() time.Time) + // chatsession.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field. + chatsession.UpdateDefaultUpdatedAt = chatsessionDescUpdatedAt.UpdateDefault.(func() time.Time) + // chatsessionDescTitle is the schema descriptor for title field. + chatsessionDescTitle := chatsessionFields[2].Descriptor() + // chatsession.TitleValidator is a validator for the "title" field. It is called by the builders before save. + chatsession.TitleValidator = func() func(string) error { + validators := chatsessionDescTitle.Validators + fns := [...]func(string) error{ + validators[0].(func(string) error), + validators[1].(func(string) error), + } + return func(title string) error { + for _, fn := range fns { + if err := fn(title); err != nil { + return err + } + } + return nil + } + }() + // chatsessionDescModel is the schema descriptor for model field. + chatsessionDescModel := chatsessionFields[3].Descriptor() + // chatsession.ModelValidator is a validator for the "model" field. It is called by the builders before save. + chatsession.ModelValidator = func() func(string) error { + validators := chatsessionDescModel.Validators + fns := [...]func(string) error{ + validators[0].(func(string) error), + validators[1].(func(string) error), + } + return func(model string) error { + for _, fn := range fns { + if err := fn(model); err != nil { + return err + } + } + return nil + } + }() + // chatsessionDescStatus is the schema descriptor for status field. + chatsessionDescStatus := chatsessionFields[4].Descriptor() + // chatsession.DefaultStatus holds the default value on creation for the status field. + chatsession.DefaultStatus = chatsessionDescStatus.Default.(string) + // chatsession.StatusValidator is a validator for the "status" field. It is called by the builders before save. + chatsession.StatusValidator = chatsessionDescStatus.Validators[0].(func(string) error) + // chatsessionDescExpiresAt is the schema descriptor for expires_at field. + chatsessionDescExpiresAt := chatsessionFields[5].Descriptor() + // chatsession.DefaultExpiresAt holds the default value on creation for the expires_at field. + chatsession.DefaultExpiresAt = chatsessionDescExpiresAt.Default.(func() time.Time) errorpassthroughruleMixin := schema.ErrorPassthroughRule{}.Mixin() errorpassthroughruleMixinFields0 := errorpassthroughruleMixin[0].Fields() _ = errorpassthroughruleMixinFields0 @@ -803,50 +917,62 @@ func init() { groupDescDefaultValidityDays := groupFields[10].Descriptor() // group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field. group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int) + // groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field. + groupDescAllowImageGeneration := groupFields[11].Descriptor() + // group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field. + group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool) + // groupDescImageRateIndependent is the schema descriptor for image_rate_independent field. + groupDescImageRateIndependent := groupFields[12].Descriptor() + // group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field. + group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool) + // groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field. + groupDescImageRateMultiplier := groupFields[13].Descriptor() + // group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field. + group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64) // groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field. - groupDescClaudeCodeOnly := groupFields[14].Descriptor() + groupDescClaudeCodeOnly := groupFields[17].Descriptor() // group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field. group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool) // groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field. - groupDescModelRoutingEnabled := groupFields[18].Descriptor() + groupDescModelRoutingEnabled := groupFields[21].Descriptor() // group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field. group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool) // groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field. - groupDescMcpXMLInject := groupFields[19].Descriptor() + groupDescMcpXMLInject := groupFields[22].Descriptor() // group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field. group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool) // groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field. - groupDescSupportedModelScopes := groupFields[20].Descriptor() + groupDescSupportedModelScopes := groupFields[23].Descriptor() // group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field. group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string) // groupDescSortOrder is the schema descriptor for sort_order field. - groupDescSortOrder := groupFields[21].Descriptor() + groupDescSortOrder := groupFields[24].Descriptor() // group.DefaultSortOrder holds the default value on creation for the sort_order field. group.DefaultSortOrder = groupDescSortOrder.Default.(int) // groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field. - groupDescAllowMessagesDispatch := groupFields[22].Descriptor() + groupDescAllowMessagesDispatch := groupFields[25].Descriptor() // group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field. group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool) // groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field. - groupDescRequireOauthOnly := groupFields[23].Descriptor() + groupDescRequireOauthOnly := groupFields[26].Descriptor() // group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field. group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool) // groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field. - groupDescRequirePrivacySet := groupFields[24].Descriptor() + groupDescRequirePrivacySet := groupFields[27].Descriptor() // group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field. group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool) // groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field. - groupDescDefaultMappedModel := groupFields[25].Descriptor() + groupDescDefaultMappedModel := groupFields[28].Descriptor() // group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field. group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string) // group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save. group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error) // groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field. - groupDescMessagesDispatchModelConfig := groupFields[26].Descriptor() + groupDescMessagesDispatchModelConfig := groupFields[29].Descriptor() // group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field. group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig) // groupDescRpmLimit is the schema descriptor for rpm_limit field. - groupDescRpmLimit := groupFields[27].Descriptor() + groupDescRpmLimit := groupFields[30].Descriptor() // group.DefaultRpmLimit holds the default value on creation for the rpm_limit field. group.DefaultRpmLimit = groupDescRpmLimit.Default.(int) idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin() diff --git a/backend/ent/schema/api_key.go b/backend/ent/schema/api_key.go index 5db51270b1b..287c93ca9b1 100644 --- a/backend/ent/schema/api_key.go +++ b/backend/ent/schema/api_key.go @@ -130,6 +130,7 @@ func (APIKey) Edges() []ent.Edge { Field("group_id"). Unique(), edge.To("usage_logs", UsageLog.Type), + edge.To("chat_sessions", ChatSession.Type), } } diff --git a/backend/ent/schema/auth_identity.go b/backend/ent/schema/auth_identity.go index 0b1b56ab0fd..5f864080086 100644 --- a/backend/ent/schema/auth_identity.go +++ b/backend/ent/schema/auth_identity.go @@ -16,6 +16,8 @@ import ( var authProviderTypes = map[string]struct{}{ "email": {}, + "github": {}, + "google": {}, "linuxdo": {}, "oidc": {}, "wechat": {}, diff --git a/backend/ent/schema/auth_identity_schema_test.go b/backend/ent/schema/auth_identity_schema_test.go index fbb932368a5..d3e2405069a 100644 --- a/backend/ent/schema/auth_identity_schema_test.go +++ b/backend/ent/schema/auth_identity_schema_test.go @@ -83,10 +83,10 @@ func TestAuthIdentityFoundationSchemas(t *testing.T) { require.Equal(t, 1, signupSource.Validators) validator := requireStringFieldValidator(t, User{}.Fields(), "signup_source") - for _, value := range []string{"email", "linuxdo", "wechat", "oidc"} { + for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google"} { require.NoError(t, validator(value)) } - require.Error(t, validator("github")) + require.Error(t, validator("unknown")) } func requireSchema(t *testing.T, schemas map[string]*load.Schema, name string) *load.Schema { diff --git a/backend/ent/schema/chat_message.go b/backend/ent/schema/chat_message.go new file mode 100644 index 00000000000..0c81f8b588c --- /dev/null +++ b/backend/ent/schema/chat_message.go @@ -0,0 +1,92 @@ +package schema + +import ( + "github.com/Wei-Shaw/sub2api/ent/schema/mixins" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// ChatMessage holds the schema definition for persisted chat messages. +type ChatMessage struct { + ent.Schema +} + +func (ChatMessage) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "chat_messages"}, + } +} + +func (ChatMessage) Mixin() []ent.Mixin { + return []ent.Mixin{ + mixins.TimeMixin{}, + } +} + +func (ChatMessage) Fields() []ent.Field { + return []ent.Field{ + field.Int64("session_id"), + field.Int64("user_id"), + field.String("role"). + MaxLen(20). + NotEmpty(), + field.String("content"). + SchemaType(map[string]string{dialect.Postgres: "text"}). + Default(""), + field.String("status"). + MaxLen(20). + Default("completed"), + field.String("model"). + MaxLen(100). + Optional(). + Nillable(), + field.Int("duration_ms"). + Optional(). + Nillable(), + field.Int64("usage_log_id"). + Optional(). + Nillable(), + field.Float("actual_cost"). + Optional(). + Nillable(). + SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}), + field.String("error_message"). + SchemaType(map[string]string{dialect.Postgres: "text"}). + Optional(). + Nillable(), + } +} + +func (ChatMessage) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("session", ChatSession.Type). + Ref("messages"). + Field("session_id"). + Required(). + Unique(), + edge.From("user", User.Type). + Ref("chat_messages"). + Field("user_id"). + Required(). + Unique(), + edge.From("usage_log", UsageLog.Type). + Ref("chat_messages"). + Field("usage_log_id"). + Unique(), + } +} + +func (ChatMessage) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("session_id", "created_at"), + index.Fields("user_id", "created_at"), + index.Fields("usage_log_id"), + index.Fields("status"), + } +} diff --git a/backend/ent/schema/chat_session.go b/backend/ent/schema/chat_session.go new file mode 100644 index 00000000000..8df29e0faec --- /dev/null +++ b/backend/ent/schema/chat_session.go @@ -0,0 +1,80 @@ +package schema + +import ( + "time" + + "github.com/Wei-Shaw/sub2api/ent/schema/mixins" + + "entgo.io/ent" + "entgo.io/ent/dialect" + "entgo.io/ent/dialect/entsql" + "entgo.io/ent/schema" + "entgo.io/ent/schema/edge" + "entgo.io/ent/schema/field" + "entgo.io/ent/schema/index" +) + +// ChatSession holds the schema definition for persisted user chat sessions. +type ChatSession struct { + ent.Schema +} + +func (ChatSession) Annotations() []schema.Annotation { + return []schema.Annotation{ + entsql.Annotation{Table: "chat_sessions"}, + } +} + +func (ChatSession) Mixin() []ent.Mixin { + return []ent.Mixin{ + mixins.TimeMixin{}, + mixins.SoftDeleteMixin{}, + } +} + +func (ChatSession) Fields() []ent.Field { + return []ent.Field{ + field.Int64("user_id"), + field.Int64("api_key_id"), + field.String("title"). + MaxLen(160). + NotEmpty(), + field.String("model"). + MaxLen(100). + NotEmpty(), + field.String("status"). + MaxLen(20). + Default("active"), + field.Time("expires_at"). + SchemaType(map[string]string{dialect.Postgres: "timestamptz"}). + Default(func() time.Time { + return time.Now().Add(30 * 24 * time.Hour) + }), + } +} + +func (ChatSession) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("user", User.Type). + Ref("chat_sessions"). + Field("user_id"). + Required(). + Unique(), + edge.From("api_key", APIKey.Type). + Ref("chat_sessions"). + Field("api_key_id"). + Required(). + Unique(), + edge.To("messages", ChatMessage.Type), + } +} + +func (ChatSession) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("user_id", "updated_at"), + index.Fields("user_id", "expires_at"), + index.Fields("user_id", "deleted_at"), + index.Fields("api_key_id"), + index.Fields("status"), + } +} diff --git a/backend/ent/schema/chat_session_schema_test.go b/backend/ent/schema/chat_session_schema_test.go new file mode 100644 index 00000000000..b3dd9d19657 --- /dev/null +++ b/backend/ent/schema/chat_session_schema_test.go @@ -0,0 +1,70 @@ +package schema + +import ( + "testing" + + "entgo.io/ent/entc/load" + "github.com/stretchr/testify/require" +) + +func TestChatSessionSchemas(t *testing.T) { + spec, err := (&load.Config{Path: "."}).Load() + require.NoError(t, err) + + schemas := map[string]*load.Schema{} + for _, schema := range spec.Schemas { + schemas[schema.Name] = schema + } + + session := requireSchema(t, schemas, "ChatSession") + requireSchemaFields(t, session, + "user_id", + "api_key_id", + "title", + "model", + "status", + "expires_at", + "deleted_at", + ) + requireHasIndex(t, session, "user_id", "updated_at") + requireHasIndex(t, session, "user_id", "expires_at") + + message := requireSchema(t, schemas, "ChatMessage") + requireSchemaFields(t, message, + "session_id", + "user_id", + "role", + "content", + "status", + "model", + "duration_ms", + "usage_log_id", + "actual_cost", + "error_message", + ) + requireHasIndex(t, message, "session_id", "created_at") + requireHasIndex(t, message, "user_id", "created_at") + requireHasIndex(t, message, "usage_log_id") +} + +func requireHasIndex(t *testing.T, schema *load.Schema, fields ...string) { + t.Helper() + + for _, index := range schema.Indexes { + if len(index.Fields) != len(fields) { + continue + } + match := true + for i := range fields { + if index.Fields[i] != fields[i] { + match = false + break + } + } + if match { + return + } + } + + require.Failf(t, "missing index", "schema %s should include index on %v", schema.Name, fields) +} diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 11f38d66f04..d47e87105d8 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -74,6 +74,16 @@ func (Group) Fields() []ent.Field { Default(30), // 图片生成计费配置(antigravity 和 gemini 平台使用) + field.Bool("allow_image_generation"). + Default(false). + Comment("是否允许该分组使用图片生成能力"), + field.Bool("image_rate_independent"). + Default(false). + Comment("图片生成是否使用独立倍率;false 表示共享分组有效倍率"), + field.Float("image_rate_multiplier"). + SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}). + Default(1.0). + Comment("图片生成独立倍率,仅 image_rate_independent=true 时生效"), field.Float("image_price_1k"). Optional(). Nillable(). diff --git a/backend/ent/schema/usage_log.go b/backend/ent/schema/usage_log.go index bd3ebfcc3ce..0719e15b443 100644 --- a/backend/ent/schema/usage_log.go +++ b/backend/ent/schema/usage_log.go @@ -172,6 +172,7 @@ func (UsageLog) Edges() []ent.Edge { Ref("usage_logs"). Field("subscription_id"). Unique(), + edge.To("chat_messages", ChatMessage.Type), } } diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index 83da5c32ba8..a753f281441 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -77,10 +77,10 @@ func (User) Fields() []ent.Field { field.String("signup_source"). Validate(func(value string) error { switch value { - case "email", "linuxdo", "wechat", "oidc": + case "email", "linuxdo", "wechat", "oidc", "github", "google": return nil default: - return fmt.Errorf("must be one of email, linuxdo, wechat, oidc") + return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google") } }). Default("email"), @@ -125,6 +125,8 @@ func (User) Edges() []ent.Edge { edge.To("allowed_groups", Group.Type). Through("user_allowed_groups", UserAllowedGroup.Type), edge.To("usage_logs", UsageLog.Type), + edge.To("chat_sessions", ChatSession.Type), + edge.To("chat_messages", ChatMessage.Type), edge.To("attribute_values", UserAttributeValue.Type), edge.To("promo_code_usages", PromoCodeUsage.Type), edge.To("payment_orders", PaymentOrder.Type), diff --git a/backend/ent/tx.go b/backend/ent/tx.go index 611028e9157..ac0f3dbf2ef 100644 --- a/backend/ent/tx.go +++ b/backend/ent/tx.go @@ -36,6 +36,10 @@ type Tx struct { ChannelMonitorHistory *ChannelMonitorHistoryClient // ChannelMonitorRequestTemplate is the client for interacting with the ChannelMonitorRequestTemplate builders. ChannelMonitorRequestTemplate *ChannelMonitorRequestTemplateClient + // ChatMessage is the client for interacting with the ChatMessage builders. + ChatMessage *ChatMessageClient + // ChatSession is the client for interacting with the ChatSession builders. + ChatSession *ChatSessionClient // ErrorPassthroughRule is the client for interacting with the ErrorPassthroughRule builders. ErrorPassthroughRule *ErrorPassthroughRuleClient // Group is the client for interacting with the Group builders. @@ -224,6 +228,8 @@ func (tx *Tx) init() { tx.ChannelMonitorDailyRollup = NewChannelMonitorDailyRollupClient(tx.config) tx.ChannelMonitorHistory = NewChannelMonitorHistoryClient(tx.config) tx.ChannelMonitorRequestTemplate = NewChannelMonitorRequestTemplateClient(tx.config) + tx.ChatMessage = NewChatMessageClient(tx.config) + tx.ChatSession = NewChatSessionClient(tx.config) tx.ErrorPassthroughRule = NewErrorPassthroughRuleClient(tx.config) tx.Group = NewGroupClient(tx.config) tx.IdempotencyRecord = NewIdempotencyRecordClient(tx.config) diff --git a/backend/ent/usagelog.go b/backend/ent/usagelog.go index a8e0cc6ce8d..43cf2cf4ee0 100644 --- a/backend/ent/usagelog.go +++ b/backend/ent/usagelog.go @@ -114,9 +114,11 @@ type UsageLogEdges struct { Group *Group `json:"group,omitempty"` // Subscription holds the value of the subscription edge. Subscription *UserSubscription `json:"subscription,omitempty"` + // ChatMessages holds the value of the chat_messages edge. + ChatMessages []*ChatMessage `json:"chat_messages,omitempty"` // loadedTypes holds the information for reporting if a // type was loaded (or requested) in eager-loading or not. - loadedTypes [5]bool + loadedTypes [6]bool } // UserOrErr returns the User value or an error if the edge @@ -174,6 +176,15 @@ func (e UsageLogEdges) SubscriptionOrErr() (*UserSubscription, error) { return nil, &NotLoadedError{edge: "subscription"} } +// ChatMessagesOrErr returns the ChatMessages value or an error if the edge +// was not loaded in eager-loading. +func (e UsageLogEdges) ChatMessagesOrErr() ([]*ChatMessage, error) { + if e.loadedTypes[5] { + return e.ChatMessages, nil + } + return nil, &NotLoadedError{edge: "chat_messages"} +} + // scanValues returns the types for scanning values from sql.Rows. func (*UsageLog) scanValues(columns []string) ([]any, error) { values := make([]any, len(columns)) @@ -484,6 +495,11 @@ func (_m *UsageLog) QuerySubscription() *UserSubscriptionQuery { return NewUsageLogClient(_m.config).QuerySubscription(_m) } +// QueryChatMessages queries the "chat_messages" edge of the UsageLog entity. +func (_m *UsageLog) QueryChatMessages() *ChatMessageQuery { + return NewUsageLogClient(_m.config).QueryChatMessages(_m) +} + // Update returns a builder for updating this UsageLog. // Note that you need to call UsageLog.Unwrap() before calling this method if this UsageLog // was returned from a transaction, and the transaction was committed or rolled back. diff --git a/backend/ent/usagelog/usagelog.go b/backend/ent/usagelog/usagelog.go index a7438e604fb..70029401197 100644 --- a/backend/ent/usagelog/usagelog.go +++ b/backend/ent/usagelog/usagelog.go @@ -98,6 +98,8 @@ const ( EdgeGroup = "group" // EdgeSubscription holds the string denoting the subscription edge name in mutations. EdgeSubscription = "subscription" + // EdgeChatMessages holds the string denoting the chat_messages edge name in mutations. + EdgeChatMessages = "chat_messages" // Table holds the table name of the usagelog in the database. Table = "usage_logs" // UserTable is the table that holds the user relation/edge. @@ -135,6 +137,13 @@ const ( SubscriptionInverseTable = "user_subscriptions" // SubscriptionColumn is the table column denoting the subscription relation/edge. SubscriptionColumn = "subscription_id" + // ChatMessagesTable is the table that holds the chat_messages relation/edge. + ChatMessagesTable = "chat_messages" + // ChatMessagesInverseTable is the table name for the ChatMessage entity. + // It exists in this package in order to avoid circular dependency with the "chatmessage" package. + ChatMessagesInverseTable = "chat_messages" + // ChatMessagesColumn is the table column denoting the chat_messages relation/edge. + ChatMessagesColumn = "usage_log_id" ) // Columns holds all SQL columns for usagelog fields. @@ -475,6 +484,20 @@ func BySubscriptionField(field string, opts ...sql.OrderTermOption) OrderOption sqlgraph.OrderByNeighborTerms(s, newSubscriptionStep(), sql.OrderByField(field, opts...)) } } + +// ByChatMessagesCount orders the results by chat_messages count. +func ByChatMessagesCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newChatMessagesStep(), opts...) + } +} + +// ByChatMessages orders the results by chat_messages terms. +func ByChatMessages(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newChatMessagesStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} func newUserStep() *sqlgraph.Step { return sqlgraph.NewStep( sqlgraph.From(Table, FieldID), @@ -510,3 +533,10 @@ func newSubscriptionStep() *sqlgraph.Step { sqlgraph.Edge(sqlgraph.M2O, true, SubscriptionTable, SubscriptionColumn), ) } +func newChatMessagesStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(ChatMessagesInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, ChatMessagesTable, ChatMessagesColumn), + ) +} diff --git a/backend/ent/usagelog/where.go b/backend/ent/usagelog/where.go index b8439a03978..4c18b862f66 100644 --- a/backend/ent/usagelog/where.go +++ b/backend/ent/usagelog/where.go @@ -2065,6 +2065,29 @@ func HasSubscriptionWith(preds ...predicate.UserSubscription) predicate.UsageLog }) } +// HasChatMessages applies the HasEdge predicate on the "chat_messages" edge. +func HasChatMessages() predicate.UsageLog { + return predicate.UsageLog(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, ChatMessagesTable, ChatMessagesColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasChatMessagesWith applies the HasEdge predicate on the "chat_messages" edge with a given conditions (other predicates). +func HasChatMessagesWith(preds ...predicate.ChatMessage) predicate.UsageLog { + return predicate.UsageLog(func(s *sql.Selector) { + step := newChatMessagesStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + // And groups predicates with the AND operator between them. func And(predicates ...predicate.UsageLog) predicate.UsageLog { return predicate.UsageLog(sql.AndPredicates(predicates...)) diff --git a/backend/ent/usagelog_create.go b/backend/ent/usagelog_create.go index fded364e0e6..5ffd06fb0e3 100644 --- a/backend/ent/usagelog_create.go +++ b/backend/ent/usagelog_create.go @@ -13,6 +13,7 @@ import ( "entgo.io/ent/schema/field" "github.com/Wei-Shaw/sub2api/ent/account" "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/usagelog" "github.com/Wei-Shaw/sub2api/ent/user" @@ -530,6 +531,21 @@ func (_c *UsageLogCreate) SetSubscription(v *UserSubscription) *UsageLogCreate { return _c.SetSubscriptionID(v.ID) } +// AddChatMessageIDs adds the "chat_messages" edge to the ChatMessage entity by IDs. +func (_c *UsageLogCreate) AddChatMessageIDs(ids ...int64) *UsageLogCreate { + _c.mutation.AddChatMessageIDs(ids...) + return _c +} + +// AddChatMessages adds the "chat_messages" edges to the ChatMessage entity. +func (_c *UsageLogCreate) AddChatMessages(v ...*ChatMessage) *UsageLogCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddChatMessageIDs(ids...) +} + // Mutation returns the UsageLogMutation object of the builder. func (_c *UsageLogCreate) Mutation() *UsageLogMutation { return _c.mutation @@ -1009,6 +1025,22 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) { _node.SubscriptionID = &nodes[0] _spec.Edges = append(_spec.Edges, edge) } + if nodes := _c.mutation.ChatMessagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: usagelog.ChatMessagesTable, + Columns: []string{usagelog.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } return _node, _spec } diff --git a/backend/ent/usagelog_query.go b/backend/ent/usagelog_query.go index c709bde0802..3aea30d6141 100644 --- a/backend/ent/usagelog_query.go +++ b/backend/ent/usagelog_query.go @@ -4,6 +4,7 @@ package ent import ( "context" + "database/sql/driver" "fmt" "math" @@ -14,6 +15,7 @@ import ( "entgo.io/ent/schema/field" "github.com/Wei-Shaw/sub2api/ent/account" "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" "github.com/Wei-Shaw/sub2api/ent/usagelog" @@ -33,6 +35,7 @@ type UsageLogQuery struct { withAccount *AccountQuery withGroup *GroupQuery withSubscription *UserSubscriptionQuery + withChatMessages *ChatMessageQuery modifiers []func(*sql.Selector) // intermediate query (i.e. traversal path). sql *sql.Selector @@ -180,6 +183,28 @@ func (_q *UsageLogQuery) QuerySubscription() *UserSubscriptionQuery { return query } +// QueryChatMessages chains the current query on the "chat_messages" edge. +func (_q *UsageLogQuery) QueryChatMessages() *ChatMessageQuery { + query := (&ChatMessageClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(usagelog.Table, usagelog.FieldID, selector), + sqlgraph.To(chatmessage.Table, chatmessage.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, usagelog.ChatMessagesTable, usagelog.ChatMessagesColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + // First returns the first UsageLog entity from the query. // Returns a *NotFoundError when no UsageLog was found. func (_q *UsageLogQuery) First(ctx context.Context) (*UsageLog, error) { @@ -377,6 +402,7 @@ func (_q *UsageLogQuery) Clone() *UsageLogQuery { withAccount: _q.withAccount.Clone(), withGroup: _q.withGroup.Clone(), withSubscription: _q.withSubscription.Clone(), + withChatMessages: _q.withChatMessages.Clone(), // clone intermediate query. sql: _q.sql.Clone(), path: _q.path, @@ -438,6 +464,17 @@ func (_q *UsageLogQuery) WithSubscription(opts ...func(*UserSubscriptionQuery)) return _q } +// WithChatMessages tells the query-builder to eager-load the nodes that are connected to +// the "chat_messages" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *UsageLogQuery) WithChatMessages(opts ...func(*ChatMessageQuery)) *UsageLogQuery { + query := (&ChatMessageClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withChatMessages = query + return _q +} + // GroupBy is used to group vertices by one or more fields/columns. // It is often used with aggregate functions, like: count, max, mean, min, sum. // @@ -516,12 +553,13 @@ func (_q *UsageLogQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Usa var ( nodes = []*UsageLog{} _spec = _q.querySpec() - loadedTypes = [5]bool{ + loadedTypes = [6]bool{ _q.withUser != nil, _q.withAPIKey != nil, _q.withAccount != nil, _q.withGroup != nil, _q.withSubscription != nil, + _q.withChatMessages != nil, } ) _spec.ScanValues = func(columns []string) ([]any, error) { @@ -575,6 +613,13 @@ func (_q *UsageLogQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Usa return nil, err } } + if query := _q.withChatMessages; query != nil { + if err := _q.loadChatMessages(ctx, query, nodes, + func(n *UsageLog) { n.Edges.ChatMessages = []*ChatMessage{} }, + func(n *UsageLog, e *ChatMessage) { n.Edges.ChatMessages = append(n.Edges.ChatMessages, e) }); err != nil { + return nil, err + } + } return nodes, nil } @@ -729,6 +774,39 @@ func (_q *UsageLogQuery) loadSubscription(ctx context.Context, query *UserSubscr } return nil } +func (_q *UsageLogQuery) loadChatMessages(ctx context.Context, query *ChatMessageQuery, nodes []*UsageLog, init func(*UsageLog), assign func(*UsageLog, *ChatMessage)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[int64]*UsageLog) + for i := range nodes { + fks = append(fks, nodes[i].ID) + nodeids[nodes[i].ID] = nodes[i] + if init != nil { + init(nodes[i]) + } + } + if len(query.ctx.Fields) > 0 { + query.ctx.AppendFieldOnce(chatmessage.FieldUsageLogID) + } + query.Where(predicate.ChatMessage(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(usagelog.ChatMessagesColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.UsageLogID + if fk == nil { + return fmt.Errorf(`foreign-key "usage_log_id" is nil for node %v`, n.ID) + } + node, ok := nodeids[*fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "usage_log_id" returned %v for node %v`, *fk, n.ID) + } + assign(node, n) + } + return nil +} func (_q *UsageLogQuery) sqlCount(ctx context.Context) (int, error) { _spec := _q.querySpec() diff --git a/backend/ent/usagelog_update.go b/backend/ent/usagelog_update.go index bb5ac86c78a..c04e35ca263 100644 --- a/backend/ent/usagelog_update.go +++ b/backend/ent/usagelog_update.go @@ -12,6 +12,7 @@ import ( "entgo.io/ent/schema/field" "github.com/Wei-Shaw/sub2api/ent/account" "github.com/Wei-Shaw/sub2api/ent/apikey" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/predicate" "github.com/Wei-Shaw/sub2api/ent/usagelog" @@ -778,6 +779,21 @@ func (_u *UsageLogUpdate) SetSubscription(v *UserSubscription) *UsageLogUpdate { return _u.SetSubscriptionID(v.ID) } +// AddChatMessageIDs adds the "chat_messages" edge to the ChatMessage entity by IDs. +func (_u *UsageLogUpdate) AddChatMessageIDs(ids ...int64) *UsageLogUpdate { + _u.mutation.AddChatMessageIDs(ids...) + return _u +} + +// AddChatMessages adds the "chat_messages" edges to the ChatMessage entity. +func (_u *UsageLogUpdate) AddChatMessages(v ...*ChatMessage) *UsageLogUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddChatMessageIDs(ids...) +} + // Mutation returns the UsageLogMutation object of the builder. func (_u *UsageLogUpdate) Mutation() *UsageLogMutation { return _u.mutation @@ -813,6 +829,27 @@ func (_u *UsageLogUpdate) ClearSubscription() *UsageLogUpdate { return _u } +// ClearChatMessages clears all "chat_messages" edges to the ChatMessage entity. +func (_u *UsageLogUpdate) ClearChatMessages() *UsageLogUpdate { + _u.mutation.ClearChatMessages() + return _u +} + +// RemoveChatMessageIDs removes the "chat_messages" edge to ChatMessage entities by IDs. +func (_u *UsageLogUpdate) RemoveChatMessageIDs(ids ...int64) *UsageLogUpdate { + _u.mutation.RemoveChatMessageIDs(ids...) + return _u +} + +// RemoveChatMessages removes "chat_messages" edges to ChatMessage entities. +func (_u *UsageLogUpdate) RemoveChatMessages(v ...*ChatMessage) *UsageLogUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveChatMessageIDs(ids...) +} + // Save executes the query and returns the number of nodes affected by the update operation. func (_u *UsageLogUpdate) Save(ctx context.Context) (int, error) { return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks) @@ -1247,6 +1284,51 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.ChatMessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: usagelog.ChatMessagesTable, + Columns: []string{usagelog.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedChatMessagesIDs(); len(nodes) > 0 && !_u.mutation.ChatMessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: usagelog.ChatMessagesTable, + Columns: []string{usagelog.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.ChatMessagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: usagelog.ChatMessagesTable, + Columns: []string{usagelog.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil { if _, ok := err.(*sqlgraph.NotFoundError); ok { err = &NotFoundError{usagelog.Label} @@ -2013,6 +2095,21 @@ func (_u *UsageLogUpdateOne) SetSubscription(v *UserSubscription) *UsageLogUpdat return _u.SetSubscriptionID(v.ID) } +// AddChatMessageIDs adds the "chat_messages" edge to the ChatMessage entity by IDs. +func (_u *UsageLogUpdateOne) AddChatMessageIDs(ids ...int64) *UsageLogUpdateOne { + _u.mutation.AddChatMessageIDs(ids...) + return _u +} + +// AddChatMessages adds the "chat_messages" edges to the ChatMessage entity. +func (_u *UsageLogUpdateOne) AddChatMessages(v ...*ChatMessage) *UsageLogUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddChatMessageIDs(ids...) +} + // Mutation returns the UsageLogMutation object of the builder. func (_u *UsageLogUpdateOne) Mutation() *UsageLogMutation { return _u.mutation @@ -2048,6 +2145,27 @@ func (_u *UsageLogUpdateOne) ClearSubscription() *UsageLogUpdateOne { return _u } +// ClearChatMessages clears all "chat_messages" edges to the ChatMessage entity. +func (_u *UsageLogUpdateOne) ClearChatMessages() *UsageLogUpdateOne { + _u.mutation.ClearChatMessages() + return _u +} + +// RemoveChatMessageIDs removes the "chat_messages" edge to ChatMessage entities by IDs. +func (_u *UsageLogUpdateOne) RemoveChatMessageIDs(ids ...int64) *UsageLogUpdateOne { + _u.mutation.RemoveChatMessageIDs(ids...) + return _u +} + +// RemoveChatMessages removes "chat_messages" edges to ChatMessage entities. +func (_u *UsageLogUpdateOne) RemoveChatMessages(v ...*ChatMessage) *UsageLogUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveChatMessageIDs(ids...) +} + // Where appends a list predicates to the UsageLogUpdate builder. func (_u *UsageLogUpdateOne) Where(ps ...predicate.UsageLog) *UsageLogUpdateOne { _u.mutation.Where(ps...) @@ -2512,6 +2630,51 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.ChatMessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: usagelog.ChatMessagesTable, + Columns: []string{usagelog.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedChatMessagesIDs(); len(nodes) > 0 && !_u.mutation.ChatMessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: usagelog.ChatMessagesTable, + Columns: []string{usagelog.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.ChatMessagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: usagelog.ChatMessagesTable, + Columns: []string{usagelog.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } _node = &UsageLog{config: _u.config} _spec.Assign = _node.assignValues _spec.ScanValues = _node.scanValues diff --git a/backend/ent/user.go b/backend/ent/user.go index 06670444897..891b78bedff 100644 --- a/backend/ent/user.go +++ b/backend/ent/user.go @@ -85,6 +85,10 @@ type UserEdges struct { AllowedGroups []*Group `json:"allowed_groups,omitempty"` // UsageLogs holds the value of the usage_logs edge. UsageLogs []*UsageLog `json:"usage_logs,omitempty"` + // ChatSessions holds the value of the chat_sessions edge. + ChatSessions []*ChatSession `json:"chat_sessions,omitempty"` + // ChatMessages holds the value of the chat_messages edge. + ChatMessages []*ChatMessage `json:"chat_messages,omitempty"` // AttributeValues holds the value of the attribute_values edge. AttributeValues []*UserAttributeValue `json:"attribute_values,omitempty"` // PromoCodeUsages holds the value of the promo_code_usages edge. @@ -99,7 +103,7 @@ type UserEdges struct { UserAllowedGroups []*UserAllowedGroup `json:"user_allowed_groups,omitempty"` // loadedTypes holds the information for reporting if a // type was loaded (or requested) in eager-loading or not. - loadedTypes [13]bool + loadedTypes [15]bool } // APIKeysOrErr returns the APIKeys value or an error if the edge @@ -165,10 +169,28 @@ func (e UserEdges) UsageLogsOrErr() ([]*UsageLog, error) { return nil, &NotLoadedError{edge: "usage_logs"} } +// ChatSessionsOrErr returns the ChatSessions value or an error if the edge +// was not loaded in eager-loading. +func (e UserEdges) ChatSessionsOrErr() ([]*ChatSession, error) { + if e.loadedTypes[7] { + return e.ChatSessions, nil + } + return nil, &NotLoadedError{edge: "chat_sessions"} +} + +// ChatMessagesOrErr returns the ChatMessages value or an error if the edge +// was not loaded in eager-loading. +func (e UserEdges) ChatMessagesOrErr() ([]*ChatMessage, error) { + if e.loadedTypes[8] { + return e.ChatMessages, nil + } + return nil, &NotLoadedError{edge: "chat_messages"} +} + // AttributeValuesOrErr returns the AttributeValues value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) AttributeValuesOrErr() ([]*UserAttributeValue, error) { - if e.loadedTypes[7] { + if e.loadedTypes[9] { return e.AttributeValues, nil } return nil, &NotLoadedError{edge: "attribute_values"} @@ -177,7 +199,7 @@ func (e UserEdges) AttributeValuesOrErr() ([]*UserAttributeValue, error) { // PromoCodeUsagesOrErr returns the PromoCodeUsages value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) PromoCodeUsagesOrErr() ([]*PromoCodeUsage, error) { - if e.loadedTypes[8] { + if e.loadedTypes[10] { return e.PromoCodeUsages, nil } return nil, &NotLoadedError{edge: "promo_code_usages"} @@ -186,7 +208,7 @@ func (e UserEdges) PromoCodeUsagesOrErr() ([]*PromoCodeUsage, error) { // PaymentOrdersOrErr returns the PaymentOrders value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) PaymentOrdersOrErr() ([]*PaymentOrder, error) { - if e.loadedTypes[9] { + if e.loadedTypes[11] { return e.PaymentOrders, nil } return nil, &NotLoadedError{edge: "payment_orders"} @@ -195,7 +217,7 @@ func (e UserEdges) PaymentOrdersOrErr() ([]*PaymentOrder, error) { // AuthIdentitiesOrErr returns the AuthIdentities value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) AuthIdentitiesOrErr() ([]*AuthIdentity, error) { - if e.loadedTypes[10] { + if e.loadedTypes[12] { return e.AuthIdentities, nil } return nil, &NotLoadedError{edge: "auth_identities"} @@ -204,7 +226,7 @@ func (e UserEdges) AuthIdentitiesOrErr() ([]*AuthIdentity, error) { // PendingAuthSessionsOrErr returns the PendingAuthSessions value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) PendingAuthSessionsOrErr() ([]*PendingAuthSession, error) { - if e.loadedTypes[11] { + if e.loadedTypes[13] { return e.PendingAuthSessions, nil } return nil, &NotLoadedError{edge: "pending_auth_sessions"} @@ -213,7 +235,7 @@ func (e UserEdges) PendingAuthSessionsOrErr() ([]*PendingAuthSession, error) { // UserAllowedGroupsOrErr returns the UserAllowedGroups value or an error if the edge // was not loaded in eager-loading. func (e UserEdges) UserAllowedGroupsOrErr() ([]*UserAllowedGroup, error) { - if e.loadedTypes[12] { + if e.loadedTypes[14] { return e.UserAllowedGroups, nil } return nil, &NotLoadedError{edge: "user_allowed_groups"} @@ -447,6 +469,16 @@ func (_m *User) QueryUsageLogs() *UsageLogQuery { return NewUserClient(_m.config).QueryUsageLogs(_m) } +// QueryChatSessions queries the "chat_sessions" edge of the User entity. +func (_m *User) QueryChatSessions() *ChatSessionQuery { + return NewUserClient(_m.config).QueryChatSessions(_m) +} + +// QueryChatMessages queries the "chat_messages" edge of the User entity. +func (_m *User) QueryChatMessages() *ChatMessageQuery { + return NewUserClient(_m.config).QueryChatMessages(_m) +} + // QueryAttributeValues queries the "attribute_values" edge of the User entity. func (_m *User) QueryAttributeValues() *UserAttributeValueQuery { return NewUserClient(_m.config).QueryAttributeValues(_m) diff --git a/backend/ent/user/user.go b/backend/ent/user/user.go index e11a8a32e42..be90bbe86f4 100644 --- a/backend/ent/user/user.go +++ b/backend/ent/user/user.go @@ -75,6 +75,10 @@ const ( EdgeAllowedGroups = "allowed_groups" // EdgeUsageLogs holds the string denoting the usage_logs edge name in mutations. EdgeUsageLogs = "usage_logs" + // EdgeChatSessions holds the string denoting the chat_sessions edge name in mutations. + EdgeChatSessions = "chat_sessions" + // EdgeChatMessages holds the string denoting the chat_messages edge name in mutations. + EdgeChatMessages = "chat_messages" // EdgeAttributeValues holds the string denoting the attribute_values edge name in mutations. EdgeAttributeValues = "attribute_values" // EdgePromoCodeUsages holds the string denoting the promo_code_usages edge name in mutations. @@ -136,6 +140,20 @@ const ( UsageLogsInverseTable = "usage_logs" // UsageLogsColumn is the table column denoting the usage_logs relation/edge. UsageLogsColumn = "user_id" + // ChatSessionsTable is the table that holds the chat_sessions relation/edge. + ChatSessionsTable = "chat_sessions" + // ChatSessionsInverseTable is the table name for the ChatSession entity. + // It exists in this package in order to avoid circular dependency with the "chatsession" package. + ChatSessionsInverseTable = "chat_sessions" + // ChatSessionsColumn is the table column denoting the chat_sessions relation/edge. + ChatSessionsColumn = "user_id" + // ChatMessagesTable is the table that holds the chat_messages relation/edge. + ChatMessagesTable = "chat_messages" + // ChatMessagesInverseTable is the table name for the ChatMessage entity. + // It exists in this package in order to avoid circular dependency with the "chatmessage" package. + ChatMessagesInverseTable = "chat_messages" + // ChatMessagesColumn is the table column denoting the chat_messages relation/edge. + ChatMessagesColumn = "user_id" // AttributeValuesTable is the table that holds the attribute_values relation/edge. AttributeValuesTable = "user_attribute_values" // AttributeValuesInverseTable is the table name for the UserAttributeValue entity. @@ -499,6 +517,34 @@ func ByUsageLogs(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { } } +// ByChatSessionsCount orders the results by chat_sessions count. +func ByChatSessionsCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newChatSessionsStep(), opts...) + } +} + +// ByChatSessions orders the results by chat_sessions terms. +func ByChatSessions(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newChatSessionsStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} + +// ByChatMessagesCount orders the results by chat_messages count. +func ByChatMessagesCount(opts ...sql.OrderTermOption) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborsCount(s, newChatMessagesStep(), opts...) + } +} + +// ByChatMessages orders the results by chat_messages terms. +func ByChatMessages(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption { + return func(s *sql.Selector) { + sqlgraph.OrderByNeighborTerms(s, newChatMessagesStep(), append([]sql.OrderTerm{term}, terms...)...) + } +} + // ByAttributeValuesCount orders the results by attribute_values count. func ByAttributeValuesCount(opts ...sql.OrderTermOption) OrderOption { return func(s *sql.Selector) { @@ -631,6 +677,20 @@ func newUsageLogsStep() *sqlgraph.Step { sqlgraph.Edge(sqlgraph.O2M, false, UsageLogsTable, UsageLogsColumn), ) } +func newChatSessionsStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(ChatSessionsInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, ChatSessionsTable, ChatSessionsColumn), + ) +} +func newChatMessagesStep() *sqlgraph.Step { + return sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.To(ChatMessagesInverseTable, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, ChatMessagesTable, ChatMessagesColumn), + ) +} func newAttributeValuesStep() *sqlgraph.Step { return sqlgraph.NewStep( sqlgraph.From(Table, FieldID), diff --git a/backend/ent/user/where.go b/backend/ent/user/where.go index 05d3b35b962..284a9d27308 100644 --- a/backend/ent/user/where.go +++ b/backend/ent/user/where.go @@ -1501,6 +1501,52 @@ func HasUsageLogsWith(preds ...predicate.UsageLog) predicate.User { }) } +// HasChatSessions applies the HasEdge predicate on the "chat_sessions" edge. +func HasChatSessions() predicate.User { + return predicate.User(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, ChatSessionsTable, ChatSessionsColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasChatSessionsWith applies the HasEdge predicate on the "chat_sessions" edge with a given conditions (other predicates). +func HasChatSessionsWith(preds ...predicate.ChatSession) predicate.User { + return predicate.User(func(s *sql.Selector) { + step := newChatSessionsStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + +// HasChatMessages applies the HasEdge predicate on the "chat_messages" edge. +func HasChatMessages() predicate.User { + return predicate.User(func(s *sql.Selector) { + step := sqlgraph.NewStep( + sqlgraph.From(Table, FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, ChatMessagesTable, ChatMessagesColumn), + ) + sqlgraph.HasNeighbors(s, step) + }) +} + +// HasChatMessagesWith applies the HasEdge predicate on the "chat_messages" edge with a given conditions (other predicates). +func HasChatMessagesWith(preds ...predicate.ChatMessage) predicate.User { + return predicate.User(func(s *sql.Selector) { + step := newChatMessagesStep() + sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) { + for _, p := range preds { + p(s) + } + }) + }) +} + // HasAttributeValues applies the HasEdge predicate on the "attribute_values" edge. func HasAttributeValues() predicate.User { return predicate.User(func(s *sql.Selector) { diff --git a/backend/ent/user_create.go b/backend/ent/user_create.go index b4161128fd6..0039b9041af 100644 --- a/backend/ent/user_create.go +++ b/backend/ent/user_create.go @@ -14,6 +14,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/authidentity" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/ent/pendingauthsession" @@ -444,6 +446,36 @@ func (_c *UserCreate) AddUsageLogs(v ...*UsageLog) *UserCreate { return _c.AddUsageLogIDs(ids...) } +// AddChatSessionIDs adds the "chat_sessions" edge to the ChatSession entity by IDs. +func (_c *UserCreate) AddChatSessionIDs(ids ...int64) *UserCreate { + _c.mutation.AddChatSessionIDs(ids...) + return _c +} + +// AddChatSessions adds the "chat_sessions" edges to the ChatSession entity. +func (_c *UserCreate) AddChatSessions(v ...*ChatSession) *UserCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddChatSessionIDs(ids...) +} + +// AddChatMessageIDs adds the "chat_messages" edge to the ChatMessage entity by IDs. +func (_c *UserCreate) AddChatMessageIDs(ids ...int64) *UserCreate { + _c.mutation.AddChatMessageIDs(ids...) + return _c +} + +// AddChatMessages adds the "chat_messages" edges to the ChatMessage entity. +func (_c *UserCreate) AddChatMessages(v ...*ChatMessage) *UserCreate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _c.AddChatMessageIDs(ids...) +} + // AddAttributeValueIDs adds the "attribute_values" edge to the UserAttributeValue entity by IDs. func (_c *UserCreate) AddAttributeValueIDs(ids ...int64) *UserCreate { _c.mutation.AddAttributeValueIDs(ids...) @@ -943,6 +975,38 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) { } _spec.Edges = append(_spec.Edges, edge) } + if nodes := _c.mutation.ChatSessionsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatSessionsTable, + Columns: []string{user.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } + if nodes := _c.mutation.ChatMessagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatMessagesTable, + Columns: []string{user.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges = append(_spec.Edges, edge) + } if nodes := _c.mutation.AttributeValuesIDs(); len(nodes) > 0 { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/ent/user_query.go b/backend/ent/user_query.go index f1ee5cfe0aa..187a1aa0a22 100644 --- a/backend/ent/user_query.go +++ b/backend/ent/user_query.go @@ -16,6 +16,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/authidentity" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/ent/pendingauthsession" @@ -43,6 +45,8 @@ type UserQuery struct { withAnnouncementReads *AnnouncementReadQuery withAllowedGroups *GroupQuery withUsageLogs *UsageLogQuery + withChatSessions *ChatSessionQuery + withChatMessages *ChatMessageQuery withAttributeValues *UserAttributeValueQuery withPromoCodeUsages *PromoCodeUsageQuery withPaymentOrders *PaymentOrderQuery @@ -240,6 +244,50 @@ func (_q *UserQuery) QueryUsageLogs() *UsageLogQuery { return query } +// QueryChatSessions chains the current query on the "chat_sessions" edge. +func (_q *UserQuery) QueryChatSessions() *ChatSessionQuery { + query := (&ChatSessionClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(user.Table, user.FieldID, selector), + sqlgraph.To(chatsession.Table, chatsession.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.ChatSessionsTable, user.ChatSessionsColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + +// QueryChatMessages chains the current query on the "chat_messages" edge. +func (_q *UserQuery) QueryChatMessages() *ChatMessageQuery { + query := (&ChatMessageClient{config: _q.config}).Query() + query.path = func(ctx context.Context) (fromU *sql.Selector, err error) { + if err := _q.prepareQuery(ctx); err != nil { + return nil, err + } + selector := _q.sqlQuery(ctx) + if err := selector.Err(); err != nil { + return nil, err + } + step := sqlgraph.NewStep( + sqlgraph.From(user.Table, user.FieldID, selector), + sqlgraph.To(chatmessage.Table, chatmessage.FieldID), + sqlgraph.Edge(sqlgraph.O2M, false, user.ChatMessagesTable, user.ChatMessagesColumn), + ) + fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step) + return fromU, nil + } + return query +} + // QueryAttributeValues chains the current query on the "attribute_values" edge. func (_q *UserQuery) QueryAttributeValues() *UserAttributeValueQuery { query := (&UserAttributeValueClient{config: _q.config}).Query() @@ -571,6 +619,8 @@ func (_q *UserQuery) Clone() *UserQuery { withAnnouncementReads: _q.withAnnouncementReads.Clone(), withAllowedGroups: _q.withAllowedGroups.Clone(), withUsageLogs: _q.withUsageLogs.Clone(), + withChatSessions: _q.withChatSessions.Clone(), + withChatMessages: _q.withChatMessages.Clone(), withAttributeValues: _q.withAttributeValues.Clone(), withPromoCodeUsages: _q.withPromoCodeUsages.Clone(), withPaymentOrders: _q.withPaymentOrders.Clone(), @@ -660,6 +710,28 @@ func (_q *UserQuery) WithUsageLogs(opts ...func(*UsageLogQuery)) *UserQuery { return _q } +// WithChatSessions tells the query-builder to eager-load the nodes that are connected to +// the "chat_sessions" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *UserQuery) WithChatSessions(opts ...func(*ChatSessionQuery)) *UserQuery { + query := (&ChatSessionClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withChatSessions = query + return _q +} + +// WithChatMessages tells the query-builder to eager-load the nodes that are connected to +// the "chat_messages" edge. The optional arguments are used to configure the query builder of the edge. +func (_q *UserQuery) WithChatMessages(opts ...func(*ChatMessageQuery)) *UserQuery { + query := (&ChatMessageClient{config: _q.config}).Query() + for _, opt := range opts { + opt(query) + } + _q.withChatMessages = query + return _q +} + // WithAttributeValues tells the query-builder to eager-load the nodes that are connected to // the "attribute_values" edge. The optional arguments are used to configure the query builder of the edge. func (_q *UserQuery) WithAttributeValues(opts ...func(*UserAttributeValueQuery)) *UserQuery { @@ -804,7 +876,7 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e var ( nodes = []*User{} _spec = _q.querySpec() - loadedTypes = [13]bool{ + loadedTypes = [15]bool{ _q.withAPIKeys != nil, _q.withRedeemCodes != nil, _q.withSubscriptions != nil, @@ -812,6 +884,8 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e _q.withAnnouncementReads != nil, _q.withAllowedGroups != nil, _q.withUsageLogs != nil, + _q.withChatSessions != nil, + _q.withChatMessages != nil, _q.withAttributeValues != nil, _q.withPromoCodeUsages != nil, _q.withPaymentOrders != nil, @@ -892,6 +966,20 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e return nil, err } } + if query := _q.withChatSessions; query != nil { + if err := _q.loadChatSessions(ctx, query, nodes, + func(n *User) { n.Edges.ChatSessions = []*ChatSession{} }, + func(n *User, e *ChatSession) { n.Edges.ChatSessions = append(n.Edges.ChatSessions, e) }); err != nil { + return nil, err + } + } + if query := _q.withChatMessages; query != nil { + if err := _q.loadChatMessages(ctx, query, nodes, + func(n *User) { n.Edges.ChatMessages = []*ChatMessage{} }, + func(n *User, e *ChatMessage) { n.Edges.ChatMessages = append(n.Edges.ChatMessages, e) }); err != nil { + return nil, err + } + } if query := _q.withAttributeValues; query != nil { if err := _q.loadAttributeValues(ctx, query, nodes, func(n *User) { n.Edges.AttributeValues = []*UserAttributeValue{} }, @@ -1186,6 +1274,66 @@ func (_q *UserQuery) loadUsageLogs(ctx context.Context, query *UsageLogQuery, no } return nil } +func (_q *UserQuery) loadChatSessions(ctx context.Context, query *ChatSessionQuery, nodes []*User, init func(*User), assign func(*User, *ChatSession)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[int64]*User) + for i := range nodes { + fks = append(fks, nodes[i].ID) + nodeids[nodes[i].ID] = nodes[i] + if init != nil { + init(nodes[i]) + } + } + if len(query.ctx.Fields) > 0 { + query.ctx.AppendFieldOnce(chatsession.FieldUserID) + } + query.Where(predicate.ChatSession(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(user.ChatSessionsColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.UserID + node, ok := nodeids[fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "user_id" returned %v for node %v`, fk, n.ID) + } + assign(node, n) + } + return nil +} +func (_q *UserQuery) loadChatMessages(ctx context.Context, query *ChatMessageQuery, nodes []*User, init func(*User), assign func(*User, *ChatMessage)) error { + fks := make([]driver.Value, 0, len(nodes)) + nodeids := make(map[int64]*User) + for i := range nodes { + fks = append(fks, nodes[i].ID) + nodeids[nodes[i].ID] = nodes[i] + if init != nil { + init(nodes[i]) + } + } + if len(query.ctx.Fields) > 0 { + query.ctx.AppendFieldOnce(chatmessage.FieldUserID) + } + query.Where(predicate.ChatMessage(func(s *sql.Selector) { + s.Where(sql.InValues(s.C(user.ChatMessagesColumn), fks...)) + })) + neighbors, err := query.All(ctx) + if err != nil { + return err + } + for _, n := range neighbors { + fk := n.UserID + node, ok := nodeids[fk] + if !ok { + return fmt.Errorf(`unexpected referenced foreign-key "user_id" returned %v for node %v`, fk, n.ID) + } + assign(node, n) + } + return nil +} func (_q *UserQuery) loadAttributeValues(ctx context.Context, query *UserAttributeValueQuery, nodes []*User, init func(*User), assign func(*User, *UserAttributeValue)) error { fks := make([]driver.Value, 0, len(nodes)) nodeids := make(map[int64]*User) diff --git a/backend/ent/user_update.go b/backend/ent/user_update.go index f1d759ce440..93385f62261 100644 --- a/backend/ent/user_update.go +++ b/backend/ent/user_update.go @@ -14,6 +14,8 @@ import ( "github.com/Wei-Shaw/sub2api/ent/announcementread" "github.com/Wei-Shaw/sub2api/ent/apikey" "github.com/Wei-Shaw/sub2api/ent/authidentity" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" "github.com/Wei-Shaw/sub2api/ent/group" "github.com/Wei-Shaw/sub2api/ent/paymentorder" "github.com/Wei-Shaw/sub2api/ent/pendingauthsession" @@ -515,6 +517,36 @@ func (_u *UserUpdate) AddUsageLogs(v ...*UsageLog) *UserUpdate { return _u.AddUsageLogIDs(ids...) } +// AddChatSessionIDs adds the "chat_sessions" edge to the ChatSession entity by IDs. +func (_u *UserUpdate) AddChatSessionIDs(ids ...int64) *UserUpdate { + _u.mutation.AddChatSessionIDs(ids...) + return _u +} + +// AddChatSessions adds the "chat_sessions" edges to the ChatSession entity. +func (_u *UserUpdate) AddChatSessions(v ...*ChatSession) *UserUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddChatSessionIDs(ids...) +} + +// AddChatMessageIDs adds the "chat_messages" edge to the ChatMessage entity by IDs. +func (_u *UserUpdate) AddChatMessageIDs(ids ...int64) *UserUpdate { + _u.mutation.AddChatMessageIDs(ids...) + return _u +} + +// AddChatMessages adds the "chat_messages" edges to the ChatMessage entity. +func (_u *UserUpdate) AddChatMessages(v ...*ChatMessage) *UserUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddChatMessageIDs(ids...) +} + // AddAttributeValueIDs adds the "attribute_values" edge to the UserAttributeValue entity by IDs. func (_u *UserUpdate) AddAttributeValueIDs(ids ...int64) *UserUpdate { _u.mutation.AddAttributeValueIDs(ids...) @@ -742,6 +774,48 @@ func (_u *UserUpdate) RemoveUsageLogs(v ...*UsageLog) *UserUpdate { return _u.RemoveUsageLogIDs(ids...) } +// ClearChatSessions clears all "chat_sessions" edges to the ChatSession entity. +func (_u *UserUpdate) ClearChatSessions() *UserUpdate { + _u.mutation.ClearChatSessions() + return _u +} + +// RemoveChatSessionIDs removes the "chat_sessions" edge to ChatSession entities by IDs. +func (_u *UserUpdate) RemoveChatSessionIDs(ids ...int64) *UserUpdate { + _u.mutation.RemoveChatSessionIDs(ids...) + return _u +} + +// RemoveChatSessions removes "chat_sessions" edges to ChatSession entities. +func (_u *UserUpdate) RemoveChatSessions(v ...*ChatSession) *UserUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveChatSessionIDs(ids...) +} + +// ClearChatMessages clears all "chat_messages" edges to the ChatMessage entity. +func (_u *UserUpdate) ClearChatMessages() *UserUpdate { + _u.mutation.ClearChatMessages() + return _u +} + +// RemoveChatMessageIDs removes the "chat_messages" edge to ChatMessage entities by IDs. +func (_u *UserUpdate) RemoveChatMessageIDs(ids ...int64) *UserUpdate { + _u.mutation.RemoveChatMessageIDs(ids...) + return _u +} + +// RemoveChatMessages removes "chat_messages" edges to ChatMessage entities. +func (_u *UserUpdate) RemoveChatMessages(v ...*ChatMessage) *UserUpdate { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveChatMessageIDs(ids...) +} + // ClearAttributeValues clears all "attribute_values" edges to the UserAttributeValue entity. func (_u *UserUpdate) ClearAttributeValues() *UserUpdate { _u.mutation.ClearAttributeValues() @@ -1362,6 +1436,96 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.ChatSessionsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatSessionsTable, + Columns: []string{user.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedChatSessionsIDs(); len(nodes) > 0 && !_u.mutation.ChatSessionsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatSessionsTable, + Columns: []string{user.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.ChatSessionsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatSessionsTable, + Columns: []string{user.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.ChatMessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatMessagesTable, + Columns: []string{user.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedChatMessagesIDs(); len(nodes) > 0 && !_u.mutation.ChatMessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatMessagesTable, + Columns: []string{user.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.ChatMessagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatMessagesTable, + Columns: []string{user.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _u.mutation.AttributeValuesCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, @@ -2083,6 +2247,36 @@ func (_u *UserUpdateOne) AddUsageLogs(v ...*UsageLog) *UserUpdateOne { return _u.AddUsageLogIDs(ids...) } +// AddChatSessionIDs adds the "chat_sessions" edge to the ChatSession entity by IDs. +func (_u *UserUpdateOne) AddChatSessionIDs(ids ...int64) *UserUpdateOne { + _u.mutation.AddChatSessionIDs(ids...) + return _u +} + +// AddChatSessions adds the "chat_sessions" edges to the ChatSession entity. +func (_u *UserUpdateOne) AddChatSessions(v ...*ChatSession) *UserUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddChatSessionIDs(ids...) +} + +// AddChatMessageIDs adds the "chat_messages" edge to the ChatMessage entity by IDs. +func (_u *UserUpdateOne) AddChatMessageIDs(ids ...int64) *UserUpdateOne { + _u.mutation.AddChatMessageIDs(ids...) + return _u +} + +// AddChatMessages adds the "chat_messages" edges to the ChatMessage entity. +func (_u *UserUpdateOne) AddChatMessages(v ...*ChatMessage) *UserUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.AddChatMessageIDs(ids...) +} + // AddAttributeValueIDs adds the "attribute_values" edge to the UserAttributeValue entity by IDs. func (_u *UserUpdateOne) AddAttributeValueIDs(ids ...int64) *UserUpdateOne { _u.mutation.AddAttributeValueIDs(ids...) @@ -2310,6 +2504,48 @@ func (_u *UserUpdateOne) RemoveUsageLogs(v ...*UsageLog) *UserUpdateOne { return _u.RemoveUsageLogIDs(ids...) } +// ClearChatSessions clears all "chat_sessions" edges to the ChatSession entity. +func (_u *UserUpdateOne) ClearChatSessions() *UserUpdateOne { + _u.mutation.ClearChatSessions() + return _u +} + +// RemoveChatSessionIDs removes the "chat_sessions" edge to ChatSession entities by IDs. +func (_u *UserUpdateOne) RemoveChatSessionIDs(ids ...int64) *UserUpdateOne { + _u.mutation.RemoveChatSessionIDs(ids...) + return _u +} + +// RemoveChatSessions removes "chat_sessions" edges to ChatSession entities. +func (_u *UserUpdateOne) RemoveChatSessions(v ...*ChatSession) *UserUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveChatSessionIDs(ids...) +} + +// ClearChatMessages clears all "chat_messages" edges to the ChatMessage entity. +func (_u *UserUpdateOne) ClearChatMessages() *UserUpdateOne { + _u.mutation.ClearChatMessages() + return _u +} + +// RemoveChatMessageIDs removes the "chat_messages" edge to ChatMessage entities by IDs. +func (_u *UserUpdateOne) RemoveChatMessageIDs(ids ...int64) *UserUpdateOne { + _u.mutation.RemoveChatMessageIDs(ids...) + return _u +} + +// RemoveChatMessages removes "chat_messages" edges to ChatMessage entities. +func (_u *UserUpdateOne) RemoveChatMessages(v ...*ChatMessage) *UserUpdateOne { + ids := make([]int64, len(v)) + for i := range v { + ids[i] = v[i].ID + } + return _u.RemoveChatMessageIDs(ids...) +} + // ClearAttributeValues clears all "attribute_values" edges to the UserAttributeValue entity. func (_u *UserUpdateOne) ClearAttributeValues() *UserUpdateOne { _u.mutation.ClearAttributeValues() @@ -2960,6 +3196,96 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) { } _spec.Edges.Add = append(_spec.Edges.Add, edge) } + if _u.mutation.ChatSessionsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatSessionsTable, + Columns: []string{user.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedChatSessionsIDs(); len(nodes) > 0 && !_u.mutation.ChatSessionsCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatSessionsTable, + Columns: []string{user.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.ChatSessionsIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatSessionsTable, + Columns: []string{user.ChatSessionsColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatsession.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } + if _u.mutation.ChatMessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatMessagesTable, + Columns: []string{user.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.RemovedChatMessagesIDs(); len(nodes) > 0 && !_u.mutation.ChatMessagesCleared() { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatMessagesTable, + Columns: []string{user.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Clear = append(_spec.Edges.Clear, edge) + } + if nodes := _u.mutation.ChatMessagesIDs(); len(nodes) > 0 { + edge := &sqlgraph.EdgeSpec{ + Rel: sqlgraph.O2M, + Inverse: false, + Table: user.ChatMessagesTable, + Columns: []string{user.ChatMessagesColumn}, + Bidi: false, + Target: &sqlgraph.EdgeTarget{ + IDSpec: sqlgraph.NewFieldSpec(chatmessage.FieldID, field.TypeInt64), + }, + } + for _, k := range nodes { + edge.Target.Nodes = append(edge.Target.Nodes, k) + } + _spec.Edges.Add = append(_spec.Edges.Add, edge) + } if _u.mutation.AttributeValuesCleared() { edge := &sqlgraph.EdgeSpec{ Rel: sqlgraph.O2M, diff --git a/backend/go.mod b/backend/go.mod index 982bf91b916..7a4f436fcb4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Wei-Shaw/sub2api -go 1.26.2 +go 1.26.3 require ( entgo.io/ent v0.14.5 @@ -20,6 +20,7 @@ require ( github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/imroc/req/v3 v3.57.0 + github.com/klauspost/compress v1.18.2 github.com/lib/pq v1.10.9 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pquerna/otp v1.5.0 @@ -39,11 +40,11 @@ require ( github.com/wechatpay-apiv3/wechatpay-go v0.2.21 github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 golang.org/x/image v0.39.0 - golang.org/x/net v0.52.0 + golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.44.3 @@ -104,13 +105,11 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/subcommands v1.2.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl/v2 v2.18.1 // indirect github.com/icholy/digest v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -174,7 +173,7 @@ require ( golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/mod v0.34.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/grpc v1.75.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0f366ee1079..db410b49ce2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -162,8 +162,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -183,8 +181,6 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -413,16 +409,16 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -434,10 +430,10 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 87263db09e6..494f6893901 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -28,7 +28,7 @@ const ( // DefaultCSPPolicy is the default Content-Security-Policy with nonce support // __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware -const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" +const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https: blob:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" // UMQ(用户消息队列)模式常量 const ( @@ -72,6 +72,8 @@ type Config struct { LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"` WeChat WeChatConnectConfig `mapstructure:"wechat_connect"` OIDC OIDCConnectConfig `mapstructure:"oidc_connect"` + GitHubOAuth EmailOAuthProviderConfig `mapstructure:"github_oauth"` + GoogleOAuth EmailOAuthProviderConfig `mapstructure:"google_oauth"` Default DefaultConfig `mapstructure:"default"` RateLimit RateLimitConfig `mapstructure:"rate_limit"` Pricing PricingConfig `mapstructure:"pricing"` @@ -240,6 +242,19 @@ type OIDCConnectConfig struct { UserInfoUsernamePath string `mapstructure:"userinfo_username_path"` } +type EmailOAuthProviderConfig struct { + Enabled bool `mapstructure:"enabled"` + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + AuthorizeURL string `mapstructure:"authorize_url"` + TokenURL string `mapstructure:"token_url"` + UserInfoURL string `mapstructure:"userinfo_url"` + EmailsURL string `mapstructure:"emails_url"` + Scopes string `mapstructure:"scopes"` + RedirectURL string `mapstructure:"redirect_url"` + FrontendRedirectURL string `mapstructure:"frontend_redirect_url"` +} + const ( defaultWeChatConnectMode = "open" defaultWeChatConnectScopes = "snsapi_login" @@ -575,6 +590,24 @@ type ConcurrencyConfig struct { PingInterval int `mapstructure:"ping_interval"` } +type ImageConcurrencyConfig struct { + // Enabled: 是否启用图片生成独立并发限制,默认关闭以保持现有行为 + Enabled bool `mapstructure:"enabled"` + // MaxConcurrentRequests: 当前进程允许同时处理的图片生成请求数,0表示不限制 + MaxConcurrentRequests int `mapstructure:"max_concurrent_requests"` + // OverflowMode: 图片并发达到上限后的处理方式:reject/wait + OverflowMode string `mapstructure:"overflow_mode"` + // WaitTimeoutSeconds: overflow_mode=wait 时等待图片并发槽位的超时时间(秒) + WaitTimeoutSeconds int `mapstructure:"wait_timeout_seconds"` + // MaxWaitingRequests: overflow_mode=wait 时当前进程允许排队等待的图片请求数 + MaxWaitingRequests int `mapstructure:"max_waiting_requests"` +} + +const ( + ImageConcurrencyOverflowModeReject = "reject" + ImageConcurrencyOverflowModeWait = "wait" +) + // GatewayConfig API网关相关配置 type GatewayConfig struct { // 等待上游响应头的超时时间(秒),0表示无超时 @@ -593,6 +626,9 @@ type GatewayConfig struct { // ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。 // 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。 ForceCodexCLI bool `mapstructure:"force_codex_cli"` + // CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。 + // 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。 + CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"` // ForcedCodexInstructionsTemplateFile: 服务端强制附加到 Codex 顶层 instructions 的模板文件路径。 // 模板渲染后会直接覆盖最终 instructions;若需要保留客户端 system 转换结果,请在模板中显式引用 {{ .ExistingInstructions }}。 ForcedCodexInstructionsTemplateFile string `mapstructure:"forced_codex_instructions_template_file"` @@ -604,6 +640,8 @@ type GatewayConfig struct { OpenAIPassthroughAllowTimeoutHeaders bool `mapstructure:"openai_passthrough_allow_timeout_headers"` // OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP) OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"` + // ImageConcurrency: 图片生成独立并发限制配置(默认关闭) + ImageConcurrency ImageConcurrencyConfig `mapstructure:"image_concurrency"` // HTTP 上游连接池配置(性能优化:支持高并发场景调优) // MaxIdleConns: 所有主机的最大空闲连接总数 @@ -635,6 +673,10 @@ type GatewayConfig struct { StreamDataIntervalTimeout int `mapstructure:"stream_data_interval_timeout"` // StreamKeepaliveInterval: 流式 keepalive 间隔(秒),0表示禁用 StreamKeepaliveInterval int `mapstructure:"stream_keepalive_interval"` + // ImageStreamDataIntervalTimeout: 图片流数据间隔超时(秒),0表示禁用 + ImageStreamDataIntervalTimeout int `mapstructure:"image_stream_data_interval_timeout"` + // ImageStreamKeepaliveInterval: 图片流式 keepalive 间隔(秒),0表示禁用 + ImageStreamKeepaliveInterval int `mapstructure:"image_stream_keepalive_interval"` // MaxLineSize: 上游 SSE 单行最大字节数(0使用默认值) MaxLineSize int `mapstructure:"max_line_size"` @@ -1625,6 +1667,7 @@ func setDefaults() { viper.SetDefault("gateway.max_account_switches", 10) viper.SetDefault("gateway.max_account_switches_gemini", 3) viper.SetDefault("gateway.force_codex_cli", false) + viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false) viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false) // OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚) viper.SetDefault("gateway.openai_ws.enabled", true) @@ -1672,6 +1715,11 @@ func setDefaults() { viper.SetDefault("gateway.openai_ws.scheduler_score_weights.queue", 0.7) viper.SetDefault("gateway.openai_ws.scheduler_score_weights.error_rate", 0.8) viper.SetDefault("gateway.openai_ws.scheduler_score_weights.ttft", 0.5) + viper.SetDefault("gateway.image_concurrency.enabled", false) + viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0) + viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject) + viper.SetDefault("gateway.image_concurrency.wait_timeout_seconds", 30) + viper.SetDefault("gateway.image_concurrency.max_waiting_requests", 100) viper.SetDefault("gateway.antigravity_fallback_cooldown_minutes", 1) viper.SetDefault("gateway.antigravity_extra_retries", 10) viper.SetDefault("gateway.max_body_size", int64(256*1024*1024)) @@ -1689,6 +1737,8 @@ func setDefaults() { viper.SetDefault("gateway.concurrency_slot_ttl_minutes", 30) // 并发槽位过期时间(支持超长请求) viper.SetDefault("gateway.stream_data_interval_timeout", 180) viper.SetDefault("gateway.stream_keepalive_interval", 10) + viper.SetDefault("gateway.image_stream_data_interval_timeout", 900) + viper.SetDefault("gateway.image_stream_keepalive_interval", 10) viper.SetDefault("gateway.max_line_size", 500*1024*1024) viper.SetDefault("gateway.scheduling.sticky_session_max_waiting", 3) viper.SetDefault("gateway.scheduling.sticky_session_wait_timeout", 120*time.Second) @@ -2239,6 +2289,21 @@ func (c *Config) Validate() error { ConnectionPoolIsolationProxy, ConnectionPoolIsolationAccount, ConnectionPoolIsolationAccountProxy) } } + if c.Gateway.ImageConcurrency.MaxConcurrentRequests < 0 { + return fmt.Errorf("gateway.image_concurrency.max_concurrent_requests must be non-negative") + } + switch strings.TrimSpace(c.Gateway.ImageConcurrency.OverflowMode) { + case "", ImageConcurrencyOverflowModeReject, ImageConcurrencyOverflowModeWait: + default: + return fmt.Errorf("gateway.image_concurrency.overflow_mode must be one of: %s/%s", + ImageConcurrencyOverflowModeReject, ImageConcurrencyOverflowModeWait) + } + if c.Gateway.ImageConcurrency.WaitTimeoutSeconds < 0 { + return fmt.Errorf("gateway.image_concurrency.wait_timeout_seconds must be non-negative") + } + if c.Gateway.ImageConcurrency.MaxWaitingRequests < 0 { + return fmt.Errorf("gateway.image_concurrency.max_waiting_requests must be non-negative") + } if c.Gateway.MaxIdleConns <= 0 { return fmt.Errorf("gateway.max_idle_conns must be positive") } @@ -2277,6 +2342,20 @@ func (c *Config) Validate() error { (c.Gateway.StreamKeepaliveInterval < 5 || c.Gateway.StreamKeepaliveInterval > 30) { return fmt.Errorf("gateway.stream_keepalive_interval must be 0 or between 5-30 seconds") } + if c.Gateway.ImageStreamDataIntervalTimeout < 0 { + return fmt.Errorf("gateway.image_stream_data_interval_timeout must be non-negative") + } + if c.Gateway.ImageStreamDataIntervalTimeout != 0 && + (c.Gateway.ImageStreamDataIntervalTimeout < 60 || c.Gateway.ImageStreamDataIntervalTimeout > 1800) { + return fmt.Errorf("gateway.image_stream_data_interval_timeout must be 0 or between 60-1800 seconds") + } + if c.Gateway.ImageStreamKeepaliveInterval < 0 { + return fmt.Errorf("gateway.image_stream_keepalive_interval must be non-negative") + } + if c.Gateway.ImageStreamKeepaliveInterval != 0 && + (c.Gateway.ImageStreamKeepaliveInterval < 5 || c.Gateway.ImageStreamKeepaliveInterval > 60) { + return fmt.Errorf("gateway.image_stream_keepalive_interval must be 0 or between 5-60 seconds") + } // 兼容旧键 sticky_previous_response_ttl_seconds if c.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 { c.Gateway.OpenAIWS.StickyResponseIDTTLSeconds = c.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 6ba86aa1be3..a47de2f8bd7 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1282,6 +1282,46 @@ func TestValidateConfigErrors(t *testing.T) { mutate: func(c *Config) { c.Gateway.StreamDataIntervalTimeout = -1 }, wantErr: "gateway.stream_data_interval_timeout must be non-negative", }, + { + name: "gateway image stream keepalive range", + mutate: func(c *Config) { c.Gateway.ImageStreamKeepaliveInterval = 4 }, + wantErr: "gateway.image_stream_keepalive_interval", + }, + { + name: "gateway image stream keepalive negative", + mutate: func(c *Config) { c.Gateway.ImageStreamKeepaliveInterval = -1 }, + wantErr: "gateway.image_stream_keepalive_interval must be non-negative", + }, + { + name: "gateway image stream data interval range", + mutate: func(c *Config) { c.Gateway.ImageStreamDataIntervalTimeout = 30 }, + wantErr: "gateway.image_stream_data_interval_timeout", + }, + { + name: "gateway image stream data interval negative", + mutate: func(c *Config) { c.Gateway.ImageStreamDataIntervalTimeout = -1 }, + wantErr: "gateway.image_stream_data_interval_timeout must be non-negative", + }, + { + name: "gateway image concurrency max negative", + mutate: func(c *Config) { c.Gateway.ImageConcurrency.MaxConcurrentRequests = -1 }, + wantErr: "gateway.image_concurrency.max_concurrent_requests must be non-negative", + }, + { + name: "gateway image concurrency overflow mode invalid", + mutate: func(c *Config) { c.Gateway.ImageConcurrency.OverflowMode = "queue" }, + wantErr: "gateway.image_concurrency.overflow_mode", + }, + { + name: "gateway image concurrency wait timeout negative", + mutate: func(c *Config) { c.Gateway.ImageConcurrency.WaitTimeoutSeconds = -1 }, + wantErr: "gateway.image_concurrency.wait_timeout_seconds must be non-negative", + }, + { + name: "gateway image concurrency max waiting negative", + mutate: func(c *Config) { c.Gateway.ImageConcurrency.MaxWaitingRequests = -1 }, + wantErr: "gateway.image_concurrency.max_waiting_requests must be non-negative", + }, { name: "gateway max line size", mutate: func(c *Config) { c.Gateway.MaxLineSize = 1024 }, @@ -1754,3 +1794,41 @@ func TestLoad_DefaultGatewayUsageRecordConfig(t *testing.T) { t.Fatalf("auto_scale_cooldown_seconds = %d, want 10", cfg.Gateway.UsageRecord.AutoScaleCooldownSeconds) } } + +func TestLoad_DefaultGatewayImageStreamConfig(t *testing.T) { + resetViperWithJWTSecret(t) + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + if cfg.Gateway.StreamDataIntervalTimeout != 180 { + t.Fatalf("stream_data_interval_timeout = %d, want 180", cfg.Gateway.StreamDataIntervalTimeout) + } + if cfg.Gateway.StreamKeepaliveInterval != 10 { + t.Fatalf("stream_keepalive_interval = %d, want 10", cfg.Gateway.StreamKeepaliveInterval) + } + if cfg.Gateway.ImageStreamDataIntervalTimeout != 900 { + t.Fatalf("image_stream_data_interval_timeout = %d, want 900", cfg.Gateway.ImageStreamDataIntervalTimeout) + } + if cfg.Gateway.ImageStreamKeepaliveInterval != 10 { + t.Fatalf("image_stream_keepalive_interval = %d, want 10", cfg.Gateway.ImageStreamKeepaliveInterval) + } + if cfg.Gateway.ImageConcurrency.Enabled { + t.Fatalf("image_concurrency.enabled = true, want false") + } + if cfg.Gateway.ImageConcurrency.MaxConcurrentRequests != 0 { + t.Fatalf("image_concurrency.max_concurrent_requests = %d, want 0", cfg.Gateway.ImageConcurrency.MaxConcurrentRequests) + } + if cfg.Gateway.ImageConcurrency.OverflowMode != ImageConcurrencyOverflowModeReject { + t.Fatalf("image_concurrency.overflow_mode = %q, want %q", cfg.Gateway.ImageConcurrency.OverflowMode, ImageConcurrencyOverflowModeReject) + } + if cfg.Gateway.ImageConcurrency.WaitTimeoutSeconds != 30 { + t.Fatalf("image_concurrency.wait_timeout_seconds = %d, want 30", cfg.Gateway.ImageConcurrency.WaitTimeoutSeconds) + } + if cfg.Gateway.ImageConcurrency.MaxWaitingRequests != 100 { + t.Fatalf("image_concurrency.max_waiting_requests = %d, want 100", cfg.Gateway.ImageConcurrency.MaxWaitingRequests) + } + if cfg.Gateway.ImageStreamDataIntervalTimeout <= cfg.Gateway.StreamDataIntervalTimeout { + t.Fatalf("image stream timeout = %d, want greater than ordinary stream timeout %d", cfg.Gateway.ImageStreamDataIntervalTimeout, cfg.Gateway.StreamDataIntervalTimeout) + } +} diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go new file mode 100644 index 00000000000..0c599522b39 --- /dev/null +++ b/backend/internal/handler/admin/account_codex_import.go @@ -0,0 +1,1045 @@ +package admin + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +const codexImportClockSkewSeconds int64 = 120 + +type CodexSessionImportRequest struct { + Content string `json:"content"` + Contents []string `json:"contents"` + Name string `json:"name"` + Notes *string `json:"notes"` + GroupIDs []int64 `json:"group_ids"` + ProxyID *int64 `json:"proxy_id"` + Concurrency *int `json:"concurrency"` + Priority *int `json:"priority"` + RateMultiplier *float64 `json:"rate_multiplier"` + LoadFactor *int `json:"load_factor"` + ExpiresAt *int64 `json:"expires_at"` + AutoPauseOnExpired *bool `json:"auto_pause_on_expired"` + CredentialExtras map[string]any `json:"credential_extras"` + Extra map[string]any `json:"extra"` + UpdateExisting *bool `json:"update_existing"` + SkipDefaultGroupBind *bool `json:"skip_default_group_bind"` + ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` +} + +type CodexSessionImportResult struct { + Total int `json:"total"` + Created int `json:"created"` + Updated int `json:"updated"` + Skipped int `json:"skipped"` + Failed int `json:"failed"` + Items []CodexSessionImportItem `json:"items,omitempty"` + Warnings []CodexSessionImportMessage `json:"warnings,omitempty"` + Errors []CodexSessionImportMessage `json:"errors,omitempty"` +} + +type CodexSessionImportItem struct { + Index int `json:"index"` + Name string `json:"name,omitempty"` + Action string `json:"action"` + AccountID int64 `json:"account_id,omitempty"` + Message string `json:"message,omitempty"` +} + +type CodexSessionImportMessage struct { + Index int `json:"index"` + Name string `json:"name,omitempty"` + Message string `json:"message"` +} + +type codexImportEntry struct { + Index int + Value any +} + +type codexImportAccount struct { + Name string + AccessToken string + RefreshToken string + IDToken string + Email string + AccountID string + UserID string + PlanType string + Organization string + Credentials map[string]any + Extra map[string]any + TokenExpiresAt *time.Time + IdentityKeys []string + WarningTexts []string +} + +type codexJWTClaims struct { + Sub string `json:"sub"` + Email string `json:"email"` + Exp int64 `json:"exp"` + Iat int64 `json:"iat"` + OpenAIAuth *codexJWTOpenAIClaims `json:"https://api.openai.com/auth,omitempty"` +} + +type codexJWTOpenAIClaims struct { + ChatGPTAccountID string `json:"chatgpt_account_id"` + ChatGPTUserID string `json:"chatgpt_user_id"` + ChatGPTPlanType string `json:"chatgpt_plan_type"` + UserID string `json:"user_id"` + POID string `json:"poid"` + Organizations []openai.OrganizationClaim `json:"organizations"` +} + +type codexAccountIndex struct { + accountsByKey map[string]service.Account +} + +func (h *AccountHandler) ImportCodexSession(c *gin.Context) { + var req CodexSessionImportRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if req.Concurrency != nil && *req.Concurrency < 0 { + response.BadRequest(c, "concurrency must be >= 0") + return + } + if req.Priority != nil && *req.Priority < 0 { + response.BadRequest(c, "priority must be >= 0") + return + } + if req.RateMultiplier != nil && *req.RateMultiplier < 0 { + response.BadRequest(c, "rate_multiplier must be >= 0") + return + } + if req.LoadFactor != nil && *req.LoadFactor > 10000 { + response.BadRequest(c, "load_factor must be <= 10000") + return + } + + entries, err := parseCodexSessionImportEntries(req) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + if len(entries) == 0 { + response.BadRequest(c, "请输入 accessToken 或 Codex session JSON") + return + } + + executeAdminIdempotentJSON(c, "admin.accounts.import_codex_session", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { + return h.importCodexSessions(ctx, req, entries) + }) +} + +func (h *AccountHandler) importCodexSessions(ctx context.Context, req CodexSessionImportRequest, entries []codexImportEntry) (CodexSessionImportResult, error) { + result := CodexSessionImportResult{ + Total: len(entries), + Items: make([]CodexSessionImportItem, 0, len(entries)), + } + + existingAccounts, err := h.listAccountsFiltered(ctx, service.PlatformOpenAI, service.AccountTypeOAuth, "", "", 0, "", "created_at", "desc") + if err != nil { + return result, err + } + index := buildCodexAccountIndex(existingAccounts) + + updateExisting := true + if req.UpdateExisting != nil { + updateExisting = *req.UpdateExisting + } + concurrency := 3 + if req.Concurrency != nil { + concurrency = *req.Concurrency + } + priority := 50 + if req.Priority != nil { + priority = *req.Priority + } + credentialExtras := sanitizeCodexImportCredentialExtras(req.CredentialExtras) + skipDefaultGroupBind := false + if req.SkipDefaultGroupBind != nil { + skipDefaultGroupBind = *req.SkipDefaultGroupBind + } + skipMixedChannelCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk + + seenIdentity := map[string]int{} + for _, entry := range entries { + item, err := normalizeCodexImportEntry(entry) + if err != nil { + result.Failed++ + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Action: "failed", + Message: err.Error(), + }) + result.Errors = append(result.Errors, CodexSessionImportMessage{ + Index: entry.Index, + Message: err.Error(), + }) + continue + } + accountName := buildCodexCreateAccountName(req.Name, item, entry.Index, len(entries)) + effectiveExpiresAt, credentialExpiresAt, autoPauseOnExpired, expiryWarnings, expiryErr := resolveCodexImportExpiry(req, item) + if expiryErr != nil { + result.Failed++ + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "failed", + Message: expiryErr.Error(), + }) + result.Errors = append(result.Errors, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: expiryErr.Error(), + }) + continue + } + item.WarningTexts = append(item.WarningTexts, expiryWarnings...) + if credentialExpiresAt != nil { + item.Credentials["expires_at"] = credentialExpiresAt.Format(time.RFC3339) + } + credentials := mergeCodexImportMap(item.Credentials, credentialExtras) + extra := mergeCodexImportMap(req.Extra, item.Extra) + for _, warning := range item.WarningTexts { + result.Warnings = append(result.Warnings, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: warning, + }) + } + + if duplicateIndex, ok := firstSeenCodexIdentity(seenIdentity, item.IdentityKeys); ok { + message := fmt.Sprintf("与第 %d 条导入项重复,已跳过", duplicateIndex) + result.Skipped++ + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "skipped", + Message: message, + }) + result.Warnings = append(result.Warnings, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: message, + }) + continue + } + markCodexIdentitySeen(seenIdentity, item.IdentityKeys, entry.Index) + + if existing := index.Find(item.IdentityKeys); existing != nil && updateExisting { + mergedCredentials := mergeCodexImportCredentials(existing.Credentials, credentials, item) + mergedExtra := mergeCodexImportMap(existing.Extra, extra) + updateInput := &service.UpdateAccountInput{ + Credentials: mergedCredentials, + Extra: mergedExtra, + Concurrency: req.Concurrency, + Priority: req.Priority, + RateMultiplier: req.RateMultiplier, + LoadFactor: req.LoadFactor, + ExpiresAt: effectiveExpiresAt, + AutoPauseOnExpired: autoPauseOnExpired, + } + if req.ProxyID != nil { + updateInput.ProxyID = req.ProxyID + } + if len(req.GroupIDs) > 0 { + groupIDs := append([]int64(nil), req.GroupIDs...) + updateInput.GroupIDs = &groupIDs + updateInput.SkipMixedChannelCheck = skipMixedChannelCheck + } + updated, updateErr := h.adminService.UpdateAccount(ctx, existing.ID, updateInput) + if updateErr != nil { + result.Failed++ + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "failed", + Message: updateErr.Error(), + }) + result.Errors = append(result.Errors, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: updateErr.Error(), + }) + continue + } + if h.tokenCacheInvalidator != nil && updated != nil { + _ = h.tokenCacheInvalidator.InvalidateToken(ctx, updated) + } + result.Updated++ + accountID := existing.ID + if updated != nil { + accountID = updated.ID + index.Add(*updated) + } + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "updated", + AccountID: accountID, + }) + continue + } + + account, createErr := h.adminService.CreateAccount(ctx, &service.CreateAccountInput{ + Name: accountName, + Notes: req.Notes, + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Credentials: credentials, + Extra: extra, + ProxyID: req.ProxyID, + Concurrency: concurrency, + Priority: priority, + RateMultiplier: req.RateMultiplier, + LoadFactor: req.LoadFactor, + GroupIDs: req.GroupIDs, + ExpiresAt: effectiveExpiresAt, + AutoPauseOnExpired: autoPauseOnExpired, + SkipDefaultGroupBind: skipDefaultGroupBind, + SkipMixedChannelCheck: skipMixedChannelCheck, + }) + if createErr != nil { + result.Failed++ + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "failed", + Message: createErr.Error(), + }) + result.Errors = append(result.Errors, CodexSessionImportMessage{ + Index: entry.Index, + Name: accountName, + Message: createErr.Error(), + }) + continue + } + if account != nil { + index.Add(*account) + } + result.Created++ + accountID := int64(0) + if account != nil { + accountID = account.ID + } + result.Items = append(result.Items, CodexSessionImportItem{ + Index: entry.Index, + Name: accountName, + Action: "created", + AccountID: accountID, + }) + } + + return result, nil +} + +func parseCodexSessionImportEntries(req CodexSessionImportRequest) ([]codexImportEntry, error) { + contents := make([]string, 0, 1+len(req.Contents)) + if strings.TrimSpace(req.Content) != "" { + contents = append(contents, req.Content) + } + for _, content := range req.Contents { + if strings.TrimSpace(content) != "" { + contents = append(contents, content) + } + } + + var entries []codexImportEntry + for _, content := range contents { + values, err := parseCodexSessionImportContent(content) + if err != nil { + return nil, err + } + for _, value := range values { + entries = append(entries, codexImportEntry{ + Index: len(entries) + 1, + Value: value, + }) + } + } + return entries, nil +} + +func parseCodexSessionImportContent(content string) ([]any, error) { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return nil, nil + } + + if looksLikeJSON(trimmed) { + values, err := decodeCodexJSONStream(trimmed) + if err != nil { + if strings.Contains(trimmed, "\n") { + if lineValues, lineErr := parseCodexSessionImportLines(trimmed); lineErr == nil { + return lineValues, nil + } + } + return nil, fmt.Errorf("JSON 解析失败: %w", err) + } + return flattenCodexImportValues(values), nil + } + + return parseCodexSessionImportLines(trimmed) +} + +func parseCodexSessionImportLines(content string) ([]any, error) { + values := make([]any, 0) + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if looksLikeJSON(line) { + lineValues, err := decodeCodexJSONStream(line) + if err != nil { + return nil, fmt.Errorf("第 %d 行 JSON 解析失败: %w", len(values)+1, err) + } + values = append(values, flattenCodexImportValues(lineValues)...) + continue + } + values = append(values, line) + } + return values, nil +} + +func decodeCodexJSONStream(content string) ([]any, error) { + decoder := json.NewDecoder(strings.NewReader(content)) + decoder.UseNumber() + values := make([]any, 0, 1) + for { + var value any + err := decoder.Decode(&value) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, err + } + values = append(values, value) + } + if len(values) == 0 { + return nil, errors.New("空 JSON 内容") + } + return values, nil +} + +func flattenCodexImportValues(values []any) []any { + out := make([]any, 0, len(values)) + var appendValue func(any) + appendValue = func(value any) { + if arr, ok := value.([]any); ok { + for _, item := range arr { + appendValue(item) + } + return + } + out = append(out, value) + } + for _, value := range values { + appendValue(value) + } + return out +} + +func normalizeCodexImportEntry(entry codexImportEntry) (*codexImportAccount, error) { + now := time.Now().UTC() + item := &codexImportAccount{ + Credentials: map[string]any{}, + Extra: map[string]any{ + "import_source": "codex_session", + "imported_at": now.Format(time.RFC3339), + }, + } + + switch raw := entry.Value.(type) { + case string: + item.AccessToken = strings.TrimSpace(raw) + case map[string]any: + item.AccessToken = firstCodexString(raw, + []string{"tokens", "access_token"}, + []string{"tokens", "accessToken"}, + []string{"access_token"}, + []string{"accessToken"}, + []string{"token"}, + ) + item.RefreshToken = firstCodexString(raw, + []string{"tokens", "refresh_token"}, + []string{"tokens", "refreshToken"}, + []string{"refresh_token"}, + []string{"refreshToken"}, + ) + item.IDToken = firstCodexString(raw, + []string{"tokens", "id_token"}, + []string{"tokens", "idToken"}, + []string{"id_token"}, + []string{"idToken"}, + ) + item.Email = firstCodexString(raw, []string{"email"}, []string{"user", "email"}) + item.AccountID = firstCodexString(raw, + []string{"chatgpt_account_id"}, + []string{"chatgptAccountId"}, + []string{"account_id"}, + []string{"accountId"}, + []string{"account", "id"}, + []string{"account", "account_id"}, + []string{"account", "chatgpt_account_id"}, + ) + item.UserID = firstCodexString(raw, + []string{"chatgpt_user_id"}, + []string{"chatgptUserId"}, + []string{"user_id"}, + []string{"userId"}, + []string{"user", "id"}, + ) + item.PlanType = firstCodexString(raw, + []string{"plan_type"}, + []string{"planType"}, + []string{"account", "plan_type"}, + []string{"account", "planType"}, + ) + item.Organization = firstCodexString(raw, + []string{"organization_id"}, + []string{"organizationId"}, + []string{"org_id"}, + []string{"orgId"}, + ) + item.Name = firstCodexString(raw, []string{"name"}, []string{"user", "name"}) + authProvider := firstCodexString(raw, []string{"auth_provider"}, []string{"authProvider"}) + if authProvider != "" { + item.Extra["auth_provider"] = authProvider + } + if sessionToken := firstCodexString(raw, []string{"session_token"}, []string{"sessionToken"}); sessionToken != "" { + item.Extra["session_token_present"] = true + item.WarningTexts = append(item.WarningTexts, "sessionToken 已忽略,不会作为 OAuth refresh_token 存储") + } + if sessionExpiresAt, ok := codexTimeAt(raw, []string{"expires"}); ok { + item.Extra["session_expires_at"] = sessionExpiresAt.Format(time.RFC3339) + } + if tokenExpiresAt, ok := firstCodexTime(raw, + []string{"tokens", "expires_at"}, + []string{"tokens", "expiresAt"}, + []string{"expires_at"}, + []string{"expiresAt"}, + ); ok { + if tokenExpiresAt.Unix() <= now.Unix()-codexImportClockSkewSeconds { + return nil, fmt.Errorf("access_token 已过期: %s", tokenExpiresAt.Format(time.RFC3339)) + } + item.TokenExpiresAt = &tokenExpiresAt + item.Credentials["expires_at"] = tokenExpiresAt.Format(time.RFC3339) + } + copyCodexExtraString(raw, item.Extra, "user_image", []string{"user", "image"}) + copyCodexExtraString(raw, item.Extra, "user_picture", []string{"user", "picture"}) + copyCodexExtraString(raw, item.Extra, "account_structure", []string{"account", "structure"}) + copyCodexExtraString(raw, item.Extra, "account_residency_region", []string{"account", "residencyRegion"}) + copyCodexExtraString(raw, item.Extra, "compute_residency", []string{"account", "computeResidency"}) + default: + return nil, fmt.Errorf("第 %d 条格式不支持", entry.Index) + } + + if item.AccessToken == "" { + return nil, errors.New("缺少 accessToken/access_token") + } + item.Credentials["access_token"] = item.AccessToken + if item.RefreshToken != "" { + item.Credentials["refresh_token"] = item.RefreshToken + item.Credentials["client_id"] = openai.ClientID + } + if item.IDToken != "" { + item.Credentials["id_token"] = item.IDToken + if err := enrichCodexImportAccountFromJWT(item, item.IDToken, false, now); err != nil { + return nil, err + } + } + if err := enrichCodexImportAccountFromJWT(item, item.AccessToken, true, now); err != nil { + return nil, err + } + if _, ok := item.Credentials["expires_at"]; !ok { + item.WarningTexts = append(item.WarningTexts, "无法从 accessToken 解析过期时间,导入后需自行确认令牌有效性") + } + if item.RefreshToken == "" { + item.WarningTexts = append(item.WarningTexts, "未包含 refresh_token,accessToken 过期后无法自动续期") + } + + setCodexCredentialIfNotEmpty(item.Credentials, "email", item.Email) + setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_account_id", item.AccountID) + setCodexCredentialIfNotEmpty(item.Credentials, "chatgpt_user_id", item.UserID) + setCodexCredentialIfNotEmpty(item.Credentials, "organization_id", item.Organization) + setCodexCredentialIfNotEmpty(item.Credentials, "plan_type", item.PlanType) + + fingerprint := codexTokenFingerprint(item.AccessToken) + item.Extra["access_token_sha256"] = fingerprint + item.IdentityKeys = buildCodexIdentityKeys(item.AccountID, item.UserID, item.Email, item.AccessToken) + item.Name = buildCodexImportAccountName(item, entry.Index) + + return item, nil +} + +func enrichCodexImportAccountFromJWT(item *codexImportAccount, token string, validateExpiry bool, now time.Time) error { + claims, err := decodeCodexJWTClaims(token) + if err != nil { + if validateExpiry { + item.WarningTexts = append(item.WarningTexts, "accessToken 不是可解析 JWT,无法校验过期时间和账号身份") + } + return nil + } + if validateExpiry && claims.Exp > 0 { + if now.Unix() > claims.Exp+codexImportClockSkewSeconds { + return fmt.Errorf("access_token 已过期: %s", time.Unix(claims.Exp, 0).UTC().Format(time.RFC3339)) + } + expiresAt := time.Unix(claims.Exp, 0).UTC() + item.TokenExpiresAt = &expiresAt + item.Credentials["expires_at"] = expiresAt.Format(time.RFC3339) + } + if item.Email == "" { + item.Email = strings.TrimSpace(claims.Email) + } + if claims.OpenAIAuth == nil { + if item.UserID == "" { + item.UserID = strings.TrimSpace(claims.Sub) + } + return nil + } + if item.AccountID == "" { + item.AccountID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTAccountID) + } + if item.UserID == "" { + item.UserID = strings.TrimSpace(claims.OpenAIAuth.ChatGPTUserID) + } + if item.UserID == "" { + item.UserID = strings.TrimSpace(claims.OpenAIAuth.UserID) + } + if item.PlanType == "" { + item.PlanType = strings.TrimSpace(claims.OpenAIAuth.ChatGPTPlanType) + } + if item.Organization == "" { + item.Organization = strings.TrimSpace(claims.OpenAIAuth.POID) + } + if item.Organization == "" { + for _, org := range claims.OpenAIAuth.Organizations { + if org.IsDefault { + item.Organization = org.ID + break + } + } + } + if item.Organization == "" && len(claims.OpenAIAuth.Organizations) > 0 { + item.Organization = claims.OpenAIAuth.Organizations[0].ID + } + if item.UserID == "" { + item.UserID = strings.TrimSpace(claims.Sub) + } + return nil +} + +func decodeCodexJWTClaims(token string) (*codexJWTClaims, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT format") + } + payload, err := decodeCodexJWTSegment(parts[1]) + if err != nil { + return nil, err + } + var claims codexJWTClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, err + } + return &claims, nil +} + +func decodeCodexJWTSegment(segment string) ([]byte, error) { + if decoded, err := base64.RawURLEncoding.DecodeString(segment); err == nil { + return decoded, nil + } + if decoded, err := base64.RawStdEncoding.DecodeString(segment); err == nil { + return decoded, nil + } + padded := segment + if rem := len(padded) % 4; rem > 0 { + padded += strings.Repeat("=", 4-rem) + } + if decoded, err := base64.URLEncoding.DecodeString(padded); err == nil { + return decoded, nil + } + return base64.StdEncoding.DecodeString(padded) +} + +func buildCodexImportAccountName(item *codexImportAccount, index int) string { + for _, candidate := range []string{item.Name, item.Email, item.AccountID, item.UserID} { + candidate = strings.TrimSpace(candidate) + if candidate != "" { + return candidate + } + } + return fmt.Sprintf("Codex 导入账号 %d", index) +} + +func buildCodexCreateAccountName(base string, item *codexImportAccount, index, total int) string { + base = strings.TrimSpace(base) + if base == "" { + if item == nil { + return fmt.Sprintf("Codex 导入账号 %d", index) + } + return item.Name + } + if total > 1 { + return fmt.Sprintf("%s #%d", base, index) + } + return base +} + +func resolveCodexImportExpiry(req CodexSessionImportRequest, item *codexImportAccount) (*int64, *time.Time, *bool, []string, error) { + if item == nil { + return nil, nil, nil, nil, errors.New("导入项为空") + } + + var requestExpiresAt *time.Time + if req.ExpiresAt != nil && *req.ExpiresAt > 0 { + t := time.Unix(*req.ExpiresAt, 0).UTC() + requestExpiresAt = &t + } + + var accountExpiresAt *time.Time + var credentialExpiresAt *time.Time + warnings := make([]string, 0, 2) + if item.RefreshToken == "" { + if item.TokenExpiresAt != nil { + tokenExpiresAt := item.TokenExpiresAt.UTC() + accountExpiresAt = &tokenExpiresAt + credentialExpiresAt = &tokenExpiresAt + } + if requestExpiresAt != nil { + accountExpiresAt = earlierCodexTime(accountExpiresAt, requestExpiresAt) + credentialExpiresAt = earlierCodexTime(credentialExpiresAt, requestExpiresAt) + } + if accountExpiresAt == nil { + return nil, nil, nil, nil, errors.New("未包含 refresh_token,且无法解析 accessToken 过期时间;请在第一步设置过期时间后再导入") + } + if accountExpiresAt.Unix() <= time.Now().UTC().Unix()-codexImportClockSkewSeconds { + return nil, nil, nil, nil, fmt.Errorf("过期时间已过期: %s", accountExpiresAt.Format(time.RFC3339)) + } + warnings = append(warnings, "未包含 refresh_token,已按 accessToken/账号过期时间设置自动停止调度") + if req.AutoPauseOnExpired != nil && !*req.AutoPauseOnExpired { + warnings = append(warnings, "未包含 refresh_token,已强制开启过期自动暂停") + } + autoPause := true + expiresAtUnix := accountExpiresAt.Unix() + return &expiresAtUnix, credentialExpiresAt, &autoPause, warnings, nil + } + + if requestExpiresAt != nil { + accountExpiresAt = requestExpiresAt + } + if item.TokenExpiresAt != nil { + tokenExpiresAt := item.TokenExpiresAt.UTC() + credentialExpiresAt = &tokenExpiresAt + } + var expiresAtUnix *int64 + if accountExpiresAt != nil { + v := accountExpiresAt.Unix() + expiresAtUnix = &v + } + return expiresAtUnix, credentialExpiresAt, req.AutoPauseOnExpired, warnings, nil +} + +func earlierCodexTime(current, candidate *time.Time) *time.Time { + if candidate == nil { + return current + } + if current == nil || candidate.Before(*current) { + t := candidate.UTC() + return &t + } + t := current.UTC() + return &t +} + +func sanitizeCodexImportCredentialExtras(input map[string]any) map[string]any { + if len(input) == 0 { + return nil + } + protected := map[string]struct{}{ + "access_token": {}, + "refresh_token": {}, + "id_token": {}, + "expires_at": {}, + "email": {}, + "chatgpt_account_id": {}, + "chatgpt_user_id": {}, + "organization_id": {}, + "plan_type": {}, + "client_id": {}, + } + out := make(map[string]any, len(input)) + for key, value := range input { + normalizedKey := strings.TrimSpace(key) + if normalizedKey == "" { + continue + } + if _, ok := protected[strings.ToLower(normalizedKey)]; ok { + continue + } + out[normalizedKey] = value + } + if len(out) == 0 { + return nil + } + return out +} + +func buildCodexIdentityKeys(accountID, userID, email, accessToken string) []string { + keys := make([]string, 0, 4) + accountID = strings.TrimSpace(accountID) + userID = strings.TrimSpace(userID) + if accountID != "" { + keys = append(keys, "account:"+accountID) + } + if userID != "" { + keys = append(keys, "user:"+userID) + } + if accountID == "" && userID == "" { + if email = strings.ToLower(strings.TrimSpace(email)); email != "" { + keys = append(keys, "email:"+email) + } + } + if accessToken = strings.TrimSpace(accessToken); accessToken != "" { + keys = append(keys, "access:"+codexTokenFingerprint(accessToken)) + } + return keys +} + +func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex { + index := &codexAccountIndex{accountsByKey: map[string]service.Account{}} + for _, account := range accounts { + index.Add(account) + } + return index +} + +func (i *codexAccountIndex) Add(account service.Account) { + if i == nil { + return + } + if i.accountsByKey == nil { + i.accountsByKey = map[string]service.Account{} + } + keys := buildCodexIdentityKeys( + codexCredentialString(account.Credentials, "chatgpt_account_id"), + codexCredentialString(account.Credentials, "chatgpt_user_id"), + codexCredentialString(account.Credentials, "email"), + codexCredentialString(account.Credentials, "access_token"), + ) + for _, key := range keys { + i.accountsByKey[key] = account + } +} + +func (i *codexAccountIndex) Find(keys []string) *service.Account { + if i == nil { + return nil + } + for _, key := range keys { + if account, ok := i.accountsByKey[key]; ok { + return &account + } + } + return nil +} + +func firstSeenCodexIdentity(seen map[string]int, keys []string) (int, bool) { + for _, key := range keys { + if index, ok := seen[key]; ok { + return index, true + } + } + return 0, false +} + +func markCodexIdentitySeen(seen map[string]int, keys []string, index int) { + for _, key := range keys { + seen[key] = index + } +} + +func mergeCodexImportMap(existing, incoming map[string]any) map[string]any { + out := make(map[string]any, len(existing)+len(incoming)) + for k, v := range existing { + out[k] = v + } + for k, v := range incoming { + out[k] = v + } + return out +} + +func mergeCodexImportCredentials(existing, incoming map[string]any, item *codexImportAccount) map[string]any { + out := mergeCodexImportMap(existing, incoming) + if item == nil { + return out + } + if strings.TrimSpace(item.RefreshToken) == "" { + delete(out, "refresh_token") + delete(out, "client_id") + } + if strings.TrimSpace(item.IDToken) == "" { + delete(out, "id_token") + } + return out +} + +func codexCredentialString(credentials map[string]any, key string) string { + if credentials == nil { + return "" + } + return codexStringValue(credentials[key]) +} + +func codexTokenFingerprint(token string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(token))) + return hex.EncodeToString(sum[:]) +} + +func looksLikeJSON(content string) bool { + if content == "" { + return false + } + switch content[0] { + case '{', '[': + return true + default: + return false + } +} + +func firstCodexString(obj map[string]any, paths ...[]string) string { + for _, path := range paths { + if value, ok := codexPathValue(obj, path); ok { + if str := codexStringValue(value); str != "" { + return str + } + } + } + return "" +} + +func copyCodexExtraString(obj map[string]any, extra map[string]any, key string, path []string) { + value := firstCodexString(obj, path) + if value != "" { + extra[key] = value + } +} + +func firstCodexTime(obj map[string]any, paths ...[]string) (time.Time, bool) { + for _, path := range paths { + if value, ok := codexTimeAt(obj, path); ok { + return value, true + } + } + return time.Time{}, false +} + +func codexTimeAt(obj map[string]any, path []string) (time.Time, bool) { + value, ok := codexPathValue(obj, path) + if !ok { + return time.Time{}, false + } + return parseCodexTimeValue(value) +} + +func codexPathValue(obj map[string]any, path []string) (any, bool) { + var current any = obj + for _, key := range path { + currentObj, ok := current.(map[string]any) + if !ok { + return nil, false + } + value, ok := currentObj[key] + if !ok { + return nil, false + } + current = value + } + return current, true +} + +func codexStringValue(value any) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case json.Number: + return strings.TrimSpace(v.String()) + case float64: + return strings.TrimSpace(strconv.FormatFloat(v, 'f', -1, 64)) + case float32: + return strings.TrimSpace(strconv.FormatFloat(float64(v), 'f', -1, 32)) + case int: + return strconv.Itoa(v) + case int64: + return strconv.FormatInt(v, 10) + case int32: + return strconv.FormatInt(int64(v), 10) + default: + return "" + } +} + +func setCodexCredentialIfNotEmpty(credentials map[string]any, key, value string) { + value = strings.TrimSpace(value) + if value != "" { + credentials[key] = value + } +} + +func parseCodexTimeValue(value any) (time.Time, bool) { + switch v := value.(type) { + case string: + v = strings.TrimSpace(v) + if v == "" { + return time.Time{}, false + } + if parsed, err := time.Parse(time.RFC3339Nano, v); err == nil { + return parsed.UTC(), true + } + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return codexUnixTime(n), true + } + case json.Number: + if n, err := v.Int64(); err == nil { + return codexUnixTime(n), true + } + if f, err := v.Float64(); err == nil { + return codexUnixTime(int64(f)), true + } + case float64: + return codexUnixTime(int64(v)), true + case int: + return codexUnixTime(int64(v)), true + case int64: + return codexUnixTime(v), true + } + return time.Time{}, false +} + +func codexUnixTime(value int64) time.Time { + if value > 1_000_000_000_000 { + return time.UnixMilli(value).UTC() + } + return time.Unix(value, 0).UTC() +} diff --git a/backend/internal/handler/admin/account_codex_import_test.go b/backend/internal/handler/admin/account_codex_import_test.go new file mode 100644 index 00000000000..3cf0d2bbcb7 --- /dev/null +++ b/backend/internal/handler/admin/account_codex_import_test.go @@ -0,0 +1,344 @@ +package admin + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "testing" + "time" +) + +func TestParseCodexSessionImportEntriesSupportsRawTokenJSONAndArray(t *testing.T) { + token1 := "raw-access-token-1" + token2 := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "email": "json@example.com", + }) + token3 := "raw-access-token-3" + + req := CodexSessionImportRequest{ + Content: fmt.Sprintf("%s\n{\"accessToken\":%q}\n[%q]", token1, token2, token3), + } + + entries, err := parseCodexSessionImportEntries(req) + if err != nil { + t.Fatalf("parseCodexSessionImportEntries error = %v", err) + } + if len(entries) != 3 { + t.Fatalf("len(entries) = %d, want 3", len(entries)) + } + + first, err := normalizeCodexImportEntry(entries[0]) + if err != nil { + t.Fatalf("normalize raw token error = %v", err) + } + if first.Credentials["access_token"] != token1 { + t.Fatalf("raw token access_token = %v, want %s", first.Credentials["access_token"], token1) + } + + second, err := normalizeCodexImportEntry(entries[1]) + if err != nil { + t.Fatalf("normalize json token error = %v", err) + } + if second.Email != "json@example.com" { + t.Fatalf("email = %q, want json@example.com", second.Email) + } + + third, err := normalizeCodexImportEntry(entries[2]) + if err != nil { + t.Fatalf("normalize array token error = %v", err) + } + if third.Credentials["access_token"] != token3 { + t.Fatalf("array token access_token = %v, want %s", third.Credentials["access_token"], token3) + } +} + +func TestParseCodexSessionImportEntriesFallsBackToLineModeForMixedJSONAndToken(t *testing.T) { + req := CodexSessionImportRequest{ + Content: "{\"accessToken\":\"json-line-token\"}\nraw-line-token", + } + + entries, err := parseCodexSessionImportEntries(req) + if err != nil { + t.Fatalf("parseCodexSessionImportEntries error = %v", err) + } + if len(entries) != 2 { + t.Fatalf("len(entries) = %d, want 2", len(entries)) + } + + first, err := normalizeCodexImportEntry(entries[0]) + if err != nil { + t.Fatalf("normalize json line error = %v", err) + } + if first.Credentials["access_token"] != "json-line-token" { + t.Fatalf("json line access_token = %v, want json-line-token", first.Credentials["access_token"]) + } + + second, err := normalizeCodexImportEntry(entries[1]) + if err != nil { + t.Fatalf("normalize raw line error = %v", err) + } + if second.Credentials["access_token"] != "raw-line-token" { + t.Fatalf("raw line access_token = %v, want raw-line-token", second.Credentials["access_token"]) + } +} + +func TestNormalizeCodexSessionJSONExtractsCredentialsAndIgnoresSessionToken(t *testing.T) { + accessToken := buildCodexImportTestJWT(t, time.Now().Add(time.Hour), map[string]any{ + "email": "claim@example.com", + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct-from-claim", + "chatgpt_user_id": "user-from-claim", + "chatgpt_plan_type": "plus", + "poid": "org-from-claim", + }, + }) + raw := map[string]any{ + "user": map[string]any{ + "id": "user-from-json", + "name": "Sup OO", + "email": "json@example.com", + "image": "https://example.com/avatar.png", + }, + "account": map[string]any{ + "id": "acct-from-json", + "planType": "free", + }, + "accessToken": accessToken, + "sessionToken": "secret-session-token", + "expires": "2026-08-05T13:40:42.836Z", + } + + item, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: raw}) + if err != nil { + t.Fatalf("normalizeCodexImportEntry error = %v", err) + } + if item.Credentials["access_token"] != accessToken { + t.Fatalf("access_token not stored") + } + if item.Credentials["email"] != "json@example.com" { + t.Fatalf("email = %v, want json@example.com", item.Credentials["email"]) + } + if item.Credentials["chatgpt_account_id"] != "acct-from-json" { + t.Fatalf("chatgpt_account_id = %v, want acct-from-json", item.Credentials["chatgpt_account_id"]) + } + if item.Credentials["chatgpt_user_id"] != "user-from-json" { + t.Fatalf("chatgpt_user_id = %v, want user-from-json", item.Credentials["chatgpt_user_id"]) + } + if item.Credentials["plan_type"] != "free" { + t.Fatalf("plan_type = %v, want free", item.Credentials["plan_type"]) + } + if _, ok := item.Credentials["session_token"]; ok { + t.Fatalf("session_token should not be written to credentials") + } + if item.Extra["session_token_present"] != true { + t.Fatalf("session_token_present = %v, want true", item.Extra["session_token_present"]) + } + if item.Extra["session_expires_at"] != "2026-08-05T13:40:42Z" { + t.Fatalf("session_expires_at = %v", item.Extra["session_expires_at"]) + } + if item.TokenExpiresAt == nil { + t.Fatalf("TokenExpiresAt should be parsed from accessToken") + } +} + +func TestMergeCodexImportCredentialsClearsStaleRefreshFieldsWhenIncomingHasNoRefreshToken(t *testing.T) { + existing := map[string]any{ + "access_token": "old-access-token", + "refresh_token": "old-refresh-token", + "client_id": "old-client-id", + "id_token": "old-id-token", + "model_mapping": map[string]any{"from": "existing"}, + "chatgpt_account_id": "acct-old", + "unrelated_existing": "keep", + } + incoming := map[string]any{ + "access_token": "new-access-token", + "expires_at": "2026-08-05T13:40:42Z", + "chatgpt_account_id": "acct-new", + } + item := &codexImportAccount{ + AccessToken: "new-access-token", + } + + merged := mergeCodexImportCredentials(existing, incoming, item) + + if merged["access_token"] != "new-access-token" { + t.Fatalf("access_token = %v, want new-access-token", merged["access_token"]) + } + if merged["chatgpt_account_id"] != "acct-new" { + t.Fatalf("chatgpt_account_id = %v, want acct-new", merged["chatgpt_account_id"]) + } + if _, ok := merged["refresh_token"]; ok { + t.Fatalf("refresh_token should be cleared") + } + if _, ok := merged["client_id"]; ok { + t.Fatalf("client_id should be cleared") + } + if _, ok := merged["id_token"]; ok { + t.Fatalf("id_token should be cleared") + } + if merged["unrelated_existing"] != "keep" { + t.Fatalf("unrelated_existing = %v, want keep", merged["unrelated_existing"]) + } + if _, ok := merged["model_mapping"]; !ok { + t.Fatalf("model_mapping should be preserved") + } +} + +func TestMergeCodexImportCredentialsKeepsRefreshFieldsWhenIncomingHasRefreshToken(t *testing.T) { + existing := map[string]any{ + "refresh_token": "old-refresh-token", + "client_id": "old-client-id", + "id_token": "old-id-token", + } + incoming := map[string]any{ + "access_token": "new-access-token", + "refresh_token": "new-refresh-token", + "client_id": "new-client-id", + "id_token": "new-id-token", + } + item := &codexImportAccount{ + AccessToken: "new-access-token", + RefreshToken: "new-refresh-token", + IDToken: "new-id-token", + } + + merged := mergeCodexImportCredentials(existing, incoming, item) + + if merged["refresh_token"] != "new-refresh-token" { + t.Fatalf("refresh_token = %v, want new-refresh-token", merged["refresh_token"]) + } + if merged["client_id"] != "new-client-id" { + t.Fatalf("client_id = %v, want new-client-id", merged["client_id"]) + } + if merged["id_token"] != "new-id-token" { + t.Fatalf("id_token = %v, want new-id-token", merged["id_token"]) + } +} + +func TestNormalizeCodexImportRejectsExpiredAccessToken(t *testing.T) { + expiredToken := buildCodexImportTestJWT(t, time.Now().Add(-time.Hour), map[string]any{}) + + _, err := normalizeCodexImportEntry(codexImportEntry{Index: 1, Value: expiredToken}) + if err == nil { + t.Fatal("normalizeCodexImportEntry error = nil, want expired token error") + } + if !strings.Contains(err.Error(), "已过期") { + t.Fatalf("error = %v, want expired token message", err) + } +} + +func TestResolveCodexImportExpiryForNoRefreshTokenUsesTokenExpiry(t *testing.T) { + tokenExpiresAt := time.Now().Add(time.Hour).UTC() + item := &codexImportAccount{ + AccessToken: "access-token", + Credentials: map[string]any{"access_token": "access-token"}, + TokenExpiresAt: &tokenExpiresAt, + WarningTexts: []string{}, + } + disabled := false + req := CodexSessionImportRequest{AutoPauseOnExpired: &disabled} + + accountExpiresAt, credentialExpiresAt, autoPause, warnings, err := resolveCodexImportExpiry(req, item) + if err != nil { + t.Fatalf("resolveCodexImportExpiry error = %v", err) + } + if accountExpiresAt == nil || *accountExpiresAt != tokenExpiresAt.Unix() { + t.Fatalf("account expires_at = %v, want %d", accountExpiresAt, tokenExpiresAt.Unix()) + } + if credentialExpiresAt == nil || credentialExpiresAt.Unix() != tokenExpiresAt.Unix() { + t.Fatalf("credential expires_at = %v, want %s", credentialExpiresAt, tokenExpiresAt) + } + if autoPause == nil || !*autoPause { + t.Fatalf("autoPause = %v, want true", autoPause) + } + if len(warnings) == 0 { + t.Fatalf("warnings should not be empty") + } +} + +func TestResolveCodexImportExpiryForNoRefreshTokenRequiresExpiry(t *testing.T) { + item := &codexImportAccount{ + AccessToken: "opaque-access-token", + Credentials: map[string]any{"access_token": "opaque-access-token"}, + WarningTexts: []string{}, + } + + _, _, _, _, err := resolveCodexImportExpiry(CodexSessionImportRequest{}, item) + if err == nil { + t.Fatal("resolveCodexImportExpiry error = nil, want missing expiry error") + } + if !strings.Contains(err.Error(), "无法解析 accessToken 过期时间") { + t.Fatalf("error = %v, want missing expiry message", err) + } +} + +func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *testing.T) { + tokenExpiresAt := time.Now().Add(2 * time.Hour).UTC() + requestExpiresAt := time.Now().Add(time.Hour).UTC() + item := &codexImportAccount{ + AccessToken: "access-token", + Credentials: map[string]any{"access_token": "access-token"}, + TokenExpiresAt: &tokenExpiresAt, + WarningTexts: []string{}, + } + reqUnix := requestExpiresAt.Unix() + req := CodexSessionImportRequest{ExpiresAt: &reqUnix} + + accountExpiresAt, credentialExpiresAt, _, _, err := resolveCodexImportExpiry(req, item) + if err != nil { + t.Fatalf("resolveCodexImportExpiry error = %v", err) + } + if accountExpiresAt == nil || *accountExpiresAt != requestExpiresAt.Unix() { + t.Fatalf("account expires_at = %v, want %d", accountExpiresAt, requestExpiresAt.Unix()) + } + if credentialExpiresAt == nil || credentialExpiresAt.Unix() != requestExpiresAt.Unix() { + t.Fatalf("credential expires_at = %v, want %s", credentialExpiresAt, requestExpiresAt) + } +} + +func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { + keys := buildCodexIdentityKeys("acct-1", "user-1", "same@example.com", "token") + for _, key := range keys { + if strings.HasPrefix(key, "email:") { + t.Fatalf("strong identity should not include email fallback: %v", keys) + } + } + + keys = buildCodexIdentityKeys("", "", "same@example.com", "token") + hasEmail := false + for _, key := range keys { + if key == "email:same@example.com" { + hasEmail = true + } + } + if !hasEmail { + t.Fatalf("weak identity should include email fallback: %v", keys) + } +} + +func buildCodexImportTestJWT(t *testing.T, exp time.Time, extraClaims map[string]any) string { + t.Helper() + header := map[string]any{ + "alg": "none", + "typ": "JWT", + } + claims := map[string]any{ + "sub": "user-from-sub", + "exp": exp.Unix(), + "iat": time.Now().Unix(), + } + for k, v := range extraClaims { + claims[k] = v + } + headerBytes, err := json.Marshal(header) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + claimBytes, err := json.Marshal(claims) + if err != nil { + t.Fatalf("marshal claims: %v", err) + } + return base64.RawURLEncoding.EncodeToString(headerBytes) + "." + base64.RawURLEncoding.EncodeToString(claimBytes) + "." +} diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index b187b47f63b..2fef94f1561 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -175,6 +175,10 @@ func (s *stubAdminService) UpdateUserBalance(ctx context.Context, userID int64, return &user, nil } +func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) { + return len(userIDs), nil +} + func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) { return s.apiKeys, int64(len(s.apiKeys)), nil } diff --git a/backend/internal/handler/admin/content_moderation_handler.go b/backend/internal/handler/admin/content_moderation_handler.go new file mode 100644 index 00000000000..4266f5d8067 --- /dev/null +++ b/backend/internal/handler/admin/content_moderation_handler.go @@ -0,0 +1,238 @@ +package admin + +import ( + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +type ContentModerationHandler struct { + service *service.ContentModerationService +} + +func NewContentModerationHandler(svc *service.ContentModerationService) *ContentModerationHandler { + return &ContentModerationHandler{service: svc} +} + +type contentModerationConfigRequest struct { + Enabled *bool `json:"enabled"` + Mode *string `json:"mode"` + BaseURL *string `json:"base_url"` + Model *string `json:"model"` + APIKey *string `json:"api_key"` + APIKeys *[]string `json:"api_keys"` + APIKeysMode string `json:"api_keys_mode"` + DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"` + ClearAPIKey bool `json:"clear_api_key"` + TimeoutMS *int `json:"timeout_ms"` + SampleRate *int `json:"sample_rate"` + AllGroups *bool `json:"all_groups"` + GroupIDs *[]int64 `json:"group_ids"` + RecordNonHits *bool `json:"record_non_hits"` + WorkerCount *int `json:"worker_count"` + QueueSize *int `json:"queue_size"` + BlockStatus *int `json:"block_status"` + BlockMessage *string `json:"block_message"` + EmailOnHit *bool `json:"email_on_hit"` + AutoBanEnabled *bool `json:"auto_ban_enabled"` + BanThreshold *int `json:"ban_threshold"` + ViolationWindowHours *int `json:"violation_window_hours"` + RetryCount *int `json:"retry_count"` + HitRetentionDays *int `json:"hit_retention_days"` + NonHitRetentionDays *int `json:"non_hit_retention_days"` + PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"` +} + +type contentModerationAPIKeyTestRequest struct { + APIKeys []string `json:"api_keys"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + TimeoutMS int `json:"timeout_ms"` + Prompt string `json:"prompt"` + Images []string `json:"images"` +} + +type contentModerationHashRequest struct { + InputHash string `json:"input_hash"` +} + +func (h *ContentModerationHandler) GetConfig(c *gin.Context) { + cfg, err := h.service.GetConfig(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, cfg) +} + +func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) { + var req contentModerationConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + cfg, err := h.service.UpdateConfig(c.Request.Context(), service.UpdateContentModerationConfigInput{ + Enabled: req.Enabled, + Mode: req.Mode, + BaseURL: req.BaseURL, + Model: req.Model, + APIKey: req.APIKey, + APIKeys: req.APIKeys, + APIKeysMode: req.APIKeysMode, + DeleteAPIKeyHashes: req.DeleteAPIKeyHashes, + ClearAPIKey: req.ClearAPIKey, + TimeoutMS: req.TimeoutMS, + SampleRate: req.SampleRate, + AllGroups: req.AllGroups, + GroupIDs: req.GroupIDs, + RecordNonHits: req.RecordNonHits, + WorkerCount: req.WorkerCount, + QueueSize: req.QueueSize, + BlockStatus: req.BlockStatus, + BlockMessage: req.BlockMessage, + EmailOnHit: req.EmailOnHit, + AutoBanEnabled: req.AutoBanEnabled, + BanThreshold: req.BanThreshold, + ViolationWindowHours: req.ViolationWindowHours, + RetryCount: req.RetryCount, + HitRetentionDays: req.HitRetentionDays, + NonHitRetentionDays: req.NonHitRetentionDays, + PreHashCheckEnabled: req.PreHashCheckEnabled, + }) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, cfg) +} + +func (h *ContentModerationHandler) TestAPIKeys(c *gin.Context) { + var req contentModerationAPIKeyTestRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + result, err := h.service.TestAPIKeys(c.Request.Context(), service.TestContentModerationAPIKeysInput{ + APIKeys: req.APIKeys, + BaseURL: req.BaseURL, + Model: req.Model, + TimeoutMS: req.TimeoutMS, + Prompt: req.Prompt, + Images: req.Images, + }) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *ContentModerationHandler) GetStatus(c *gin.Context) { + status, err := h.service.GetStatus(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, status) +} + +func (h *ContentModerationHandler) ListLogs(c *gin.Context) { + page, pageSize := response.ParsePagination(c) + filter := service.ContentModerationLogFilter{ + Pagination: pagination.PaginationParams{ + Page: page, + PageSize: pageSize, + SortOrder: pagination.SortOrderDesc, + }, + Result: c.Query("result"), + Endpoint: c.Query("endpoint"), + Search: c.Query("search"), + } + if raw := strings.TrimSpace(c.Query("group_id")); raw != "" { + groupID, err := strconv.ParseInt(raw, 10, 64) + if err != nil || groupID <= 0 { + response.BadRequest(c, "Invalid group_id") + return + } + filter.GroupID = &groupID + } + if raw := strings.TrimSpace(c.Query("from")); raw != "" { + t, _, err := parseContentModerationDate(raw) + if err != nil { + response.BadRequest(c, "Invalid from") + return + } + filter.From = &t + } + if raw := strings.TrimSpace(c.Query("to")); raw != "" { + t, dateOnly, err := parseContentModerationDate(raw) + if err != nil { + response.BadRequest(c, "Invalid to") + return + } + if dateOnly { + t = t.Add(24*time.Hour - time.Nanosecond) + } + filter.To = &t + } + items, pageResult, err := h.service.ListLogs(c.Request.Context(), filter) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Paginated(c, items, pageResult.Total, pageResult.Page, pageResult.PageSize) +} + +func (h *ContentModerationHandler) UnbanUser(c *gin.Context) { + userID, err := strconv.ParseInt(strings.TrimSpace(c.Param("user_id")), 10, 64) + if err != nil || userID <= 0 { + response.BadRequest(c, "Invalid user_id") + return + } + result, err := h.service.UnbanUser(c.Request.Context(), userID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *ContentModerationHandler) DeleteFlaggedHash(c *gin.Context) { + var req contentModerationHashRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + result, err := h.service.DeleteFlaggedInputHash(c.Request.Context(), req.InputHash) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func (h *ContentModerationHandler) ClearFlaggedHashes(c *gin.Context) { + result, err := h.service.ClearFlaggedInputHashes(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, result) +} + +func parseContentModerationDate(raw string) (time.Time, bool, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, false, nil + } + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return t, false, nil + } + t, err := time.Parse("2006-01-02", raw) + return t, err == nil, err +} diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 65e5ec7802b..3667bbcd1d7 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -92,6 +92,9 @@ type CreateGroupRequest struct { WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) + AllowImageGeneration bool `json:"allow_image_generation"` + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier *float64 `json:"image_rate_multiplier"` ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` @@ -129,6 +132,9 @@ type UpdateGroupRequest struct { WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"` MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"` // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置) + AllowImageGeneration *bool `json:"allow_image_generation"` + ImageRateIndependent *bool `json:"image_rate_independent"` + ImageRateMultiplier *float64 `json:"image_rate_multiplier"` ImagePrice1K *float64 `json:"image_price_1k"` ImagePrice2K *float64 `json:"image_price_2k"` ImagePrice4K *float64 `json:"image_price_4k"` @@ -251,6 +257,9 @@ func (h *GroupHandler) Create(c *gin.Context) { DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(), MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(), + AllowImageGeneration: req.AllowImageGeneration, + ImageRateIndependent: req.ImageRateIndependent, + ImageRateMultiplier: req.ImageRateMultiplier, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, @@ -303,6 +312,9 @@ func (h *GroupHandler) Update(c *gin.Context) { DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(), MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(), + AllowImageGeneration: req.AllowImageGeneration, + ImageRateIndependent: req.ImageRateIndependent, + ImageRateMultiplier: req.ImageRateMultiplier, ImagePrice1K: req.ImagePrice1K, ImagePrice2K: req.ImagePrice2K, ImagePrice4K: req.ImagePrice4K, diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 59f4fe85d36..02c7f468e78 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -117,6 +117,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), SMTPHost: settings.SMTPHost, SMTPPort: settings.SMTPPort, SMTPUsername: settings.SMTPUsername, @@ -169,6 +173,16 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { OIDCConnectUserInfoEmailPath: settings.OIDCConnectUserInfoEmailPath, OIDCConnectUserInfoIDPath: settings.OIDCConnectUserInfoIDPath, OIDCConnectUserInfoUsernamePath: settings.OIDCConnectUserInfoUsernamePath, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GitHubOAuthClientID: settings.GitHubOAuthClientID, + GitHubOAuthClientSecretConfigured: settings.GitHubOAuthClientSecretConfigured, + GitHubOAuthRedirectURL: settings.GitHubOAuthRedirectURL, + GitHubOAuthFrontendRedirectURL: settings.GitHubOAuthFrontendRedirectURL, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, + GoogleOAuthClientID: settings.GoogleOAuthClientID, + GoogleOAuthClientSecretConfigured: settings.GoogleOAuthClientSecretConfigured, + GoogleOAuthRedirectURL: settings.GoogleOAuthRedirectURL, + GoogleOAuthFrontendRedirectURL: settings.GoogleOAuthFrontendRedirectURL, SiteName: settings.SiteName, SiteLogo: settings.SiteLogo, SiteSubtitle: settings.SiteSubtitle, @@ -185,6 +199,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints), DefaultConcurrency: settings.DefaultConcurrency, DefaultBalance: settings.DefaultBalance, + RiskControlEnabled: settings.RiskControlEnabled, AffiliateRebateRate: settings.AffiliateRebateRate, AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours, AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays, @@ -247,6 +262,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { AvailableChannelsEnabled: settings.AvailableChannelsEnabled, + ImageGenerationEnabled: settings.ImageGenerationEnabled, + + ChatCompletionEnabled: settings.ChatCompletionEnabled, + AffiliateEnabled: settings.AffiliateEnabled, } @@ -294,17 +313,50 @@ func openaiFastPolicySettingsFromDTO(s *dto.OpenAIFastPolicySettings) *service.O return &service.OpenAIFastPolicySettings{Rules: rules} } +func loginAgreementDocumentsToDTO(items []service.LoginAgreementDocument) []dto.LoginAgreementDocument { + result := make([]dto.LoginAgreementDocument, 0, len(items)) + for _, item := range items { + result = append(result, dto.LoginAgreementDocument{ + ID: item.ID, + Title: item.Title, + ContentMD: item.ContentMD, + }) + } + return result +} + +func loginAgreementDocumentsToService(items []dto.LoginAgreementDocument) []service.LoginAgreementDocument { + result := make([]service.LoginAgreementDocument, 0, len(items)) + for _, item := range items { + title := strings.TrimSpace(item.Title) + content := strings.TrimSpace(item.ContentMD) + if title == "" && content == "" { + continue + } + result = append(result, service.LoginAgreementDocument{ + ID: strings.TrimSpace(item.ID), + Title: title, + ContentMD: content, + }) + } + return result +} + // UpdateSettingsRequest 更新设置请求 type UpdateSettingsRequest struct { // 注册设置 - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - FrontendURL string `json:"frontend_url"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + FrontendURL string `json:"frontend_url"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"` // 邮件服务设置 SMTPHost string `json:"smtp_host"` @@ -368,6 +420,17 @@ type UpdateSettingsRequest struct { OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"` OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GitHubOAuthClientID string `json:"github_oauth_client_id"` + GitHubOAuthClientSecret string `json:"github_oauth_client_secret"` + GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"` + GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + GoogleOAuthClientID string `json:"google_oauth_client_id"` + GoogleOAuthClientSecret string `json:"google_oauth_client_secret"` + GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"` + GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"` + // OEM设置 SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` @@ -413,6 +476,16 @@ type UpdateSettingsRequest struct { AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"` AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"` AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"` + AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"` + AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"` + AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"` + AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"` + AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"` + AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"` + AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"` + AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"` + AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"` + AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"` ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"` // Model fallback configuration @@ -494,9 +567,18 @@ type UpdateSettingsRequest struct { // Available Channels feature switch (user-facing) AvailableChannelsEnabled *bool `json:"available_channels_enabled"` + // Image Generation feature switch (user-facing) + ImageGenerationEnabled *bool `json:"image_generation_enabled"` + + // Chat Completion feature switch (user-facing) + ChatCompletionEnabled *bool `json:"chat_completion_enabled"` + // Affiliate (邀请返利) feature switch AffiliateEnabled *bool `json:"affiliate_enabled"` + // 风控中心功能开关 + RiskControlEnabled *bool `json:"risk_control_enabled"` + // OpenAI fast/flex policy (optional, only updated when provided) OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"` } @@ -633,6 +715,44 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { return } } + loginAgreementMode := strings.ToLower(strings.TrimSpace(req.LoginAgreementMode)) + if loginAgreementMode == "" { + loginAgreementMode = strings.ToLower(strings.TrimSpace(previousSettings.LoginAgreementMode)) + } + switch loginAgreementMode { + case "", "modal": + loginAgreementMode = "modal" + case "checkbox": + default: + response.BadRequest(c, "Login agreement mode must be modal or checkbox") + return + } + loginAgreementUpdatedAt := strings.TrimSpace(req.LoginAgreementUpdatedAt) + if loginAgreementUpdatedAt == "" { + loginAgreementUpdatedAt = strings.TrimSpace(previousSettings.LoginAgreementUpdatedAt) + } + loginAgreementDocuments := loginAgreementDocumentsToService(req.LoginAgreementDocuments) + if len(loginAgreementDocuments) == 0 { + loginAgreementDocuments = previousSettings.LoginAgreementDocuments + } + for _, doc := range loginAgreementDocuments { + if strings.TrimSpace(doc.Title) == "" { + response.BadRequest(c, "Login agreement document title is required") + return + } + if len(doc.Title) > 80 { + response.BadRequest(c, "Login agreement document title is too long (max 80 characters)") + return + } + if len(doc.ContentMD) > 200*1024 { + response.BadRequest(c, "Login agreement document content is too large (max 200KB)") + return + } + } + if req.LoginAgreementEnabled && len(loginAgreementDocuments) == 0 { + response.BadRequest(c, "Login agreement documents are required when enabled") + return + } // LinuxDo Connect 参数验证 if req.LinuxDoConnectEnabled { @@ -994,17 +1114,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { response.BadRequest(c, "Custom menu item label is too long (max 50 characters)") return } - if strings.TrimSpace(item.URL) == "" { - response.BadRequest(c, "Custom menu item URL is required") - return - } - if len(item.URL) > maxMenuItemURLLen { - response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)") - return - } - if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil { - response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL") - return + urlTrimmed := strings.TrimSpace(item.URL) + if strings.HasPrefix(urlTrimmed, "md:") { + // Markdown page mode: URL = "md:" + slug := strings.TrimPrefix(urlTrimmed, "md:") + if slug == "" { + response.BadRequest(c, "Custom menu item markdown slug cannot be empty (use md:slug format)") + return + } + } else { + if urlTrimmed == "" { + response.BadRequest(c, "Custom menu item URL is required (use md:slug for markdown pages)") + return + } + if len(item.URL) > maxMenuItemURLLen { + response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)") + return + } + if err := config.ValidateAbsoluteHTTPURL(urlTrimmed); err != nil { + response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL or md:") + return + } } if item.Visibility != "user" && item.Visibility != "admin" { response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'") @@ -1148,6 +1278,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { FrontendURL: req.FrontendURL, InvitationCodeEnabled: req.InvitationCodeEnabled, TotpEnabled: req.TotpEnabled, + LoginAgreementEnabled: req.LoginAgreementEnabled, + LoginAgreementMode: loginAgreementMode, + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort, SMTPUsername: req.SMTPUsername, @@ -1200,6 +1334,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath, OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath, OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath, + GitHubOAuthEnabled: req.GitHubOAuthEnabled, + GitHubOAuthClientID: req.GitHubOAuthClientID, + GitHubOAuthClientSecret: req.GitHubOAuthClientSecret, + GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL, + GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL, + GoogleOAuthEnabled: req.GoogleOAuthEnabled, + GoogleOAuthClientID: req.GoogleOAuthClientID, + GoogleOAuthClientSecret: req.GoogleOAuthClientSecret, + GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL, + GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL, SiteName: req.SiteName, SiteLogo: req.SiteLogo, SiteSubtitle: req.SiteSubtitle, @@ -1359,12 +1503,30 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.AvailableChannelsEnabled }(), + ImageGenerationEnabled: func() bool { + if req.ImageGenerationEnabled != nil { + return *req.ImageGenerationEnabled + } + return previousSettings.ImageGenerationEnabled + }(), + ChatCompletionEnabled: func() bool { + if req.ChatCompletionEnabled != nil { + return *req.ChatCompletionEnabled + } + return previousSettings.ChatCompletionEnabled + }(), AffiliateEnabled: func() bool { if req.AffiliateEnabled != nil { return *req.AffiliateEnabled } return previousSettings.AffiliateEnabled }(), + RiskControlEnabled: func() bool { + if req.RiskControlEnabled != nil { + return *req.RiskControlEnabled + } + return previousSettings.RiskControlEnabled + }(), } authSourceDefaults := &service.AuthSourceDefaultSettings{ @@ -1396,6 +1558,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnSignup, previousAuthSourceDefaults.WeChat.GrantOnSignup), GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnFirstBind, previousAuthSourceDefaults.WeChat.GrantOnFirstBind), }, + GitHub: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultGitHubBalance, previousAuthSourceDefaults.GitHub.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultGitHubConcurrency, previousAuthSourceDefaults.GitHub.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGitHubSubscriptions, previousAuthSourceDefaults.GitHub.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnSignup, previousAuthSourceDefaults.GitHub.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnFirstBind, previousAuthSourceDefaults.GitHub.GrantOnFirstBind), + }, + Google: service.ProviderDefaultGrantSettings{ + Balance: float64ValueOrDefault(req.AuthSourceDefaultGoogleBalance, previousAuthSourceDefaults.Google.Balance), + Concurrency: intValueOrDefault(req.AuthSourceDefaultGoogleConcurrency, previousAuthSourceDefaults.Google.Concurrency), + Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGoogleSubscriptions, previousAuthSourceDefaults.Google.Subscriptions), + GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnSignup, previousAuthSourceDefaults.Google.GrantOnSignup), + GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnFirstBind, previousAuthSourceDefaults.Google.GrantOnFirstBind), + }, ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup), } if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil { @@ -1486,6 +1662,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, TotpEnabled: updatedSettings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), + LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled, + LoginAgreementMode: updatedSettings.LoginAgreementMode, + LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments), SMTPHost: updatedSettings.SMTPHost, SMTPPort: updatedSettings.SMTPPort, SMTPUsername: updatedSettings.SMTPUsername, @@ -1538,6 +1718,16 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath, OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath, OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath, + GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled, + GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID, + GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured, + GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL, + GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL, + GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled, + GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID, + GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured, + GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL, + GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL, SiteName: updatedSettings.SiteName, SiteLogo: updatedSettings.SiteLogo, SiteSubtitle: updatedSettings.SiteSubtitle, @@ -1615,7 +1805,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled, + ImageGenerationEnabled: updatedSettings.ImageGenerationEnabled, + + ChatCompletionEnabled: updatedSettings.ChatCompletionEnabled, + AffiliateEnabled: updatedSettings.AffiliateEnabled, + + RiskControlEnabled: updatedSettings.RiskControlEnabled, } if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil { slog.Error("openai_fast_policy_settings_get_failed", "error", err) @@ -1685,6 +1881,18 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.TotpEnabled != after.TotpEnabled { changed = append(changed, "totp_enabled") } + if before.LoginAgreementEnabled != after.LoginAgreementEnabled { + changed = append(changed, "login_agreement_enabled") + } + if before.LoginAgreementMode != after.LoginAgreementMode { + changed = append(changed, "login_agreement_mode") + } + if before.LoginAgreementUpdatedAt != after.LoginAgreementUpdatedAt { + changed = append(changed, "login_agreement_updated_at") + } + if !equalLoginAgreementDocuments(before.LoginAgreementDocuments, after.LoginAgreementDocuments) { + changed = append(changed, "login_agreement_documents") + } if before.SMTPHost != after.SMTPHost { changed = append(changed, "smtp_host") } @@ -2001,9 +2209,18 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled { changed = append(changed, "available_channels_enabled") } + if before.ImageGenerationEnabled != after.ImageGenerationEnabled { + changed = append(changed, "image_generation_enabled") + } + if before.ChatCompletionEnabled != after.ChatCompletionEnabled { + changed = append(changed, "chat_completion_enabled") + } if before.AffiliateEnabled != after.AffiliateEnabled { changed = append(changed, "affiliate_enabled") } + if before.RiskControlEnabled != after.RiskControlEnabled { + changed = append(changed, "risk_control_enabled") + } changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults) return changed } @@ -2027,6 +2244,8 @@ func appendAuthSourceDefaultChanges(changed []string, before *service.AuthSource {name: "linuxdo", before: before.LinuxDo, after: after.LinuxDo}, {name: "oidc", before: before.OIDC, after: after.OIDC}, {name: "wechat", before: before.WeChat, after: after.WeChat}, + {name: "github", before: before.GitHub, after: after.GitHub}, + {name: "google", before: before.Google, after: after.Google}, } for _, field := range fields { if field.before.Balance != field.after.Balance { @@ -2141,6 +2360,16 @@ func systemSettingsResponseData(settings dto.SystemSettings, authSourceDefaults data["auth_source_default_wechat_subscriptions"] = authSourceDefaults.WeChat.Subscriptions data["auth_source_default_wechat_grant_on_signup"] = authSourceDefaults.WeChat.GrantOnSignup data["auth_source_default_wechat_grant_on_first_bind"] = authSourceDefaults.WeChat.GrantOnFirstBind + data["auth_source_default_github_balance"] = authSourceDefaults.GitHub.Balance + data["auth_source_default_github_concurrency"] = authSourceDefaults.GitHub.Concurrency + data["auth_source_default_github_subscriptions"] = authSourceDefaults.GitHub.Subscriptions + data["auth_source_default_github_grant_on_signup"] = authSourceDefaults.GitHub.GrantOnSignup + data["auth_source_default_github_grant_on_first_bind"] = authSourceDefaults.GitHub.GrantOnFirstBind + data["auth_source_default_google_balance"] = authSourceDefaults.Google.Balance + data["auth_source_default_google_concurrency"] = authSourceDefaults.Google.Concurrency + data["auth_source_default_google_subscriptions"] = authSourceDefaults.Google.Subscriptions + data["auth_source_default_google_grant_on_signup"] = authSourceDefaults.Google.GrantOnSignup + data["auth_source_default_google_grant_on_first_bind"] = authSourceDefaults.Google.GrantOnFirstBind data["force_email_on_third_party_signup"] = authSourceDefaults.ForceEmailOnThirdPartySignup return data @@ -2170,6 +2399,18 @@ func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool { return true } +func equalLoginAgreementDocuments(a, b []service.LoginAgreementDocument) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].ID != b[i].ID || a[i].Title != b[i].Title || a[i].ContentMD != b[i].ContentMD { + return false + } + } + return true +} + func equalIntSlice(a, b []int) bool { if len(a) != len(b) { return false @@ -2462,6 +2703,58 @@ func (h *SettingHandler) UpdateOverloadCooldownSettings(c *gin.Context) { }) } +// GetRateLimit429CooldownSettings 获取429默认回避配置 +// GET /api/v1/admin/settings/rate-limit-429-cooldown +func (h *SettingHandler) GetRateLimit429CooldownSettings(c *gin.Context) { + settings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.RateLimit429CooldownSettings{ + Enabled: settings.Enabled, + CooldownSeconds: settings.CooldownSeconds, + }) +} + +// UpdateRateLimit429CooldownSettingsRequest 更新429默认回避配置请求 +type UpdateRateLimit429CooldownSettingsRequest struct { + Enabled bool `json:"enabled"` + CooldownSeconds int `json:"cooldown_seconds"` +} + +// UpdateRateLimit429CooldownSettings 更新429默认回避配置 +// PUT /api/v1/admin/settings/rate-limit-429-cooldown +func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) { + var req UpdateRateLimit429CooldownSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + settings := &service.RateLimit429CooldownSettings{ + Enabled: req.Enabled, + CooldownSeconds: req.CooldownSeconds, + } + + if err := h.settingService.SetRateLimit429CooldownSettings(c.Request.Context(), settings); err != nil { + response.BadRequest(c, err.Error()) + return + } + + updatedSettings, err := h.settingService.GetRateLimit429CooldownSettings(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, err) + return + } + + response.Success(c, dto.RateLimit429CooldownSettings{ + Enabled: updatedSettings.Enabled, + CooldownSeconds: updatedSettings.CooldownSeconds, + }) +} + // GetStreamTimeoutSettings 获取流超时处理配置 // GET /api/v1/admin/settings/stream-timeout func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) { diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index a297c56ce39..db35472e99d 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -477,3 +477,63 @@ func (h *UserHandler) GetUserRPMStatus(c *gin.Context) { response.Success(c, status) } + +// BatchUpdateConcurrency 批量修改用户并发数 +// POST /api/v1/admin/users/batch-concurrency +type BatchUpdateConcurrencyRequest struct { + UserIDs []int64 `json:"user_ids"` + All bool `json:"all"` + Concurrency int `json:"concurrency"` + Mode string `json:"mode" binding:"required,oneof=set add"` +} + +func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) { + var req BatchUpdateConcurrencyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if !req.All && len(req.UserIDs) == 0 { + response.BadRequest(c, "user_ids is required unless all=true") + return + } + if len(req.UserIDs) > 500 { + response.BadRequest(c, "user_ids cannot exceed 500") + return + } + + var userIDs []int64 + if req.All { + // Fetch all user IDs via pagination + page := 1 + const pageSize = 500 + for { + users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc") + if err != nil { + response.ErrorFrom(c, err) + return + } + for _, u := range users { + userIDs = append(userIDs, u.ID) + } + if len(users) < pageSize { + break + } + page++ + } + } else { + userIDs = req.UserIDs + } + + if len(userIDs) == 0 { + response.Success(c, gin.H{"affected": 0}) + return + } + + affected, err := h.adminService.BatchUpdateConcurrency(c.Request.Context(), userIDs, req.Concurrency, req.Mode) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, gin.H{"affected": affected}) +} diff --git a/backend/internal/handler/auth_email_oauth.go b/backend/internal/handler/auth_email_oauth.go new file mode 100644 index 00000000000..d43acef6353 --- /dev/null +++ b/backend/internal/handler/auth_email_oauth.go @@ -0,0 +1,621 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/config" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/oauth" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/imroc/req/v3" + "github.com/tidwall/gjson" +) + +const ( + emailOAuthCookiePath = "/api/v1/auth/oauth" + emailOAuthStateCookieName = "email_oauth_state" + emailOAuthRedirectCookie = "email_oauth_redirect" + emailOAuthProviderCookie = "email_oauth_provider" + emailOAuthAffiliateCookie = "email_oauth_affiliate" + emailOAuthCookieMaxAgeSec = 10 * 60 + emailOAuthDefaultRedirect = "/dashboard" +) + +type emailOAuthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope,omitempty"` +} + +type emailOAuthProfile struct { + Subject string + Email string + EmailVerified bool + Username string + DisplayName string + AvatarURL string + Metadata map[string]any +} + +func (h *AuthHandler) GitHubOAuthStart(c *gin.Context) { h.emailOAuthStart(c, "github") } +func (h *AuthHandler) GoogleOAuthStart(c *gin.Context) { h.emailOAuthStart(c, "google") } + +func (h *AuthHandler) GitHubOAuthCallback(c *gin.Context) { h.emailOAuthCallback(c, "github") } +func (h *AuthHandler) GoogleOAuthCallback(c *gin.Context) { h.emailOAuthCallback(c, "google") } +func (h *AuthHandler) CompleteGitHubOAuthRegistration(c *gin.Context) { + h.completeEmailOAuthRegistration(c, "github") +} +func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) { + h.completeEmailOAuthRegistration(c, "google") +} + +func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) { + cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider) + if err != nil { + response.ErrorFrom(c, err) + return + } + state, err := oauth.GenerateState() + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_STATE_GEN_FAILED", "failed to generate oauth state").WithCause(err)) + return + } + redirectTo := sanitizeFrontendRedirectPath(c.Query("redirect")) + if redirectTo == "" { + redirectTo = emailOAuthDefaultRedirect + } + + secureCookie := isRequestHTTPS(c) + emailOAuthSetCookie(c, emailOAuthStateCookieName, encodeCookieValue(state), secureCookie) + emailOAuthSetCookie(c, emailOAuthRedirectCookie, encodeCookieValue(redirectTo), secureCookie) + emailOAuthSetCookie(c, emailOAuthProviderCookie, encodeCookieValue(provider), secureCookie) + if affCode := strings.TrimSpace(firstNonEmpty(c.Query("aff_code"), c.Query("aff"))); affCode != "" { + emailOAuthSetCookie(c, emailOAuthAffiliateCookie, encodeCookieValue(affCode), secureCookie) + } else { + emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie) + } + + authURL, err := buildEmailOAuthAuthorizeURL(cfg, state) + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err)) + return + } + c.Redirect(http.StatusFound, authURL) +} + +func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) { + cfg, cfgErr := h.getEmailOAuthConfig(c.Request.Context(), provider) + if cfgErr != nil { + response.ErrorFrom(c, cfgErr) + return + } + frontendCallback := strings.TrimSpace(cfg.FrontendRedirectURL) + if frontendCallback == "" { + frontendCallback = "/auth/oauth/callback" + } + if providerErr := strings.TrimSpace(c.Query("error")); providerErr != "" { + redirectOAuthError(c, frontendCallback, "provider_error", providerErr, c.Query("error_description")) + return + } + code := strings.TrimSpace(c.Query("code")) + state := strings.TrimSpace(c.Query("state")) + if code == "" || state == "" { + redirectOAuthError(c, frontendCallback, "missing_params", "missing code/state", "") + return + } + + secureCookie := isRequestHTTPS(c) + defer func() { + emailOAuthClearCookie(c, emailOAuthStateCookieName, secureCookie) + emailOAuthClearCookie(c, emailOAuthRedirectCookie, secureCookie) + emailOAuthClearCookie(c, emailOAuthProviderCookie, secureCookie) + emailOAuthClearCookie(c, emailOAuthAffiliateCookie, secureCookie) + }() + expectedState, err := readCookieDecoded(c, emailOAuthStateCookieName) + if err != nil || expectedState == "" || expectedState != state { + redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth state", "") + return + } + expectedProvider, _ := readCookieDecoded(c, emailOAuthProviderCookie) + if !strings.EqualFold(strings.TrimSpace(expectedProvider), provider) { + redirectOAuthError(c, frontendCallback, "invalid_state", "invalid oauth provider", "") + return + } + redirectTo, _ := readCookieDecoded(c, emailOAuthRedirectCookie) + redirectTo = sanitizeFrontendRedirectPath(redirectTo) + if redirectTo == "" { + redirectTo = emailOAuthDefaultRedirect + } + + tokenResp, err := exchangeEmailOAuthCode(c.Request.Context(), cfg, code) + if err != nil { + redirectOAuthError(c, frontendCallback, "token_exchange_failed", "failed to exchange oauth code", singleLine(err.Error())) + return + } + profile, err := fetchEmailOAuthProfile(c.Request.Context(), provider, cfg, tokenResp) + if err != nil { + redirectOAuthError(c, frontendCallback, "userinfo_failed", "failed to fetch verified email", singleLine(err.Error())) + return + } + h.emailOAuthCallbackWithProfile(c, provider, cfg, frontendCallback, redirectTo, profile) +} + +func (h *AuthHandler) emailOAuthCallbackWithProfile( + c *gin.Context, + provider string, + cfg config.EmailOAuthProviderConfig, + frontendCallback string, + redirectTo string, + profile *emailOAuthProfile, +) { + input := service.EmailOAuthIdentityInput{ + ProviderType: provider, + ProviderKey: provider, + ProviderSubject: profile.Subject, + Email: profile.Email, + EmailVerified: profile.EmailVerified, + Username: profile.Username, + DisplayName: profile.DisplayName, + AvatarURL: profile.AvatarURL, + UpstreamMetadata: profile.Metadata, + } + affiliateCode := h.emailOAuthAffiliateCode(c) + if shouldCreate, err := h.emailOAuthShouldCreatePendingRegistration(c.Request.Context(), input); err != nil { + redirectOAuthError(c, frontendCallback, infraerrors.Reason(err), infraerrors.Message(err), "") + return + } else if shouldCreate { + if pendingErr := h.createEmailOAuthRegistrationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil { + redirectOAuthError(c, frontendCallback, infraerrors.Reason(pendingErr), infraerrors.Message(pendingErr), "") + return + } + redirectToFrontendCallback(c, frontendCallback) + return + } + + tokenPair, user, err := h.authService.LoginOrRegisterVerifiedEmailOAuthWithInvitation(c.Request.Context(), input, "", affiliateCode) + if err != nil { + if errors.Is(err, service.ErrOAuthInvitationRequired) { + if pendingErr := h.createEmailOAuthRegistrationPendingSession(c, provider, frontendCallback, redirectTo, profile); pendingErr != nil { + redirectOAuthError(c, frontendCallback, infraerrors.Reason(pendingErr), infraerrors.Message(pendingErr), "") + return + } + redirectToFrontendCallback(c, frontendCallback) + return + } + redirectOAuthError(c, frontendCallback, infraerrors.Reason(err), infraerrors.Message(err), "") + return + } + if err := h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil { + redirectOAuthError(c, frontendCallback, "login_blocked", infraerrors.Reason(err), infraerrors.Message(err)) + return + } + + fragment := url.Values{} + fragment.Set("access_token", tokenPair.AccessToken) + fragment.Set("refresh_token", tokenPair.RefreshToken) + fragment.Set("expires_in", fmt.Sprintf("%d", tokenPair.ExpiresIn)) + fragment.Set("token_type", "Bearer") + fragment.Set("redirect", redirectTo) + redirectWithFragment(c, frontendCallback, fragment) +} + +func (h *AuthHandler) emailOAuthShouldCreatePendingRegistration(ctx context.Context, input service.EmailOAuthIdentityInput) (bool, error) { + client := h.entClient() + if client == nil { + return false, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready") + } + identityUser, err := h.findOAuthIdentityUser(ctx, service.PendingAuthIdentityKey{ + ProviderType: strings.TrimSpace(input.ProviderType), + ProviderKey: strings.TrimSpace(input.ProviderKey), + ProviderSubject: strings.TrimSpace(input.ProviderSubject), + }) + if err != nil { + return false, err + } + email := strings.TrimSpace(strings.ToLower(input.Email)) + if identityUser != nil { + if !strings.EqualFold(strings.TrimSpace(identityUser.Email), email) { + return false, infraerrors.Conflict("AUTH_IDENTITY_EMAIL_MISMATCH", "oauth identity belongs to a different email") + } + return false, nil + } + if _, err := findUserByNormalizedEmail(ctx, client, email); err != nil { + if errors.Is(err, service.ErrUserNotFound) { + return true, nil + } + return false, err + } + return false, nil +} + +func (h *AuthHandler) emailOAuthAffiliateCode(c *gin.Context) string { + if c == nil { + return "" + } + if code, err := readCookieDecoded(c, emailOAuthAffiliateCookie); err == nil { + return strings.TrimSpace(code) + } + return "" +} + +func (h *AuthHandler) createEmailOAuthRegistrationPendingSession( + c *gin.Context, + provider string, + frontendCallback string, + redirectTo string, + profile *emailOAuthProfile, +) error { + if h == nil || profile == nil { + return infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready") + } + browserSessionKey, err := generateOAuthPendingBrowserSession() + if err != nil { + return infraerrors.InternalServer("PENDING_AUTH_SESSION_CREATE_FAILED", "failed to create pending auth session").WithCause(err) + } + setOAuthPendingBrowserCookie(c, browserSessionKey, isRequestHTTPS(c)) + + email := strings.TrimSpace(strings.ToLower(profile.Email)) + username := strings.TrimSpace(profile.Username) + affiliateCode := h.emailOAuthAffiliateCode(c) + upstreamClaims := map[string]any{ + "email": email, + "email_verified": profile.EmailVerified, + "username": username, + "provider": provider, + "provider_key": provider, + "provider_subject": strings.TrimSpace(profile.Subject), + } + if strings.TrimSpace(profile.DisplayName) != "" { + upstreamClaims["suggested_display_name"] = strings.TrimSpace(profile.DisplayName) + } + if strings.TrimSpace(profile.AvatarURL) != "" { + upstreamClaims["suggested_avatar_url"] = strings.TrimSpace(profile.AvatarURL) + } + if affiliateCode != "" { + upstreamClaims["aff_code"] = affiliateCode + } + for key, value := range profile.Metadata { + if _, exists := upstreamClaims[key]; !exists { + upstreamClaims[key] = value + } + } + + invitationRequired := h != nil && h.settingSvc != nil && h.settingSvc.IsInvitationCodeEnabled(c.Request.Context()) + pendingError := "registration_completion_required" + choiceReason := "registration_completion_required" + if invitationRequired { + pendingError = "invitation_required" + choiceReason = "invitation_required" + } + completionResponse := map[string]any{ + "step": oauthPendingChoiceStep, + "error": pendingError, + "choice_reason": choiceReason, + "adoption_required": false, + "create_account_allowed": true, + "existing_account_bindable": false, + "force_email_on_signup": true, + "invitation_required": invitationRequired, + "email": email, + "resolved_email": email, + "provider": provider, + "redirect": redirectTo, + } + if strings.TrimSpace(frontendCallback) != "" { + completionResponse["frontend_callback"] = strings.TrimSpace(frontendCallback) + } + + return h.createOAuthPendingSession(c, oauthPendingSessionPayload{ + Intent: oauthIntentLogin, + Identity: service.PendingAuthIdentityKey{ProviderType: provider, ProviderKey: provider, ProviderSubject: strings.TrimSpace(profile.Subject)}, + ResolvedEmail: email, + RedirectTo: redirectTo, + BrowserSessionKey: browserSessionKey, + UpstreamIdentityClaims: upstreamClaims, + CompletionResponse: completionResponse, + }) +} + +type completeEmailOAuthRequest struct { + Password string `json:"password" binding:"required,min=6"` + InvitationCode string `json:"invitation_code,omitempty"` + AffCode string `json:"aff_code,omitempty"` +} + +func (h *AuthHandler) completeEmailOAuthRegistration(c *gin.Context, provider string) { + var req completeEmailOAuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + _, session, clearCookies, err := readPendingOAuthBrowserSession(c, h) + if err != nil { + response.ErrorFrom(c, err) + return + } + if err := ensurePendingOAuthCompleteRegistrationSession(session); err != nil { + response.ErrorFrom(c, err) + return + } + if !strings.EqualFold(strings.TrimSpace(session.ProviderType), provider) { + response.BadRequest(c, "Pending oauth session provider mismatch") + return + } + if err := h.ensureBackendModeAllowsNewUserLogin(c.Request.Context()); err != nil { + response.ErrorFrom(c, err) + return + } + + affiliateCode := strings.TrimSpace(req.AffCode) + if affiliateCode == "" { + affiliateCode = pendingSessionStringValue(session.UpstreamIdentityClaims, "aff_code") + } + + tokenPair, user, err := h.authService.RegisterVerifiedOAuthEmailAccount( + c.Request.Context(), + strings.TrimSpace(session.ResolvedEmail), + req.Password, + strings.TrimSpace(req.InvitationCode), + strings.TrimSpace(session.ProviderType), + ) + if err != nil { + response.ErrorFrom(c, err) + return + } + + client := h.entClient() + if client == nil { + response.ErrorFrom(c, infraerrors.ServiceUnavailable("PENDING_AUTH_NOT_READY", "pending auth service is not ready")) + return + } + tx, err := client.Tx(c.Request.Context()) + if err != nil { + response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err)) + return + } + defer func() { _ = tx.Rollback() }() + txCtx := dbent.NewTxContext(c.Request.Context(), tx) + sessionForBinding := *session + sessionForBinding.UpstreamIdentityClaims = clonePendingMap(session.UpstreamIdentityClaims) + if strings.TrimSpace(req.InvitationCode) != "" { + sessionForBinding.UpstreamIdentityClaims["invitation_code"] = strings.TrimSpace(req.InvitationCode) + } + decision, err := h.ensurePendingOAuthAdoptionDecision(c, session.ID, oauthAdoptionDecisionRequest{}) + if err != nil { + _ = tx.Rollback() + _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode)) + response.ErrorFrom(c, err) + return + } + if err := applyPendingOAuthBinding(txCtx, client, h.authService, h.userService, &sessionForBinding, decision, &user.ID, true, false); err != nil { + _ = tx.Rollback() + _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode)) + respondPendingOAuthBindingApplyError(c, err) + return + } + if err := h.authService.FinalizeOAuthEmailAccount( + txCtx, + user, + strings.TrimSpace(req.InvitationCode), + strings.TrimSpace(session.ProviderType), + affiliateCode, + ); err != nil { + _ = tx.Rollback() + _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode)) + response.ErrorFrom(c, err) + return + } + if err := consumePendingOAuthBrowserSessionTx(c.Request.Context(), tx, session); err != nil { + _ = tx.Rollback() + _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode)) + clearCookies() + response.ErrorFrom(c, err) + return + } + if err := tx.Commit(); err != nil { + _ = h.authService.RollbackOAuthEmailAccountCreation(c.Request.Context(), user.ID, strings.TrimSpace(req.InvitationCode)) + response.ErrorFrom(c, infraerrors.InternalServer("PENDING_AUTH_BIND_APPLY_FAILED", "failed to consume pending oauth session").WithCause(err)) + return + } + h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID) + clearCookies() + writeOAuthTokenPairResponse(c, tokenPair) +} + +func (h *AuthHandler) getEmailOAuthConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) { + if h != nil && h.settingSvc != nil { + return h.settingSvc.GetEmailOAuthProviderConfig(ctx, provider) + } + return config.EmailOAuthProviderConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded") +} + +func buildEmailOAuthAuthorizeURL(cfg config.EmailOAuthProviderConfig, state string) (string, error) { + u, err := url.Parse(cfg.AuthorizeURL) + if err != nil { + return "", fmt.Errorf("parse authorize_url: %w", err) + } + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", cfg.ClientID) + q.Set("redirect_uri", cfg.RedirectURL) + q.Set("state", state) + if strings.TrimSpace(cfg.Scopes) != "" { + q.Set("scope", cfg.Scopes) + } + u.RawQuery = q.Encode() + return u.String(), nil +} + +func exchangeEmailOAuthCode(ctx context.Context, cfg config.EmailOAuthProviderConfig, code string) (*emailOAuthTokenResponse, error) { + resp, err := req.C(). + R(). + SetContext(ctx). + SetHeader("Accept", "application/json"). + SetFormData(map[string]string{ + "grant_type": "authorization_code", + "client_id": cfg.ClientID, + "client_secret": cfg.ClientSecret, + "code": code, + "redirect_uri": cfg.RedirectURL, + }). + Post(cfg.TokenURL) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("token endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024)) + } + var tokenResp emailOAuthTokenResponse + if err := json.Unmarshal(resp.Bytes(), &tokenResp); err != nil { + return nil, err + } + if strings.TrimSpace(tokenResp.AccessToken) == "" { + return nil, errors.New("missing access_token") + } + return &tokenResp, nil +} + +func fetchEmailOAuthProfile(ctx context.Context, provider string, cfg config.EmailOAuthProviderConfig, token *emailOAuthTokenResponse) (*emailOAuthProfile, error) { + resp, err := req.C(). + R(). + SetContext(ctx). + SetBearerAuthToken(token.AccessToken). + SetHeader("Accept", "application/json"). + Get(cfg.UserInfoURL) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("userinfo endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024)) + } + switch strings.ToLower(strings.TrimSpace(provider)) { + case "github": + return parseGitHubOAuthProfile(ctx, cfg, token, resp.String()) + case "google": + return parseGoogleOAuthProfile(resp.String()) + default: + return nil, errors.New("unsupported oauth provider") + } +} + +func parseGitHubOAuthProfile(ctx context.Context, cfg config.EmailOAuthProviderConfig, token *emailOAuthTokenResponse, body string) (*emailOAuthProfile, error) { + subject := strings.TrimSpace(gjson.Get(body, "id").String()) + if subject == "" { + return nil, errors.New("github user id is missing") + } + email := "" + emailsURL := strings.TrimSpace(cfg.EmailsURL) + if emailsURL == "" { + return nil, errors.New("github verified email is missing") + } + verifiedEmail, err := fetchGitHubPrimaryVerifiedEmail(ctx, emailsURL, token.AccessToken) + if err != nil { + return nil, err + } + email = verifiedEmail + if email == "" { + return nil, errors.New("github verified email is missing") + } + login := strings.TrimSpace(gjson.Get(body, "login").String()) + name := strings.TrimSpace(gjson.Get(body, "name").String()) + return &emailOAuthProfile{ + Subject: subject, + Email: email, + EmailVerified: true, + Username: firstNonEmpty(login, name, "github_"+subject), + DisplayName: firstNonEmpty(name, login), + AvatarURL: strings.TrimSpace(gjson.Get(body, "avatar_url").String()), + Metadata: map[string]any{ + "login": login, + }, + }, nil +} + +func fetchGitHubPrimaryVerifiedEmail(ctx context.Context, emailsURL string, accessToken string) (string, error) { + resp, err := req.C(). + R(). + SetContext(ctx). + SetBearerAuthToken(accessToken). + SetHeader("Accept", "application/json"). + Get(emailsURL) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("github emails endpoint status %d: %s", resp.StatusCode, truncateLogValue(resp.String(), 1024)) + } + items := gjson.Parse(resp.String()).Array() + for _, item := range items { + if item.Get("primary").Bool() && item.Get("verified").Bool() { + if email := strings.TrimSpace(item.Get("email").String()); email != "" { + return email, nil + } + } + } + for _, item := range items { + if item.Get("verified").Bool() { + if email := strings.TrimSpace(item.Get("email").String()); email != "" { + return email, nil + } + } + } + return "", errors.New("github verified email is missing") +} + +func parseGoogleOAuthProfile(body string) (*emailOAuthProfile, error) { + subject := strings.TrimSpace(gjson.Get(body, "sub").String()) + email := strings.TrimSpace(gjson.Get(body, "email").String()) + verified := gjson.Get(body, "email_verified").Bool() + if subject == "" { + return nil, errors.New("google subject is missing") + } + if email == "" || !verified { + return nil, errors.New("google verified email is missing") + } + name := strings.TrimSpace(gjson.Get(body, "name").String()) + return &emailOAuthProfile{ + Subject: subject, + Email: email, + EmailVerified: true, + Username: firstNonEmpty(strings.TrimSpace(gjson.Get(body, "given_name").String()), name, email), + DisplayName: name, + AvatarURL: strings.TrimSpace(gjson.Get(body, "picture").String()), + Metadata: map[string]any{ + "email_verified": true, + }, + }, nil +} + +func emailOAuthSetCookie(c *gin.Context, name, value string, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: value, + Path: emailOAuthCookiePath, + MaxAge: emailOAuthCookieMaxAgeSec, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +func emailOAuthClearCookie(c *gin.Context, name string, secure bool) { + http.SetCookie(c.Writer, &http.Cookie{ + Name: name, + Value: "", + Path: emailOAuthCookiePath, + MaxAge: -1, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} diff --git a/backend/internal/handler/auth_email_oauth_test.go b/backend/internal/handler/auth_email_oauth_test.go new file mode 100644 index 00000000000..ecf71c5a24c --- /dev/null +++ b/backend/internal/handler/auth_email_oauth_test.go @@ -0,0 +1,414 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/authidentity" + "github.com/Wei-Shaw/sub2api/ent/redeemcode" + dbuser "github.com/Wei-Shaw/sub2api/ent/user" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestEmailOAuthCallbackRequiresPendingRegistrationWhenInvitationEnabled(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandler(t, true) + ctx := context.Background() + + state := "github-oauth-state" + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback?code=code-1&state="+url.QueryEscape(state), nil) + req.AddCookie(&http.Cookie{Name: emailOAuthStateCookieName, Value: encodeCookieValue(state)}) + req.AddCookie(&http.Cookie{Name: emailOAuthRedirectCookie, Value: encodeCookieValue("/dashboard")}) + req.AddCookie(&http.Cookie{Name: emailOAuthProviderCookie, Value: encodeCookieValue("github")}) + c.Request = req + + profile := &emailOAuthProfile{ + Subject: "github-123", + Email: "fresh@example.com", + EmailVerified: true, + Username: "fresh", + DisplayName: "Fresh User", + AvatarURL: "https://cdn.example/fresh.png", + Metadata: map[string]any{ + "login": "fresh", + }, + } + handler.emailOAuthCallbackWithProfile(c, "github", config.EmailOAuthProviderConfig{ + Enabled: true, + ClientID: "github-client", + ClientSecret: "github-secret", + RedirectURL: "https://app.example/api/v1/auth/oauth/github/callback", + FrontendRedirectURL: "/auth/oauth/callback", + }, "/auth/oauth/callback", "/dashboard", profile) + + require.Equal(t, http.StatusFound, recorder.Code) + location := recorder.Header().Get("Location") + require.Contains(t, location, "/auth/oauth/callback") + require.NotContains(t, location, "access_token=") + + userCount, err := client.User.Query().Where(dbuser.EmailEQ("fresh@example.com")).Count(ctx) + require.NoError(t, err) + require.Zero(t, userCount) + + session, err := client.PendingAuthSession.Query().Only(ctx) + require.NoError(t, err) + require.Equal(t, "github", session.ProviderType) + require.Equal(t, "github", session.ProviderKey) + require.Equal(t, "github-123", session.ProviderSubject) + require.Equal(t, "fresh@example.com", session.ResolvedEmail) + require.Equal(t, "/dashboard", session.RedirectTo) + require.Nil(t, session.TargetUserID) + + completion, ok := readCompletionResponse(session.LocalFlowState) + require.True(t, ok) + require.Equal(t, oauthPendingChoiceStep, completion["step"]) + require.Equal(t, "invitation_required", completion["error"]) + require.Equal(t, true, completion["invitation_required"]) + require.Equal(t, "fresh@example.com", completion["email"]) + require.Equal(t, "fresh@example.com", completion["resolved_email"]) + require.Equal(t, true, completion["create_account_allowed"]) + + require.NotEmpty(t, findSetCookieValue(recorder.Result().Cookies(), oauthPendingSessionCookieName)) + require.NotEmpty(t, findSetCookieValue(recorder.Result().Cookies(), oauthPendingBrowserCookieName)) +} + +func TestEmailOAuthCallbackExistingEmailLogsInWhenInvitationEnabled(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandler(t, true) + ctx := context.Background() + + user, err := client.User.Create(). + SetEmail("existing@example.com"). + SetUsername("existing"). + SetPasswordHash("hash"). + SetRole(service.RoleUser). + SetStatus(service.StatusActive). + Save(ctx) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/google/callback", nil) + + handler.emailOAuthCallbackWithProfile(c, "google", config.EmailOAuthProviderConfig{ + Enabled: true, + ClientID: "google-client", + ClientSecret: "google-secret", + RedirectURL: "https://app.example/api/v1/auth/oauth/google/callback", + FrontendRedirectURL: "/auth/oauth/callback", + }, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{ + Subject: "google-123", + Email: "existing@example.com", + EmailVerified: true, + Username: "existing", + }) + + require.Equal(t, http.StatusFound, recorder.Code) + location := recorder.Header().Get("Location") + require.Contains(t, location, "access_token=") + require.Contains(t, location, "redirect=%252Fdashboard") + + sessionCount, err := client.PendingAuthSession.Query().Count(ctx) + require.NoError(t, err) + require.Zero(t, sessionCount) + + identityCount, err := client.AuthIdentity.Query().Where( + authidentity.ProviderTypeEQ("google"), + authidentity.ProviderSubjectEQ("google-123"), + ).Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, identityCount) + _ = user +} + +func TestEmailOAuthCallbackCreatesPasswordRegistrationSessionForNewEmail(t *testing.T) { + affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF123": 1001}) + handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + settingValues: map[string]string{ + service.SettingKeyAffiliateEnabled: "true", + }, + affiliateFactory: func(_ *dbent.Client, settingSvc *service.SettingService) *service.AffiliateService { + return service.NewAffiliateService(affiliateRepo, settingSvc, nil, nil) + }, + }) + ctx := context.Background() + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/callback", nil) + req.AddCookie(&http.Cookie{Name: emailOAuthAffiliateCookie, Value: encodeCookieValue("AFF123")}) + c.Request = req + + handler.emailOAuthCallbackWithProfile(c, "github", config.EmailOAuthProviderConfig{ + Enabled: true, + ClientID: "github-client", + ClientSecret: "github-secret", + RedirectURL: "https://app.example/api/v1/auth/oauth/github/callback", + FrontendRedirectURL: "/auth/oauth/callback", + }, "/auth/oauth/callback", "/dashboard", &emailOAuthProfile{ + Subject: "github-aff-user", + Email: "aff-user@example.com", + EmailVerified: true, + Username: "aff-user", + }) + + require.Equal(t, http.StatusFound, recorder.Code) + require.NotContains(t, recorder.Header().Get("Location"), "access_token=") + userCount, err := client.User.Query().Where(dbuser.EmailEQ("aff-user@example.com")).Count(ctx) + require.NoError(t, err) + require.Zero(t, userCount) + require.Empty(t, affiliateRepo.ensureUserIDs) + require.Empty(t, affiliateRepo.bindCalls) + + session, err := client.PendingAuthSession.Query().Only(ctx) + require.NoError(t, err) + require.Equal(t, "aff-user@example.com", session.ResolvedEmail) + require.Equal(t, "AFF123", pendingSessionStringValue(session.UpstreamIdentityClaims, "aff_code")) + + completion, ok := readCompletionResponse(session.LocalFlowState) + require.True(t, ok) + require.Equal(t, oauthPendingChoiceStep, completion["step"]) + require.Equal(t, "registration_completion_required", completion["error"]) + require.Equal(t, false, completion["invitation_required"]) + require.Equal(t, true, completion["create_account_allowed"]) + require.Equal(t, true, completion["force_email_on_signup"]) + require.Equal(t, "aff-user@example.com", completion["resolved_email"]) +} + +func TestCompleteEmailOAuthRegistrationUsesAffiliateCodeFromPendingSession(t *testing.T) { + affiliateRepo := newOAuthEmailAffiliateRepoStub(map[string]int64{"AFF456": 2002}) + handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{ + invitationEnabled: true, + settingValues: map[string]string{ + service.SettingKeyAffiliateEnabled: "true", + }, + affiliateFactory: func(_ *dbent.Client, settingSvc *service.SettingService) *service.AffiliateService { + return service.NewAffiliateService(affiliateRepo, settingSvc, nil, nil) + }, + }) + ctx := context.Background() + invitation, err := client.RedeemCode.Create(). + SetCode("INVITE456"). + SetType(service.RedeemTypeInvitation). + SetStatus(service.StatusUnused). + SetValue(0). + Save(ctx) + require.NoError(t, err) + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("email-oauth-aff-session-token"). + SetIntent(oauthIntentLogin). + SetProviderType("google"). + SetProviderKey("google"). + SetProviderSubject("google-aff-user"). + SetResolvedEmail("pending-aff@example.com"). + SetRedirectTo("/dashboard"). + SetBrowserSessionKey("browser-aff-key"). + SetUpstreamIdentityClaims(map[string]any{ + "email": "pending-aff@example.com", + "email_verified": true, + "username": "pending-aff", + "provider": "google", + "provider_key": "google", + "provider_subject": "google-aff-user", + "aff_code": "AFF456", + }). + SetLocalFlowState(map[string]any{ + "step": oauthPendingChoiceStep, + "error": "invitation_required", + }). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/google/complete-registration", strings.NewReader(`{"password":"secret-123","invitation_code":"INVITE456","email":"tampered@example.com"}`)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-aff-key")}) + c.Request = req + + handler.completeEmailOAuthRegistration(c, "google") + + require.Equal(t, http.StatusOK, recorder.Code) + user, err := client.User.Query().Where(dbuser.EmailEQ("pending-aff@example.com")).Only(ctx) + require.NoError(t, err) + require.NotEmpty(t, user.PasswordHash) + require.NotEqual(t, "secret-123", user.PasswordHash) + tamperedCount, err := client.User.Query().Where(dbuser.EmailEQ("tampered@example.com")).Count(ctx) + require.NoError(t, err) + require.Zero(t, tamperedCount) + require.Equal(t, []oauthEmailAffiliateBindCall{{userID: user.ID, inviterID: 2002}}, affiliateRepo.bindCalls) + storedInvitation, err := client.RedeemCode.Query().Where(redeemcode.IDEQ(invitation.ID)).Only(ctx) + require.NoError(t, err) + require.NotNil(t, storedInvitation.UsedBy) + require.Equal(t, user.ID, *storedInvitation.UsedBy) +} + +func TestCompleteEmailOAuthRegistrationRequiresPassword(t *testing.T) { + handler, client := newOAuthPendingFlowTestHandler(t, false) + ctx := context.Background() + + session, err := client.PendingAuthSession.Create(). + SetSessionToken("email-oauth-password-session-token"). + SetIntent(oauthIntentLogin). + SetProviderType("github"). + SetProviderKey("github"). + SetProviderSubject("github-password-user"). + SetResolvedEmail("password-required@example.com"). + SetRedirectTo("/dashboard"). + SetBrowserSessionKey("browser-password-key"). + SetUpstreamIdentityClaims(map[string]any{ + "email": "password-required@example.com", + "email_verified": true, + "username": "password-required", + "provider": "github", + "provider_key": "github", + "provider_subject": "github-password-user", + }). + SetLocalFlowState(map[string]any{ + "step": oauthPendingChoiceStep, + "error": "registration_completion_required", + }). + SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)). + Save(ctx) + require.NoError(t, err) + + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/github/complete-registration", strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)}) + req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("browser-password-key")}) + c.Request = req + + handler.completeEmailOAuthRegistration(c, "github") + + require.Equal(t, http.StatusBadRequest, recorder.Code) + userCount, err := client.User.Query().Where(dbuser.EmailEQ("password-required@example.com")).Count(ctx) + require.NoError(t, err) + require.Zero(t, userCount) +} + +func TestParseGitHubOAuthProfileRejectsPublicEmailWhenEmailsEndpointFails(t *testing.T) { + emailServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "missing scope", http.StatusForbidden) + })) + t.Cleanup(emailServer.Close) + + profile, err := parseGitHubOAuthProfile(context.Background(), config.EmailOAuthProviderConfig{ + EmailsURL: emailServer.URL, + }, &emailOAuthTokenResponse{AccessToken: "token"}, `{"id":123,"login":"octo","email":"public@example.com"}`) + + require.Error(t, err) + require.Nil(t, profile) + require.Contains(t, err.Error(), "github emails endpoint status 403") +} + +type oauthEmailAffiliateBindCall struct { + userID int64 + inviterID int64 +} + +type oauthEmailAffiliateRepoStub struct { + codeOwners map[string]int64 + ensureUserIDs []int64 + bindCalls []oauthEmailAffiliateBindCall +} + +func newOAuthEmailAffiliateRepoStub(codeOwners map[string]int64) *oauthEmailAffiliateRepoStub { + return &oauthEmailAffiliateRepoStub{codeOwners: codeOwners} +} + +func (r *oauthEmailAffiliateRepoStub) EnsureUserAffiliate(_ context.Context, userID int64) (*service.AffiliateSummary, error) { + r.ensureUserIDs = append(r.ensureUserIDs, userID) + return &service.AffiliateSummary{UserID: userID, AffCode: "SELF"}, nil +} + +func (r *oauthEmailAffiliateRepoStub) GetAffiliateByCode(_ context.Context, code string) (*service.AffiliateSummary, error) { + userID, ok := r.codeOwners[strings.ToUpper(strings.TrimSpace(code))] + if !ok { + return nil, service.ErrAffiliateProfileNotFound + } + return &service.AffiliateSummary{UserID: userID, AffCode: strings.ToUpper(strings.TrimSpace(code))}, nil +} + +func (r *oauthEmailAffiliateRepoStub) BindInviter(_ context.Context, userID, inviterID int64) (bool, error) { + r.bindCalls = append(r.bindCalls, oauthEmailAffiliateBindCall{userID: userID, inviterID: inviterID}) + return true, nil +} + +func (r *oauthEmailAffiliateRepoStub) AccrueQuota(context.Context, int64, int64, float64, int, *int64) (bool, error) { + panic("unexpected AccrueQuota call") +} + +func (r *oauthEmailAffiliateRepoStub) GetAccruedRebateFromInvitee(context.Context, int64, int64) (float64, error) { + panic("unexpected GetAccruedRebateFromInvitee call") +} + +func (r *oauthEmailAffiliateRepoStub) ThawFrozenQuota(context.Context, int64) (float64, error) { + panic("unexpected ThawFrozenQuota call") +} + +func (r *oauthEmailAffiliateRepoStub) TransferQuotaToBalance(context.Context, int64) (float64, float64, error) { + panic("unexpected TransferQuotaToBalance call") +} + +func (r *oauthEmailAffiliateRepoStub) ListInvitees(context.Context, int64, int) ([]service.AffiliateInvitee, error) { + panic("unexpected ListInvitees call") +} + +func (r *oauthEmailAffiliateRepoStub) UpdateUserAffCode(context.Context, int64, string) error { + panic("unexpected UpdateUserAffCode call") +} + +func (r *oauthEmailAffiliateRepoStub) ResetUserAffCode(context.Context, int64) (string, error) { + panic("unexpected ResetUserAffCode call") +} + +func (r *oauthEmailAffiliateRepoStub) SetUserRebateRate(context.Context, int64, *float64) error { + panic("unexpected SetUserRebateRate call") +} + +func (r *oauthEmailAffiliateRepoStub) BatchSetUserRebateRate(context.Context, []int64, *float64) error { + panic("unexpected BatchSetUserRebateRate call") +} + +func (r *oauthEmailAffiliateRepoStub) ListUsersWithCustomSettings(context.Context, service.AffiliateAdminFilter) ([]service.AffiliateAdminEntry, int64, error) { + panic("unexpected ListUsersWithCustomSettings call") +} + +func (r *oauthEmailAffiliateRepoStub) ListAffiliateInviteRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateInviteRecord, int64, error) { + panic("unexpected ListAffiliateInviteRecords call") +} + +func (r *oauthEmailAffiliateRepoStub) ListAffiliateRebateRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateRebateRecord, int64, error) { + panic("unexpected ListAffiliateRebateRecords call") +} + +func (r *oauthEmailAffiliateRepoStub) ListAffiliateTransferRecords(context.Context, service.AffiliateRecordFilter) ([]service.AffiliateTransferRecord, int64, error) { + panic("unexpected ListAffiliateTransferRecords call") +} + +func (r *oauthEmailAffiliateRepoStub) GetAffiliateUserOverview(context.Context, int64) (*service.AffiliateUserOverview, error) { + panic("unexpected GetAffiliateUserOverview call") +} + +func findSetCookieValue(cookies []*http.Cookie, name string) string { + for _, cookie := range cookies { + if cookie != nil && strings.EqualFold(cookie.Name, name) && cookie.MaxAge >= 0 { + return cookie.Value + } + } + return "" +} diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go index ffe9ff5f533..584e575178f 100644 --- a/backend/internal/handler/auth_oauth_pending_flow_test.go +++ b/backend/internal/handler/auth_oauth_pending_flow_test.go @@ -2121,6 +2121,8 @@ type oauthPendingFlowTestHandlerOptions struct { emailCache service.EmailCache settingValues map[string]string defaultSubAssigner service.DefaultSubscriptionAssigner + affiliateService *service.AffiliateService + affiliateFactory func(*dbent.Client, *service.SettingService) *service.AffiliateService totpCache service.TotpCache totpEncryptor service.SecretEncryptor userRepoOptions oauthPendingFlowUserRepoOptions @@ -2160,6 +2162,21 @@ CREATE TABLE IF NOT EXISTS user_avatars ( updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP )`) require.NoError(t, err) + _, err = db.Exec(` +CREATE TABLE IF NOT EXISTS user_affiliates ( + user_id INTEGER PRIMARY KEY, + aff_code TEXT NOT NULL UNIQUE, + aff_code_custom BOOLEAN NOT NULL DEFAULT false, + aff_rebate_rate_percent REAL NULL, + inviter_id INTEGER NULL, + aff_count INTEGER NOT NULL DEFAULT 0, + aff_quota REAL NOT NULL DEFAULT 0, + aff_frozen_quota REAL NOT NULL DEFAULT 0, + aff_history_quota REAL NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +)`) + require.NoError(t, err) drv := entsql.OpenDB(dialect.SQLite, db) client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv))) @@ -2177,14 +2194,19 @@ CREATE TABLE IF NOT EXISTS user_avatars ( }, } settingValues := map[string]string{ - service.SettingKeyRegistrationEnabled: "true", - service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled), - service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled), + service.SettingKeyRegistrationEnabled: "true", + service.SettingKeyInvitationCodeEnabled: boolSettingValue(options.invitationEnabled), + service.SettingKeyEmailVerifyEnabled: boolSettingValue(options.emailVerifyEnabled), + service.SettingKeyRegistrationEmailSuffixWhitelist: "[]", } for key, value := range options.settingValues { settingValues[key] = value } settingSvc := service.NewSettingService(&oauthPendingFlowSettingRepoStub{values: settingValues}, cfg) + affiliateService := options.affiliateService + if affiliateService == nil && options.affiliateFactory != nil { + affiliateService = options.affiliateFactory(client, settingSvc) + } userRepo := &oauthPendingFlowUserRepo{ client: client, options: options.userRepoOptions, @@ -2210,7 +2232,7 @@ CREATE TABLE IF NOT EXISTS user_avatars ( nil, nil, options.defaultSubAssigner, - nil, + affiliateService, ) userSvc := service.NewUserService(userRepo, nil, nil, nil) var totpSvc *service.TotpService @@ -2798,6 +2820,14 @@ func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int panic("unexpected UpdateConcurrency call") } +func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { + panic("unexpected BatchSetConcurrency call") +} + +func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { + panic("unexpected BatchAddConcurrency call") +} + func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) { return map[int64]*time.Time{}, nil } diff --git a/backend/internal/handler/available_channel_handler.go b/backend/internal/handler/available_channel_handler.go index 8982b80defc..867233afa5c 100644 --- a/backend/internal/handler/available_channel_handler.go +++ b/backend/internal/handler/available_channel_handler.go @@ -2,6 +2,7 @@ package handler import ( "sort" + "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/server/middleware" @@ -24,6 +25,7 @@ type AvailableChannelHandler struct { channelService *service.ChannelService apiKeyService *service.APIKeyService settingService *service.SettingService + billingService *service.BillingService } // NewAvailableChannelHandler 创建用户侧可用渠道 handler。 @@ -31,11 +33,13 @@ func NewAvailableChannelHandler( channelService *service.ChannelService, apiKeyService *service.APIKeyService, settingService *service.SettingService, + billingService *service.BillingService, ) *AvailableChannelHandler { return &AvailableChannelHandler{ channelService: channelService, apiKeyService: apiKeyService, settingService: settingService, + billingService: billingService, } } @@ -97,6 +101,7 @@ type userSupportedModel struct { // 后面的平台行按 sections 顺序铺开。 type userChannelPlatformSection struct { Platform string `json:"platform"` + BaseURL string `json:"base_url"` Groups []userAvailableGroup `json:"groups"` SupportedModels []userSupportedModel `json:"supported_models"` } @@ -111,6 +116,31 @@ type userAvailableChannel struct { Platforms []userChannelPlatformSection `json:"platforms"` } +type userModelPricingBatchRequest struct { + Models []string `json:"models"` +} + +type userDefaultModelPricing struct { + Found bool `json:"found"` + BillingMode string `json:"billing_mode,omitempty"` + InputPrice *float64 `json:"input_price,omitempty"` + OutputPrice *float64 `json:"output_price,omitempty"` + CacheWritePrice *float64 `json:"cache_write_price,omitempty"` + CacheReadPrice *float64 `json:"cache_read_price,omitempty"` + ImageOutputPrice *float64 `json:"image_output_price,omitempty"` + PerRequestPrice *float64 `json:"per_request_price,omitempty"` +} + +type userModelPricingBatchResponse struct { + Prices map[string]userDefaultModelPricing `json:"prices"` +} + +// ListPublic 列出公开模型广场可见的「可用渠道」。 +// GET /api/v1/public/channels/available +func (h *AvailableChannelHandler) ListPublic(c *gin.Context) { + h.listVisible(c, nil) +} + // List 列出当前用户可见的「可用渠道」。 // GET /api/v1/channels/available func (h *AvailableChannelHandler) List(c *gin.Context) { @@ -120,13 +150,6 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { return } - // Feature 未启用时返回空数组(不暴露渠道信息)。检查放在认证之后, - // 保持与未开关前的 401 行为一致:未登录先 401,登录后再按开关决定。 - if !h.featureEnabled(c) { - response.Success(c, []userAvailableChannel{}) - return - } - userGroups, err := h.apiKeyService.GetAvailableGroups(c.Request.Context(), subject.UserID) if err != nil { response.ErrorFrom(c, err) @@ -136,6 +159,14 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { for i := range userGroups { allowedGroupIDs[userGroups[i].ID] = struct{}{} } + h.listVisible(c, allowedGroupIDs) +} + +func (h *AvailableChannelHandler) listVisible(c *gin.Context, allowedGroupIDs map[int64]struct{}) { + if !h.featureEnabled(c) { + response.Success(c, []userAvailableChannel{}) + return + } channels, err := h.channelService.ListAvailable(c.Request.Context()) if err != nil { @@ -148,7 +179,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { if ch.Status != service.StatusActive { continue } - visibleGroups := filterUserVisibleGroups(ch.Groups, allowedGroupIDs) + visibleGroups := filterVisibleGroups(ch.Groups, allowedGroupIDs) if len(visibleGroups) == 0 { continue } @@ -166,6 +197,52 @@ func (h *AvailableChannelHandler) List(c *gin.Context) { response.Success(c, out) } +// GetModelPricingBatch 批量查询模型默认定价。 +// POST /api/v1/channels/model-pricing/batch +func (h *AvailableChannelHandler) GetModelPricingBatch(c *gin.Context) { + if h.billingService == nil { + response.InternalError(c, "Billing service not available") + return + } + + var req userModelPricingBatchRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "invalid request body") + return + } + + prices := make(map[string]userDefaultModelPricing, len(req.Models)) + seen := make(map[string]struct{}, len(req.Models)) + for _, raw := range req.Models { + model := strings.TrimSpace(raw) + if model == "" { + continue + } + key := strings.ToLower(model) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + + pricing, err := h.billingService.GetModelPricing(model) + if err != nil { + prices[model] = userDefaultModelPricing{Found: false} + continue + } + prices[model] = userDefaultModelPricing{ + Found: true, + BillingMode: string(service.BillingModeToken), + InputPrice: &pricing.InputPricePerToken, + OutputPrice: &pricing.OutputPricePerToken, + CacheWritePrice: &pricing.CacheCreationPricePerToken, + CacheReadPrice: &pricing.CacheReadPricePerToken, + ImageOutputPrice: &pricing.ImageOutputPricePerToken, + } + } + + response.Success(c, userModelPricingBatchResponse{Prices: prices}) +} + // buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表: // 每个 section 对应一个平台,只包含该平台的 groups 和 supported_models。 // 输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。 @@ -195,6 +272,7 @@ func buildPlatformSections( platformSet := map[string]struct{}{platform: {}} sections = append(sections, userChannelPlatformSection{ Platform: platform, + BaseURL: defaultBaseURLForPlatform(platform), Groups: groupsByPlatform[platform], SupportedModels: toUserSupportedModels(ch.SupportedModels, platformSet), }) @@ -202,14 +280,41 @@ func buildPlatformSections( return sections } +func defaultBaseURLForPlatform(platform string) string { + switch platform { + case service.PlatformAnthropic: + return "https://api.anthropic.com" + case service.PlatformGemini: + return "https://generativelanguage.googleapis.com" + case service.PlatformAntigravity: + return "https://cloudcode-pa.googleapis.com" + case service.PlatformOpenAI: + return "https://api.openai.com" + default: + return "" + } +} + // filterUserVisibleGroups 仅保留用户可访问的分组。 func filterUserVisibleGroups( groups []service.AvailableGroupRef, allowed map[int64]struct{}, +) []userAvailableGroup { + return filterVisibleGroups(groups, allowed) +} + +// filterVisibleGroups 过滤可见分组。allowed 为 nil 时表示匿名模型广场,只展示公开分组。 +func filterVisibleGroups( + groups []service.AvailableGroupRef, + allowed map[int64]struct{}, ) []userAvailableGroup { visible := make([]userAvailableGroup, 0, len(groups)) for _, g := range groups { - if _, ok := allowed[g.ID]; !ok { + if allowed == nil { + if g.IsExclusive { + continue + } + } else if _, ok := allowed[g.ID]; !ok { continue } visible = append(visible, userAvailableGroup{ diff --git a/backend/internal/handler/available_channel_handler_test.go b/backend/internal/handler/available_channel_handler_test.go index 0a7ce6c466a..b48391086c9 100644 --- a/backend/internal/handler/available_channel_handler_test.go +++ b/backend/internal/handler/available_channel_handler_test.go @@ -3,11 +3,14 @@ package handler import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" @@ -27,6 +30,41 @@ func TestUserAvailableChannel_Unauthenticated401(t *testing.T) { require.Equal(t, http.StatusUnauthorized, w.Code) } +func TestUserModelPricingBatch_ReturnsPricingForKnownAndUnknownModels(t *testing.T) { + gin.SetMode(gin.TestMode) + h := &AvailableChannelHandler{ + billingService: service.NewBillingService(&config.Config{}, nil), + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest( + http.MethodPost, + "/api/v1/channels/model-pricing/batch", + bytes.NewBufferString(`{"models":["gpt-5.4","totally-unknown-model"]}`), + ) + c.Request.Header.Set("Content-Type", "application/json") + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1}) + + h.GetModelPricingBatch(c) + + require.Equal(t, http.StatusOK, w.Code) + var resp struct { + Code int `json:"code"` + Data struct { + Prices map[string]struct { + Found bool `json:"found"` + InputPrice *float64 `json:"input_price"` + } `json:"prices"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) + require.Equal(t, 0, resp.Code) + require.True(t, resp.Data.Prices["gpt-5.4"].Found) + require.NotNil(t, resp.Data.Prices["gpt-5.4"].InputPrice) + require.False(t, resp.Data.Prices["totally-unknown-model"].Found) + require.Nil(t, resp.Data.Prices["totally-unknown-model"].InputPrice) +} + func TestFilterUserVisibleGroups_IntersectionOnly(t *testing.T) { // 渠道挂在 {g1, g2, g3},用户只允许 {g1, g3} —— 响应必须仅含 g1/g3。 groups := []service.AvailableGroupRef{ @@ -42,6 +80,19 @@ func TestFilterUserVisibleGroups_IntersectionOnly(t *testing.T) { require.ElementsMatch(t, []int64{1, 3}, ids) } +func TestFilterVisibleGroups_PublicOnlyForAnonymous(t *testing.T) { + groups := []service.AvailableGroupRef{ + {ID: 1, Name: "public", Platform: "openai", IsExclusive: false}, + {ID: 2, Name: "exclusive", Platform: "openai", IsExclusive: true}, + } + + visible := filterVisibleGroups(groups, nil) + + require.Len(t, visible, 1) + require.Equal(t, int64(1), visible[0].ID) + require.False(t, visible[0].IsExclusive) +} + func TestToUserSupportedModels_FiltersByAllowedPlatforms(t *testing.T) { // 用户可访问分组只覆盖 anthropic;anthropic 平台的模型保留,openai 模型被剔除。 src := []service.SupportedModel{ @@ -72,6 +123,7 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { Platforms: []userChannelPlatformSection{ { Platform: "anthropic", + BaseURL: "https://api.anthropic.com", Groups: []userAvailableGroup{{ID: 1, Name: "g1", Platform: "anthropic"}}, SupportedModels: []userSupportedModel{}, }, @@ -96,7 +148,7 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) { require.NoError(t, err) var sectionDecoded map[string]any require.NoError(t, json.Unmarshal(rawSection, §ionDecoded)) - for _, key := range []string{"platform", "groups", "supported_models"} { + for _, key := range []string{"platform", "base_url", "groups", "supported_models"} { _, exists := sectionDecoded[key] require.Truef(t, exists, "platform section must expose %q", key) } @@ -149,7 +201,9 @@ func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) { sections := buildPlatformSections(ch, visible) require.Len(t, sections, 2) require.Equal(t, "anthropic", sections[0].Platform) + require.Equal(t, "https://api.anthropic.com", sections[0].BaseURL) require.Equal(t, "openai", sections[1].Platform) + require.Equal(t, "https://api.openai.com", sections[1].BaseURL) require.Len(t, sections[0].Groups, 1) require.Equal(t, int64(2), sections[0].Groups[0].ID) require.Len(t, sections[0].SupportedModels, 1) diff --git a/backend/internal/handler/chat_session_handler.go b/backend/internal/handler/chat_session_handler.go new file mode 100644 index 00000000000..c3b9f4c67ed --- /dev/null +++ b/backend/internal/handler/chat_session_handler.go @@ -0,0 +1,392 @@ +package handler + +import ( + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/chatmessage" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/gin-gonic/gin" +) + +const chatSessionRetention = 30 * 24 * time.Hour + +type ChatSessionHandler struct { + client *ent.Client +} + +func NewChatSessionHandler(client *ent.Client) *ChatSessionHandler { + return &ChatSessionHandler{client: client} +} + +type chatSessionDTO struct { + ID int64 `json:"id"` + APIKeyID int64 `json:"api_key_id"` + Title string `json:"title"` + Model string `json:"model"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +type chatMessageDTO struct { + ID int64 `json:"id"` + SessionID int64 `json:"session_id"` + Role string `json:"role"` + Content string `json:"content"` + Status string `json:"status"` + Model *string `json:"model,omitempty"` + DurationMs *int `json:"duration_ms,omitempty"` + UsageLogID *int64 `json:"usage_log_id,omitempty"` + ActualCost *float64 `json:"actual_cost,omitempty"` + ErrorMessage *string `json:"error_message,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type createChatSessionRequest struct { + APIKeyID int64 `json:"api_key_id" binding:"required"` + Title string `json:"title"` + Model string `json:"model" binding:"required"` +} + +type updateChatSessionRequest struct { + Title *string `json:"title"` + Status *string `json:"status"` + Model *string `json:"model"` +} + +type createChatMessageRequest struct { + Role string `json:"role" binding:"required"` + Content string `json:"content"` + Status string `json:"status"` + Model *string `json:"model"` + DurationMs *int `json:"duration_ms"` + UsageLogID *int64 `json:"usage_log_id"` + ActualCost *float64 `json:"actual_cost"` + ErrorMessage *string `json:"error_message"` +} + +type updateChatMessageRequest struct { + Content *string `json:"content"` + Status *string `json:"status"` + Model *string `json:"model"` + DurationMs *int `json:"duration_ms"` + UsageLogID *int64 `json:"usage_log_id"` + ActualCost *float64 `json:"actual_cost"` + ErrorMessage *string `json:"error_message"` +} + +func (h *ChatSessionHandler) ListSessions(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + sessions, err := h.client.ChatSession.Query(). + Where( + chatsession.UserIDEQ(subject.UserID), + chatsession.ExpiresAtGT(time.Now()), + chatsession.DeletedAtIsNil(), + ). + Order(ent.Desc(chatsession.FieldUpdatedAt)). + Limit(100). + All(c.Request.Context()) + if err != nil { + response.InternalError(c, "Failed to list chat sessions") + return + } + + out := make([]chatSessionDTO, 0, len(sessions)) + for _, session := range sessions { + out = append(out, toChatSessionDTO(session)) + } + response.Success(c, out) +} + +func (h *ChatSessionHandler) CreateSession(c *gin.Context) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return + } + + var req createChatSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + title := normalizeChatTitle(req.Title) + if title == "" { + title = "New chat" + } + + apiKey, err := h.client.APIKey.Get(c.Request.Context(), req.APIKeyID) + if err != nil { + response.NotFound(c, "API key not found") + return + } + if apiKey.UserID != subject.UserID { + response.Forbidden(c, "Not authorized to use this API key") + return + } + + session, err := h.client.ChatSession.Create(). + SetUserID(subject.UserID). + SetAPIKeyID(req.APIKeyID). + SetTitle(title). + SetModel(strings.TrimSpace(req.Model)). + SetExpiresAt(time.Now().Add(chatSessionRetention)). + Save(c.Request.Context()) + if err != nil { + response.InternalError(c, "Failed to create chat session") + return + } + response.Success(c, toChatSessionDTO(session)) +} + +func (h *ChatSessionHandler) UpdateSession(c *gin.Context) { + session, ok := h.requireOwnedSession(c) + if !ok { + return + } + + var req updateChatSessionRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + update := h.client.ChatSession.UpdateOneID(session.ID) + if req.Title != nil { + update.SetTitle(normalizeChatTitle(*req.Title)) + } + if req.Status != nil { + update.SetStatus(strings.TrimSpace(*req.Status)) + } + if req.Model != nil { + update.SetModel(strings.TrimSpace(*req.Model)) + } + updated, err := update.Save(c.Request.Context()) + if err != nil { + response.InternalError(c, "Failed to update chat session") + return + } + response.Success(c, toChatSessionDTO(updated)) +} + +func (h *ChatSessionHandler) DeleteSession(c *gin.Context) { + session, ok := h.requireOwnedSession(c) + if !ok { + return + } + + now := time.Now() + if _, err := h.client.ChatSession.UpdateOneID(session.ID).SetDeletedAt(now).Save(c.Request.Context()); err != nil { + response.InternalError(c, "Failed to delete chat session") + return + } + response.Success(c, gin.H{"deleted": true}) +} + +func (h *ChatSessionHandler) ListMessages(c *gin.Context) { + session, ok := h.requireOwnedSession(c) + if !ok { + return + } + + messages, err := h.client.ChatMessage.Query(). + Where(chatmessage.SessionIDEQ(session.ID), chatmessage.UserIDEQ(session.UserID)). + Order(ent.Asc(chatmessage.FieldCreatedAt), ent.Asc(chatmessage.FieldID)). + All(c.Request.Context()) + if err != nil { + response.InternalError(c, "Failed to list chat messages") + return + } + + out := make([]chatMessageDTO, 0, len(messages)) + for _, message := range messages { + out = append(out, toChatMessageDTO(message)) + } + response.Success(c, out) +} + +func (h *ChatSessionHandler) CreateMessage(c *gin.Context) { + session, ok := h.requireOwnedSession(c) + if !ok { + return + } + + var req createChatMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + status := strings.TrimSpace(req.Status) + if status == "" { + status = "completed" + } + create := h.client.ChatMessage.Create(). + SetSessionID(session.ID). + SetUserID(session.UserID). + SetRole(strings.TrimSpace(req.Role)). + SetContent(req.Content). + SetStatus(status) + if req.Model != nil { + create.SetModel(strings.TrimSpace(*req.Model)) + } + if req.DurationMs != nil { + create.SetDurationMs(*req.DurationMs) + } + if req.UsageLogID != nil { + create.SetUsageLogID(*req.UsageLogID) + } + if req.ActualCost != nil { + create.SetActualCost(*req.ActualCost) + } + if req.ErrorMessage != nil { + create.SetErrorMessage(*req.ErrorMessage) + } + message, err := create.Save(c.Request.Context()) + if err != nil { + response.InternalError(c, "Failed to create chat message") + return + } + _, _ = h.client.ChatSession.UpdateOneID(session.ID).SetUpdatedAt(time.Now()).Save(c.Request.Context()) + response.Success(c, toChatMessageDTO(message)) +} + +func (h *ChatSessionHandler) UpdateMessage(c *gin.Context) { + session, ok := h.requireOwnedSession(c) + if !ok { + return + } + messageID, ok := parseIDParam(c, "message_id") + if !ok { + return + } + + var req updateChatMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + message, err := h.client.ChatMessage.Query(). + Where(chatmessage.IDEQ(messageID), chatmessage.SessionIDEQ(session.ID), chatmessage.UserIDEQ(session.UserID)). + Only(c.Request.Context()) + if err != nil { + response.NotFound(c, "Chat message not found") + return + } + + update := h.client.ChatMessage.UpdateOneID(message.ID) + if req.Content != nil { + update.SetContent(*req.Content) + } + if req.Status != nil { + update.SetStatus(strings.TrimSpace(*req.Status)) + } + if req.Model != nil { + update.SetModel(strings.TrimSpace(*req.Model)) + } + if req.DurationMs != nil { + update.SetDurationMs(*req.DurationMs) + } + if req.UsageLogID != nil { + update.SetUsageLogID(*req.UsageLogID) + } + if req.ActualCost != nil { + update.SetActualCost(*req.ActualCost) + } + if req.ErrorMessage != nil { + update.SetErrorMessage(*req.ErrorMessage) + } + updated, err := update.Save(c.Request.Context()) + if err != nil { + response.InternalError(c, "Failed to update chat message") + return + } + _, _ = h.client.ChatSession.UpdateOneID(session.ID).SetUpdatedAt(time.Now()).Save(c.Request.Context()) + response.Success(c, toChatMessageDTO(updated)) +} + +func (h *ChatSessionHandler) requireOwnedSession(c *gin.Context) (*ent.ChatSession, bool) { + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + response.Unauthorized(c, "User not authenticated") + return nil, false + } + sessionID, ok := parseIDParam(c, "id") + if !ok { + return nil, false + } + session, err := h.client.ChatSession.Get(c.Request.Context(), sessionID) + if err != nil { + response.NotFound(c, "Chat session not found") + return nil, false + } + if session.UserID != subject.UserID { + response.Forbidden(c, "Not authorized to access this chat session") + return nil, false + } + if session.DeletedAt != nil || time.Now().After(session.ExpiresAt) { + response.NotFound(c, "Chat session not found") + return nil, false + } + return session, true +} + +func parseIDParam(c *gin.Context, name string) (int64, bool) { + id, err := strconv.ParseInt(c.Param(name), 10, 64) + if err != nil || id <= 0 { + response.BadRequest(c, "Invalid "+name) + return 0, false + } + return id, true +} + +func normalizeChatTitle(title string) string { + title = strings.TrimSpace(title) + if len([]rune(title)) > 160 { + runes := []rune(title) + title = string(runes[:160]) + } + return title +} + +func toChatSessionDTO(session *ent.ChatSession) chatSessionDTO { + return chatSessionDTO{ + ID: session.ID, + APIKeyID: session.APIKeyID, + Title: session.Title, + Model: session.Model, + Status: session.Status, + ExpiresAt: session.ExpiresAt, + CreatedAt: session.CreatedAt, + UpdatedAt: session.UpdatedAt, + DeletedAt: session.DeletedAt, + } +} + +func toChatMessageDTO(message *ent.ChatMessage) chatMessageDTO { + return chatMessageDTO{ + ID: message.ID, + SessionID: message.SessionID, + Role: message.Role, + Content: message.Content, + Status: message.Status, + Model: message.Model, + DurationMs: message.DurationMs, + UsageLogID: message.UsageLogID, + ActualCost: message.ActualCost, + ErrorMessage: message.ErrorMessage, + CreatedAt: message.CreatedAt, + UpdatedAt: message.UpdatedAt, + } +} diff --git a/backend/internal/handler/chat_session_handler_test.go b/backend/internal/handler/chat_session_handler_test.go new file mode 100644 index 00000000000..fdf0736a1c1 --- /dev/null +++ b/backend/internal/handler/chat_session_handler_test.go @@ -0,0 +1,210 @@ +package handler + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/chatsession" + "github.com/Wei-Shaw/sub2api/ent/enttest" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + _ "modernc.org/sqlite" +) + +func newChatSessionHandlerTestClient(t *testing.T) *ent.Client { + t.Helper() + + dbName := "file:chat_session_handler_" + strconv.FormatInt(time.Now().UnixNano(), 10) + "?mode=memory&cache=shared&_fk=1" + db, err := sql.Open("sqlite", dbName) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + _, err = db.Exec("PRAGMA foreign_keys = ON") + require.NoError(t, err) + + drv := entsql.OpenDB(dialect.SQLite, db) + return enttest.NewClient(t, enttest.WithOptions(ent.Driver(drv))) +} + +func chatSessionTestContext(method, target string, body []byte, userID int64) (*gin.Context, *httptest.ResponseRecorder) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(method, target, bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: userID}) + return c, recorder +} + +func seedChatSessionUserAndKey(t *testing.T, client *ent.Client, email string) (int64, *ent.APIKey) { + t.Helper() + + ctx := context.Background() + user := client.User.Create(). + SetEmail(email). + SetPasswordHash("hash"). + SaveX(ctx) + key := client.APIKey.Create(). + SetUserID(user.ID). + SetKey("sk-test-" + email). + SetName("Key"). + SaveX(ctx) + return user.ID, key +} + +func TestChatSessionHandlerCreatesAndListsOnlyCurrentUsersActiveSessions(t *testing.T) { + gin.SetMode(gin.TestMode) + client := newChatSessionHandlerTestClient(t) + userID, key := seedChatSessionUserAndKey(t, client, "chat-user@example.com") + otherUserID, otherKey := seedChatSessionUserAndKey(t, client, "chat-other@example.com") + + expired := time.Now().Add(-time.Hour) + client.ChatSession.Create(). + SetUserID(userID). + SetAPIKeyID(key.ID). + SetTitle("Expired"). + SetModel("gpt-5.4"). + SetExpiresAt(expired). + SaveX(context.Background()) + client.ChatSession.Create(). + SetUserID(otherUserID). + SetAPIKeyID(otherKey.ID). + SetTitle("Other"). + SetModel("gpt-5.4"). + SetExpiresAt(time.Now().Add(24 * time.Hour)). + SaveX(context.Background()) + + h := NewChatSessionHandler(client) + c, recorder := chatSessionTestContext(http.MethodPost, "/api/v1/chat/sessions", []byte(`{"api_key_id":1,"title":"Hello world","model":"gpt-5.4"}`), userID) + h.CreateSession(c) + require.Equal(t, http.StatusOK, recorder.Code) + + var createResp struct { + Code int `json:"code"` + Data struct { + ID int64 `json:"id"` + Title string `json:"title"` + Model string `json:"model"` + ExpiresAt string `json:"expires_at"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &createResp)) + require.Equal(t, 0, createResp.Code) + require.Equal(t, "Hello world", createResp.Data.Title) + require.Equal(t, "gpt-5.4", createResp.Data.Model) + require.NotEmpty(t, createResp.Data.ExpiresAt) + + c, recorder = chatSessionTestContext(http.MethodGet, "/api/v1/chat/sessions", nil, userID) + h.ListSessions(c) + require.Equal(t, http.StatusOK, recorder.Code) + + var listResp struct { + Code int `json:"code"` + Data []struct { + ID int64 `json:"id"` + Title string `json:"title"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &listResp)) + require.Len(t, listResp.Data, 1) + require.Equal(t, createResp.Data.ID, listResp.Data[0].ID) +} + +func formatTestID(id int64) string { + return strconv.FormatInt(id, 10) +} + +func TestChatSessionHandlerRejectsCrossUserMessagesAndSavesOwnMessages(t *testing.T) { + gin.SetMode(gin.TestMode) + client := newChatSessionHandlerTestClient(t) + userID, key := seedChatSessionUserAndKey(t, client, "chat-owner@example.com") + intruderID, _ := seedChatSessionUserAndKey(t, client, "chat-intruder@example.com") + session := client.ChatSession.Create(). + SetUserID(userID). + SetAPIKeyID(key.ID). + SetTitle("Owned"). + SetModel("gpt-5.4"). + SetExpiresAt(time.Now().Add(24 * time.Hour)). + SaveX(context.Background()) + + h := NewChatSessionHandler(client) + c, recorder := chatSessionTestContext(http.MethodGet, "/api/v1/chat/sessions/1/messages", nil, intruderID) + c.Params = gin.Params{{Key: "id", Value: formatTestID(session.ID)}} + h.ListMessages(c) + require.Equal(t, http.StatusForbidden, recorder.Code) + + c, recorder = chatSessionTestContext(http.MethodPost, "/api/v1/chat/sessions/1/messages", []byte(`{"role":"user","content":"hello","status":"completed"}`), userID) + c.Params = gin.Params{{Key: "id", Value: formatTestID(session.ID)}} + h.CreateMessage(c) + require.Equal(t, http.StatusOK, recorder.Code) + + c, recorder = chatSessionTestContext(http.MethodGet, "/api/v1/chat/sessions/1/messages", nil, userID) + c.Params = gin.Params{{Key: "id", Value: formatTestID(session.ID)}} + h.ListMessages(c) + require.Equal(t, http.StatusOK, recorder.Code) + + var resp struct { + Data []struct { + SessionID int64 `json:"session_id"` + Role string `json:"role"` + Content string `json:"content"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &resp)) + require.Len(t, resp.Data, 1) + require.Equal(t, session.ID, resp.Data[0].SessionID) + require.Equal(t, "user", resp.Data[0].Role) + require.Equal(t, "hello", resp.Data[0].Content) +} + +func TestChatSessionHandlerUpdatesAssistantMessageAndDeletesSession(t *testing.T) { + gin.SetMode(gin.TestMode) + client := newChatSessionHandlerTestClient(t) + userID, key := seedChatSessionUserAndKey(t, client, "chat-update@example.com") + session := client.ChatSession.Create(). + SetUserID(userID). + SetAPIKeyID(key.ID). + SetTitle("Update"). + SetModel("gpt-5.4"). + SetExpiresAt(time.Now().Add(24 * time.Hour)). + SaveX(context.Background()) + message := client.ChatMessage.Create(). + SetSessionID(session.ID). + SetUserID(userID). + SetRole("assistant"). + SetStatus("streaming"). + SetContent(""). + SaveX(context.Background()) + + h := NewChatSessionHandler(client) + c, recorder := chatSessionTestContext(http.MethodPatch, "/api/v1/chat/sessions/1/messages/1", []byte(`{"content":"done","status":"completed","duration_ms":1200,"actual_cost":0.0012}`), userID) + c.Params = gin.Params{{Key: "id", Value: formatTestID(session.ID)}, {Key: "message_id", Value: formatTestID(message.ID)}} + h.UpdateMessage(c) + require.Equal(t, http.StatusOK, recorder.Code) + + updated := client.ChatMessage.GetX(context.Background(), message.ID) + require.Equal(t, "completed", updated.Status) + require.Equal(t, "done", updated.Content) + require.NotNil(t, updated.DurationMs) + require.Equal(t, 1200, *updated.DurationMs) + require.NotNil(t, updated.ActualCost) + + c, recorder = chatSessionTestContext(http.MethodDelete, "/api/v1/chat/sessions/1", nil, userID) + c.Params = gin.Params{{Key: "id", Value: formatTestID(session.ID)}} + h.DeleteSession(c) + require.Equal(t, http.StatusOK, recorder.Code) + + count := client.ChatSession.Query(). + Where(chatsession.IDEQ(session.ID), chatsession.DeletedAtIsNil()). + CountX(context.Background()) + require.Equal(t, 0, count) +} diff --git a/backend/internal/handler/content_moderation_helper.go b/backend/internal/handler/content_moderation_helper.go new file mode 100644 index 00000000000..af6dbd8ee90 --- /dev/null +++ b/backend/internal/handler/content_moderation_helper.go @@ -0,0 +1,130 @@ +package handler + +import ( + "context" + "net/http" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func (h *GatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision { + if h == nil || h.contentModerationService == nil { + return nil + } + return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body) +} + +func contentModerationStatus(decision *service.ContentModerationDecision) int { + if decision == nil || decision.StatusCode < 400 || decision.StatusCode > 599 { + return http.StatusForbidden + } + return decision.StatusCode +} + +func contentModerationErrorCode(decision *service.ContentModerationDecision) string { + return "content_policy_violation" +} + +func (h *OpenAIGatewayHandler) checkContentModeration(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision { + if h == nil || h.contentModerationService == nil { + return nil + } + return runContentModeration(c, reqLog, h.contentModerationService, apiKey, subject, protocol, model, body) +} + +func runContentModeration(c *gin.Context, reqLog *zap.Logger, svc *service.ContentModerationService, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision { + if svc == nil || c == nil || c.Request == nil { + return nil + } + input := buildContentModerationInput(c, apiKey, subject, protocol, model, body) + if reqLog != nil { + reqLog.Info("content_moderation.gateway_check_start", + zap.String("request_id", input.RequestID), + zap.Int64("user_id", input.UserID), + zap.Int64("api_key_id", input.APIKeyID), + zap.String("api_key_name", input.APIKeyName), + zap.Int64p("group_id", input.GroupID), + zap.String("group_name", input.GroupName), + zap.String("endpoint", input.Endpoint), + zap.String("provider", input.Provider), + zap.String("protocol", input.Protocol), + zap.String("model", input.Model), + zap.Int("body_bytes", len(body)), + ) + } + decision, err := svc.Check(c.Request.Context(), input) + if err != nil { + if reqLog != nil { + reqLog.Warn("content_moderation.check_failed", zap.Error(err)) + } + return nil + } + if reqLog != nil && decision != nil { + reqLog.Info("content_moderation.gateway_check_done", + zap.String("request_id", input.RequestID), + zap.Bool("allowed", decision.Allowed), + zap.Bool("blocked", decision.Blocked), + zap.Bool("flagged", decision.Flagged), + zap.String("action", decision.Action), + zap.Int("status_code", decision.StatusCode), + zap.String("highest_category", decision.HighestCategory), + zap.Float64("highest_score", decision.HighestScore), + ) + } + return decision +} + +func buildContentModerationInput(c *gin.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) service.ContentModerationCheckInput { + input := service.ContentModerationCheckInput{ + RequestID: contentModerationRequestID(c.Request.Context()), + UserID: subject.UserID, + Endpoint: GetInboundEndpoint(c), + Provider: contentModerationProvider(apiKey), + Model: strings.TrimSpace(model), + Protocol: protocol, + Body: body, + } + if forcedPlatform, ok := middleware2.GetForcePlatformFromContext(c); ok { + input.Provider = strings.TrimSpace(forcedPlatform) + } + if apiKey != nil { + input.APIKeyID = apiKey.ID + input.APIKeyName = apiKey.Name + if apiKey.User != nil { + input.UserEmail = apiKey.User.Email + } + if apiKey.GroupID != nil { + groupID := *apiKey.GroupID + input.GroupID = &groupID + } + if apiKey.Group != nil { + input.GroupName = apiKey.Group.Name + } + } + if input.Endpoint == "" && c.Request != nil && c.Request.URL != nil { + input.Endpoint = c.Request.URL.Path + } + return input +} + +func contentModerationProvider(apiKey *service.APIKey) string { + if apiKey == nil || apiKey.Group == nil { + return "" + } + return strings.TrimSpace(apiKey.Group.Platform) +} + +func contentModerationRequestID(ctx context.Context) string { + if ctx == nil { + return "" + } + if requestID, ok := ctx.Value(ctxkey.RequestID).(string); ok { + return strings.TrimSpace(requestID) + } + return "" +} diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index f7503c2ea15..2559b112cb9 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -176,6 +176,9 @@ func groupFromServiceBase(g *service.Group) Group { DailyLimitUSD: g.DailyLimitUSD, WeeklyLimitUSD: g.WeeklyLimitUSD, MonthlyLimitUSD: g.MonthlyLimitUSD, + AllowImageGeneration: g.AllowImageGeneration, + ImageRateIndependent: g.ImageRateIndependent, + ImageRateMultiplier: g.ImageRateMultiplier, ImagePrice1K: g.ImagePrice1K, ImagePrice2K: g.ImagePrice2K, ImagePrice4K: g.ImagePrice4K, diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 492be170bee..6f455c7fb43 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -11,6 +11,7 @@ type CustomMenuItem struct { Label string `json:"label"` IconSVG string `json:"icon_svg"` URL string `json:"url"` + PageSlug string `json:"page_slug,omitempty"` Visibility string `json:"visibility"` // "user" or "admin" SortOrder int `json:"sort_order"` } @@ -24,15 +25,19 @@ type CustomEndpoint struct { // SystemSettings represents the admin settings API response payload. type SystemSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - FrontendURL string `json:"frontend_url"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + FrontendURL string `json:"frontend_url"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置 + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` @@ -91,6 +96,17 @@ type SystemSettings struct { OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"` OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GitHubOAuthClientID string `json:"github_oauth_client_id"` + GitHubOAuthClientSecretConfigured bool `json:"github_oauth_client_secret_configured"` + GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"` + GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + GoogleOAuthClientID string `json:"google_oauth_client_id"` + GoogleOAuthClientSecretConfigured bool `json:"google_oauth_client_secret_configured"` + GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"` + GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"` + SiteName string `json:"site_name"` SiteLogo string `json:"site_logo"` SiteSubtitle string `json:"site_subtitle"` @@ -197,6 +213,15 @@ type SystemSettings struct { // Available Channels feature switch (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` + // Image Generation feature switch (user-facing tool) + ImageGenerationEnabled bool `json:"image_generation_enabled"` + + // Chat Completion feature switch (user-facing tool) + ChatCompletionEnabled bool `json:"chat_completion_enabled"` + + // 风控中心功能开关 + RiskControlEnabled bool `json:"risk_control_enabled"` + // Affiliate (邀请返利) feature switch AffiliateEnabled bool `json:"affiliate_enabled"` @@ -210,52 +235,71 @@ type DefaultSubscriptionSetting struct { } type PublicSettings struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` - CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` - WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` - WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` - WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` - SoraClientEnabled bool `json:"sora_client_enabled"` - BackendModeEnabled bool `json:"backend_mode_enabled"` - PaymentEnabled bool `json:"payment_enabled"` - Version string `json:"version"` - BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` - AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` - BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` - BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证 + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementRevision string `json:"login_agreement_revision"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` + CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` + WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` + WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` + WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + SoraClientEnabled bool `json:"sora_client_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` + Version string `json:"version"` + BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` + AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` + BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` + ImageGenerationEnabled bool `json:"image_generation_enabled"` + + ChatCompletionEnabled bool `json:"chat_completion_enabled"` + AffiliateEnabled bool `json:"affiliate_enabled"` + + RiskControlEnabled bool `json:"risk_control_enabled"` +} + +type LoginAgreementDocument struct { + ID string `json:"id"` + Title string `json:"title"` + ContentMD string `json:"content_md"` } // OverloadCooldownSettings 529过载冷却配置 DTO @@ -264,6 +308,12 @@ type OverloadCooldownSettings struct { CooldownMinutes int `json:"cooldown_minutes"` } +// RateLimit429CooldownSettings 429默认回避配置 DTO +type RateLimit429CooldownSettings struct { + Enabled bool `json:"enabled"` + CooldownSeconds int `json:"cooldown_seconds"` +} + // StreamTimeoutSettings 流超时处理配置 DTO type StreamTimeoutSettings struct { Enabled bool `json:"enabled"` diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 5cc2f8e4dd4..e15a916eec4 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -94,9 +94,12 @@ type Group struct { MonthlyLimitUSD *float64 `json:"monthly_limit_usd"` // 图片生成计费配置(仅 antigravity 平台使用) - ImagePrice1K *float64 `json:"image_price_1k"` - ImagePrice2K *float64 `json:"image_price_2k"` - ImagePrice4K *float64 `json:"image_price_4k"` + AllowImageGeneration bool `json:"allow_image_generation"` + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier float64 `json:"image_rate_multiplier"` + ImagePrice1K *float64 `json:"image_price_1k"` + ImagePrice2K *float64 `json:"image_price_2k"` + ImagePrice4K *float64 `json:"image_price_4k"` // Claude Code 客户端限制 ClaudeCodeOnly bool `json:"claude_code_only"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 7b082b07ff6..65836a7e452 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -45,6 +45,7 @@ type GatewayHandler struct { apiKeyService *service.APIKeyService usageRecordWorkerPool *service.UsageRecordWorkerPool errorPassthroughService *service.ErrorPassthroughService + contentModerationService *service.ContentModerationService concurrencyHelper *ConcurrencyHelper userMsgQueueHelper *UserMsgQueueHelper maxAccountSwitches int @@ -65,6 +66,7 @@ func NewGatewayHandler( apiKeyService *service.APIKeyService, usageRecordWorkerPool *service.UsageRecordWorkerPool, errorPassthroughService *service.ErrorPassthroughService, + contentModerationService *service.ContentModerationService, userMsgQueueService *service.UserMessageQueueService, cfg *config.Config, settingService *service.SettingService, @@ -98,6 +100,7 @@ func NewGatewayHandler( apiKeyService: apiKeyService, usageRecordWorkerPool: usageRecordWorkerPool, errorPassthroughService: errorPassthroughService, + contentModerationService: contentModerationService, concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatClaude, pingInterval), userMsgQueueHelper: umqHelper, maxAccountSwitches: maxAccountSwitches, @@ -189,6 +192,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) { return } + if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked { + h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message) + return + } + // Track if we've started streaming (for error handling) streamStarted := false diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go index 4290e54bfbb..c6b73190367 100644 --- a/backend/internal/handler/gateway_handler_chat_completions.go +++ b/backend/internal/handler/gateway_handler_chat_completions.go @@ -91,6 +91,11 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) { return } + if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked { + h.chatCompletionsErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message) + return + } + // Error passthrough binding if h.errorPassthroughService != nil { service.BindErrorPassthroughService(c, h.errorPassthroughService) diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go index 683cf2b7719..a97f572d2a8 100644 --- a/backend/internal/handler/gateway_handler_responses.go +++ b/backend/internal/handler/gateway_handler_responses.go @@ -96,6 +96,11 @@ func (h *GatewayHandler) Responses(c *gin.Context) { return } + if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked { + h.responsesErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message) + return + } + // Error passthrough binding if h.errorPassthroughService != nil { service.BindErrorPassthroughService(c, h.errorPassthroughService) diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go index 2a34e3f079d..90ebe9ecc69 100644 --- a/backend/internal/handler/gemini_v1beta_handler.go +++ b/backend/internal/handler/gemini_v1beta_handler.go @@ -185,6 +185,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) { setOpsRequestContext(c, modelName, stream, body) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false))) + if decision := h.checkContentModeration(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && decision.Blocked { + googleError(c, contentModerationStatus(decision), decision.Message) + return + } + // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, modelName) reqModel := modelName // 保存映射前的原始模型名 diff --git a/backend/internal/handler/handler.go b/backend/internal/handler/handler.go index 13e3ac8869a..4835caf7ee7 100644 --- a/backend/internal/handler/handler.go +++ b/backend/internal/handler/handler.go @@ -33,6 +33,7 @@ type AdminHandlers struct { Channel *admin.ChannelHandler ChannelMonitor *admin.ChannelMonitorHandler ChannelMonitorTemplate *admin.ChannelMonitorRequestTemplateHandler + ContentModeration *admin.ContentModerationHandler Payment *admin.PaymentHandler Affiliate *admin.AffiliateHandler } @@ -47,6 +48,7 @@ type Handlers struct { Subscription *SubscriptionHandler Announcement *AnnouncementHandler ChannelMonitor *ChannelMonitorUserHandler + ChatSession *ChatSessionHandler Admin *AdminHandlers Gateway *GatewayHandler OpenAIGateway *OpenAIGatewayHandler diff --git a/backend/internal/handler/image_concurrency_limiter.go b/backend/internal/handler/image_concurrency_limiter.go new file mode 100644 index 00000000000..6e7cbb676c7 --- /dev/null +++ b/backend/internal/handler/image_concurrency_limiter.go @@ -0,0 +1,126 @@ +package handler + +import ( + "context" + "sync" + "time" +) + +type imageConcurrencyLimiter struct { + mu sync.Mutex + notify chan struct{} + limit int + active int + waiting int + enabled bool +} + +func (l *imageConcurrencyLimiter) TryAcquire(enabled bool, limit int) (func(), bool) { + return l.acquire(context.Background(), enabled, limit, false, 0, 0) +} + +func (l *imageConcurrencyLimiter) Acquire(ctx context.Context, enabled bool, limit int, wait bool, timeout time.Duration, maxWaiting int) (func(), bool) { + return l.acquire(ctx, enabled, limit, wait, timeout, maxWaiting) +} + +func (l *imageConcurrencyLimiter) acquire(ctx context.Context, enabled bool, limit int, wait bool, timeout time.Duration, maxWaiting int) (func(), bool) { + if !enabled || limit <= 0 { + return nil, true + } + if ctx == nil { + ctx = context.Background() + } + if wait { + if timeout <= 0 { + return nil, false + } + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ctx = waitCtx + } + if maxWaiting < 0 { + maxWaiting = 0 + } + for { + release, acquired, waitRelease, notify := l.tryAcquireLocked(enabled, limit, wait, maxWaiting) + if acquired { + return release, acquired + } + if !wait || notify == nil { + return nil, false + } + if !l.waitForSlot(ctx, notify) { + if waitRelease != nil { + waitRelease() + } + return nil, false + } + if waitRelease != nil { + waitRelease() + } + } +} + +func (l *imageConcurrencyLimiter) tryAcquireLocked(enabled bool, limit int, wait bool, maxWaiting int) (func(), bool, func(), <-chan struct{}) { + l.mu.Lock() + defer l.mu.Unlock() + + if l.notify == nil { + l.notify = make(chan struct{}) + } + if l.enabled != enabled || l.limit != limit { + l.enabled = enabled + l.limit = limit + } + if l.active < l.limit { + l.active++ + return l.releaseFunc(), true, nil, nil + } + if !wait { + return nil, false, nil, nil + } + if maxWaiting > 0 && l.waiting >= maxWaiting { + return nil, false, nil, nil + } + l.waiting++ + return nil, false, l.waiterReleaseFunc(), l.notify +} + +func (l *imageConcurrencyLimiter) waitForSlot(ctx context.Context, notify <-chan struct{}) bool { + select { + case <-notify: + return true + case <-ctx.Done(): + return false + } +} + +func (l *imageConcurrencyLimiter) releaseFunc() func() { + var once sync.Once + return func() { + once.Do(func() { + l.mu.Lock() + if l.active > 0 { + l.active-- + } + if l.notify != nil { + close(l.notify) + l.notify = make(chan struct{}) + } + l.mu.Unlock() + }) + } +} + +func (l *imageConcurrencyLimiter) waiterReleaseFunc() func() { + var once sync.Once + return func() { + once.Do(func() { + l.mu.Lock() + if l.waiting > 0 { + l.waiting-- + } + l.mu.Unlock() + }) + } +} diff --git a/backend/internal/handler/image_concurrency_limiter_test.go b/backend/internal/handler/image_concurrency_limiter_test.go new file mode 100644 index 00000000000..20147f16119 --- /dev/null +++ b/backend/internal/handler/image_concurrency_limiter_test.go @@ -0,0 +1,230 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestImageConcurrencyLimiter_DefaultDisabledAllowsRequests(t *testing.T) { + limiter := &imageConcurrencyLimiter{} + + release, acquired := limiter.TryAcquire(false, 1) + + require.True(t, acquired) + require.Nil(t, release) +} + +func TestImageConcurrencyLimiter_RejectsWhenLimitReachedAndAllowsAfterRelease(t *testing.T) { + limiter := &imageConcurrencyLimiter{} + + release, acquired := limiter.TryAcquire(true, 1) + require.True(t, acquired) + require.NotNil(t, release) + + secondRelease, secondAcquired := limiter.TryAcquire(true, 1) + require.False(t, secondAcquired) + require.Nil(t, secondRelease) + + release() + thirdRelease, thirdAcquired := limiter.TryAcquire(true, 1) + require.True(t, thirdAcquired) + require.NotNil(t, thirdRelease) + thirdRelease() +} + +func TestImageConcurrencyLimiter_WaitsUntilSlotReleased(t *testing.T) { + limiter := &imageConcurrencyLimiter{} + release, acquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1) + require.True(t, acquired) + require.NotNil(t, release) + + acquiredCh := make(chan func(), 1) + go func() { + waitRelease, waitAcquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1) + require.True(t, waitAcquired) + acquiredCh <- waitRelease + }() + + time.Sleep(20 * time.Millisecond) + release() + + select { + case waitRelease := <-acquiredCh: + require.NotNil(t, waitRelease) + waitRelease() + case <-time.After(time.Second): + t.Fatal("timed out waiting for image concurrency slot") + } +} + +func TestImageConcurrencyLimiter_WaitTimesOut(t *testing.T) { + limiter := &imageConcurrencyLimiter{} + release, acquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1) + require.True(t, acquired) + require.NotNil(t, release) + defer release() + + waitRelease, waitAcquired := limiter.Acquire(context.Background(), true, 1, true, 10*time.Millisecond, 1) + + require.False(t, waitAcquired) + require.Nil(t, waitRelease) +} + +func TestImageConcurrencyLimiter_MaxWaitingRequestsRejectsOverflow(t *testing.T) { + limiter := &imageConcurrencyLimiter{} + release, acquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1) + require.True(t, acquired) + require.NotNil(t, release) + defer release() + + waitingStarted := make(chan struct{}) + waitingDone := make(chan struct{}) + go func() { + close(waitingStarted) + waitRelease, waitAcquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1) + if waitAcquired && waitRelease != nil { + waitRelease() + } + close(waitingDone) + }() + <-waitingStarted + time.Sleep(20 * time.Millisecond) + + overflowRelease, overflowAcquired := limiter.Acquire(context.Background(), true, 1, true, time.Second, 1) + + require.False(t, overflowAcquired) + require.Nil(t, overflowRelease) + release() + <-waitingDone +} + +func TestOpenAIGatewayHandlerAcquireImageGenerationSlot_Returns429WhenFull(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/images/generations", nil) + + h := &OpenAIGatewayHandler{ + cfg: &config.Config{ + Gateway: config.GatewayConfig{ + ImageConcurrency: config.ImageConcurrencyConfig{ + Enabled: true, + MaxConcurrentRequests: 1, + OverflowMode: config.ImageConcurrencyOverflowModeReject, + }, + }, + }, + imageLimiter: &imageConcurrencyLimiter{}, + } + release, acquired := h.acquireImageGenerationSlot(c, false) + require.True(t, acquired) + require.NotNil(t, release) + defer release() + + blockedRelease, blocked := h.acquireImageGenerationSlot(c, false) + + require.False(t, blocked) + require.Nil(t, blockedRelease) + require.Equal(t, http.StatusTooManyRequests, rec.Code) + require.Equal(t, "rate_limit_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String()) + require.Contains(t, rec.Body.String(), "Image generation concurrency limit exceeded") +} + +func TestOpenAIGatewayHandlerResponses_ImageIntentRejectedByImageConcurrency(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{"model":"gpt-5.4","input":"draw","tools":[{"type":"image_generation"}]}` + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + groupID := int64(1) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 10, + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + AllowImageGeneration: true, + }, + User: &service.User{ID: 20}, + }) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 20, Concurrency: 1}) + + h := &OpenAIGatewayHandler{ + gatewayService: &service.OpenAIGatewayService{}, + billingCacheService: &service.BillingCacheService{}, + apiKeyService: &service.APIKeyService{}, + concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService(&helperConcurrencyCacheStub{userSeq: []bool{true}})}, + errorPassthroughService: nil, + cfg: &config.Config{Gateway: config.GatewayConfig{ImageConcurrency: config.ImageConcurrencyConfig{ + Enabled: true, + MaxConcurrentRequests: 1, + OverflowMode: config.ImageConcurrencyOverflowModeReject, + }}}, + imageLimiter: &imageConcurrencyLimiter{}, + } + release, acquired := h.acquireImageGenerationSlot(c, false) + require.True(t, acquired) + require.NotNil(t, release) + defer release() + rec.Body.Reset() + rec.Code = 0 + + h.Responses(c) + + require.Equal(t, http.StatusTooManyRequests, rec.Code) + require.Equal(t, "rate_limit_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String()) + require.Contains(t, rec.Body.String(), "Image generation concurrency limit exceeded") +} + +func TestOpenAIGatewayHandlerResponses_TextOnlyNotRejectedByImageConcurrency(t *testing.T) { + gin.SetMode(gin.TestMode) + body := `{"model":"gpt-5.4","input":"write code"}` + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + groupID := int64(1) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 10, + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + AllowImageGeneration: true, + }, + User: &service.User{ID: 20}, + }) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 20, Concurrency: 1}) + + h := &OpenAIGatewayHandler{ + gatewayService: &service.OpenAIGatewayService{}, + billingCacheService: service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}), + apiKeyService: &service.APIKeyService{}, + concurrencyHelper: &ConcurrencyHelper{concurrencyService: service.NewConcurrencyService(&helperConcurrencyCacheStub{userSeq: []bool{true}})}, + cfg: &config.Config{Gateway: config.GatewayConfig{ImageConcurrency: config.ImageConcurrencyConfig{ + Enabled: true, + MaxConcurrentRequests: 1, + OverflowMode: config.ImageConcurrencyOverflowModeReject, + }}}, + imageLimiter: &imageConcurrencyLimiter{}, + } + release, acquired := h.acquireImageGenerationSlot(c, false) + require.True(t, acquired) + require.NotNil(t, release) + defer release() + rec.Body.Reset() + rec.Code = 0 + + h.Responses(c) + + require.NotEqual(t, http.StatusTooManyRequests, rec.Code) + require.NotContains(t, rec.Body.String(), "Image generation concurrency limit exceeded") +} diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index 23844508968..de384710284 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -81,6 +81,11 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { setOpsRequestContext(c, reqModel, reqStream, body) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) + if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, reqModel, body); decision != nil && decision.Blocked { + h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message) + return + } + // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) @@ -187,52 +192,60 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs)) } if err != nil { - var failoverErr *service.UpstreamFailoverError - if errors.As(err, &failoverErr) { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) - // Pool mode: retry on the same account - if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { - sameAccountRetryCount[account.ID]++ - reqLog.Warn("openai_chat_completions.pool_mode_same_account_retry", - zap.Int64("account_id", account.ID), - zap.Int("upstream_status", failoverErr.StatusCode), - zap.Int("retry_limit", retryLimit), - zap.Int("retry_count", sameAccountRetryCount[account.ID]), - ) - select { - case <-c.Request.Context().Done(): - return - case <-time.After(sameAccountRetryDelay): + if result != nil && result.ImageCount > 0 { + reqLog.Warn("openai_chat_completions.forward_partial_error_with_image_result", + zap.Int64("account_id", account.ID), + zap.Int("image_count", result.ImageCount), + zap.Error(err), + ) + } else { + var failoverErr *service.UpstreamFailoverError + if errors.As(err, &failoverErr) { + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + // Pool mode: retry on the same account + if failoverErr.RetryableOnSameAccount { + retryLimit := account.GetPoolModeRetryCount() + if sameAccountRetryCount[account.ID] < retryLimit { + sameAccountRetryCount[account.ID]++ + reqLog.Warn("openai_chat_completions.pool_mode_same_account_retry", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("retry_limit", retryLimit), + zap.Int("retry_count", sameAccountRetryCount[account.ID]), + ) + select { + case <-c.Request.Context().Done(): + return + case <-time.After(sameAccountRetryDelay): + } + continue } - continue } + h.gatewayService.RecordOpenAIAccountSwitch() + failedAccountIDs[account.ID] = struct{}{} + lastFailoverErr = failoverErr + if switchCount >= maxAccountSwitches { + h.handleFailoverExhausted(c, failoverErr, streamStarted) + return + } + switchCount++ + reqLog.Warn("openai_chat_completions.upstream_failover_switching", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("switch_count", switchCount), + zap.Int("max_switches", maxAccountSwitches), + ) + continue } - h.gatewayService.RecordOpenAIAccountSwitch() - failedAccountIDs[account.ID] = struct{}{} - lastFailoverErr = failoverErr - if switchCount >= maxAccountSwitches { - h.handleFailoverExhausted(c, failoverErr, streamStarted) - return - } - switchCount++ - reqLog.Warn("openai_chat_completions.upstream_failover_switching", + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + wroteFallback := h.ensureForwardErrorResponse(c, streamStarted) + reqLog.Warn("openai_chat_completions.forward_failed", zap.Int64("account_id", account.ID), - zap.Int("upstream_status", failoverErr.StatusCode), - zap.Int("switch_count", switchCount), - zap.Int("max_switches", maxAccountSwitches), + zap.Bool("fallback_error_response_written", wroteFallback), + zap.Error(err), ) - continue + return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) - wroteFallback := h.ensureForwardErrorResponse(c, streamStarted) - reqLog.Warn("openai_chat_completions.forward_failed", - zap.Int64("account_id", account.ID), - zap.Bool("fallback_error_response_written", wroteFallback), - zap.Error(err), - ) - return } if result != nil { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) @@ -242,16 +255,18 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := resolveRawCCUpstreamEndpoint(c, account) - h.submitUsageRecordTask(func(ctx context.Context) { + h.submitOpenAIUsageRecordTask(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: GetInboundEndpoint(c), - UpstreamEndpoint: resolveRawCCUpstreamEndpoint(c, account), + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, APIKeyService: h.apiKeyService, diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index b5eec3932aa..e0f9fa3393a 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -27,14 +27,17 @@ import ( // OpenAIGatewayHandler handles OpenAI API gateway requests type OpenAIGatewayHandler struct { - gatewayService *service.OpenAIGatewayService - billingCacheService *service.BillingCacheService - apiKeyService *service.APIKeyService - usageRecordWorkerPool *service.UsageRecordWorkerPool - errorPassthroughService *service.ErrorPassthroughService - concurrencyHelper *ConcurrencyHelper - maxAccountSwitches int - cfg *config.Config + gatewayService *service.OpenAIGatewayService + imageTaskService *service.ImageTaskService + billingCacheService *service.BillingCacheService + apiKeyService *service.APIKeyService + usageRecordWorkerPool *service.UsageRecordWorkerPool + errorPassthroughService *service.ErrorPassthroughService + contentModerationService *service.ContentModerationService + concurrencyHelper *ConcurrencyHelper + imageLimiter *imageConcurrencyLimiter + maxAccountSwitches int + cfg *config.Config } func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedModel string) string { @@ -47,11 +50,13 @@ func resolveOpenAIMessagesDispatchMappedModel(apiKey *service.APIKey, requestedM // NewOpenAIGatewayHandler creates a new OpenAIGatewayHandler func NewOpenAIGatewayHandler( gatewayService *service.OpenAIGatewayService, + imageTaskService *service.ImageTaskService, concurrencyService *service.ConcurrencyService, billingCacheService *service.BillingCacheService, apiKeyService *service.APIKeyService, usageRecordWorkerPool *service.UsageRecordWorkerPool, errorPassthroughService *service.ErrorPassthroughService, + contentModerationService *service.ContentModerationService, cfg *config.Config, ) *OpenAIGatewayHandler { pingInterval := time.Duration(0) @@ -63,14 +68,17 @@ func NewOpenAIGatewayHandler( } } return &OpenAIGatewayHandler{ - gatewayService: gatewayService, - billingCacheService: billingCacheService, - apiKeyService: apiKeyService, - usageRecordWorkerPool: usageRecordWorkerPool, - errorPassthroughService: errorPassthroughService, - concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval), - maxAccountSwitches: maxAccountSwitches, - cfg: cfg, + gatewayService: gatewayService, + imageTaskService: imageTaskService, + billingCacheService: billingCacheService, + apiKeyService: apiKeyService, + usageRecordWorkerPool: usageRecordWorkerPool, + errorPassthroughService: errorPassthroughService, + contentModerationService: contentModerationService, + concurrencyHelper: NewConcurrencyHelper(concurrencyService, SSEPingFormatComment, pingInterval), + imageLimiter: &imageConcurrencyLimiter{}, + maxAccountSwitches: maxAccountSwitches, + cfg: cfg, } } @@ -187,6 +195,28 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { setOpsRequestContext(c, reqModel, reqStream, body) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) + if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, body); decision != nil && decision.Blocked { + h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message) + return + } + + imageIntent := service.IsImageGenerationIntent("/v1/responses", reqModel, body) + if imageIntent && !service.GroupAllowsImageGeneration(apiKey.Group) { + h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage()) + return + } + var imageReleaseFunc func() + if imageIntent { + var imageAcquired bool + imageReleaseFunc, imageAcquired = h.acquireImageGenerationSlot(c, streamStarted) + if !imageAcquired { + return + } + if imageReleaseFunc != nil { + defer imageReleaseFunc() + } + } + // 解析渠道级模型映射 channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) @@ -318,57 +348,65 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs)) } if err != nil { - var failoverErr *service.UpstreamFailoverError - if errors.As(err, &failoverErr) { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) - // 池模式:同账号重试 - if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { - sameAccountRetryCount[account.ID]++ - reqLog.Warn("openai.pool_mode_same_account_retry", - zap.Int64("account_id", account.ID), - zap.Int("upstream_status", failoverErr.StatusCode), - zap.Int("retry_limit", retryLimit), - zap.Int("retry_count", sameAccountRetryCount[account.ID]), - ) - select { - case <-c.Request.Context().Done(): - return - case <-time.After(sameAccountRetryDelay): + if result != nil && result.ImageCount > 0 { + reqLog.Warn("openai.forward_partial_error_with_image_result", + zap.Int64("account_id", account.ID), + zap.Int("image_count", result.ImageCount), + zap.Error(err), + ) + } else { + var failoverErr *service.UpstreamFailoverError + if errors.As(err, &failoverErr) { + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + // 池模式:同账号重试 + if failoverErr.RetryableOnSameAccount { + retryLimit := account.GetPoolModeRetryCount() + if sameAccountRetryCount[account.ID] < retryLimit { + sameAccountRetryCount[account.ID]++ + reqLog.Warn("openai.pool_mode_same_account_retry", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("retry_limit", retryLimit), + zap.Int("retry_count", sameAccountRetryCount[account.ID]), + ) + select { + case <-c.Request.Context().Done(): + return + case <-time.After(sameAccountRetryDelay): + } + continue } - continue } + h.gatewayService.RecordOpenAIAccountSwitch() + failedAccountIDs[account.ID] = struct{}{} + lastFailoverErr = failoverErr + if switchCount >= maxAccountSwitches { + h.handleFailoverExhausted(c, failoverErr, streamStarted) + return + } + switchCount++ + reqLog.Warn("openai.upstream_failover_switching", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("switch_count", switchCount), + zap.Int("max_switches", maxAccountSwitches), + ) + continue } - h.gatewayService.RecordOpenAIAccountSwitch() - failedAccountIDs[account.ID] = struct{}{} - lastFailoverErr = failoverErr - if switchCount >= maxAccountSwitches { - h.handleFailoverExhausted(c, failoverErr, streamStarted) + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + wroteFallback := h.ensureForwardErrorResponse(c, streamStarted) + fields := []zap.Field{ + zap.Int64("account_id", account.ID), + zap.Bool("fallback_error_response_written", wroteFallback), + zap.Error(err), + } + if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) { + reqLog.Warn("openai.forward_failed", fields...) return } - switchCount++ - reqLog.Warn("openai.upstream_failover_switching", - zap.Int64("account_id", account.ID), - zap.Int("upstream_status", failoverErr.StatusCode), - zap.Int("switch_count", switchCount), - zap.Int("max_switches", maxAccountSwitches), - ) - continue - } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) - wroteFallback := h.ensureForwardErrorResponse(c, streamStarted) - fields := []zap.Field{ - zap.Int64("account_id", account.ID), - zap.Bool("fallback_error_response_written", wroteFallback), - zap.Error(err), - } - if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) { - reqLog.Warn("openai.forward_failed", fields...) + reqLog.Error("openai.forward_failed", fields...) return } - reqLog.Error("openai.forward_failed", fields...) - return } if result != nil { if account.Type == service.AccountTypeOAuth { @@ -383,17 +421,19 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) { userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) requestPayloadHash := service.HashUsageRequestPayload(body) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) // 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。 - h.submitUsageRecordTask(func(ctx context.Context) { + h.submitOpenAIUsageRecordTask(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: GetInboundEndpoint(c), - UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform), + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, @@ -570,6 +610,11 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { setOpsRequestContext(c, reqModel, reqStream, body) setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false))) + if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && decision.Blocked { + h.anthropicErrorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message) + return + } + // 解析渠道级模型映射 channelMappingMsg, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel) @@ -603,21 +648,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { sessionHash := h.gatewayService.GenerateSessionHash(c, body) promptCacheKey := h.gatewayService.ExtractSessionID(c, body) - - // Anthropic 格式的请求在 metadata.user_id 中携带 session 标识, - // 而非 OpenAI 的 session_id/conversation_id headers。 - // 从中派生 sessionHash(sticky session)和 promptCacheKey(upstream cache)。 - if sessionHash == "" || promptCacheKey == "" { - if userID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String()); userID != "" { - seed := reqModel + "-" + userID - if promptCacheKey == "" { - promptCacheKey = service.GenerateSessionUUID(seed) - } - if sessionHash == "" { - sessionHash = service.DeriveSessionHashFromSeed(seed) - } - } - } + sessionHash, promptCacheKey = resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel, body) maxAccountSwitches := h.maxAccountSwitches switchCount := 0 @@ -701,52 +732,60 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs)) } if err != nil { - var failoverErr *service.UpstreamFailoverError - if errors.As(err, &failoverErr) { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) - // 池模式:同账号重试 - if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { - sameAccountRetryCount[account.ID]++ - reqLog.Warn("openai_messages.pool_mode_same_account_retry", - zap.Int64("account_id", account.ID), - zap.Int("upstream_status", failoverErr.StatusCode), - zap.Int("retry_limit", retryLimit), - zap.Int("retry_count", sameAccountRetryCount[account.ID]), - ) - select { - case <-c.Request.Context().Done(): - return - case <-time.After(sameAccountRetryDelay): + if result != nil && result.ImageCount > 0 { + reqLog.Warn("openai_messages.forward_partial_error_with_image_result", + zap.Int64("account_id", account.ID), + zap.Int("image_count", result.ImageCount), + zap.Error(err), + ) + } else { + var failoverErr *service.UpstreamFailoverError + if errors.As(err, &failoverErr) { + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + // 池模式:同账号重试 + if failoverErr.RetryableOnSameAccount { + retryLimit := account.GetPoolModeRetryCount() + if sameAccountRetryCount[account.ID] < retryLimit { + sameAccountRetryCount[account.ID]++ + reqLog.Warn("openai_messages.pool_mode_same_account_retry", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("retry_limit", retryLimit), + zap.Int("retry_count", sameAccountRetryCount[account.ID]), + ) + select { + case <-c.Request.Context().Done(): + return + case <-time.After(sameAccountRetryDelay): + } + continue } - continue } + h.gatewayService.RecordOpenAIAccountSwitch() + failedAccountIDs[account.ID] = struct{}{} + lastFailoverErr = failoverErr + if switchCount >= maxAccountSwitches { + h.handleAnthropicFailoverExhausted(c, failoverErr, streamStarted) + return + } + switchCount++ + reqLog.Warn("openai_messages.upstream_failover_switching", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("switch_count", switchCount), + zap.Int("max_switches", maxAccountSwitches), + ) + continue } - h.gatewayService.RecordOpenAIAccountSwitch() - failedAccountIDs[account.ID] = struct{}{} - lastFailoverErr = failoverErr - if switchCount >= maxAccountSwitches { - h.handleAnthropicFailoverExhausted(c, failoverErr, streamStarted) - return - } - switchCount++ - reqLog.Warn("openai_messages.upstream_failover_switching", + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + wroteFallback := h.ensureAnthropicErrorResponse(c, streamStarted) + reqLog.Warn("openai_messages.forward_failed", zap.Int64("account_id", account.ID), - zap.Int("upstream_status", failoverErr.StatusCode), - zap.Int("switch_count", switchCount), - zap.Int("max_switches", maxAccountSwitches), + zap.Bool("fallback_error_response_written", wroteFallback), + zap.Error(err), ) - continue + return } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) - wroteFallback := h.ensureAnthropicErrorResponse(c, streamStarted) - reqLog.Warn("openai_messages.forward_failed", - zap.Int64("account_id", account.ID), - zap.Bool("fallback_error_response_written", wroteFallback), - zap.Error(err), - ) - return } if result != nil { h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) @@ -757,16 +796,18 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { userAgent := c.GetHeader("User-Agent") clientIP := ip.GetClientIP(c) requestPayloadHash := service.HashUsageRequestPayload(body) + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) - h.submitUsageRecordTask(func(ctx context.Context) { + h.submitOpenAIUsageRecordTask(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: GetInboundEndpoint(c), - UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform), + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, @@ -791,6 +832,20 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) { } } +func resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel string, body []byte) (string, string) { + // Anthropic metadata.user_id 只作为账号粘性信号。上游 GPT/Codex 缓存键 + // 交给 ForwardAsAnthropic 从 cache_control 或完整消息 digest 派生,避免 + // 固定 metadata key 压住后续 turn 的缓存滚动。 + if sessionHash != "" { + return sessionHash, promptCacheKey + } + if userID := strings.TrimSpace(gjson.GetBytes(body, "metadata.user_id").String()); userID != "" { + seed := reqModel + "-" + userID + sessionHash = service.DeriveSessionHashFromSeed(seed) + } + return sessionHash, promptCacheKey +} + // anthropicErrorResponse writes an error in Anthropic Messages API format. func (h *OpenAIGatewayHandler) anthropicErrorResponse(c *gin.Context, status int, errType, message string) { c.JSON(status, gin.H{ @@ -1114,6 +1169,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { setOpsRequestContext(c, reqModel, true, firstMessage) setOpsEndpointContext(c, "", int16(service.RequestTypeWSV2)) + if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, reqModel, firstMessage); decision != nil && decision.Blocked { + writeContentModerationWSError(ctx, wsConn, decision) + closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, decision.Message) + return + } + + if service.IsImageGenerationIntent("/v1/responses", reqModel, firstMessage) && !service.GroupAllowsImageGeneration(apiKey.Group) { + closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, service.ImageGenerationPermissionMessage()) + return + } + // 解析渠道级模型映射 channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel) @@ -1224,6 +1290,26 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { hooks := &service.OpenAIWSIngressHooks{ InitialRequestModel: reqModel, + BeforeRequest: func(turn int, payload []byte, originalModel string) error { + if turn == 1 { + return nil + } + if !gjson.ValidBytes(payload) { + return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", errors.New("invalid json")) + } + model := strings.TrimSpace(originalModel) + if model == "" { + model = strings.TrimSpace(gjson.GetBytes(payload, "model").String()) + } + if model == "" { + model = reqModel + } + if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, model, payload); decision != nil && decision.Blocked { + writeContentModerationWSError(ctx, wsConn, decision) + return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, decision.Message, nil) + } + return nil + }, BeforeTurn: func(turn int) error { if turn == 1 { return nil @@ -1257,22 +1343,34 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) { }, AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) { releaseTurnSlots() - if turnErr != nil || result == nil { + if turnErr != nil { + if result == nil || result.ImageCount <= 0 { + return + } + reqLog.Warn("openai.websocket_partial_error_with_image_result", + zap.Int64("account_id", account.ID), + zap.Int("image_count", result.ImageCount), + zap.Error(turnErr), + ) + } + if result == nil { return } if account.Type == service.AccountTypeOAuth { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders) } h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, true, result.FirstTokenMs) - h.submitUsageRecordTask(func(taskCtx context.Context) { + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) + h.submitOpenAIUsageRecordTask(result, func(taskCtx context.Context) { if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{ Result: result, APIKey: apiKey, User: apiKey.User, Account: account, Subscription: subscription, - InboundEndpoint: GetInboundEndpoint(c), - UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform), + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: service.HashUsageRequestPayload(firstMessage), @@ -1440,6 +1538,60 @@ func (h *OpenAIGatewayHandler) submitUsageRecordTask(task service.UsageRecordTas task(ctx) } +func (h *OpenAIGatewayHandler) submitOpenAIUsageRecordTask(result *service.OpenAIForwardResult, task service.UsageRecordTask) { + if result != nil && result.ImageCount > 0 { + h.submitMandatoryUsageRecordTask(task) + return + } + h.submitUsageRecordTask(task) +} + +func (h *OpenAIGatewayHandler) submitMandatoryUsageRecordTask(task service.UsageRecordTask) { + if task == nil { + return + } + if h.usageRecordWorkerPool != nil { + if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDropped { + return + } + logger.L().With( + zap.String("component", "handler.openai_gateway.usage"), + ).Warn("openai.usage_record_task_mandatory_sync_fallback") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + defer func() { + if recovered := recover(); recovered != nil { + logger.L().With( + zap.String("component", "handler.openai_gateway.usage"), + zap.Any("panic", recovered), + ).Error("openai.usage_record_task_panic_recovered") + } + }() + task(ctx) +} + +func (h *OpenAIGatewayHandler) acquireImageGenerationSlot(c *gin.Context, streamStarted bool) (func(), bool) { + if h == nil || h.cfg == nil || h.imageLimiter == nil { + return nil, true + } + imageConcurrency := h.cfg.Gateway.ImageConcurrency + wait := strings.TrimSpace(imageConcurrency.OverflowMode) == config.ImageConcurrencyOverflowModeWait + release, acquired := h.imageLimiter.Acquire( + c.Request.Context(), + imageConcurrency.Enabled, + imageConcurrency.MaxConcurrentRequests, + wait, + time.Duration(imageConcurrency.WaitTimeoutSeconds)*time.Second, + imageConcurrency.MaxWaitingRequests, + ) + if acquired { + return release, true + } + h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Image generation concurrency limit exceeded, please retry later", streamStarted) + return nil, false +} + // handleConcurrencyError handles concurrency-related errors with proper 429 response func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) { h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", @@ -1602,6 +1754,34 @@ func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason s _ = conn.CloseNow() } +func writeContentModerationWSError(ctx context.Context, conn *coderws.Conn, decision *service.ContentModerationDecision) { + if conn == nil || decision == nil { + return + } + if ctx == nil { + ctx = context.Background() + } + message := strings.TrimSpace(decision.Message) + if message == "" { + message = "content moderation blocked this request" + } + payload, err := json.Marshal(gin.H{ + "event_id": "evt_content_moderation_blocked", + "type": "error", + "error": gin.H{ + "type": "invalid_request_error", + "code": contentModerationErrorCode(decision), + "message": message, + }, + }) + if err != nil { + payload = []byte(`{"event_id":"evt_content_moderation_blocked","type":"error","error":{"type":"invalid_request_error","code":"content_policy_violation","message":"content moderation blocked this request"}}`) + } + writeCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + _ = conn.Write(writeCtx, coderws.MessageText, payload) +} + func summarizeWSCloseErrorForLog(err error) (string, string) { if err == nil { return "-", "-" diff --git a/backend/internal/handler/openai_gateway_handler_test.go b/backend/internal/handler/openai_gateway_handler_test.go index 2744e0cc0dc..fbeb3ad302d 100644 --- a/backend/internal/handler/openai_gateway_handler_test.go +++ b/backend/internal/handler/openai_gateway_handler_test.go @@ -12,6 +12,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/config" pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/server/middleware" "github.com/Wei-Shaw/sub2api/internal/service" coderws "github.com/coder/websocket" @@ -92,6 +93,76 @@ func TestOpenAIHandleStreamingAwareError_JSONEscaping(t *testing.T) { } } +func TestImageAsyncTaskError_ExtractsFailoverMessage(t *testing.T) { + err := imageAsyncTaskError(&service.UpstreamFailoverError{ + StatusCode: http.StatusForbidden, + ResponseBody: []byte(`{"error":{"message":"Your request was rejected as a result of our safety system."}}`), + }) + + require.EqualError(t, err, "Your request was rejected as a result of our safety system.") +} + +func TestImageAsyncTaskError_ExtractsNestedPolicyViolationMessage(t *testing.T) { + err := imageAsyncTaskError(&service.UpstreamFailoverError{ + StatusCode: http.StatusForbidden, + ResponseBody: []byte(`{ + "error": { + "message": "{\"error\":{\"message\":\"This prompt may violate our content policy.\",\"type\":\"invalid_request_error\",\"code\":\"content_policy_violation\"}}", + "type": "upstream_error" + } + }`), + }) + + require.EqualError(t, err, "This prompt may violate our content policy.") +} + +func TestImageAsyncTaskError_MapsPolicyViolationCodeWhenMessageIsGeneric(t *testing.T) { + err := imageAsyncTaskError(&service.UpstreamFailoverError{ + StatusCode: http.StatusForbidden, + ResponseBody: []byte(`{ + "error": { + "message": "Upstream request failed", + "type": "upstream_error", + "code": "content_policy_violation" + } + }`), + }) + + require.EqualError(t, err, "Prompt violates content policy") +} + +func TestImageAsyncFinalError_PreservesPreviousUpstreamError(t *testing.T) { + previous := errors.New("Upstream request failed") + selected := imageAsyncFinalError(errors.New("no available accounts"), previous) + + require.Same(t, previous, selected) +} + +func TestImageAsyncFinalError_PreservesPreviousPolicyErrorWhenSchedulerAddsModelContext(t *testing.T) { + previous := errors.New("Prompt violates content policy") + selected := imageAsyncFinalError(errors.New("no available OpenAI accounts supporting model: gpt-image-2"), previous) + + require.Same(t, previous, selected) +} + +func TestResolveOpenAIMessagesMetadataSession_DoesNotDerivePromptCacheKey(t *testing.T) { + body := []byte(`{"model":"claude-sonnet-4-5","metadata":{"user_id":"claude-code-session"},"messages":[{"role":"user","content":"hello"}]}`) + + sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession("", "", "claude-sonnet-4-5", body) + + require.NotEmpty(t, sessionHash) + require.Empty(t, promptCacheKey) +} + +func TestResolveOpenAIMessagesMetadataSession_PreservesExplicitPromptCacheKey(t *testing.T) { + body := []byte(`{"metadata":{"user_id":"claude-code-session"}}`) + + sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession("", "explicit-cache", "claude-sonnet-4-5", body) + + require.NotEmpty(t, sessionHash) + require.Equal(t, "explicit-cache", promptCacheKey) +} + func TestOpenAIHandleStreamingAwareError_NonStreaming(t *testing.T) { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() @@ -628,6 +699,180 @@ func TestOpenAIResponsesWebSocket_PreviousResponseIDKindLoggedBeforeAcquireFailu require.Contains(t, strings.ToLower(closeErr.Reason), "failed to acquire user concurrency slot") } +type contentModerationHandlerSettingRepo struct { + values map[string]string +} + +func (r *contentModerationHandlerSettingRepo) Get(ctx context.Context, key string) (*service.Setting, error) { + if value, ok := r.values[key]; ok { + return &service.Setting{Key: key, Value: value}, nil + } + return nil, service.ErrSettingNotFound +} + +func (r *contentModerationHandlerSettingRepo) GetValue(ctx context.Context, key string) (string, error) { + if value, ok := r.values[key]; ok { + return value, nil + } + return "", service.ErrSettingNotFound +} + +func (r *contentModerationHandlerSettingRepo) Set(ctx context.Context, key, value string) error { + if r.values == nil { + r.values = map[string]string{} + } + r.values[key] = value + return nil +} + +func (r *contentModerationHandlerSettingRepo) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + out := map[string]string{} + for _, key := range keys { + if value, ok := r.values[key]; ok { + out[key] = value + } + } + return out, nil +} + +func (r *contentModerationHandlerSettingRepo) SetMultiple(ctx context.Context, settings map[string]string) error { + if r.values == nil { + r.values = map[string]string{} + } + for key, value := range settings { + r.values[key] = value + } + return nil +} + +func (r *contentModerationHandlerSettingRepo) GetAll(ctx context.Context) (map[string]string, error) { + out := make(map[string]string, len(r.values)) + for key, value := range r.values { + out[key] = value + } + return out, nil +} + +func (r *contentModerationHandlerSettingRepo) Delete(ctx context.Context, key string) error { + delete(r.values, key) + return nil +} + +type contentModerationHandlerTestRepo struct { + logs []service.ContentModerationLog +} + +func (r *contentModerationHandlerTestRepo) CreateLog(ctx context.Context, log *service.ContentModerationLog) error { + if log != nil { + r.logs = append(r.logs, *log) + } + return nil +} + +func (r *contentModerationHandlerTestRepo) ListLogs(ctx context.Context, filter service.ContentModerationLogFilter) ([]service.ContentModerationLog, *pagination.PaginationResult, error) { + return nil, nil, nil +} + +func (r *contentModerationHandlerTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) { + return 0, nil +} + +func (r *contentModerationHandlerTestRepo) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*service.ContentModerationCleanupResult, error) { + return &service.ContentModerationCleanupResult{}, nil +} + +func TestOpenAIResponsesWebSocket_ContentModerationBlocksFirstFrame(t *testing.T) { + gin.SetMode(gin.TestMode) + + moderationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/moderations", r.URL.Path) + _, _ = w.Write([]byte(`{"results":[{"category_scores":{"sexual":0.9}}]}`)) + })) + defer moderationServer.Close() + + cfg := &service.ContentModerationConfig{ + Enabled: true, + Mode: service.ContentModerationModePreBlock, + BaseURL: moderationServer.URL, + Model: "omni-moderation-latest", + APIKeys: []string{"sk-test"}, + SampleRate: 100, + AllGroups: true, + BlockMessage: "内容审计测试阻断", + } + rawCfg, err := json.Marshal(cfg) + require.NoError(t, err) + + repo := &contentModerationHandlerTestRepo{} + settingRepo := &contentModerationHandlerSettingRepo{values: map[string]string{ + service.SettingKeyRiskControlEnabled: "true", + service.SettingKeyContentModerationConfig: string(rawCfg), + }} + moderationSvc := service.NewContentModerationService( + settingRepo, + repo, + nil, + nil, + nil, + nil, + nil, + ) + decision, err := moderationSvc.Check(context.Background(), service.ContentModerationCheckInput{ + UserID: 1, + Endpoint: "/v1/responses", + Provider: "openai", + Model: "gpt-5.5", + Protocol: service.ContentModerationProtocolOpenAIResponses, + Body: []byte(`{"model":"gpt-5.5","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"bad prompt"}]}]}`), + }) + require.NoError(t, err) + require.True(t, decision.Blocked) + repo.logs = nil + h := &OpenAIGatewayHandler{ + gatewayService: &service.OpenAIGatewayService{}, + billingCacheService: &service.BillingCacheService{}, + apiKeyService: &service.APIKeyService{}, + contentModerationService: moderationSvc, + concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&concurrencyCacheMock{}), SSEPingFormatNone, time.Second), + } + wsServer := newOpenAIWSHandlerTestServer(t, h, middleware.AuthSubject{UserID: 1, Concurrency: 1}) + defer wsServer.Close() + + dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second) + clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http")+"/openai/v1/responses", nil) + cancelDial() + require.NoError(t, err) + defer func() { + _ = clientConn.CloseNow() + }() + + writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second) + err = clientConn.Write(writeCtx, coderws.MessageText, []byte(`{ + "type":"response.create", + "model":"gpt-5.5", + "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"bad prompt"}]}] + }`)) + cancelWrite() + require.NoError(t, err) + + readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second) + _, payload, readErr := clientConn.Read(readCtx) + cancelRead() + if readErr == nil { + require.Contains(t, string(payload), "content_policy_violation") + require.Contains(t, string(payload), "内容审计测试阻断") + } else { + var closeErr coderws.CloseError + require.ErrorAs(t, readErr, &closeErr) + require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code) + require.Contains(t, closeErr.Reason, "内容审计测试阻断") + } + require.Len(t, repo.logs, 1) + require.True(t, repo.logs[0].Flagged) + require.Equal(t, service.ContentModerationActionBlock, repo.logs[0].Action) + require.Equal(t, "bad prompt", repo.logs[0].InputExcerpt) +} + func TestOpenAIResponsesWebSocket_PassthroughUsageLogPersistsUserAgentAndReasoningEffort(t *testing.T) { got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{ firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false,"reasoning":{"effort":"HIGH"}}`, diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go index 4d0078a7af5..08a6b6e85cf 100644 --- a/backend/internal/handler/openai_images.go +++ b/backend/internal/handler/openai_images.go @@ -81,6 +81,22 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { zap.String("capability", string(parsed.RequiredCapability)), ) + if !service.GroupAllowsImageGeneration(apiKey.Group) { + h.errorResponse(c, http.StatusForbidden, "permission_error", service.ImageGenerationPermissionMessage()) + return + } + if decision := h.checkContentModeration(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIImages, parsed.Model, parsed.ModerationBody()); decision != nil && decision.Blocked { + h.errorResponse(c, contentModerationStatus(decision), contentModerationErrorCode(decision), decision.Message) + return + } + imageReleaseFunc, acquired := h.acquireImageGenerationSlot(c, streamStarted) + if !acquired { + return + } + if imageReleaseFunc != nil { + defer imageReleaseFunc() + } + if parsed.Multipart { setOpsRequestContext(c, parsed.Model, parsed.Stream, nil) } else { @@ -188,62 +204,69 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { responseLatencyMs = forwardDurationMs - upstreamLatencyMs } service.SetOpsLatencyMs(c, service.OpsResponseLatencyMsKey, responseLatencyMs) - if err == nil && result != nil && result.FirstTokenMs != nil { + if result != nil && result.FirstTokenMs != nil { service.SetOpsLatencyMs(c, service.OpsTimeToFirstTokenMsKey, int64(*result.FirstTokenMs)) } if err != nil { - var failoverErr *service.UpstreamFailoverError - if errors.As(err, &failoverErr) { - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) - if failoverErr.RetryableOnSameAccount { - retryLimit := account.GetPoolModeRetryCount() - if sameAccountRetryCount[account.ID] < retryLimit { - sameAccountRetryCount[account.ID]++ - reqLog.Warn("openai.images.pool_mode_same_account_retry", - zap.Int64("account_id", account.ID), - zap.Int("upstream_status", failoverErr.StatusCode), - zap.Int("retry_limit", retryLimit), - zap.Int("retry_count", sameAccountRetryCount[account.ID]), - ) - select { - case <-c.Request.Context().Done(): - return - case <-time.After(sameAccountRetryDelay): + if result != nil && result.ImageCount > 0 { + reqLog.Warn("openai.images.forward_partial_error_with_image_result", + zap.Int64("account_id", account.ID), + zap.Int("image_count", result.ImageCount), + zap.Error(err), + ) + } else { + var failoverErr *service.UpstreamFailoverError + if errors.As(err, &failoverErr) { + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + if failoverErr.RetryableOnSameAccount { + retryLimit := account.GetPoolModeRetryCount() + if sameAccountRetryCount[account.ID] < retryLimit { + sameAccountRetryCount[account.ID]++ + reqLog.Warn("openai.images.pool_mode_same_account_retry", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("retry_limit", retryLimit), + zap.Int("retry_count", sameAccountRetryCount[account.ID]), + ) + select { + case <-c.Request.Context().Done(): + return + case <-time.After(sameAccountRetryDelay): + } + continue } - continue } + h.gatewayService.RecordOpenAIAccountSwitch() + failedAccountIDs[account.ID] = struct{}{} + lastFailoverErr = failoverErr + if switchCount >= maxAccountSwitches { + h.handleFailoverExhausted(c, failoverErr, streamStarted) + return + } + switchCount++ + reqLog.Warn("openai.images.upstream_failover_switching", + zap.Int64("account_id", account.ID), + zap.Int("upstream_status", failoverErr.StatusCode), + zap.Int("switch_count", switchCount), + zap.Int("max_switches", maxAccountSwitches), + ) + continue + } + h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) + wroteFallback := h.ensureForwardErrorResponse(c, streamStarted) + fields := []zap.Field{ + zap.Int64("account_id", account.ID), + zap.Bool("fallback_error_response_written", wroteFallback), + zap.Error(err), } - h.gatewayService.RecordOpenAIAccountSwitch() - failedAccountIDs[account.ID] = struct{}{} - lastFailoverErr = failoverErr - if switchCount >= maxAccountSwitches { - h.handleFailoverExhausted(c, failoverErr, streamStarted) + if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) { + reqLog.Warn("openai.images.forward_failed", fields...) return } - switchCount++ - reqLog.Warn("openai.images.upstream_failover_switching", - zap.Int64("account_id", account.ID), - zap.Int("upstream_status", failoverErr.StatusCode), - zap.Int("switch_count", switchCount), - zap.Int("max_switches", maxAccountSwitches), - ) - continue - } - h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, false, nil) - wroteFallback := h.ensureForwardErrorResponse(c, streamStarted) - fields := []zap.Field{ - zap.Int64("account_id", account.ID), - zap.Bool("fallback_error_response_written", wroteFallback), - zap.Error(err), - } - if shouldLogOpenAIForwardFailureAsWarn(c, wroteFallback) { - reqLog.Warn("openai.images.forward_failed", fields...) + reqLog.Error("openai.images.forward_failed", fields...) return } - reqLog.Error("openai.images.forward_failed", fields...) - return } - if result != nil { if account.Type == service.AccountTypeOAuth { h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(c.Request.Context(), account.ID, result.ResponseHeaders) @@ -259,21 +282,27 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) { if parsed.Multipart { requestPayloadHash = service.HashUsageRequestPayload([]byte(parsed.StickySessionSeed())) } + inboundEndpoint := GetInboundEndpoint(c) + upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform) - h.submitUsageRecordTask(func(ctx context.Context) { + upstreamModel := "" + if result != nil { + upstreamModel = result.UpstreamModel + } + h.submitMandatoryUsageRecordTask(func(ctx context.Context) { if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ Result: result, APIKey: apiKey, User: apiKey.User, Account: account, Subscription: subscription, - InboundEndpoint: GetInboundEndpoint(c), - UpstreamEndpoint: GetUpstreamEndpoint(c, account.Platform), + InboundEndpoint: inboundEndpoint, + UpstreamEndpoint: upstreamEndpoint, UserAgent: userAgent, IPAddress: clientIP, RequestPayloadHash: requestPayloadHash, APIKeyService: h.apiKeyService, - ChannelUsageFields: channelMapping.ToUsageFields(parsed.Model, result.UpstreamModel), + ChannelUsageFields: channelMapping.ToUsageFields(parsed.Model, upstreamModel), }); err != nil { logger.L().With( zap.String("component", "handler.openai_gateway.images"), diff --git a/backend/internal/handler/openai_images_async.go b/backend/internal/handler/openai_images_async.go new file mode 100644 index 00000000000..24cac4501f7 --- /dev/null +++ b/backend/internal/handler/openai_images_async.go @@ -0,0 +1,335 @@ +package handler + +import ( + "context" + "errors" + "net/http" + "os" + "strconv" + "strings" + "time" + + pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil" + "github.com/Wei-Shaw/sub2api/internal/pkg/ip" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +func (h *OpenAIGatewayHandler) ImagesAsync(c *gin.Context) { + requestStart := time.Now() + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok { + h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") + return + } + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") + return + } + if h.imageTaskService == nil { + h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Async image generation is unavailable") + return + } + reqLog := requestLogger(c, "handler.openai_gateway.images_async", + zap.Int64("user_id", subject.UserID), + zap.Int64("api_key_id", apiKey.ID), + zap.Any("group_id", apiKey.GroupID), + ) + if !h.ensureResponsesDependencies(c, reqLog) { + return + } + + body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request) + if err != nil { + if maxErr, ok := extractMaxBytesError(err); ok { + h.errorResponse(c, http.StatusRequestEntityTooLarge, "invalid_request_error", buildBodyTooLargeMessage(maxErr.Limit)) + return + } + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body") + return + } + if len(body) == 0 { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty") + return + } + if isMultipartImagesContentType(c.GetHeader("Content-Type")) { + setOpsRequestContext(c, "", false, nil) + } else { + setOpsRequestContext(c, "", false, body) + } + + parsed, err := h.gatewayService.ParseOpenAIImagesRequest(c, body) + if err != nil { + h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + return + } + parsed.Stream = false + if parsed.Multipart { + setOpsRequestContext(c, parsed.Model, false, nil) + } else { + setOpsRequestContext(c, parsed.Model, false, body) + } + setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false))) + + subscription, _ := middleware2.GetSubscriptionFromContext(c) + if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription); err != nil { + status, code, message, retryAfter := billingErrorDetails(err) + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(retryAfter)) + } + h.errorResponse(c, status, code, message) + return + } + + task, err := h.imageTaskService.CreatePending(c.Request.Context(), subject.UserID, apiKey.ID, parsed.Endpoint, parsed.Model, parsed.Prompt) + if err != nil { + reqLog.Error("openai.images_async.create_task_failed", zap.Error(err)) + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to create image task") + return + } + + channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, parsed.Model) + userAgent := c.GetHeader("User-Agent") + clientIP := ip.GetClientIP(c) + requestPayloadHash := service.HashUsageRequestPayload(body) + if parsed.Multipart { + requestPayloadHash = service.HashUsageRequestPayload([]byte(parsed.StickySessionSeed())) + } + sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, body) + service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds()) + + go h.runImageAsyncTask(task.TaskID, apiKey, subject.UserID, subscription, body, parsed, channelMapping, userAgent, clientIP, requestPayloadHash, sessionHash) + + c.JSON(http.StatusAccepted, gin.H{ + "task_id": task.TaskID, + "status": task.Status, + "expires_at": task.ExpiresAt.Format(time.RFC3339), + }) +} + +func (h *OpenAIGatewayHandler) ImageAsyncTaskStatus(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok { + h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") + return + } + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") + return + } + task, err := h.imageTaskService.GetForAPIKey(c.Request.Context(), c.Param("task_id"), subject.UserID, apiKey.ID) + if err != nil { + if errors.Is(err, service.ErrImageTaskNotFound) { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Image task not found") + return + } + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to load image task") + return + } + c.JSON(http.StatusOK, imageTaskResponse(c, task)) +} + +func (h *OpenAIGatewayHandler) ImageAsyncTaskDownload(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok { + h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key") + return + } + subject, ok := middleware2.GetAuthSubjectFromContext(c) + if !ok { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found") + return + } + task, err := h.imageTaskService.GetForAPIKey(c.Request.Context(), c.Param("task_id"), subject.UserID, apiKey.ID) + if err != nil { + if errors.Is(err, service.ErrImageTaskNotFound) { + h.errorResponse(c, http.StatusNotFound, "not_found_error", "Image task not found") + return + } + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to load image task") + return + } + if task.Status != service.ImageTaskStatusSucceeded || strings.TrimSpace(task.FilePath) == "" { + h.errorResponse(c, http.StatusConflict, "invalid_request_error", "Image task is not ready") + return + } + if _, err := os.Stat(task.FilePath); err != nil { + h.errorResponse(c, http.StatusGone, "not_found_error", "Image file has expired") + return + } + c.Header("Content-Type", task.MimeType) + c.Header("Content-Disposition", `attachment; filename="`+task.TaskID+imageDownloadExtension(task.MimeType)+`"`) + c.File(task.FilePath) +} + +func (h *OpenAIGatewayHandler) runImageAsyncTask( + taskID string, + apiKey *service.APIKey, + userID int64, + subscription *service.UserSubscription, + body []byte, + parsed *service.OpenAIImagesRequest, + channelMapping service.ChannelMappingResult, + userAgent string, + clientIP string, + requestPayloadHash string, + sessionHash string, +) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + if err := h.imageTaskService.MarkRunning(ctx, taskID); err != nil { + logger.L().Warn("openai.images_async.mark_running_failed", zap.String("task_id", taskID), zap.Error(err)) + } + + failedAccountIDs := make(map[int64]struct{}) + var lastErr error + for switchCount := 0; switchCount <= h.maxAccountSwitches; switchCount++ { + selection, _, err := h.gatewayService.SelectAccountWithSchedulerForImages(ctx, apiKey.GroupID, sessionHash, parsed.Model, failedAccountIDs, parsed.RequiredCapability) + if err != nil || selection == nil || selection.Account == nil { + lastErr = imageAsyncFinalError(err, lastErr) + break + } + account := selection.Account + release := selection.ReleaseFunc + result, assets, err := h.gatewayService.ForwardImagesBuffered(ctx, account, body, parsed, channelMapping.MappedModel) + if release != nil { + release() + } + if err != nil { + var failoverErr *service.UpstreamFailoverError + if errors.As(err, &failoverErr) { + failedAccountIDs[account.ID] = struct{}{} + lastErr = imageAsyncTaskError(failoverErr) + continue + } + lastErr = err + break + } + if len(assets) == 0 { + lastErr = errors.New("no images returned") + break + } + if _, err := h.imageTaskService.SaveResult(ctx, taskID, assets[0].Data, assets[0].MimeType); err != nil { + lastErr = err + break + } + if result != nil { + _ = h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{ + Result: result, + APIKey: apiKey, + User: apiKey.User, + Account: account, + Subscription: subscription, + InboundEndpoint: parsed.Endpoint, + UpstreamEndpoint: parsed.Endpoint, + UserAgent: userAgent, + IPAddress: clientIP, + RequestPayloadHash: requestPayloadHash, + APIKeyService: h.apiKeyService, + ChannelUsageFields: channelMapping.ToUsageFields(parsed.Model, result.UpstreamModel), + }) + } + return + } + _ = h.imageTaskService.MarkFailed(context.Background(), taskID, lastErr) + if lastErr != nil { + logger.L().Warn("openai.images_async.task_failed", zap.String("task_id", taskID), zap.Int64("user_id", userID), zap.Error(lastErr)) + } +} + +func imageAsyncTaskError(err error) error { + if err == nil { + return nil + } + var failoverErr *service.UpstreamFailoverError + if errors.As(err, &failoverErr) && failoverErr != nil { + msg := strings.TrimSpace(service.ExtractUpstreamErrorMessage(failoverErr.ResponseBody)) + if msg != "" { + if imageAsyncIsGenericUpstreamMessage(msg) { + if policyMsg := imageAsyncPolicyViolationMessage(failoverErr.ResponseBody); policyMsg != "" { + return errors.New(policyMsg) + } + } + return errors.New(msg) + } + if policyMsg := imageAsyncPolicyViolationMessage(failoverErr.ResponseBody); policyMsg != "" { + return errors.New(policyMsg) + } + } + return err +} + +func imageAsyncIsGenericUpstreamMessage(message string) bool { + switch strings.ToLower(strings.TrimSpace(message)) { + case "upstream request failed", "upstream error": + return true + default: + return false + } +} + +func imageAsyncPolicyViolationMessage(body []byte) string { + lower := strings.ToLower(string(body)) + if strings.Contains(lower, "content_policy") || + strings.Contains(lower, "policy_violation") || + strings.Contains(lower, "safety system") || + strings.Contains(lower, "violates our content policy") { + return "Prompt violates content policy" + } + return "" +} + +func imageAsyncFinalError(current error, previous error) error { + if previous != nil && imageAsyncIsNoAvailableAccountError(current) { + return previous + } + if current != nil { + return current + } + return previous +} + +func imageAsyncIsNoAvailableAccountError(err error) bool { + msg := strings.ToLower(strings.TrimSpace(errorString(err))) + return strings.Contains(msg, "no available") && strings.Contains(msg, "account") +} + +func errorString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func imageTaskResponse(c *gin.Context, task *service.ImageTask) gin.H { + out := gin.H{ + "task_id": task.TaskID, + "status": task.Status, + "expires_at": task.ExpiresAt.Format(time.RFC3339), + } + if task.Status == service.ImageTaskStatusSucceeded { + out["download_url"] = "/api/v1/images/async/tasks/" + task.TaskID + "/download" + out["mime_type"] = task.MimeType + out["byte_size"] = task.ByteSize + } + if task.Status == service.ImageTaskStatusFailed { + out["error_message"] = task.ErrorMessage + } + return out +} + +func imageDownloadExtension(mimeType string) string { + switch strings.ToLower(strings.TrimSpace(mimeType)) { + case "image/jpeg": + return ".jpg" + case "image/webp": + return ".webp" + default: + return ".png" + } +} diff --git a/backend/internal/handler/openai_images_controls_test.go b/backend/internal/handler/openai_images_controls_test.go new file mode 100644 index 00000000000..cebcccac9e9 --- /dev/null +++ b/backend/internal/handler/openai_images_controls_test.go @@ -0,0 +1,49 @@ +package handler + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIGatewayHandlerImages_DisabledGroupRejectsBeforeScheduling(t *testing.T) { + gin.SetMode(gin.TestMode) + + body := []byte(`{"model":"gpt-image-2","prompt":"draw","size":"1024x1024"}`) + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + groupID := int64(111) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + ID: 222, + GroupID: &groupID, + Group: &service.Group{ + ID: groupID, + AllowImageGeneration: false, + }, + User: &service.User{ID: 333}, + }) + c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 333, Concurrency: 1}) + + h := &OpenAIGatewayHandler{ + gatewayService: &service.OpenAIGatewayService{}, + billingCacheService: &service.BillingCacheService{}, + apiKeyService: &service.APIKeyService{}, + concurrencyHelper: &ConcurrencyHelper{concurrencyService: &service.ConcurrencyService{}}, + } + + h.Images(c) + + require.Equal(t, http.StatusForbidden, rec.Code) + require.Equal(t, "permission_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String()) + require.Contains(t, rec.Body.String(), service.ImageGenerationPermissionMessage()) +} diff --git a/backend/internal/handler/page_handler.go b/backend/internal/handler/page_handler.go new file mode 100644 index 00000000000..7d4d5078490 --- /dev/null +++ b/backend/internal/handler/page_handler.go @@ -0,0 +1,283 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/response" + middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" +) + +var validSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +const maxPageFileSize = 1 << 20 // 1MB + +type PageHandler struct { + pagesDir string + settingService *service.SettingService +} + +func NewPageHandler(dataDir string, settingService *service.SettingService) *PageHandler { + pagesDir := filepath.Join(dataDir, "pages") + _ = os.MkdirAll(pagesDir, 0755) + return &PageHandler{pagesDir: pagesDir, settingService: settingService} +} + +// GetPageContent serves raw markdown content for a given slug. +// GET /api/v1/pages/:slug +func (h *PageHandler) GetPageContent(c *gin.Context) { + slug := c.Param("slug") + if !validSlugPattern.MatchString(slug) || len(slug) > 64 { + response.BadRequest(c, "Invalid page slug") + return + } + + // Visibility check: slug must be configured in custom_menu_items + // and the user must have permission based on visibility setting + if !h.checkSlugVisibility(c, slug) { + c.JSON(http.StatusNotFound, gin.H{"error": "page not found"}) + return + } + + filePath := filepath.Join(h.pagesDir, slug+".md") + cleaned := filepath.Clean(filePath) + if !strings.HasPrefix(cleaned, filepath.Clean(h.pagesDir)) { + response.BadRequest(c, "Invalid page slug") + return + } + + info, err := os.Stat(cleaned) + if err != nil || info.IsDir() { + c.JSON(http.StatusNotFound, gin.H{"error": "page not found"}) + return + } + if info.Size() > maxPageFileSize { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "page too large"}) + return + } + + content, err := os.ReadFile(cleaned) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read page"}) + return + } + + c.Data(http.StatusOK, "text/markdown; charset=utf-8", content) +} + +// ListPages returns available page slugs. +// GET /api/v1/pages +func (h *PageHandler) ListPages(c *gin.Context) { + entries, err := os.ReadDir(h.pagesDir) + if err != nil { + response.Success(c, []string{}) + return + } + + slugs := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if strings.HasSuffix(name, ".md") { + slugs = append(slugs, strings.TrimSuffix(name, ".md")) + } + } + response.Success(c, slugs) +} + +// ServePageImage serves images from data/pages/{slug}/ directory. +// GET /api/v1/pages/:slug/images/*filename +// No JWT required (browser img tags can't carry tokens), but visibility is checked. +func (h *PageHandler) ServePageImage(c *gin.Context) { + slug := c.Param("slug") + filename := c.Param("filename") + filename = strings.TrimPrefix(filename, "/") + + if !validSlugPattern.MatchString(slug) || len(slug) > 64 { + c.Status(http.StatusNotFound) + return + } + + if !h.checkImageSlugVisibility(c, slug) { + c.Status(http.StatusNotFound) + return + } + + imagesDir := filepath.Join(h.pagesDir, slug) + cleaned, ok := resolvePageImagePath(h.pagesDir, imagesDir, filename) + if !ok { + c.Status(http.StatusNotFound) + return + } + + info, err := os.Stat(cleaned) + if err != nil || info.IsDir() { + c.Status(http.StatusNotFound) + return + } + + c.File(cleaned) +} + +func resolvePageImagePath(pagesDir, imagesDir, filename string) (string, bool) { + relPath, ok := cleanPageImageRelativePath(filename) + if !ok { + return "", false + } + + cleanedPagesDir := filepath.Clean(pagesDir) + cleanedImagesDir := filepath.Clean(imagesDir) + cleanedTarget := filepath.Clean(filepath.Join(cleanedImagesDir, relPath)) + if !isPathWithinBase(cleanedTarget, cleanedImagesDir) { + return "", false + } + + realPagesDir, err := filepath.EvalSymlinks(cleanedPagesDir) + if err != nil { + return "", false + } + realImagesDir, err := filepath.EvalSymlinks(cleanedImagesDir) + if err != nil || !isPathWithinBase(realImagesDir, realPagesDir) { + return "", false + } + realTarget, err := filepath.EvalSymlinks(cleanedTarget) + if err != nil || !isPathWithinBase(realTarget, realImagesDir) { + return "", false + } + return realTarget, true +} + +func cleanPageImageRelativePath(filename string) (string, bool) { + if filename == "" { + return "", false + } + if strings.HasPrefix(filename, "/") { + return "", false + } + decoded, err := url.PathUnescape(filename) + if err != nil { + return "", false + } + if decoded == "" || strings.HasPrefix(decoded, "/") || strings.Contains(decoded, "\\") || strings.ContainsRune(decoded, 0) { + return "", false + } + + parts := make([]string, 0) + for _, part := range strings.Split(decoded, "/") { + switch part { + case "", ".": + continue + case "..": + return "", false + default: + parts = append(parts, part) + } + } + if len(parts) == 0 { + return "", false + } + + relPath := filepath.Join(parts...) + if filepath.IsAbs(relPath) || filepath.VolumeName(relPath) != "" { + return "", false + } + return relPath, true +} + +func isPathWithinBase(path, base string) bool { + rel, err := filepath.Rel(filepath.Clean(base), filepath.Clean(path)) + if err != nil { + return false + } + return rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + +// findSlugVisibility looks up the slug in custom_menu_items and returns (visibility, found). +func (h *PageHandler) findSlugVisibility(c *gin.Context, slug string) (string, bool) { + if h.settingService == nil { + return "", false + } + + raw := h.settingService.GetCustomMenuItemsRaw(c.Request.Context()) + if raw == "" || raw == "[]" { + return "", false + } + + var items []struct { + URL string `json:"url"` + PageSlug string `json:"page_slug"` + Visibility string `json:"visibility"` + } + if err := json.Unmarshal([]byte(raw), &items); err != nil { + return "", false + } + + for _, item := range items { + itemSlug := item.PageSlug + if itemSlug == "" && strings.HasPrefix(item.URL, "md:") { + itemSlug = strings.TrimPrefix(item.URL, "md:") + } + if itemSlug == slug { + return item.Visibility, true + } + } + return "", false +} + +// checkSlugVisibility verifies the slug is configured in custom_menu_items +// and the authenticated user has permission to view it. +func (h *PageHandler) checkSlugVisibility(c *gin.Context, slug string) bool { + visibility, found := h.findSlugVisibility(c, slug) + if !found { + return false + } + if visibility == "admin" { + role, _ := middleware2.GetUserRoleFromContext(c) + return role == "admin" + } + return true +} + +// checkImageSlugVisibility checks visibility for image requests (no JWT available). +// Only allows user-visible pages; admin-only pages are blocked. +func (h *PageHandler) checkImageSlugVisibility(c *gin.Context, slug string) bool { + visibility, found := h.findSlugVisibility(c, slug) + if !found { + return false + } + return visibility != "admin" +} + +// RegisterPageRoutes registers page routes on a router group. +func RegisterPageRoutes(v1 *gin.RouterGroup, dataDir string, jwtAuth gin.HandlerFunc, adminAuth gin.HandlerFunc, settingService *service.SettingService) { + h := NewPageHandler(dataDir, settingService) + + // Authenticated page content (JWT required + visibility check) + pages := v1.Group("/pages") + pages.Use(jwtAuth) + { + pages.GET("/:slug", h.GetPageContent) + } + + // Images: no JWT (browser img tags can't carry tokens), visibility check in handler + pageImages := v1.Group("/pages") + { + pageImages.GET("/:slug/images/*filename", h.ServePageImage) + } + + // Admin-only: list all available pages + adminPages := v1.Group("/pages") + adminPages.Use(adminAuth) + { + adminPages.GET("", h.ListPages) + } +} diff --git a/backend/internal/handler/page_handler_test.go b/backend/internal/handler/page_handler_test.go new file mode 100644 index 00000000000..0a9f0d96157 --- /dev/null +++ b/backend/internal/handler/page_handler_test.go @@ -0,0 +1,102 @@ +package handler + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCleanPageImageRelativePath(t *testing.T) { + tests := []struct { + name string + in string + want string + ok bool + }{ + {name: "single filename", in: "logo.png", want: "logo.png", ok: true}, + {name: "nested path", in: "images/logo.png", want: filepath.Join("images", "logo.png"), ok: true}, + {name: "dot prefix", in: "./logo.png", want: "logo.png", ok: true}, + {name: "url escaped slash", in: "images%2Flogo.png", want: filepath.Join("images", "logo.png"), ok: true}, + {name: "parent traversal", in: "../secret.png", ok: false}, + {name: "encoded parent traversal", in: "%2e%2e/secret.png", ok: false}, + {name: "backslash traversal", in: `images\secret.png`, ok: false}, + {name: "absolute path", in: "/etc/passwd", ok: false}, + {name: "encoded absolute path", in: "%2fetc/passwd", ok: false}, + {name: "encoded nul byte", in: "logo.png%00", ok: false}, + {name: "invalid escape", in: "logo.png%zz", ok: false}, + {name: "empty path", in: "", ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := cleanPageImageRelativePath(tt.in) + if ok != tt.ok { + t.Fatalf("ok = %v, want %v", ok, tt.ok) + } + if got != tt.want { + t.Fatalf("path = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolvePageImagePath(t *testing.T) { + root := t.TempDir() + pagesDir := filepath.Join(root, "pages") + base := filepath.Join(pagesDir, "guide") + if err := os.MkdirAll(filepath.Join(base, "images"), 0755); err != nil { + t.Fatalf("create images dir: %v", err) + } + if err := os.WriteFile(filepath.Join(base, "logo.png"), []byte("fake"), 0644); err != nil { + t.Fatalf("create direct image: %v", err) + } + if err := os.WriteFile(filepath.Join(base, "images", "logo.png"), []byte("fake"), 0644); err != nil { + t.Fatalf("create image: %v", err) + } + + got, ok := resolvePageImagePath(pagesDir, base, "logo.png") + if !ok { + t.Fatal("expected direct image path to be accepted") + } + want := filepath.Join(base, "logo.png") + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } + + got, ok = resolvePageImagePath(pagesDir, base, "images/logo.png") + if !ok { + t.Fatal("expected nested image path to be accepted") + } + want = filepath.Join(base, "images", "logo.png") + if got != want { + t.Fatalf("path = %q, want %q", got, want) + } + + if got, ok := resolvePageImagePath(pagesDir, base, "../guide.md"); ok { + t.Fatalf("expected traversal to be rejected, got %q", got) + } +} + +func TestResolvePageImagePathRejectsSymlinkEscape(t *testing.T) { + root := t.TempDir() + pagesDir := filepath.Join(root, "pages") + base := filepath.Join(pagesDir, "guide") + outside := filepath.Join(root, "outside") + + if err := os.MkdirAll(base, 0755); err != nil { + t.Fatalf("create page dir: %v", err) + } + if err := os.MkdirAll(outside, 0755); err != nil { + t.Fatalf("create outside dir: %v", err) + } + if err := os.WriteFile(filepath.Join(outside, "secret.png"), []byte("secret"), 0644); err != nil { + t.Fatalf("create outside file: %v", err) + } + if err := os.Symlink(outside, filepath.Join(base, "images")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + if got, ok := resolvePageImagePath(pagesDir, base, "images/secret.png"); ok { + t.Fatalf("expected symlink escape to be rejected, got %q", got) + } +} diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index 22f2aa15b61..393f374305f 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -40,6 +40,11 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { PasswordResetEnabled: settings.PasswordResetEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementRevision: settings.LoginAgreementRevision, + LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments), TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, SiteName: settings.SiteName, @@ -63,6 +68,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, OIDCOAuthEnabled: settings.OIDCOAuthEnabled, OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, PaymentEnabled: settings.PaymentEnabled, Version: h.version, @@ -76,6 +83,24 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { AvailableChannelsEnabled: settings.AvailableChannelsEnabled, + ImageGenerationEnabled: settings.ImageGenerationEnabled, + + ChatCompletionEnabled: settings.ChatCompletionEnabled, + AffiliateEnabled: settings.AffiliateEnabled, + + RiskControlEnabled: settings.RiskControlEnabled, }) } + +func publicLoginAgreementDocumentsToDTO(items []service.LoginAgreementDocument) []dto.LoginAgreementDocument { + result := make([]dto.LoginAgreementDocument, 0, len(items)) + for _, item := range items { + result = append(result, dto.LoginAgreementDocument{ + ID: item.ID, + Title: item.Title, + ContentMD: item.ContentMD, + }) + } + return result +} diff --git a/backend/internal/handler/usage_record_submit_task_test.go b/backend/internal/handler/usage_record_submit_task_test.go index 5c9458158a2..e4c2837a592 100644 --- a/backend/internal/handler/usage_record_submit_task_test.go +++ b/backend/internal/handler/usage_record_submit_task_test.go @@ -129,3 +129,63 @@ func TestOpenAIGatewayHandlerSubmitUsageRecordTask_WithoutPool_TaskPanicRecovere }) require.True(t, called.Load(), "panic 后后续任务应仍可执行") } + +func TestOpenAIGatewayHandlerSubmitMandatoryUsageRecordTask_DroppedTaskSyncFallback(t *testing.T) { + pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{ + WorkerCount: 1, + QueueSize: 1, + TaskTimeout: time.Second, + OverflowPolicy: "drop", + OverflowSamplePercent: 0, + AutoScaleEnabled: false, + }) + t.Cleanup(pool.Stop) + h := &OpenAIGatewayHandler{usageRecordWorkerPool: pool} + + block := make(chan struct{}) + release := make(chan struct{}) + pool.Submit(func(ctx context.Context) { + close(block) + <-release + }) + <-block + pool.Submit(func(ctx context.Context) {}) + + var called atomic.Bool + h.submitMandatoryUsageRecordTask(func(ctx context.Context) { + called.Store(true) + }) + close(release) + + require.True(t, called.Load(), "mandatory usage task must run synchronously when async submit is dropped") +} + +func TestOpenAIGatewayHandlerSubmitOpenAIUsageRecordTask_ImageResultUsesMandatoryFallback(t *testing.T) { + pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{ + WorkerCount: 1, + QueueSize: 1, + TaskTimeout: time.Second, + OverflowPolicy: "drop", + OverflowSamplePercent: 0, + AutoScaleEnabled: false, + }) + t.Cleanup(pool.Stop) + h := &OpenAIGatewayHandler{usageRecordWorkerPool: pool} + + block := make(chan struct{}) + release := make(chan struct{}) + pool.Submit(func(ctx context.Context) { + close(block) + <-release + }) + <-block + pool.Submit(func(ctx context.Context) {}) + + var called atomic.Bool + h.submitOpenAIUsageRecordTask(&service.OpenAIForwardResult{ImageCount: 1}, func(ctx context.Context) { + called.Store(true) + }) + close(release) + + require.True(t, called.Load(), "image usage task must be mandatory when async submit is dropped") +} diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 8a864b5131f..ffca86dcd0a 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -87,6 +87,8 @@ func (s *userHandlerRepoStub) ListWithFilters(context.Context, pagination.Pagina func (s *userHandlerRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil } func (s *userHandlerRepoStub) DeductBalance(context.Context, int64, float64) error { return nil } func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil } +func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil diff --git a/backend/internal/handler/wire.go b/backend/internal/handler/wire.go index a8725875fba..f3214402a04 100644 --- a/backend/internal/handler/wire.go +++ b/backend/internal/handler/wire.go @@ -36,6 +36,7 @@ func ProvideAdminHandlers( channelHandler *admin.ChannelHandler, channelMonitorHandler *admin.ChannelMonitorHandler, channelMonitorTemplateHandler *admin.ChannelMonitorRequestTemplateHandler, + contentModerationHandler *admin.ContentModerationHandler, paymentHandler *admin.PaymentHandler, affiliateHandler *admin.AffiliateHandler, ) *AdminHandlers { @@ -67,6 +68,7 @@ func ProvideAdminHandlers( Channel: channelHandler, ChannelMonitor: channelMonitorHandler, ChannelMonitorTemplate: channelMonitorTemplateHandler, + ContentModeration: contentModerationHandler, Payment: paymentHandler, Affiliate: affiliateHandler, } @@ -92,6 +94,7 @@ func ProvideHandlers( subscriptionHandler *SubscriptionHandler, announcementHandler *AnnouncementHandler, channelMonitorUserHandler *ChannelMonitorUserHandler, + chatSessionHandler *ChatSessionHandler, adminHandlers *AdminHandlers, gatewayHandler *GatewayHandler, openaiGatewayHandler *OpenAIGatewayHandler, @@ -112,6 +115,7 @@ func ProvideHandlers( Subscription: subscriptionHandler, Announcement: announcementHandler, ChannelMonitor: channelMonitorUserHandler, + ChatSession: chatSessionHandler, Admin: adminHandlers, Gateway: gatewayHandler, OpenAIGateway: openaiGatewayHandler, @@ -134,6 +138,7 @@ var ProviderSet = wire.NewSet( NewSubscriptionHandler, NewAnnouncementHandler, NewChannelMonitorUserHandler, + NewChatSessionHandler, NewGatewayHandler, NewOpenAIGatewayHandler, NewTotpHandler, @@ -170,6 +175,7 @@ var ProviderSet = wire.NewSet( admin.NewChannelHandler, admin.NewChannelMonitorHandler, admin.NewChannelMonitorRequestTemplateHandler, + admin.NewContentModerationHandler, admin.NewPaymentHandler, admin.NewAffiliateHandler, diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index edde85d330a..aa36ef0b9a9 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -32,7 +32,13 @@ func TestAnthropicToResponses_BasicText(t *testing.T) { var items []ResponsesInputItem require.NoError(t, json.Unmarshal(resp.Input, &items)) require.Len(t, items, 1) + assert.Equal(t, "message", items[0].Type) assert.Equal(t, "user", items[0].Role) + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_text", parts[0].Type) + assert.Equal(t, "Hello", parts[0].Text) } func TestAnthropicToResponses_SystemPrompt(t *testing.T) { @@ -49,7 +55,12 @@ func TestAnthropicToResponses_SystemPrompt(t *testing.T) { var items []ResponsesInputItem require.NoError(t, json.Unmarshal(resp.Input, &items)) require.Len(t, items, 2) - assert.Equal(t, "system", items[0].Role) + assert.Equal(t, "developer", items[0].Role) + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "input_text", parts[0].Type) + assert.Equal(t, "You are helpful.", parts[0].Text) }) t.Run("array", func(t *testing.T) { @@ -65,11 +76,33 @@ func TestAnthropicToResponses_SystemPrompt(t *testing.T) { var items []ResponsesInputItem require.NoError(t, json.Unmarshal(resp.Input, &items)) require.Len(t, items, 2) - assert.Equal(t, "system", items[0].Role) - // System text should be joined with double newline. - var text string - require.NoError(t, json.Unmarshal(items[0].Content, &text)) - assert.Equal(t, "Part 1\n\nPart 2", text) + assert.Equal(t, "developer", items[0].Role) + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 2) + assert.Equal(t, "input_text", parts[0].Type) + assert.Equal(t, "Part 1", parts[0].Text) + assert.Equal(t, "input_text", parts[1].Type) + assert.Equal(t, "Part 2", parts[1].Text) + }) + + t.Run("billing header skipped", func(t *testing.T) { + req := &AnthropicRequest{ + Model: "gpt-5.2", + MaxTokens: 100, + System: json.RawMessage(`[{"type":"text","text":"x-anthropic-billing-header: cc_version=1;"},{"type":"text","text":"Project prompt"}]`), + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := AnthropicToResponses(req) + require.NoError(t, err) + + var items []ResponsesInputItem + require.NoError(t, json.Unmarshal(resp.Input, &items)) + require.Len(t, items, 2) + var parts []ResponsesContentPart + require.NoError(t, json.Unmarshal(items[0].Content, &parts)) + require.Len(t, parts, 1) + assert.Equal(t, "Project prompt", parts[0].Text) }) } @@ -94,6 +127,8 @@ func TestAnthropicToResponses_ToolUse(t *testing.T) { require.Len(t, resp.Tools, 1) assert.Equal(t, "function", resp.Tools[0].Type) assert.Equal(t, "get_weather", resp.Tools[0].Name) + require.NotNil(t, resp.Tools[0].Strict) + assert.False(t, *resp.Tools[0].Strict) // Check input items var items []ResponsesInputItem @@ -104,10 +139,10 @@ func TestAnthropicToResponses_ToolUse(t *testing.T) { assert.Equal(t, "user", items[0].Role) assert.Equal(t, "assistant", items[1].Role) assert.Equal(t, "function_call", items[2].Type) - assert.Equal(t, "fc_call_1", items[2].CallID) + assert.Equal(t, "call_1", items[2].CallID) assert.Empty(t, items[2].ID) assert.Equal(t, "function_call_output", items[3].Type) - assert.Equal(t, "fc_call_1", items[3].CallID) + assert.Equal(t, "call_1", items[3].CallID) assert.Equal(t, "Sunny, 72°F", items[3].Output) } @@ -261,6 +296,34 @@ func TestResponsesToAnthropic_ToolUse(t *testing.T) { assert.JSONEq(t, `{"city":"NYC"}`, string(anth.Content[1].Input)) } +func TestResponsesToAnthropic_ToolUseStopReasonDoesNotDependOnLastBlock(t *testing.T) { + resp := &ResponsesResponse{ + ID: "resp_tool_then_text", + Model: "gpt-5.5", + Status: "completed", + Output: []ResponsesOutput{ + { + Type: "function_call", + CallID: "call_todo", + Name: "TodoWrite", + Arguments: `{"todos":[{"content":"review changes","status":"in_progress"}]}`, + }, + { + Type: "message", + Content: []ResponsesContentPart{ + {Type: "output_text", Text: "Task list updated."}, + }, + }, + }, + } + + anth := ResponsesToAnthropic(resp, "claude-opus-4-6") + assert.Equal(t, "tool_use", anth.StopReason) + require.Len(t, anth.Content, 2) + assert.Equal(t, "tool_use", anth.Content[0].Type) + assert.Equal(t, "text", anth.Content[1].Type) +} + func TestResponsesToAnthropic_ReadToolDropsEmptyPages(t *testing.T) { resp := &ResponsesResponse{ ID: "resp_read", @@ -553,6 +616,81 @@ func TestStreamingToolCall(t *testing.T) { assert.Equal(t, "tool_use", events[0].Delta.StopReason) } +func TestStreamingToolCallStopReasonSurvivesLaterText(t *testing.T) { + state := NewResponsesEventToAnthropicState() + + ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.created", + Response: &ResponsesResponse{ID: "resp_tool_then_text", Model: "gpt-5.5"}, + }, state) + + events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 0, + Item: &ResponsesOutput{Type: "function_call", CallID: "call_todo", Name: "TodoWrite"}, + }, state) + require.Len(t, events, 1) + assert.Equal(t, "content_block_start", events[0].Type) + + events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.done", + OutputIndex: 0, + Arguments: `{"todos":[{"content":"review changes","status":"in_progress","activeForm":"reviewing changes"}]}`, + }, state) + require.Len(t, events, 2) + assert.Equal(t, "content_block_delta", events[0].Type) + assert.Equal(t, "content_block_stop", events[1].Type) + + events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.output_text.delta", + OutputIndex: 1, + Delta: "I will continue after the task list updates.", + }, state) + require.Len(t, events, 2) + assert.Equal(t, "content_block_start", events[0].Type) + assert.Equal(t, "content_block_delta", events[1].Type) + + events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.completed", + Response: &ResponsesResponse{ + Status: "completed", + Usage: &ResponsesUsage{InputTokens: 20, OutputTokens: 10}, + }, + }, state) + require.Len(t, events, 3) + assert.Equal(t, "content_block_stop", events[0].Type) + assert.Equal(t, "tool_use", events[1].Delta.StopReason) + assert.Equal(t, "message_stop", events[2].Type) +} + +func TestStreamingToolCallDoneWithoutDeltaEmitsArguments(t *testing.T) { + state := NewResponsesEventToAnthropicState() + + ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.created", + Response: &ResponsesResponse{ID: "resp_bash", Model: "gpt-5.5"}, + }, state) + + events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 0, + Item: &ResponsesOutput{Type: "function_call", CallID: "call_bash", Name: "Bash"}, + }, state) + require.Len(t, events, 1) + assert.Equal(t, "content_block_start", events[0].Type) + + events = ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.function_call_arguments.done", + OutputIndex: 0, + Arguments: `{"command":"git -C \"/mnt/d/nodejs/other/edmt\" status --short --ignored"}`, + }, state) + require.Len(t, events, 2) + assert.Equal(t, "content_block_delta", events[0].Type) + assert.Equal(t, "input_json_delta", events[0].Delta.Type) + assert.JSONEq(t, `{"command":"git -C \"/mnt/d/nodejs/other/edmt\" status --short --ignored"}`, events[0].Delta.PartialJSON) + assert.Equal(t, "content_block_stop", events[1].Type) +} + func TestStreamingReadToolDropsEmptyPages(t *testing.T) { state := NewResponsesEventToAnthropicState() @@ -692,6 +830,27 @@ func TestFinalizeStream_AbnormalTermination(t *testing.T) { assert.Equal(t, "message_stop", events[2].Type) } +func TestFinalizeStream_ToolCallAbnormalTerminationKeepsToolUseStopReason(t *testing.T) { + state := NewResponsesEventToAnthropicState() + + ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.created", + Response: &ResponsesResponse{ID: "resp_tool_interrupted", Model: "gpt-5.5"}, + }, state) + ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.output_item.added", + OutputIndex: 0, + Item: &ResponsesOutput{Type: "function_call", CallID: "call_todo", Name: "TodoWrite"}, + }, state) + + events := FinalizeResponsesAnthropicStream(state) + require.Len(t, events, 3) + assert.Equal(t, "content_block_stop", events[0].Type) + assert.Equal(t, "message_delta", events[1].Type) + assert.Equal(t, "tool_use", events[1].Delta.StopReason) + assert.Equal(t, "message_stop", events[2].Type) +} + func TestStreamingEmptyResponse(t *testing.T) { state := NewResponsesEventToAnthropicState() @@ -827,8 +986,8 @@ func TestAnthropicToResponses_ThinkingEnabled(t *testing.T) { resp, err := AnthropicToResponses(req) require.NoError(t, err) require.NotNil(t, resp.Reasoning) - // thinking.type is ignored for effort; default high applies. - assert.Equal(t, "high", resp.Reasoning.Effort) + // thinking.type is ignored for effort; Codex bridge default medium applies. + assert.Equal(t, "medium", resp.Reasoning.Effort) assert.Equal(t, "auto", resp.Reasoning.Summary) assert.Contains(t, resp.Include, "reasoning.encrypted_content") assert.NotContains(t, resp.Include, "reasoning.summary") @@ -845,8 +1004,8 @@ func TestAnthropicToResponses_ThinkingAdaptive(t *testing.T) { resp, err := AnthropicToResponses(req) require.NoError(t, err) require.NotNil(t, resp.Reasoning) - // thinking.type is ignored for effort; default high applies. - assert.Equal(t, "high", resp.Reasoning.Effort) + // thinking.type is ignored for effort; Codex bridge default medium applies. + assert.Equal(t, "medium", resp.Reasoning.Effort) assert.Equal(t, "auto", resp.Reasoning.Summary) assert.NotContains(t, resp.Include, "reasoning.summary") } @@ -861,9 +1020,9 @@ func TestAnthropicToResponses_ThinkingDisabled(t *testing.T) { resp, err := AnthropicToResponses(req) require.NoError(t, err) - // Default effort applies (high → high) even when thinking is disabled. + // Default effort applies (medium) even when thinking is disabled. require.NotNil(t, resp.Reasoning) - assert.Equal(t, "high", resp.Reasoning.Effort) + assert.Equal(t, "medium", resp.Reasoning.Effort) } func TestAnthropicToResponses_NoThinking(t *testing.T) { @@ -875,9 +1034,9 @@ func TestAnthropicToResponses_NoThinking(t *testing.T) { resp, err := AnthropicToResponses(req) require.NoError(t, err) - // Default effort applies (high → high) when no thinking/output_config is set. + // Default effort applies (medium) when no thinking/output_config is set. require.NotNil(t, resp.Reasoning) - assert.Equal(t, "high", resp.Reasoning.Effort) + assert.Equal(t, "medium", resp.Reasoning.Effort) } // --------------------------------------------------------------------------- @@ -885,7 +1044,7 @@ func TestAnthropicToResponses_NoThinking(t *testing.T) { // --------------------------------------------------------------------------- func TestAnthropicToResponses_OutputConfigOverridesDefault(t *testing.T) { - // Default is high, but output_config.effort="low" overrides. low→low after mapping. + // Default is medium, but output_config.effort="low" overrides. low→low after mapping. req := &AnthropicRequest{ Model: "gpt-5.2", MaxTokens: 1024, @@ -919,7 +1078,7 @@ func TestAnthropicToResponses_OutputConfigWithoutThinking(t *testing.T) { } func TestAnthropicToResponses_OutputConfigHigh(t *testing.T) { - // output_config.effort="high" → mapped to "high" (1:1, both sides' default). + // output_config.effort="high" → mapped to "high" (1:1). req := &AnthropicRequest{ Model: "gpt-5.2", MaxTokens: 1024, @@ -951,7 +1110,7 @@ func TestAnthropicToResponses_OutputConfigMax(t *testing.T) { } func TestAnthropicToResponses_NoOutputConfig(t *testing.T) { - // No output_config → default high regardless of thinking.type. + // No output_config → default medium regardless of thinking.type. req := &AnthropicRequest{ Model: "gpt-5.2", MaxTokens: 1024, @@ -962,11 +1121,11 @@ func TestAnthropicToResponses_NoOutputConfig(t *testing.T) { resp, err := AnthropicToResponses(req) require.NoError(t, err) require.NotNil(t, resp.Reasoning) - assert.Equal(t, "high", resp.Reasoning.Effort) + assert.Equal(t, "medium", resp.Reasoning.Effort) } func TestAnthropicToResponses_OutputConfigWithoutEffort(t *testing.T) { - // output_config present but effort empty (e.g. only format set) → default high. + // output_config present but effort empty (e.g. only format set) → default medium. req := &AnthropicRequest{ Model: "gpt-5.2", MaxTokens: 1024, @@ -977,7 +1136,7 @@ func TestAnthropicToResponses_OutputConfigWithoutEffort(t *testing.T) { resp, err := AnthropicToResponses(req) require.NoError(t, err) require.NotNil(t, resp.Reasoning) - assert.Equal(t, "high", resp.Reasoning.Effort) + assert.Equal(t, "medium", resp.Reasoning.Effort) } // --------------------------------------------------------------------------- @@ -1149,7 +1308,7 @@ func TestAnthropicToResponses_ToolResultWithImage(t *testing.T) { // function_call_output should have text-only output (no image). assert.Equal(t, "function_call_output", items[2].Type) - assert.Equal(t, "fc_toolu_1", items[2].CallID) + assert.Equal(t, "toolu_1", items[2].CallID) assert.Equal(t, "(empty)", items[2].Output) // Image should be in a separate user message. diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index 268f9f22e32..5f04004de50 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -32,6 +32,9 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { storeFalse := false out.Store = &storeFalse + parallelToolCalls := true + out.ParallelToolCalls = ¶llelToolCalls + out.Text = &ResponsesText{Verbosity: "medium"} if req.MaxTokens > 0 { v := req.MaxTokens @@ -46,10 +49,10 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { } // Determine reasoning effort: only output_config.effort controls the - // level; thinking.type is ignored. Default is high when unset (both - // Anthropic and OpenAI default to high). + // level; thinking.type is ignored. Default follows Codex CLI / airgate's + // Anthropic bridge shape, which uses medium when unset. // Anthropic levels map 1:1 to OpenAI: low→low, medium→medium, high→high, max→xhigh. - effort := "high" // default → both sides' default + effort := "medium" if req.OutputConfig != nil && req.OutputConfig.Effort != "" { effort = req.OutputConfig.Effort } @@ -108,16 +111,19 @@ func convertAnthropicToolChoiceToResponses(raw json.RawMessage) (json.RawMessage func convertAnthropicToResponsesInput(system json.RawMessage, msgs []AnthropicMessage) ([]ResponsesInputItem, error) { var out []ResponsesInputItem - // System prompt → system role input item. + // System prompt → developer role input item. ChatGPT Codex SSE behaves like + // Codex CLI here: keeping Anthropic system text in input preserves the + // conversation/cache shape better than moving it into instructions. if len(system) > 0 { - sysText, err := parseAnthropicSystemPrompt(system) + sysParts, err := parseAnthropicSystemContentParts(system) if err != nil { return nil, err } - if sysText != "" { - content, _ := json.Marshal(sysText) + if len(sysParts) > 0 { + content, _ := json.Marshal(sysParts) out = append(out, ResponsesInputItem{ - Role: "system", + Type: "message", + Role: "developer", Content: content, }) } @@ -133,24 +139,32 @@ func convertAnthropicToResponsesInput(system json.RawMessage, msgs []AnthropicMe return out, nil } -// parseAnthropicSystemPrompt handles the Anthropic system field which can be -// a plain string or an array of text blocks. -func parseAnthropicSystemPrompt(raw json.RawMessage) (string, error) { +// parseAnthropicSystemContentParts handles the Anthropic system field which can +// be a plain string or an array of text blocks. Claude Code may include an +// x-anthropic-billing-header block; airgate drops it before sending to Codex. +func parseAnthropicSystemContentParts(raw json.RawMessage) ([]ResponsesContentPart, error) { var s string if err := json.Unmarshal(raw, &s); err == nil { - return s, nil + if isAnthropicBillingHeaderText(s) || s == "" { + return nil, nil + } + return []ResponsesContentPart{{Type: "input_text", Text: s}}, nil } var blocks []AnthropicContentBlock if err := json.Unmarshal(raw, &blocks); err != nil { - return "", err + return nil, err } - var parts []string + var parts []ResponsesContentPart for _, b := range blocks { - if b.Type == "text" && b.Text != "" { - parts = append(parts, b.Text) + if b.Type == "text" && b.Text != "" && !isAnthropicBillingHeaderText(b.Text) { + parts = append(parts, ResponsesContentPart{Type: "input_text", Text: b.Text}) } } - return strings.Join(parts, "\n\n"), nil + return parts, nil +} + +func isAnthropicBillingHeaderText(text string) bool { + return strings.HasPrefix(text, "x-anthropic-billing-header: ") } // anthropicMsgToResponsesItems converts a single Anthropic message into one @@ -173,8 +187,12 @@ func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error) // Try plain string. var s string if err := json.Unmarshal(raw, &s); err == nil { - content, _ := json.Marshal(s) - return []ResponsesInputItem{{Role: "user", Content: content}}, nil + parts := []ResponsesContentPart{{Type: "input_text", Text: s}} + partsJSON, err := json.Marshal(parts) + if err != nil { + return nil, err + } + return []ResponsesInputItem{{Type: "message", Role: "user", Content: partsJSON}}, nil } var blocks []AnthropicContentBlock @@ -223,7 +241,7 @@ func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error) if err != nil { return nil, err } - out = append(out, ResponsesInputItem{Role: "user", Content: content}) + out = append(out, ResponsesInputItem{Type: "message", Role: "user", Content: content}) } return out, nil @@ -242,7 +260,7 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e if err != nil { return nil, err } - return []ResponsesInputItem{{Role: "assistant", Content: partsJSON}}, nil + return []ResponsesInputItem{{Type: "message", Role: "assistant", Content: partsJSON}}, nil } var blocks []AnthropicContentBlock @@ -260,7 +278,7 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e if err != nil { return nil, err } - items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON}) + items = append(items, ResponsesInputItem{Type: "message", Role: "assistant", Content: partsJSON}) } // tool_use → function_call items. @@ -284,17 +302,14 @@ func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, e return items, nil } -// toResponsesCallID converts an Anthropic tool ID (toolu_xxx / call_xxx) to a -// Responses API function_call ID that starts with "fc_". +// toResponsesCallID preserves Anthropic tool IDs as Responses call_id values. +// Claude Code sends tool_result.tool_use_id back verbatim, and ChatGPT Codex +// continuation expects that call_id to match the original tool_use id. func toResponsesCallID(id string) string { - if strings.HasPrefix(id, "fc_") { - return id - } - return "fc_" + id + return id } -// fromResponsesCallID reverses toResponsesCallID, stripping the "fc_" prefix -// that was added during request conversion. +// fromResponsesCallID reverses old prefixed IDs while preserving current IDs. func fromResponsesCallID(id string) string { if after, ok := strings.CutPrefix(id, "fc_"); ok { // Only strip if the remainder doesn't look like it was already "fc_" prefixed. @@ -412,11 +427,16 @@ func convertAnthropicToolsToResponses(tools []AnthropicTool) []ResponsesTool { Name: t.Name, Description: t.Description, Parameters: normalizeToolParameters(t.InputSchema), + Strict: boolPtr(false), }) } return out } +func boolPtr(v bool) *bool { + return &v +} + // normalizeToolParameters ensures the tool parameter schema is valid for // OpenAI's Responses API, which requires "properties" on object schemas. // diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic.go b/backend/internal/pkg/apicompat/responses_to_anthropic.go index b76f384d69c..d7ef014537b 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic.go @@ -120,7 +120,7 @@ func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncom } return "end_turn" case "completed": - if len(blocks) > 0 && blocks[len(blocks)-1].Type == "tool_use" { + if containsAnthropicToolUseBlock(blocks) { return "tool_use" } return "end_turn" @@ -129,6 +129,15 @@ func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncom } } +func containsAnthropicToolUseBlock(blocks []AnthropicContentBlock) bool { + for _, block := range blocks { + if block.Type == "tool_use" { + return true + } + } + return false +} + func sanitizeAnthropicToolUseInput(name string, raw string) json.RawMessage { if name != "Read" || raw == "" { return json.RawMessage(raw) @@ -161,11 +170,13 @@ type ResponsesEventToAnthropicState struct { MessageStartSent bool MessageStopSent bool - ContentBlockIndex int - ContentBlockOpen bool - CurrentBlockType string // "text" | "thinking" | "tool_use" - CurrentToolName string - CurrentToolArgs string + ContentBlockIndex int + ContentBlockOpen bool + CurrentBlockType string // "text" | "thinking" | "tool_use" + CurrentToolName string + CurrentToolArgs string + CurrentToolHadDelta bool + HasToolCall bool // OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index. OutputIndexToBlockIdx map[int]int @@ -231,11 +242,16 @@ func FinalizeResponsesAnthropicStream(state *ResponsesEventToAnthropicState) []A var events []AnthropicStreamEvent events = append(events, closeCurrentBlock(state)...) + stopReason := "end_turn" + if state.HasToolCall { + stopReason = "tool_use" + } + events = append(events, AnthropicStreamEvent{ Type: "message_delta", Delta: &AnthropicDelta{ - StopReason: "end_turn", + StopReason: stopReason, }, Usage: &AnthropicUsage{ InputTokens: state.InputTokens, @@ -306,6 +322,8 @@ func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesE state.CurrentBlockType = "tool_use" state.CurrentToolName = evt.Item.Name state.CurrentToolArgs = "" + state.CurrentToolHadDelta = false + state.HasToolCall = true events = append(events, AnthropicStreamEvent{ Type: "content_block_start", @@ -390,6 +408,9 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve state.CurrentToolArgs += evt.Delta return nil } + if state.CurrentBlockType == "tool_use" { + state.CurrentToolHadDelta = true + } blockIdx, ok := state.OutputIndexToBlockIdx[evt.OutputIndex] if !ok { @@ -407,7 +428,7 @@ func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve } func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent { - if state.CurrentBlockType != "tool_use" || state.CurrentToolName != "Read" { + if state.CurrentBlockType != "tool_use" { return resToAnthHandleBlockDone(state) } @@ -415,10 +436,16 @@ func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEven if raw == "" { raw = state.CurrentToolArgs } - sanitized := sanitizeAnthropicToolUseInput(state.CurrentToolName, raw) - if len(sanitized) == 0 { + if raw == "" || state.CurrentToolHadDelta { return closeCurrentBlock(state) } + if state.CurrentToolName == "Read" { + sanitized := sanitizeAnthropicToolUseInput(state.CurrentToolName, raw) + if len(sanitized) == 0 { + return closeCurrentBlock(state) + } + raw = string(sanitized) + } idx := state.ContentBlockIndex events := []AnthropicStreamEvent{{ @@ -426,7 +453,7 @@ func resToAnthHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEven Index: &idx, Delta: &AnthropicDelta{ Type: "input_json_delta", - PartialJSON: string(sanitized), + PartialJSON: raw, }, }} events = append(events, closeCurrentBlock(state)...) @@ -553,7 +580,7 @@ func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo stopReason = "max_tokens" } case "completed": - if state.ContentBlockIndex > 0 && state.CurrentBlockType == "tool_use" { + if state.HasToolCall { stopReason = "tool_use" } } @@ -586,6 +613,7 @@ func closeCurrentBlock(state *ResponsesEventToAnthropicState) []AnthropicStreamE state.ContentBlockIndex++ state.CurrentToolName = "" state.CurrentToolArgs = "" + state.CurrentToolHadDelta = false return []AnthropicStreamEvent{{ Type: "content_block_stop", Index: &idx, diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index 0ff2cf49a8f..f9cd5a1c7f9 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -53,6 +53,8 @@ type AnthropicMessage struct { type AnthropicContentBlock struct { Type string `json:"type"` + CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"` + // type=text Text string `json:"text,omitempty"` @@ -165,19 +167,23 @@ type AnthropicDelta struct { // ResponsesRequest is the request body for POST /v1/responses. type ResponsesRequest struct { - Model string `json:"model"` - Instructions string `json:"instructions,omitempty"` - Input json.RawMessage `json:"input"` // string or []ResponsesInputItem - MaxOutputTokens *int `json:"max_output_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopP *float64 `json:"top_p,omitempty"` - Stream bool `json:"stream,omitempty"` - Tools []ResponsesTool `json:"tools,omitempty"` - Include []string `json:"include,omitempty"` - Store *bool `json:"store,omitempty"` - Reasoning *ResponsesReasoning `json:"reasoning,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` + Model string `json:"model"` + Instructions string `json:"instructions,omitempty"` + Input json.RawMessage `json:"input"` // string or []ResponsesInputItem + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []ResponsesTool `json:"tools,omitempty"` + Include []string `json:"include,omitempty"` + Store *bool `json:"store,omitempty"` + ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"` + Reasoning *ResponsesReasoning `json:"reasoning,omitempty"` + Text *ResponsesText `json:"text,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + PreviousResponseID string `json:"previous_response_id,omitempty"` } // ResponsesReasoning configures reasoning effort in the Responses API. @@ -186,13 +192,18 @@ type ResponsesReasoning struct { Summary string `json:"summary,omitempty"` // "auto" | "concise" | "detailed" } +// ResponsesText configures text output options in the Responses API. +type ResponsesText struct { + Verbosity string `json:"verbosity,omitempty"` // "low" | "medium" | "high" +} + // ResponsesInputItem is one item in the Responses API input array. // The Type field determines which other fields are populated. type ResponsesInputItem struct { // Common Type string `json:"type,omitempty"` // "" for role-based messages - // Role-based messages (system/user/assistant) + // Role-based messages (developer/system/user/assistant) Role string `json:"role,omitempty"` Content json.RawMessage `json:"content,omitempty"` // string or []ResponsesContentPart diff --git a/backend/internal/pkg/claude/constants.go b/backend/internal/pkg/claude/constants.go index aa59ba645fc..351f2f8b939 100644 --- a/backend/internal/pkg/claude/constants.go +++ b/backend/internal/pkg/claude/constants.go @@ -75,6 +75,7 @@ const CLICurrentVersion = "2.1.92" // - OAuth 账号 + 非 haiku:追加这整份列表,再按需保留 client 带来的 beta。 // - OAuth 账号 + haiku:Anthropic 对 haiku 不做 third-party 判定,使用 HaikuBetaHeader 即可。 // - API-key 账号:不要使用本函数,参见 APIKeyBetaHeader。 +// - 不默认加入 redact-thinking,避免上游抹除 thinking 内容;客户端显式传入时由合并逻辑保留。 func FullClaudeCodeMimicryBetas() []string { return []string{ BetaClaudeCode, @@ -82,7 +83,6 @@ func FullClaudeCodeMimicryBetas() []string { BetaInterleavedThinking, BetaPromptCachingScope, BetaEffort, - BetaRedactThinking, BetaContextManagement, BetaExtendedCacheTTL, } diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 3a52740512d..43b13937b17 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -125,6 +125,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se apikey.FieldID, apikey.FieldUserID, apikey.FieldGroupID, + apikey.FieldName, apikey.FieldStatus, apikey.FieldIPWhitelist, apikey.FieldIPBlacklist, @@ -166,6 +167,9 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, + group.FieldAllowImageGeneration, + group.FieldImageRateIndependent, + group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, @@ -699,6 +703,9 @@ func groupEntityToService(g *dbent.Group) *service.Group { DailyLimitUSD: g.DailyLimitUsd, WeeklyLimitUSD: g.WeeklyLimitUsd, MonthlyLimitUSD: g.MonthlyLimitUsd, + AllowImageGeneration: g.AllowImageGeneration, + ImageRateIndependent: g.ImageRateIndependent, + ImageRateMultiplier: g.ImageRateMultiplier, ImagePrice1K: g.ImagePrice1k, ImagePrice2K: g.ImagePrice2k, ImagePrice4K: g.ImagePrice4k, diff --git a/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go b/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go index aba62ead2eb..4a462ab154f 100644 --- a/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go +++ b/backend/internal/repository/api_key_repo_messages_dispatch_unit_test.go @@ -69,6 +69,7 @@ func TestAPIKeyRepository_GetByKeyForAuth_PreservesMessagesDispatchModelConfig_S got, err := repo.GetByKeyForAuth(ctx, key.Key) require.NoError(t, err) + require.Equal(t, key.Name, got.Name) require.NotNil(t, got.Group) require.Equal(t, group.MessagesDispatchModelConfig, got.Group.MessagesDispatchModelConfig) } diff --git a/backend/internal/repository/content_moderation_hash_cache.go b/backend/internal/repository/content_moderation_hash_cache.go new file mode 100644 index 00000000000..782999e7a37 --- /dev/null +++ b/backend/internal/repository/content_moderation_hash_cache.go @@ -0,0 +1,71 @@ +package repository + +import ( + "context" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/redis/go-redis/v9" +) + +const contentModerationFlaggedHashSetKey = "content_moderation:flagged_hashes" + +type contentModerationHashCache struct { + rdb *redis.Client +} + +func NewContentModerationHashCache(rdb *redis.Client) service.ContentModerationHashCache { + return &contentModerationHashCache{rdb: rdb} +} + +func (c *contentModerationHashCache) RecordFlaggedInputHash(ctx context.Context, inputHash string) error { + inputHash = strings.TrimSpace(inputHash) + if c == nil || c.rdb == nil || inputHash == "" { + return nil + } + return c.rdb.SAdd(ctx, contentModerationFlaggedHashSetKey, inputHash).Err() +} + +func (c *contentModerationHashCache) HasFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) { + inputHash = strings.TrimSpace(inputHash) + if c == nil || c.rdb == nil || inputHash == "" { + return false, nil + } + return c.rdb.SIsMember(ctx, contentModerationFlaggedHashSetKey, inputHash).Result() +} + +func (c *contentModerationHashCache) DeleteFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) { + inputHash = strings.TrimSpace(inputHash) + if c == nil || c.rdb == nil || inputHash == "" { + return false, nil + } + deleted, err := c.rdb.SRem(ctx, contentModerationFlaggedHashSetKey, inputHash).Result() + if err != nil { + return false, err + } + return deleted > 0, nil +} + +func (c *contentModerationHashCache) ClearFlaggedInputHashes(ctx context.Context) (int64, error) { + if c == nil || c.rdb == nil { + return 0, nil + } + deleted, err := c.rdb.SCard(ctx, contentModerationFlaggedHashSetKey).Result() + if err != nil { + return 0, err + } + if deleted == 0 { + return 0, nil + } + if err := c.rdb.Del(ctx, contentModerationFlaggedHashSetKey).Err(); err != nil { + return 0, err + } + return deleted, nil +} + +func (c *contentModerationHashCache) CountFlaggedInputHashes(ctx context.Context) (int64, error) { + if c == nil || c.rdb == nil { + return 0, nil + } + return c.rdb.SCard(ctx, contentModerationFlaggedHashSetKey).Result() +} diff --git a/backend/internal/repository/content_moderation_repo.go b/backend/internal/repository/content_moderation_repo.go new file mode 100644 index 00000000000..6ada004a132 --- /dev/null +++ b/backend/internal/repository/content_moderation_repo.go @@ -0,0 +1,274 @@ +package repository + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type contentModerationRepository struct { + db *sql.DB +} + +func NewContentModerationRepository(db *sql.DB) service.ContentModerationRepository { + return &contentModerationRepository{db: db} +} + +func (r *contentModerationRepository) CreateLog(ctx context.Context, log *service.ContentModerationLog) error { + if log == nil { + return nil + } + categoryScores, err := json.Marshal(log.CategoryScores) + if err != nil { + return fmt.Errorf("marshal moderation category scores: %w", err) + } + thresholdSnapshot, err := json.Marshal(log.ThresholdSnapshot) + if err != nil { + return fmt.Errorf("marshal moderation thresholds: %w", err) + } + var userID any + if log.UserID != nil { + userID = *log.UserID + } + var apiKeyID any + if log.APIKeyID != nil { + apiKeyID = *log.APIKeyID + } + var groupID any + if log.GroupID != nil { + groupID = *log.GroupID + } + var latency any + if log.UpstreamLatencyMS != nil { + latency = *log.UpstreamLatencyMS + } + err = r.db.QueryRowContext(ctx, ` +INSERT INTO content_moderation_logs ( + request_id, user_id, user_email, api_key_id, api_key_name, group_id, group_name, + endpoint, provider, model, mode, action, flagged, highest_category, highest_score, + category_scores, threshold_snapshot, input_excerpt, upstream_latency_ms, error, + violation_count, auto_banned, email_sent, queue_delay_ms +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, $10, $11, $12, $13, $14, $15, + $16::jsonb, $17::jsonb, $18, $19, $20, + $21, $22, $23, $24 +) RETURNING id, created_at`, + log.RequestID, userID, log.UserEmail, apiKeyID, log.APIKeyName, groupID, log.GroupName, + log.Endpoint, log.Provider, log.Model, log.Mode, log.Action, log.Flagged, log.HighestCategory, log.HighestScore, + string(categoryScores), string(thresholdSnapshot), log.InputExcerpt, latency, log.Error, + log.ViolationCount, log.AutoBanned, log.EmailSent, nullableIntPtr(log.QueueDelayMS), + ).Scan(&log.ID, &log.CreatedAt) + if err != nil { + return fmt.Errorf("insert content moderation log: %w", err) + } + return nil +} + +func (r *contentModerationRepository) ListLogs(ctx context.Context, filter service.ContentModerationLogFilter) ([]service.ContentModerationLog, *pagination.PaginationResult, error) { + where, args := buildContentModerationLogWhere(filter) + whereSQL := "WHERE " + strings.Join(where, " AND ") + + var total int64 + if err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM content_moderation_logs l "+whereSQL, args...).Scan(&total); err != nil { + return nil, nil, fmt.Errorf("count content moderation logs: %w", err) + } + + params := filter.Pagination + if params.Page <= 0 { + params.Page = 1 + } + if params.PageSize <= 0 { + params.PageSize = 20 + } + if params.PageSize > 100 { + params.PageSize = 100 + } + queryArgs := append([]any{}, args...) + queryArgs = append(queryArgs, params.Limit(), params.Offset()) + rows, err := r.db.QueryContext(ctx, ` +SELECT + l.id, l.request_id, l.user_id, l.user_email, l.api_key_id, l.api_key_name, l.group_id, l.group_name, + l.endpoint, l.provider, l.model, l.mode, l.action, l.flagged, l.highest_category, l.highest_score, + l.category_scores, l.threshold_snapshot, l.input_excerpt, l.upstream_latency_ms, l.error, + l.violation_count, l.auto_banned, l.email_sent, COALESCE(u.status, ''), l.queue_delay_ms, l.created_at +FROM content_moderation_logs l +LEFT JOIN users u ON u.id = l.user_id `+whereSQL+` +ORDER BY l.created_at DESC, l.id DESC +LIMIT $`+fmt.Sprint(len(queryArgs)-1)+` OFFSET $`+fmt.Sprint(len(queryArgs)), + queryArgs..., + ) + if err != nil { + return nil, nil, fmt.Errorf("list content moderation logs: %w", err) + } + defer func() { _ = rows.Close() }() + + items := make([]service.ContentModerationLog, 0) + for rows.Next() { + var item service.ContentModerationLog + var userID, apiKeyID, groupID, latency, queueDelay sql.NullInt64 + var scoresRaw, thresholdsRaw []byte + if err := rows.Scan( + &item.ID, + &item.RequestID, + &userID, + &item.UserEmail, + &apiKeyID, + &item.APIKeyName, + &groupID, + &item.GroupName, + &item.Endpoint, + &item.Provider, + &item.Model, + &item.Mode, + &item.Action, + &item.Flagged, + &item.HighestCategory, + &item.HighestScore, + &scoresRaw, + &thresholdsRaw, + &item.InputExcerpt, + &latency, + &item.Error, + &item.ViolationCount, + &item.AutoBanned, + &item.EmailSent, + &item.UserStatus, + &queueDelay, + &item.CreatedAt, + ); err != nil { + return nil, nil, fmt.Errorf("scan content moderation log: %w", err) + } + if userID.Valid { + v := userID.Int64 + item.UserID = &v + } + if apiKeyID.Valid { + v := apiKeyID.Int64 + item.APIKeyID = &v + } + if groupID.Valid { + v := groupID.Int64 + item.GroupID = &v + } + if latency.Valid { + v := int(latency.Int64) + item.UpstreamLatencyMS = &v + } + if queueDelay.Valid { + v := int(queueDelay.Int64) + item.QueueDelayMS = &v + } + item.CategoryScores = map[string]float64{} + _ = json.Unmarshal(scoresRaw, &item.CategoryScores) + item.ThresholdSnapshot = map[string]float64{} + _ = json.Unmarshal(thresholdsRaw, &item.ThresholdSnapshot) + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, nil, fmt.Errorf("iterate content moderation logs: %w", err) + } + return items, paginationResultFromTotal(total, params), nil +} + +func (r *contentModerationRepository) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) { + if userID <= 0 { + return 0, nil + } + var count int + err := r.db.QueryRowContext(ctx, ` +WITH last_auto_ban AS ( + SELECT MAX(created_at) AS at + FROM content_moderation_logs + WHERE user_id = $1 AND auto_banned = TRUE +) +SELECT COUNT(*) +FROM content_moderation_logs +WHERE user_id = $1 + AND flagged = TRUE + AND created_at >= $2 + AND created_at > COALESCE((SELECT at FROM last_auto_ban), '-infinity'::timestamptz) +`, userID, since).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count user content moderation flagged logs: %w", err) + } + return count, nil +} + +func (r *contentModerationRepository) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*service.ContentModerationCleanupResult, error) { + result := &service.ContentModerationCleanupResult{FinishedAt: time.Now()} + if r == nil || r.db == nil { + return result, nil + } + hitExec, err := r.db.ExecContext(ctx, ` +DELETE FROM content_moderation_logs +WHERE flagged = TRUE AND created_at < $1 +`, hitBefore) + if err != nil { + return nil, fmt.Errorf("delete expired hit content moderation logs: %w", err) + } + result.DeletedHit, _ = hitExec.RowsAffected() + + nonHitExec, err := r.db.ExecContext(ctx, ` +DELETE FROM content_moderation_logs +WHERE flagged = FALSE AND created_at < $1 +`, nonHitBefore) + if err != nil { + return nil, fmt.Errorf("delete expired non-hit content moderation logs: %w", err) + } + result.DeletedNonHit, _ = nonHitExec.RowsAffected() + + result.FinishedAt = time.Now() + return result, nil +} + +func nullableIntPtr(value *int) any { + if value == nil { + return nil + } + return *value +} + +func buildContentModerationLogWhere(filter service.ContentModerationLogFilter) ([]string, []any) { + where := []string{"l.id IS NOT NULL"} + args := make([]any, 0) + add := func(expr string, value any) { + args = append(args, value) + where = append(where, fmt.Sprintf(expr, len(args))) + } + switch strings.ToLower(strings.TrimSpace(filter.Result)) { + case "hit", "flagged": + where = append(where, "l.flagged = TRUE") + case "blocked", "block": + where = append(where, "l.action = 'block'") + case "pass", "allow": + where = append(where, "l.flagged = FALSE AND l.error = ''") + case "error": + where = append(where, "l.error <> ''") + } + if filter.GroupID != nil { + add("l.group_id = $%d", *filter.GroupID) + } + if endpoint := strings.TrimSpace(filter.Endpoint); endpoint != "" { + add("l.endpoint = $%d", endpoint) + } + if search := strings.TrimSpace(filter.Search); search != "" { + like := "%" + search + "%" + args = append(args, like, like, like, like, like) + idx := len(args) - 4 + where = append(where, fmt.Sprintf("(l.request_id ILIKE $%d OR l.user_email ILIKE $%d OR l.api_key_name ILIKE $%d OR l.model ILIKE $%d OR l.input_excerpt ILIKE $%d)", idx, idx+1, idx+2, idx+3, idx+4)) + } + if filter.From != nil && !filter.From.IsZero() { + add("l.created_at >= $%d", *filter.From) + } + if filter.To != nil && !filter.To.IsZero() { + add("l.created_at <= $%d", *filter.To) + } + return where, args +} diff --git a/backend/internal/repository/group_repo.go b/backend/internal/repository/group_repo.go index 5e16475a3a1..112575f49bd 100644 --- a/backend/internal/repository/group_repo.go +++ b/backend/internal/repository/group_repo.go @@ -50,6 +50,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er SetNillableDailyLimitUsd(groupIn.DailyLimitUSD). SetNillableWeeklyLimitUsd(groupIn.WeeklyLimitUSD). SetNillableMonthlyLimitUsd(groupIn.MonthlyLimitUSD). + SetAllowImageGeneration(groupIn.AllowImageGeneration). + SetImageRateIndependent(groupIn.ImageRateIndependent). + SetImageRateMultiplier(groupIn.ImageRateMultiplier). SetNillableImagePrice1k(groupIn.ImagePrice1K). SetNillableImagePrice2k(groupIn.ImagePrice2K). SetNillableImagePrice4k(groupIn.ImagePrice4K). @@ -120,6 +123,9 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er SetNillableDailyLimitUsd(groupIn.DailyLimitUSD). SetNillableWeeklyLimitUsd(groupIn.WeeklyLimitUSD). SetNillableMonthlyLimitUsd(groupIn.MonthlyLimitUSD). + SetAllowImageGeneration(groupIn.AllowImageGeneration). + SetImageRateIndependent(groupIn.ImageRateIndependent). + SetImageRateMultiplier(groupIn.ImageRateMultiplier). SetNillableImagePrice1k(groupIn.ImagePrice1K). SetNillableImagePrice2k(groupIn.ImagePrice2K). SetNillableImagePrice4k(groupIn.ImagePrice4K). diff --git a/backend/internal/repository/image_task_repo.go b/backend/internal/repository/image_task_repo.go new file mode 100644 index 00000000000..62da2a6f403 --- /dev/null +++ b/backend/internal/repository/image_task_repo.go @@ -0,0 +1,113 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type imageTaskRepository struct { + sql sqlExecutor +} + +func NewImageTaskRepository(sqlDB *sql.DB) service.ImageTaskRepository { + return &imageTaskRepository{sql: sqlDB} +} + +func (r *imageTaskRepository) Create(ctx context.Context, task *service.ImageTask) error { + query := ` + INSERT INTO image_tasks ( + task_id, user_id, api_key_id, status, endpoint, model, prompt, + file_path, mime_type, byte_size, error_message, expires_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, '', '', 0, '', $8, NOW(), NOW()) + RETURNING created_at, updated_at + ` + return scanSingleRow(ctx, r.sql, query, []any{ + task.TaskID, task.UserID, task.APIKeyID, task.Status, task.Endpoint, task.Model, task.Prompt, task.ExpiresAt, + }, &task.CreatedAt, &task.UpdatedAt) +} + +func (r *imageTaskRepository) GetByTaskID(ctx context.Context, taskID string) (*service.ImageTask, error) { + query := ` + SELECT task_id, user_id, api_key_id, status, endpoint, model, prompt, + file_path, mime_type, byte_size, error_message, created_at, updated_at, expires_at + FROM image_tasks + WHERE task_id = $1 + ` + var task service.ImageTask + if err := scanSingleRow(ctx, r.sql, query, []any{taskID}, + &task.TaskID, &task.UserID, &task.APIKeyID, &task.Status, &task.Endpoint, &task.Model, &task.Prompt, + &task.FilePath, &task.MimeType, &task.ByteSize, &task.ErrorMessage, &task.CreatedAt, &task.UpdatedAt, &task.ExpiresAt, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, service.ErrImageTaskNotFound + } + return nil, err + } + return &task, nil +} + +func (r *imageTaskRepository) MarkRunning(ctx context.Context, taskID string) error { + _, err := r.sql.ExecContext(ctx, ` + UPDATE image_tasks + SET status = $1, updated_at = NOW() + WHERE task_id = $2 + `, service.ImageTaskStatusRunning, taskID) + return err +} + +func (r *imageTaskRepository) MarkSucceeded(ctx context.Context, taskID, filePath, mimeType string, byteSize int64, expiresAt time.Time) error { + _, err := r.sql.ExecContext(ctx, ` + UPDATE image_tasks + SET status = $1, file_path = $2, mime_type = $3, byte_size = $4, error_message = '', expires_at = $5, updated_at = NOW() + WHERE task_id = $6 + `, service.ImageTaskStatusSucceeded, filePath, mimeType, byteSize, expiresAt, taskID) + return err +} + +func (r *imageTaskRepository) MarkFailed(ctx context.Context, taskID, message string, expiresAt time.Time) error { + _, err := r.sql.ExecContext(ctx, ` + UPDATE image_tasks + SET status = $1, error_message = $2, expires_at = $3, updated_at = NOW() + WHERE task_id = $4 + `, service.ImageTaskStatusFailed, message, expiresAt, taskID) + return err +} + +func (r *imageTaskRepository) DeleteExpired(ctx context.Context, now time.Time) ([]service.ImageTask, error) { + rows, err := r.sql.QueryContext(ctx, ` + DELETE FROM image_tasks + WHERE expires_at <= $1 + RETURNING task_id, user_id, api_key_id, status, endpoint, model, prompt, + file_path, mime_type, byte_size, error_message, created_at, updated_at, expires_at + `, now) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var tasks []service.ImageTask + for rows.Next() { + var task service.ImageTask + if err := rows.Scan( + &task.TaskID, &task.UserID, &task.APIKeyID, &task.Status, &task.Endpoint, &task.Model, &task.Prompt, + &task.FilePath, &task.MimeType, &task.ByteSize, &task.ErrorMessage, &task.CreatedAt, &task.UpdatedAt, &task.ExpiresAt, + ); err != nil { + return nil, err + } + tasks = append(tasks, task) + } + return tasks, rows.Err() +} + +func (r *imageTaskRepository) MarkStaleRunningFailed(ctx context.Context, message string) error { + _, err := r.sql.ExecContext(ctx, ` + UPDATE image_tasks + SET status = $1, error_message = $2, updated_at = NOW() + WHERE status IN ($3, $4) + `, service.ImageTaskStatusFailed, message, service.ImageTaskStatusPending, service.ImageTaskStatusRunning) + return err +} diff --git a/backend/internal/repository/user_repo.go b/backend/internal/repository/user_repo.go index d1f10cbdcce..1566756d2bd 100644 --- a/backend/internal/repository/user_repo.go +++ b/backend/internal/repository/user_repo.go @@ -737,6 +737,37 @@ func (r *userRepository) UpdateConcurrency(ctx context.Context, id int64, amount return nil } +func (r *userRepository) BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) { + if len(userIDs) == 0 { + return 0, nil + } + if value < 0 { + value = 0 + } + res, err := r.sql.ExecContext(ctx, + "UPDATE users SET concurrency = $1, updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL", + value, pq.Array(userIDs)) + if err != nil { + return 0, fmt.Errorf("batch set concurrency: %w", err) + } + affected, _ := res.RowsAffected() + return int(affected), nil +} + +func (r *userRepository) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) { + if len(userIDs) == 0 { + return 0, nil + } + res, err := r.sql.ExecContext(ctx, + "UPDATE users SET concurrency = GREATEST(concurrency + $1, 0), updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL", + delta, pq.Array(userIDs)) + if err != nil { + return 0, fmt.Errorf("batch add concurrency: %w", err) + } + affected, _ := res.RowsAffected() + return int(affected), nil +} + func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) { return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx) } diff --git a/backend/internal/repository/wire.go b/backend/internal/repository/wire.go index f07bbb33018..70c7ae5b455 100644 --- a/backend/internal/repository/wire.go +++ b/backend/internal/repository/wire.go @@ -79,6 +79,7 @@ var ProviderSet = wire.NewSet( NewUsageBillingRepository, NewIdempotencyRepository, NewUsageCleanupRepository, + NewImageTaskRepository, NewDashboardAggregationRepository, NewSettingRepository, NewOpsRepository, @@ -91,6 +92,7 @@ var ProviderSet = wire.NewSet( NewChannelRepository, NewChannelMonitorRepository, NewChannelMonitorRequestTemplateRepository, + NewContentModerationRepository, NewAffiliateRepository, // Cache implementations @@ -119,6 +121,7 @@ var ProviderSet = wire.NewSet( NewRefreshTokenCache, NewErrorPassthroughCache, NewTLSFingerprintProfileCache, + NewContentModerationHashCache, // Encryptors NewAESEncryptor, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 607b93dcef0..27358865666 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -328,6 +328,9 @@ func TestAPIContracts(t *testing.T) { "image_price_1k": null, "image_price_2k": null, "image_price_4k": null, + "allow_image_generation": false, + "image_rate_independent": false, + "image_rate_multiplier": 0, "claude_code_only": false, "allow_messages_dispatch": false, "fallback_group_id": null, @@ -643,12 +646,21 @@ func TestAPIContracts(t *testing.T) { "registration_email_suffix_whitelist": [], "promo_code_enabled": true, "password_reset_enabled": false, - "frontend_url": "", - "totp_enabled": false, - "totp_encryption_key_configured": false, - "smtp_host": "smtp.example.com", - "smtp_port": 587, - "smtp_username": "user", + "frontend_url": "", + "totp_enabled": false, + "totp_encryption_key_configured": false, + "login_agreement_enabled": false, + "login_agreement_mode": "modal", + "login_agreement_updated_at": "2026-03-31", + "login_agreement_documents": [ + {"id": "terms", "title": "服务条款", "content_md": ""}, + {"id": "usage-policy", "title": "使用政策", "content_md": ""}, + {"id": "supported-regions", "title": "支持的国家和地区", "content_md": ""}, + {"id": "service-specific-terms", "title": "服务特定条款", "content_md": ""} + ], + "smtp_host": "smtp.example.com", + "smtp_port": 587, + "smtp_username": "user", "smtp_password_configured": true, "smtp_from_email": "no-reply@example.com", "smtp_from_name": "Sub2API", @@ -682,6 +694,16 @@ func TestAPIContracts(t *testing.T) { "oidc_connect_userinfo_email_path": "", "oidc_connect_userinfo_id_path": "", "oidc_connect_userinfo_username_path": "", + "github_oauth_enabled": false, + "github_oauth_client_id": "", + "github_oauth_client_secret_configured": false, + "github_oauth_redirect_url": "", + "github_oauth_frontend_redirect_url": "/auth/oauth/callback", + "google_oauth_enabled": false, + "google_oauth_client_id": "", + "google_oauth_client_secret_configured": false, + "google_oauth_redirect_url": "", + "google_oauth_frontend_redirect_url": "/auth/oauth/callback", "ops_monitoring_enabled": false, "ops_realtime_monitoring_enabled": true, "ops_query_mode_default": "auto", @@ -697,6 +719,16 @@ func TestAPIContracts(t *testing.T) { "auth_source_default_email_subscriptions": [], "auth_source_default_email_grant_on_signup": false, "auth_source_default_email_grant_on_first_bind": false, + "auth_source_default_github_balance": 0, + "auth_source_default_github_concurrency": 5, + "auth_source_default_github_subscriptions": [], + "auth_source_default_github_grant_on_signup": false, + "auth_source_default_github_grant_on_first_bind": false, + "auth_source_default_google_balance": 0, + "auth_source_default_google_concurrency": 5, + "auth_source_default_google_subscriptions": [], + "auth_source_default_google_grant_on_signup": false, + "auth_source_default_google_grant_on_first_bind": false, "auth_source_default_linuxdo_balance": 0, "auth_source_default_linuxdo_concurrency": 5, "auth_source_default_linuxdo_subscriptions": [], @@ -789,6 +821,7 @@ func TestAPIContracts(t *testing.T) { "channel_monitor_enabled": true, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, + "risk_control_enabled": false, "affiliate_enabled": false, "wechat_connect_enabled": false, "wechat_connect_app_id": "", @@ -856,12 +889,21 @@ func TestAPIContracts(t *testing.T) { "promo_code_enabled": true, "password_reset_enabled": false, "frontend_url": "", - "invitation_code_enabled": false, - "totp_enabled": false, - "totp_encryption_key_configured": false, - "smtp_host": "", - "smtp_port": 587, - "smtp_username": "", + "invitation_code_enabled": false, + "totp_enabled": false, + "totp_encryption_key_configured": false, + "login_agreement_enabled": false, + "login_agreement_mode": "modal", + "login_agreement_updated_at": "2026-03-31", + "login_agreement_documents": [ + {"id": "terms", "title": "服务条款", "content_md": ""}, + {"id": "usage-policy", "title": "使用政策", "content_md": ""}, + {"id": "supported-regions", "title": "支持的国家和地区", "content_md": ""}, + {"id": "service-specific-terms", "title": "服务特定条款", "content_md": ""} + ], + "smtp_host": "", + "smtp_port": 587, + "smtp_username": "", "smtp_password_configured": false, "smtp_from_email": "", "smtp_from_name": "", @@ -895,6 +937,16 @@ func TestAPIContracts(t *testing.T) { "oidc_connect_userinfo_email_path": "", "oidc_connect_userinfo_id_path": "", "oidc_connect_userinfo_username_path": "", + "github_oauth_enabled": false, + "github_oauth_client_id": "", + "github_oauth_client_secret_configured": false, + "github_oauth_redirect_url": "", + "github_oauth_frontend_redirect_url": "/auth/oauth/callback", + "google_oauth_enabled": false, + "google_oauth_client_id": "", + "google_oauth_client_secret_configured": false, + "google_oauth_redirect_url": "", + "google_oauth_frontend_redirect_url": "/auth/oauth/callback", "site_name": "Sub2API", "site_logo": "", "site_subtitle": "Subscription to API Conversion Platform", @@ -980,6 +1032,7 @@ func TestAPIContracts(t *testing.T) { "channel_monitor_enabled": true, "channel_monitor_default_interval_seconds": 60, "available_channels_enabled": false, + "risk_control_enabled": false, "affiliate_enabled": false, "wechat_connect_enabled": true, "wechat_connect_app_id": "wx-open-config", @@ -1002,6 +1055,16 @@ func TestAPIContracts(t *testing.T) { "auth_source_default_email_subscriptions": [], "auth_source_default_email_grant_on_signup": false, "auth_source_default_email_grant_on_first_bind": false, + "auth_source_default_github_balance": 0, + "auth_source_default_github_concurrency": 5, + "auth_source_default_github_subscriptions": [], + "auth_source_default_github_grant_on_signup": false, + "auth_source_default_github_grant_on_first_bind": false, + "auth_source_default_google_balance": 0, + "auth_source_default_google_concurrency": 5, + "auth_source_default_google_subscriptions": [], + "auth_source_default_google_grant_on_signup": false, + "auth_source_default_google_grant_on_first_bind": false, "auth_source_default_linuxdo_balance": 0, "auth_source_default_linuxdo_concurrency": 5, "auth_source_default_linuxdo_subscriptions": [], @@ -1120,7 +1183,7 @@ func newContractDeps(t *testing.T) *contractDeps { subscriptionService := service.NewSubscriptionService(groupRepo, userSubRepo, nil, nil, cfg) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) - redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil) + redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil, nil) redeemHandler := handler.NewRedeemHandler(redeemService) settingRepo := newStubSettingRepo() @@ -1291,6 +1354,9 @@ func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i return errors.New("not implemented") } +func (r *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (r *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } + func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { return false, errors.New("not implemented") } diff --git a/backend/internal/server/middleware/admin_auth_test.go b/backend/internal/server/middleware/admin_auth_test.go index dde92dfdc1a..3fbbb7161b7 100644 --- a/backend/internal/server/middleware/admin_auth_test.go +++ b/backend/internal/server/middleware/admin_auth_test.go @@ -198,6 +198,9 @@ func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i panic("unexpected UpdateConcurrency call") } +func (s *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } + func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { panic("unexpected ExistsByEmail call") } diff --git a/backend/internal/server/middleware/backend_mode_guard.go b/backend/internal/server/middleware/backend_mode_guard.go index ae53037e67e..157f06b0983 100644 --- a/backend/internal/server/middleware/backend_mode_guard.go +++ b/backend/internal/server/middleware/backend_mode_guard.go @@ -40,6 +40,8 @@ func backendModeAllowsAuthPath(path string) bool { "/auth/oauth/wechat/callback", "/auth/oauth/wechat/payment/callback", "/auth/oauth/oidc/callback", + "/auth/oauth/github/callback", + "/auth/oauth/google/callback", "/auth/oauth/linuxdo/complete-registration", "/auth/oauth/wechat/complete-registration", "/auth/oauth/oidc/complete-registration", diff --git a/backend/internal/server/middleware/backend_mode_guard_test.go b/backend/internal/server/middleware/backend_mode_guard_test.go index bd77677b74d..de9c9ec9dbd 100644 --- a/backend/internal/server/middleware/backend_mode_guard_test.go +++ b/backend/internal/server/middleware/backend_mode_guard_test.go @@ -246,6 +246,30 @@ func TestBackendModeAuthGuard(t *testing.T) { path: "/api/v1/auth/oauth/oidc/callback", wantStatus: http.StatusOK, }, + { + name: "enabled_blocks_github_oauth_start", + enabled: "true", + path: "/api/v1/auth/oauth/github/start", + wantStatus: http.StatusForbidden, + }, + { + name: "enabled_allows_github_oauth_callback", + enabled: "true", + path: "/api/v1/auth/oauth/github/callback", + wantStatus: http.StatusOK, + }, + { + name: "enabled_blocks_google_oauth_start", + enabled: "true", + path: "/api/v1/auth/oauth/google/start", + wantStatus: http.StatusForbidden, + }, + { + name: "enabled_allows_google_oauth_callback", + enabled: "true", + path: "/api/v1/auth/oauth/google/callback", + wantStatus: http.StatusOK, + }, { name: "enabled_allows_oauth_pending_exchange", enabled: "true", diff --git a/backend/internal/server/middleware/security_headers.go b/backend/internal/server/middleware/security_headers.go index 398c0351dc0..7384fb819c0 100644 --- a/backend/internal/server/middleware/security_headers.go +++ b/backend/internal/server/middleware/security_headers.go @@ -120,6 +120,11 @@ func enhanceCSPPolicy(policy string) string { policy = addToDirective(policy, "frame-src", StripeDomain) } + // Generated image previews and reference thumbnails use object URLs. + if !strings.Contains(policy, "blob:") { + policy = addToDirective(policy, "img-src", "blob:") + } + return policy } diff --git a/backend/internal/server/middleware/security_headers_test.go b/backend/internal/server/middleware/security_headers_test.go index 031385d062e..aab6bd3db89 100644 --- a/backend/internal/server/middleware/security_headers_test.go +++ b/backend/internal/server/middleware/security_headers_test.go @@ -322,6 +322,13 @@ func TestEnhanceCSPPolicy(t *testing.T) { assert.Contains(t, enhanced, CloudflareInsightsDomain) }) + t.Run("adds_blob_to_img_src_for_generated_image_previews", func(t *testing.T) { + policy := "default-src 'self'; img-src 'self' data: https:" + enhanced := enhanceCSPPolicy(policy) + + assert.Contains(t, enhanced, "img-src 'self' data: https: blob:") + }) + t.Run("preserves_existing_nonce", func(t *testing.T) { policy := "script-src 'self' 'nonce-existing'" enhanced := enhanceCSPPolicy(policy) diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index a507b6f831e..f477f3a754c 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -112,4 +112,6 @@ func registerRoutes( routes.RegisterAdminRoutes(v1, h, adminAuth) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg) routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService) + + handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir, gin.HandlerFunc(jwtAuth), gin.HandlerFunc(adminAuth), settingService) } diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index fe4c4b1b29f..6e1059bc829 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -92,11 +92,28 @@ func RegisterAdminRoutes( // 渠道监控 registerChannelMonitorRoutes(admin, h) + // 风控中心 + registerContentModerationRoutes(admin, h) + // 邀请返利(专属用户管理) registerAffiliateRoutes(admin, h) } } +func registerContentModerationRoutes(admin *gin.RouterGroup, h *handler.Handlers) { + risk := admin.Group("/risk-control") + { + risk.GET("/config", h.Admin.ContentModeration.GetConfig) + risk.PUT("/config", h.Admin.ContentModeration.UpdateConfig) + risk.POST("/api-keys/test", h.Admin.ContentModeration.TestAPIKeys) + risk.GET("/status", h.Admin.ContentModeration.GetStatus) + risk.GET("/logs", h.Admin.ContentModeration.ListLogs) + risk.POST("/users/:user_id/unban", h.Admin.ContentModeration.UnbanUser) + risk.DELETE("/hashes", h.Admin.ContentModeration.DeleteFlaggedHash) + risk.DELETE("/hashes/all", h.Admin.ContentModeration.ClearFlaggedHashes) + } +} + func registerAdminAPIKeyRoutes(admin *gin.RouterGroup, h *handler.Handlers) { apiKeys := admin.Group("/api-keys") { @@ -228,6 +245,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) { users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory) users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup) users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus) + users.POST("/batch-concurrency", h.Admin.User.BatchUpdateConcurrency) // User attribute values users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes) @@ -264,6 +282,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.GET("/:id", h.Admin.Account.GetByID) accounts.POST("", h.Admin.Account.Create) accounts.POST("/check-mixed-channel", h.Admin.Account.CheckMixedChannel) + accounts.POST("/import/codex-session", h.Admin.Account.ImportCodexSession) accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS) accounts.POST("/sync/crs/preview", h.Admin.Account.PreviewFromCRS) accounts.PUT("/:id", h.Admin.Account.Update) @@ -408,6 +427,9 @@ func registerSettingsRoutes(admin *gin.RouterGroup, h *handler.Handlers) { // 529过载冷却配置 adminSettings.GET("/overload-cooldown", h.Admin.Setting.GetOverloadCooldownSettings) adminSettings.PUT("/overload-cooldown", h.Admin.Setting.UpdateOverloadCooldownSettings) + // 429默认回避配置 + adminSettings.GET("/rate-limit-429-cooldown", h.Admin.Setting.GetRateLimit429CooldownSettings) + adminSettings.PUT("/rate-limit-429-cooldown", h.Admin.Setting.UpdateRateLimit429CooldownSettings) // 流超时处理配置 adminSettings.GET("/stream-timeout", h.Admin.Setting.GetStreamTimeoutSettings) adminSettings.PUT("/stream-timeout", h.Admin.Setting.UpdateStreamTimeoutSettings) diff --git a/backend/internal/server/routes/auth.go b/backend/internal/server/routes/auth.go index 642a2103e32..54d40e921b3 100644 --- a/backend/internal/server/routes/auth.go +++ b/backend/internal/server/routes/auth.go @@ -63,6 +63,22 @@ func RegisterAuthRoutes( FailureMode: middleware.RateLimitFailClose, }), h.Auth.ResetPassword) auth.GET("/oauth/linuxdo/start", h.Auth.LinuxDoOAuthStart) + auth.GET("/oauth/github/start", h.Auth.GitHubOAuthStart) + auth.GET("/oauth/github/callback", h.Auth.GitHubOAuthCallback) + auth.POST("/oauth/github/complete-registration", + rateLimiter.LimitWithOptions("oauth-github-complete", 10, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), + h.Auth.CompleteGitHubOAuthRegistration, + ) + auth.GET("/oauth/google/start", h.Auth.GoogleOAuthStart) + auth.GET("/oauth/google/callback", h.Auth.GoogleOAuthCallback) + auth.POST("/oauth/google/complete-registration", + rateLimiter.LimitWithOptions("oauth-google-complete", 10, time.Minute, middleware.RateLimitOptions{ + FailureMode: middleware.RateLimitFailClose, + }), + h.Auth.CompleteGoogleOAuthRegistration, + ) auth.GET("/oauth/linuxdo/bind/start", func(c *gin.Context) { query := c.Request.URL.Query() query.Set("intent", "bind_current_user") diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 9541cda1abf..a09ada341e1 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -30,6 +30,30 @@ func RegisterGatewayRoutes( // 未分组 Key 拦截中间件(按协议格式区分错误响应) requireGroupAnthropic := middleware.RequireGroupAssignment(settingService, middleware.AnthropicErrorWriter) requireGroupGoogle := middleware.RequireGroupAssignment(settingService, middleware.GoogleErrorWriter) + openAIImagesHandler := func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformOpenAI { + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "type": "not_found_error", + "message": "Images API is not supported for this platform", + }, + }) + return + } + h.OpenAIGateway.Images(c) + } + openAIImagesAsyncHandler := func(c *gin.Context) { + if getGroupPlatform(c) != service.PlatformOpenAI { + c.JSON(http.StatusNotFound, gin.H{ + "error": gin.H{ + "type": "not_found_error", + "message": "Images API is not supported for this platform", + }, + }) + return + } + h.OpenAIGateway.ImagesAsync(c) + } // API网关(Claude API兼容) gateway := r.Group("/v1") @@ -89,28 +113,10 @@ func RegisterGatewayRoutes( h.Gateway.ChatCompletions(c) }) gateway.POST("/images/generations", func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { - c.JSON(http.StatusNotFound, gin.H{ - "error": gin.H{ - "type": "not_found_error", - "message": "Images API is not supported for this platform", - }, - }) - return - } - h.OpenAIGateway.Images(c) + openAIImagesHandler(c) }) gateway.POST("/images/edits", func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { - c.JSON(http.StatusNotFound, gin.H{ - "error": gin.H{ - "type": "not_found_error", - "message": "Images API is not supported for this platform", - }, - }) - return - } - h.OpenAIGateway.Images(c) + openAIImagesHandler(c) }) } @@ -155,30 +161,29 @@ func RegisterGatewayRoutes( } h.Gateway.ChatCompletions(c) }) - r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { - c.JSON(http.StatusNotFound, gin.H{ - "error": gin.H{ - "type": "not_found_error", - "message": "Images API is not supported for this platform", - }, - }) + r.POST("/api/v1/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { + if getGroupPlatform(c) == service.PlatformOpenAI { + h.OpenAIGateway.ChatCompletions(c) return } - h.OpenAIGateway.Images(c) + h.Gateway.ChatCompletions(c) + }) + r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { + openAIImagesHandler(c) }) r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { - c.JSON(http.StatusNotFound, gin.H{ - "error": gin.H{ - "type": "not_found_error", - "message": "Images API is not supported for this platform", - }, - }) - return - } - h.OpenAIGateway.Images(c) + openAIImagesHandler(c) + }) + r.POST("/api/v1/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { + openAIImagesHandler(c) + }) + r.POST("/api/v1/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { + openAIImagesHandler(c) }) + r.POST("/api/v1/images/async/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, openAIImagesAsyncHandler) + r.POST("/api/v1/images/async/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, openAIImagesAsyncHandler) + r.GET("/api/v1/images/async/tasks/:task_id", clientRequestID, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ImageAsyncTaskStatus) + r.GET("/api/v1/images/async/tasks/:task_id/download", clientRequestID, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.OpenAIGateway.ImageAsyncTaskDownload) // Antigravity 模型列表 r.GET("/antigravity/models", gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, h.Gateway.AntigravityModels) diff --git a/backend/internal/server/routes/gateway_test.go b/backend/internal/server/routes/gateway_test.go index 19ef568600c..a8ddd0a2c2a 100644 --- a/backend/internal/server/routes/gateway_test.go +++ b/backend/internal/server/routes/gateway_test.go @@ -68,6 +68,8 @@ func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) { "/v1/images/edits", "/images/generations", "/images/edits", + "/api/v1/images/generations", + "/api/v1/images/edits", } { req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-image-2","prompt":"draw a cat"}`)) req.Header.Set("Content-Type", "application/json") @@ -77,3 +79,20 @@ func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) { require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit OpenAI images handler", path) } } + +func TestGatewayRoutesOpenAIChatCompletionAppPathIsRegistered(t *testing.T) { + router := newGatewayRoutesTestRouter() + + for _, path := range []string{ + "/v1/chat/completions", + "/chat/completions", + "/api/v1/chat/completions", + } { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hi"}],"stream":true}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + require.NotEqual(t, http.StatusNotFound, w.Code, "path=%s should hit OpenAI chat completions handler", path) + } +} diff --git a/backend/internal/server/routes/user.go b/backend/internal/server/routes/user.go index 9976954cf34..a45305dcd3e 100644 --- a/backend/internal/server/routes/user.go +++ b/backend/internal/server/routes/user.go @@ -15,6 +15,21 @@ func RegisterUserRoutes( jwtAuth middleware.JWTAuthMiddleware, settingService *service.SettingService, ) { + // 公开模型广场接口:只暴露公开分组可见的渠道和模型。 + public := v1.Group("/public") + { + channels := public.Group("/channels") + { + channels.GET("/available", h.AvailableChannel.ListPublic) + } + } + + // 模型广场价格对比接口不包含用户数据,允许匿名页面复用。 + publicChannels := v1.Group("/channels") + { + publicChannels.POST("/model-pricing/batch", h.AvailableChannel.GetModelPricingBatch) + } + authenticated := v1.Group("") authenticated.Use(gin.HandlerFunc(jwtAuth)) authenticated.Use(middleware.BackendModeUserGuard(settingService)) @@ -89,6 +104,18 @@ func RegisterUserRoutes( usage.POST("/dashboard/api-keys-usage", h.Usage.DashboardAPIKeysUsage) } + // 聊天会话历史 + chatSessions := authenticated.Group("/chat/sessions") + { + chatSessions.GET("", h.ChatSession.ListSessions) + chatSessions.POST("", h.ChatSession.CreateSession) + chatSessions.PATCH("/:id", h.ChatSession.UpdateSession) + chatSessions.DELETE("/:id", h.ChatSession.DeleteSession) + chatSessions.GET("/:id/messages", h.ChatSession.ListMessages) + chatSessions.POST("/:id/messages", h.ChatSession.CreateMessage) + chatSessions.PATCH("/:id/messages/:message_id", h.ChatSession.UpdateMessage) + } + // 公告(用户可见) announcements := authenticated.Group("/announcements") { diff --git a/backend/internal/service/account_stats_pricing.go b/backend/internal/service/account_stats_pricing.go index 90ff450f48a..221021d85cb 100644 --- a/backend/internal/service/account_stats_pricing.go +++ b/backend/internal/service/account_stats_pricing.go @@ -230,7 +230,11 @@ func applyAccountStatsCost( if model == "" { model = requestedModel } + requestCount := 1 + if usageLog != nil && usageLog.ImageCount > 0 { + requestCount = usageLog.ImageCount + } usageLog.AccountStatsCost = resolveAccountStatsCost( - ctx, cs, bs, accountID, groupID, model, tokens, 1, totalCost, + ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, ) } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index be4c23dcab4..eb5994d5498 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -33,6 +33,7 @@ type AdminService interface { UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) DeleteUser(ctx context.Context, id int64) error UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) + BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) @@ -189,11 +190,14 @@ type CreateGroupInput struct { WeeklyLimitUSD *float64 // 周限额 (USD) MonthlyLimitUSD *float64 // 月限额 (USD) // 图片生成计费配置(仅 antigravity 平台使用) - ImagePrice1K *float64 - ImagePrice2K *float64 - ImagePrice4K *float64 - ClaudeCodeOnly bool // 仅允许 Claude Code 客户端 - FallbackGroupID *int64 // 降级分组 ID + AllowImageGeneration bool + ImageRateIndependent bool + ImageRateMultiplier *float64 + ImagePrice1K *float64 + ImagePrice2K *float64 + ImagePrice4K *float64 + ClaudeCodeOnly bool // 仅允许 Claude Code 客户端 + FallbackGroupID *int64 // 降级分组 ID // 无效请求兜底分组 ID(仅 anthropic 平台使用) FallbackGroupIDOnInvalidRequest *int64 // 模型路由配置(仅 anthropic 平台使用) @@ -226,11 +230,14 @@ type UpdateGroupInput struct { WeeklyLimitUSD *float64 // 周限额 (USD) MonthlyLimitUSD *float64 // 月限额 (USD) // 图片生成计费配置(仅 antigravity 平台使用) - ImagePrice1K *float64 - ImagePrice2K *float64 - ImagePrice4K *float64 - ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端 - FallbackGroupID *int64 // 降级分组 ID + AllowImageGeneration *bool + ImageRateIndependent *bool + ImageRateMultiplier *float64 + ImagePrice1K *float64 + ImagePrice2K *float64 + ImagePrice4K *float64 + ClaudeCodeOnly *bool // 仅允许 Claude Code 客户端 + FallbackGroupID *int64 // 降级分组 ID // 无效请求兜底分组 ID(仅 anthropic 平台使用) FallbackGroupIDOnInvalidRequest *int64 // 模型路由配置(仅 anthropic 平台使用) @@ -811,6 +818,39 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error { return nil } +func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) { + cleaned := make([]int64, 0, len(userIDs)) + for _, uid := range userIDs { + if uid > 0 { + cleaned = append(cleaned, uid) + } + } + if len(cleaned) == 0 { + return 0, nil + } + + var affected int + var err error + switch mode { + case "set": + affected, err = s.userRepo.BatchSetConcurrency(ctx, cleaned, value) + case "add": + affected, err = s.userRepo.BatchAddConcurrency(ctx, cleaned, value) + default: + return 0, errors.New("invalid mode: must be 'set' or 'add'") + } + if err != nil { + return 0, err + } + + if s.authCacheInvalidator != nil { + for _, uid := range cleaned { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, uid) + } + } + return affected, nil +} + func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) { user, err := s.userRepo.GetByID(ctx, userID) if err != nil { @@ -1557,6 +1597,13 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn imagePrice1K := normalizePrice(input.ImagePrice1K) imagePrice2K := normalizePrice(input.ImagePrice2K) imagePrice4K := normalizePrice(input.ImagePrice4K) + imageRateMultiplier := 1.0 + if input.ImageRateMultiplier != nil { + if *input.ImageRateMultiplier < 0 { + return nil, errors.New("image_rate_multiplier must be >= 0") + } + imageRateMultiplier = *input.ImageRateMultiplier + } // 校验降级分组 if input.FallbackGroupID != nil { @@ -1624,6 +1671,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn DailyLimitUSD: dailyLimit, WeeklyLimitUSD: weeklyLimit, MonthlyLimitUSD: monthlyLimit, + AllowImageGeneration: input.AllowImageGeneration, + ImageRateIndependent: input.ImageRateIndependent, + ImageRateMultiplier: imageRateMultiplier, ImagePrice1K: imagePrice1K, ImagePrice2K: imagePrice2K, ImagePrice4K: imagePrice4K, @@ -1800,6 +1850,18 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd group.WeeklyLimitUSD = normalizeLimit(input.WeeklyLimitUSD) group.MonthlyLimitUSD = normalizeLimit(input.MonthlyLimitUSD) // 图片生成计费配置:负数表示清除(使用默认价格) + if input.AllowImageGeneration != nil { + group.AllowImageGeneration = *input.AllowImageGeneration + } + if input.ImageRateIndependent != nil { + group.ImageRateIndependent = *input.ImageRateIndependent + } + if input.ImageRateMultiplier != nil { + if *input.ImageRateMultiplier < 0 { + return nil, errors.New("image_rate_multiplier must be >= 0") + } + group.ImageRateMultiplier = *input.ImageRateMultiplier + } if input.ImagePrice1K != nil { group.ImagePrice1K = normalizePrice(input.ImagePrice1K) } diff --git a/backend/internal/service/admin_service_apikey_test.go b/backend/internal/service/admin_service_apikey_test.go index fcde5cbf4ab..3b3dbc21cfb 100644 --- a/backend/internal/service/admin_service_apikey_test.go +++ b/backend/internal/service/admin_service_apikey_test.go @@ -68,6 +68,9 @@ func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error { panic("unexpected") } + +func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) { panic("unexpected") } diff --git a/backend/internal/service/admin_service_delete_test.go b/backend/internal/service/admin_service_delete_test.go index fe9e7701a26..a9492a1d6e9 100644 --- a/backend/internal/service/admin_service_delete_test.go +++ b/backend/internal/service/admin_service_delete_test.go @@ -131,6 +131,9 @@ func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount i panic("unexpected UpdateConcurrency call") } +func (s *userRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *userRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } + func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) { if s.existsErr != nil { return false, s.existsErr diff --git a/backend/internal/service/admin_service_email_identity_sync_test.go b/backend/internal/service/admin_service_email_identity_sync_test.go index 2232c9c38b6..c791b747cf7 100644 --- a/backend/internal/service/admin_service_email_identity_sync_test.go +++ b/backend/internal/service/admin_service_email_identity_sync_test.go @@ -113,6 +113,9 @@ func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) return 0, nil } +func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } + func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } func (s *emailSyncRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { diff --git a/backend/internal/service/admin_service_group_test.go b/backend/internal/service/admin_service_group_test.go index eef02240699..0a2020eacbc 100644 --- a/backend/internal/service/admin_service_group_test.go +++ b/backend/internal/service/admin_service_group_test.go @@ -266,6 +266,50 @@ func TestAdminService_UpdateGroup_PartialImagePricing(t *testing.T) { require.Nil(t, repo.updated.ImagePrice4K) } +func TestAdminService_UpdateGroup_PreservesImageGenerationControlsWhenOmitted(t *testing.T) { + imageMultiplier := 0.5 + existingGroup := &Group{ + ID: 1, + Name: "existing-group", + Platform: PlatformOpenAI, + Status: StatusActive, + AllowImageGeneration: true, + ImageRateIndependent: true, + ImageRateMultiplier: imageMultiplier, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + + group, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{ + Description: "updated", + }) + require.NoError(t, err) + require.NotNil(t, group) + require.NotNil(t, repo.updated) + require.True(t, repo.updated.AllowImageGeneration) + require.True(t, repo.updated.ImageRateIndependent) + require.InDelta(t, 0.5, repo.updated.ImageRateMultiplier, 1e-12) +} + +func TestAdminService_UpdateGroup_RejectsNegativeImageRateMultiplier(t *testing.T) { + existingGroup := &Group{ + ID: 1, + Name: "existing-group", + Platform: PlatformOpenAI, + Status: StatusActive, + ImageRateMultiplier: 1, + } + repo := &groupRepoStubForAdmin{getByID: existingGroup} + svc := &adminServiceImpl{groupRepo: repo} + negative := -0.1 + + _, err := svc.UpdateGroup(context.Background(), 1, &UpdateGroupInput{ + ImageRateMultiplier: &negative, + }) + require.Error(t, err) + require.Nil(t, repo.updated) +} + func TestAdminService_UpdateGroup_InvalidatesAuthCacheOnRPMLimitChange(t *testing.T) { existingGroup := &Group{ ID: 1, diff --git a/backend/internal/service/api_key_auth_cache.go b/backend/internal/service/api_key_auth_cache.go index 1a1c78b8de1..3553a18abe5 100644 --- a/backend/internal/service/api_key_auth_cache.go +++ b/backend/internal/service/api_key_auth_cache.go @@ -8,6 +8,7 @@ type APIKeyAuthSnapshot struct { APIKeyID int64 `json:"api_key_id"` UserID int64 `json:"user_id"` GroupID *int64 `json:"group_id,omitempty"` + Name string `json:"name"` Status string `json:"status"` IPWhitelist []string `json:"ip_whitelist,omitempty"` IPBlacklist []string `json:"ip_blacklist,omitempty"` @@ -63,6 +64,9 @@ type APIKeyAuthGroupSnapshot struct { DailyLimitUSD *float64 `json:"daily_limit_usd,omitempty"` WeeklyLimitUSD *float64 `json:"weekly_limit_usd,omitempty"` MonthlyLimitUSD *float64 `json:"monthly_limit_usd,omitempty"` + AllowImageGeneration bool `json:"allow_image_generation"` + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier float64 `json:"image_rate_multiplier"` ImagePrice1K *float64 `json:"image_price_1k,omitempty"` ImagePrice2K *float64 `json:"image_price_2k,omitempty"` ImagePrice4K *float64 `json:"image_price_4k,omitempty"` diff --git a/backend/internal/service/api_key_auth_cache_impl.go b/backend/internal/service/api_key_auth_cache_impl.go index 974ea66efad..877888b1e51 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 = 7 // v7: added UserGroupRPMOverride on user snapshot +const apiKeyAuthSnapshotVersion = 9 // v9: added API Key name for audit logs type apiKeyAuthCacheConfig struct { l1Size int @@ -210,6 +210,7 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) APIKeyID: apiKey.ID, UserID: apiKey.UserID, GroupID: apiKey.GroupID, + Name: apiKey.Name, Status: apiKey.Status, IPWhitelist: apiKey.IPWhitelist, IPBlacklist: apiKey.IPBlacklist, @@ -255,6 +256,9 @@ func (s *APIKeyService) snapshotFromAPIKey(ctx context.Context, apiKey *APIKey) DailyLimitUSD: apiKey.Group.DailyLimitUSD, WeeklyLimitUSD: apiKey.Group.WeeklyLimitUSD, MonthlyLimitUSD: apiKey.Group.MonthlyLimitUSD, + AllowImageGeneration: apiKey.Group.AllowImageGeneration, + ImageRateIndependent: apiKey.Group.ImageRateIndependent, + ImageRateMultiplier: apiKey.Group.ImageRateMultiplier, ImagePrice1K: apiKey.Group.ImagePrice1K, ImagePrice2K: apiKey.Group.ImagePrice2K, ImagePrice4K: apiKey.Group.ImagePrice4K, @@ -283,6 +287,7 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho UserID: snapshot.UserID, GroupID: snapshot.GroupID, Key: key, + Name: snapshot.Name, Status: snapshot.Status, IPWhitelist: snapshot.IPWhitelist, IPBlacklist: snapshot.IPBlacklist, @@ -321,6 +326,9 @@ func (s *APIKeyService) snapshotToAPIKey(key string, snapshot *APIKeyAuthSnapsho DailyLimitUSD: snapshot.Group.DailyLimitUSD, WeeklyLimitUSD: snapshot.Group.WeeklyLimitUSD, MonthlyLimitUSD: snapshot.Group.MonthlyLimitUSD, + AllowImageGeneration: snapshot.Group.AllowImageGeneration, + ImageRateIndependent: snapshot.Group.ImageRateIndependent, + ImageRateMultiplier: snapshot.Group.ImageRateMultiplier, ImagePrice1K: snapshot.Group.ImagePrice1K, ImagePrice2K: snapshot.Group.ImagePrice2K, ImagePrice4K: snapshot.Group.ImagePrice4K, diff --git a/backend/internal/service/api_key_service_cache_test.go b/backend/internal/service/api_key_service_cache_test.go index 8cb1b8c4267..eaac9a1c898 100644 --- a/backend/internal/service/api_key_service_cache_test.go +++ b/backend/internal/service/api_key_service_cache_test.go @@ -235,6 +235,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t UserID: 2, GroupID: &groupID, Key: "k-roundtrip", + Name: "Audit Key", Status: StatusActive, User: &User{ ID: 2, @@ -267,6 +268,7 @@ func TestAPIKeyService_SnapshotRoundTrip_PreservesMessagesDispatchModelConfig(t roundTrip := svc.snapshotToAPIKey(apiKey.Key, snapshot) require.NotNil(t, roundTrip) + require.Equal(t, apiKey.Name, roundTrip.Name) require.NotNil(t, roundTrip.Group) require.Equal(t, apiKey.Group.MessagesDispatchModelConfig, roundTrip.Group.MessagesDispatchModelConfig) } diff --git a/backend/internal/service/auth_email_oauth_auto.go b/backend/internal/service/auth_email_oauth_auto.go new file mode 100644 index 00000000000..56fd4004186 --- /dev/null +++ b/backend/internal/service/auth_email_oauth_auto.go @@ -0,0 +1,274 @@ +package service + +import ( + "context" + "errors" + "fmt" + "net/mail" + "strings" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/authidentity" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" +) + +type EmailOAuthIdentityInput struct { + ProviderType string + ProviderKey string + ProviderSubject string + Email string + EmailVerified bool + Username string + DisplayName string + AvatarURL string + UpstreamMetadata map[string]any +} + +func (s *AuthService) LoginOrRegisterVerifiedEmailOAuth(ctx context.Context, input EmailOAuthIdentityInput) (*TokenPair, *User, error) { + return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, "", "") +} + +func (s *AuthService) LoginOrRegisterVerifiedEmailOAuthWithInvitation( + ctx context.Context, + input EmailOAuthIdentityInput, + invitationCode string, + affiliateCode string, +) (*TokenPair, *User, error) { + return s.loginOrRegisterVerifiedEmailOAuth(ctx, input, invitationCode, affiliateCode) +} + +func (s *AuthService) loginOrRegisterVerifiedEmailOAuth( + ctx context.Context, + input EmailOAuthIdentityInput, + invitationCode string, + affiliateCode string, +) (*TokenPair, *User, error) { + if s == nil || s.userRepo == nil || s.entClient == nil { + return nil, nil, ErrServiceUnavailable + } + + providerType := normalizeOAuthSignupSource(input.ProviderType) + if providerType != "github" && providerType != "google" { + return nil, nil, infraerrors.BadRequest("OAUTH_PROVIDER_INVALID", "oauth provider is invalid") + } + providerKey := strings.TrimSpace(input.ProviderKey) + if providerKey == "" { + providerKey = providerType + } + providerSubject := strings.TrimSpace(input.ProviderSubject) + if providerSubject == "" { + return nil, nil, infraerrors.BadRequest("OAUTH_SUBJECT_MISSING", "oauth subject is missing") + } + if !input.EmailVerified { + return nil, nil, infraerrors.Forbidden("OAUTH_EMAIL_NOT_VERIFIED", "oauth email is not verified") + } + + email := strings.TrimSpace(strings.ToLower(input.Email)) + if email == "" || len(email) > 255 { + return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email") + } + if _, err := mail.ParseAddress(email); err != nil { + return nil, nil, infraerrors.BadRequest("INVALID_EMAIL", "invalid email") + } + if isReservedEmail(email) { + return nil, nil, ErrEmailReserved + } + if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil { + return nil, nil, err + } + + identityUser, err := s.findEmailOAuthIdentityOwner(ctx, providerType, providerKey, providerSubject) + if err != nil { + return nil, nil, err + } + if identityUser != nil && !strings.EqualFold(strings.TrimSpace(identityUser.Email), email) { + return nil, nil, infraerrors.Conflict("AUTH_IDENTITY_EMAIL_MISMATCH", "oauth identity belongs to a different email") + } + + user := identityUser + created := false + if user == nil { + user, err = s.userRepo.GetByEmail(ctx, email) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + user, err = s.createEmailOAuthUser(ctx, email, input.Username, providerType, invitationCode, affiliateCode) + if err != nil { + return nil, nil, err + } + created = true + } else { + logger.LegacyPrintf("service.auth", "[Auth] Database error during %s oauth login: %v", providerType, err) + return nil, nil, ErrServiceUnavailable + } + } + } + + if !user.IsActive() { + return nil, nil, ErrUserNotActive + } + if err := s.ensureEmailOAuthIdentity(ctx, user.ID, EmailOAuthIdentityInput{ + ProviderType: providerType, + ProviderKey: providerKey, + ProviderSubject: providerSubject, + Email: email, + EmailVerified: input.EmailVerified, + Username: input.Username, + DisplayName: input.DisplayName, + AvatarURL: input.AvatarURL, + UpstreamMetadata: input.UpstreamMetadata, + }); err != nil { + return nil, nil, err + } + + if user.Username == "" && strings.TrimSpace(input.Username) != "" { + user.Username = strings.TrimSpace(input.Username) + if err := s.userRepo.Update(ctx, user); err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to update username after %s oauth login: %v", providerType, err) + } + } + if !created { + if err := s.ApplyProviderDefaultSettingsOnFirstBind(ctx, user.ID, providerType); err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to apply %s first bind defaults: %v", providerType, err) + } + } + s.RecordSuccessfulLogin(ctx, user.ID) + + tokenPair, err := s.GenerateTokenPair(ctx, user, "") + if err != nil { + return nil, nil, fmt.Errorf("generate token pair: %w", err) + } + return tokenPair, user, nil +} + +func (s *AuthService) createEmailOAuthUser(ctx context.Context, email, username, providerType, invitationCode, affiliateCode string) (*User, error) { + if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { + return nil, ErrRegDisabled + } + invitationRedeemCode, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode) + if err != nil { + if errors.Is(err, ErrInvitationCodeRequired) { + return nil, ErrOAuthInvitationRequired + } + return nil, err + } + + randomPassword, err := randomHexString(32) + if err != nil { + return nil, ErrServiceUnavailable + } + hashedPassword, err := s.HashPassword(randomPassword) + if err != nil { + return nil, fmt.Errorf("hash password: %w", err) + } + grantPlan := s.resolveSignupGrantPlan(ctx, providerType) + var defaultRPMLimit int + if s.settingService != nil { + defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx) + } + user := &User{ + Email: email, + Username: strings.TrimSpace(username), + PasswordHash: hashedPassword, + Role: RoleUser, + Balance: grantPlan.Balance, + Concurrency: grantPlan.Concurrency, + RPMLimit: defaultRPMLimit, + Status: StatusActive, + SignupSource: providerType, + } + if err := s.userRepo.Create(ctx, user); err != nil { + if errors.Is(err, ErrEmailExists) { + existing, loadErr := s.userRepo.GetByEmail(ctx, email) + if loadErr != nil { + return nil, ErrServiceUnavailable + } + return existing, nil + } + return nil, ErrServiceUnavailable + } + s.postAuthUserBootstrap(ctx, user, providerType, false) + s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") + s.bindOAuthAffiliate(ctx, user.ID, affiliateCode) + if invitationRedeemCode != nil { + if err := s.useOAuthRegistrationInvitation(ctx, invitationRedeemCode.ID, user.ID); err != nil { + _ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, invitationCode) + return nil, ErrInvitationCodeInvalid + } + } + return user, nil +} + +func (s *AuthService) findEmailOAuthIdentityOwner(ctx context.Context, providerType, providerKey, providerSubject string) (*User, error) { + identity, err := s.entClient.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ(providerType), + authidentity.ProviderKeyEQ(providerKey), + authidentity.ProviderSubjectEQ(providerSubject), + ). + Only(ctx) + if err != nil { + if dbent.IsNotFound(err) { + return nil, nil + } + return nil, infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err) + } + user, err := s.userRepo.GetByID(ctx, identity.UserID) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return nil, nil + } + return nil, ErrServiceUnavailable + } + return user, nil +} + +func (s *AuthService) ensureEmailOAuthIdentity(ctx context.Context, userID int64, input EmailOAuthIdentityInput) error { + metadata := map[string]any{ + "email": strings.TrimSpace(strings.ToLower(input.Email)), + "email_verified": input.EmailVerified, + } + for key, value := range input.UpstreamMetadata { + metadata[key] = value + } + if strings.TrimSpace(input.Username) != "" { + metadata["username"] = strings.TrimSpace(input.Username) + } + if strings.TrimSpace(input.DisplayName) != "" { + metadata["display_name"] = strings.TrimSpace(input.DisplayName) + } + if strings.TrimSpace(input.AvatarURL) != "" { + metadata["avatar_url"] = strings.TrimSpace(input.AvatarURL) + } + + providerType := normalizeOAuthSignupSource(input.ProviderType) + providerKey := strings.TrimSpace(input.ProviderKey) + providerSubject := strings.TrimSpace(input.ProviderSubject) + identity, err := s.entClient.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ(providerType), + authidentity.ProviderKeyEQ(providerKey), + authidentity.ProviderSubjectEQ(providerSubject), + ). + Only(ctx) + if err != nil && !dbent.IsNotFound(err) { + return infraerrors.InternalServer("AUTH_IDENTITY_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err) + } + if identity != nil { + if identity.UserID != userID { + return infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user") + } + _, err = s.entClient.AuthIdentity.UpdateOneID(identity.ID). + SetMetadata(metadata). + Save(ctx) + return err + } + _, err = s.entClient.AuthIdentity.Create(). + SetUserID(userID). + SetProviderType(providerType). + SetProviderKey(providerKey). + SetProviderSubject(providerSubject). + SetMetadata(metadata). + Save(ctx) + return err +} diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index 9815f31be35..e3c8298c299 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -10,6 +10,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/ent/redeemcode" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" ) func normalizeOAuthSignupSource(signupSource string) string { @@ -17,7 +18,7 @@ func normalizeOAuthSignupSource(signupSource string) string { switch signupSource { case "", "email": return "email" - case "linuxdo", "wechat", "oidc": + case "linuxdo", "wechat", "oidc", "github", "google": return signupSource default: return "email" @@ -168,6 +169,87 @@ func (s *AuthService) RegisterOAuthEmailAccount( return tokenPair, user, nil } +// RegisterVerifiedOAuthEmailAccount creates a local account from an OAuth +// provider that has already returned a verified email address. +func (s *AuthService) RegisterVerifiedOAuthEmailAccount( + ctx context.Context, + email string, + password string, + invitationCode string, + signupSource string, +) (*TokenPair, *User, error) { + if s == nil { + return nil, nil, ErrServiceUnavailable + } + if s.settingService == nil || !s.settingService.IsRegistrationEnabled(ctx) { + return nil, nil, ErrRegDisabled + } + + email = strings.TrimSpace(strings.ToLower(email)) + if email == "" || len(email) > 255 { + return nil, nil, ErrEmailVerifyRequired + } + if _, err := mail.ParseAddress(email); err != nil { + return nil, nil, ErrEmailVerifyRequired + } + if isReservedEmail(email) { + return nil, nil, ErrEmailReserved + } + if err := s.validateRegistrationEmailPolicy(ctx, email); err != nil { + return nil, nil, err + } + if strings.TrimSpace(password) == "" { + return nil, nil, infraerrors.BadRequest("PASSWORD_REQUIRED", "password is required") + } + if _, err := s.validateOAuthRegistrationInvitation(ctx, invitationCode); err != nil { + return nil, nil, err + } + + existsEmail, err := s.userRepo.ExistsByEmail(ctx, email) + if err != nil { + return nil, nil, ErrServiceUnavailable + } + if existsEmail { + return nil, nil, ErrEmailExists + } + + hashedPassword, err := s.HashPassword(password) + if err != nil { + return nil, nil, fmt.Errorf("hash password: %w", err) + } + + signupSource = normalizeOAuthSignupSource(signupSource) + grantPlan := s.resolveSignupGrantPlan(ctx, signupSource) + var defaultRPMLimit int + if s.settingService != nil { + defaultRPMLimit = s.settingService.GetDefaultUserRPMLimit(ctx) + } + user := &User{ + Email: email, + PasswordHash: hashedPassword, + Role: RoleUser, + Balance: grantPlan.Balance, + Concurrency: grantPlan.Concurrency, + RPMLimit: defaultRPMLimit, + Status: StatusActive, + SignupSource: signupSource, + } + + if err := s.userRepo.Create(ctx, user); err != nil { + if errors.Is(err, ErrEmailExists) { + return nil, nil, ErrEmailExists + } + return nil, nil, ErrServiceUnavailable + } + + tokenPair, err := s.GenerateTokenPair(ctx, user, "") + if err != nil { + _ = s.RollbackOAuthEmailAccountCreation(ctx, user.ID, "") + return nil, nil, fmt.Errorf("generate token pair: %w", err) + } + return tokenPair, user, nil +} + // FinalizeOAuthEmailAccount applies invitation usage and normal signup bootstrap // only after the pending OAuth flow has fully reached its last reversible step. func (s *AuthService) FinalizeOAuthEmailAccount( diff --git a/backend/internal/service/auth_oauth_email_flow_test.go b/backend/internal/service/auth_oauth_email_flow_test.go index 21d9d6e9342..cd76c6b7401 100644 --- a/backend/internal/service/auth_oauth_email_flow_test.go +++ b/backend/internal/service/auth_oauth_email_flow_test.go @@ -229,6 +229,67 @@ func TestRegisterOAuthEmailAccountSetsNormalizedSignupSourceOnCreatedUser(t *tes require.Equal(t, "oidc", userRepo.created[0].SignupSource) } +func TestRegisterOAuthEmailAccountKeepsGitHubAndGoogleSignupSource(t *testing.T) { + tests := []struct { + name string + email string + signupSource string + want string + }{ + { + name: "github", + email: "github@example.com", + signupSource: " GitHub ", + want: "github", + }, + { + name: "google", + email: "google@example.com", + signupSource: " Google ", + want: "google", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + userRepo := &userRepoStub{nextID: 43} + emailCache := &emailCacheStub{ + data: &VerificationCodeData{ + Code: "246810", + Attempts: 0, + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(15 * time.Minute), + }, + } + authService := newOAuthEmailFlowAuthService( + userRepo, + &redeemCodeRepoStub{}, + &refreshTokenCacheStub{}, + map[string]string{ + SettingKeyRegistrationEnabled: "true", + SettingKeyEmailVerifyEnabled: "true", + }, + emailCache, + ) + + tokenPair, user, err := authService.RegisterOAuthEmailAccount( + context.Background(), + tt.email, + "secret-123", + "246810", + "", + tt.signupSource, + ) + + require.NoError(t, err) + require.NotNil(t, tokenPair) + require.NotNil(t, user) + require.Len(t, userRepo.created, 1) + require.Equal(t, tt.want, userRepo.created[0].SignupSource) + }) + } +} + func TestRegisterOAuthEmailAccountFallsBackUnknownSignupSourceToEmail(t *testing.T) { userRepo := &userRepoStub{nextID: 43} emailCache := &emailCacheStub{ @@ -256,7 +317,7 @@ func TestRegisterOAuthEmailAccountFallsBackUnknownSignupSourceToEmail(t *testing "secret-123", "246810", "", - "github", + "unknown-provider", ) require.NoError(t, err) diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index b1adf071c47..e01e8217ab7 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -775,6 +775,10 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource return defaults.OIDC, true case "wechat": return defaults.WeChat, true + case "github": + return defaults.GitHub, true + case "google": + return defaults.Google, true default: return ProviderDefaultGrantSettings{}, false } diff --git a/backend/internal/service/auth_service_email_bind_test.go b/backend/internal/service/auth_service_email_bind_test.go index ea2308f78e9..8f03f857efa 100644 --- a/backend/internal/service/auth_service_email_bind_test.go +++ b/backend/internal/service/auth_service_email_bind_test.go @@ -820,6 +820,9 @@ func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) ( return ok, nil } +func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (s *emailBindUserRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } + func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index cb502a2e087..a9c21884445 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -294,8 +294,7 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { } // OpenAI 仅匹配已知 GPT-5/Codex 族,避免未知 OpenAI 型号误计价。 - if strings.Contains(modelLower, "gpt-5") || strings.Contains(modelLower, "codex") { - normalized := normalizeCodexModel(modelLower) + if normalized := normalizeKnownOpenAICodexModel(modelLower); normalized != "" { switch normalized { case "gpt-5.5": return s.fallbackPrices["gpt-5.5"] @@ -644,13 +643,10 @@ func (s *BillingService) shouldApplySessionLongContextPricing(tokens UsageTokens } func isOpenAIGPT54Model(model string) bool { - trimmed := strings.TrimSpace(strings.ToLower(model)) - // 仅当模型字符串实际属于 GPT-5/Codex 族时才做归一判定,避免 normalizeCodexModel - // 的默认兜底把非 OpenAI 模型(claude-*、gemini-*、gpt-4o)误识别为 gpt-5.4。 - if !strings.Contains(trimmed, "gpt-5") && !strings.Contains(trimmed, "codex") { - return false - } - normalized := normalizeCodexModel(trimmed) + // 仅当模型字符串实际属于已知 GPT-5/Codex 族时才做归一判定,避免 + // normalizeCodexModel 的默认兜底把非 OpenAI 模型(claude-*、gemini-*、gpt-4o) + // 误识别为 gpt-5.4。 + normalized := normalizeKnownOpenAICodexModel(model) return normalized == "gpt-5.4" || normalized == "gpt-5.5" } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 222abd6990d..df3e3a0a4fb 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -137,6 +137,35 @@ func TestGetModelPricing_OpenAIGPT54Fallback(t *testing.T) { require.InDelta(t, 1.5, pricing.LongContextOutputMultiplier, 1e-12) } +func TestGetModelPricing_OpenAICompactAliasesFallback(t *testing.T) { + svc := newTestBillingService() + + tests := []struct { + model string + inputPrice float64 + outputPrice float64 + cacheRead float64 + longContext int + }{ + {model: "gpt5.5", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000}, + {model: "openai/gpt5.4", inputPrice: 2.5e-6, outputPrice: 15e-6, cacheRead: 0.25e-6, longContext: 272000}, + {model: "gpt5.4-mini", inputPrice: 7.5e-7, outputPrice: 4.5e-6, cacheRead: 7.5e-8, longContext: 0}, + {model: "gpt5.3codexspark", inputPrice: 1.5e-6, outputPrice: 12e-6, cacheRead: 0.15e-6, longContext: 0}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + pricing, err := svc.GetModelPricing(tt.model) + require.NoError(t, err) + require.NotNil(t, pricing) + require.InDelta(t, tt.inputPrice, pricing.InputPricePerToken, 1e-12) + require.InDelta(t, tt.outputPrice, pricing.OutputPricePerToken, 1e-12) + require.InDelta(t, tt.cacheRead, pricing.CacheReadPricePerToken, 1e-12) + require.Equal(t, tt.longContext, pricing.LongContextInputThreshold) + }) + } +} + func TestGetModelPricing_OpenAIGPT54MiniFallback(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/channel.go b/backend/internal/service/channel.go index 158bf8a31bf..8f7d3accab4 100644 --- a/backend/internal/service/channel.go +++ b/backend/internal/service/channel.go @@ -283,6 +283,27 @@ func ValidateIntervals(intervals []PricingInterval) error { return validateIntervalOverlap(sorted) } +// ValidateImageTiers 校验图片计费层级。 +// 图片模式按 TierLabel(如 1K/2K/4K)命中价格,MinTokens/MaxTokens 不参与分辨率计费。 +func ValidateImageTiers(intervals []PricingInterval) error { + seen := make(map[string]struct{}, len(intervals)) + for i := range intervals { + iv := &intervals[i] + label := strings.ToUpper(strings.TrimSpace(iv.TierLabel)) + if label == "" { + return fmt.Errorf("tier #%d: tier_label is required", i+1) + } + if _, ok := seen[label]; ok { + return fmt.Errorf("tier #%d: duplicate tier_label %q", i+1, label) + } + seen[label] = struct{}{} + if err := validateIntervalPrices(iv, i); err != nil { + return err + } + } + return nil +} + // validateSingleInterval 校验单个区间的字段合法性 func validateSingleInterval(iv *PricingInterval, idx int) error { if iv.MinTokens < 0 { diff --git a/backend/internal/service/channel_service.go b/backend/internal/service/channel_service.go index 4e08df4a569..e227493fa6b 100644 --- a/backend/internal/service/channel_service.go +++ b/backend/internal/service/channel_service.go @@ -951,7 +951,7 @@ func validateNoConflictingMappings(mapping map[string]map[string]string) error { func validatePricingIntervals(pricingList []ChannelModelPricing) error { for _, pricing := range pricingList { - if err := ValidateIntervals(pricing.Intervals); err != nil { + if err := validatePricingIntervalsForMode(pricing); err != nil { return infraerrors.BadRequest( "INVALID_PRICING_INTERVALS", fmt.Sprintf("invalid pricing intervals for platform '%s' models %v: %v", @@ -962,6 +962,13 @@ func validatePricingIntervals(pricingList []ChannelModelPricing) error { return nil } +func validatePricingIntervalsForMode(pricing ChannelModelPricing) error { + if pricing.BillingMode == BillingModeImage { + return ValidateImageTiers(pricing.Intervals) + } + return ValidateIntervals(pricing.Intervals) +} + // detectConflicts 在一组 modelEntry 中检测冲突,返回带有 errCode 和 label 的错误 func detectConflicts(entries []modelEntry, platform, errCode, label string) error { for i := 0; i < len(entries); i++ { diff --git a/backend/internal/service/channel_test.go b/backend/internal/service/channel_test.go index 164861fb93d..c953fd0f7c5 100644 --- a/backend/internal/service/channel_test.go +++ b/backend/internal/service/channel_test.go @@ -434,6 +434,22 @@ func TestValidateIntervals_UnboundedNotLast(t *testing.T) { require.Contains(t, err.Error(), "last") } +func TestValidatePricingIntervals_AllowsImageTiersWithUnboundedTokenRanges(t *testing.T) { + err := validatePricingIntervals([]ChannelModelPricing{ + { + Platform: "openai", + Models: []string{"gpt-image-2"}, + BillingMode: BillingModeImage, + Intervals: []PricingInterval{ + {MinTokens: 0, MaxTokens: nil, TierLabel: "1K", PerRequestPrice: testPtrFloat64(0.04)}, + {MinTokens: 0, MaxTokens: nil, TierLabel: "2K", PerRequestPrice: testPtrFloat64(0.08)}, + {MinTokens: 0, MaxTokens: nil, TierLabel: "4K", PerRequestPrice: testPtrFloat64(0.16)}, + }, + }, + }) + require.NoError(t, err) +} + func TestSupportedModels_ExactKeysAndPricing(t *testing.T) { ch := &Channel{ ModelPricing: []ChannelModelPricing{ diff --git a/backend/internal/service/codex_image_generation_bridge.go b/backend/internal/service/codex_image_generation_bridge.go new file mode 100644 index 00000000000..c7a894a7929 --- /dev/null +++ b/backend/internal/service/codex_image_generation_bridge.go @@ -0,0 +1,64 @@ +package service + +import "strings" + +const featureKeyCodexImageGenerationBridge = "codex_image_generation_bridge" + +func boolOverridePtr(v bool) *bool { + return &v +} + +func boolOverrideFromMap(values map[string]any, keys ...string) *bool { + if values == nil { + return nil + } + for _, key := range keys { + if v, ok := values[key].(bool); ok { + return boolOverridePtr(v) + } + } + return nil +} + +func platformBoolOverride(values map[string]any, key string, platform string) *bool { + if values == nil { + return nil + } + if v, ok := values[key].(bool); ok { + return boolOverridePtr(v) + } + raw, ok := values[key].(map[string]any) + if !ok { + return nil + } + platform = strings.TrimSpace(platform) + if platform == "" { + return nil + } + if v, ok := raw[platform].(bool); ok { + return boolOverridePtr(v) + } + return nil +} + +// CodexImageGenerationBridgeOverride returns the channel-level override for Codex +// image_generation bridge injection. Nil means follow the global/account policy. +func (c *Channel) CodexImageGenerationBridgeOverride(platform string) *bool { + if c == nil { + return nil + } + return platformBoolOverride(c.FeaturesConfig, featureKeyCodexImageGenerationBridge, platform) +} + +// CodexImageGenerationBridgeOverride returns the account-level override for Codex +// image_generation bridge injection. Nil means follow the channel/global policy. +func (a *Account) CodexImageGenerationBridgeOverride() *bool { + if a == nil || a.Platform != PlatformOpenAI || a.Extra == nil { + return nil + } + if override := boolOverrideFromMap(a.Extra, featureKeyCodexImageGenerationBridge, "codex_image_generation_bridge_enabled"); override != nil { + return override + } + openaiConfig, _ := a.Extra[PlatformOpenAI].(map[string]any) + return boolOverrideFromMap(openaiConfig, featureKeyCodexImageGenerationBridge, "codex_image_generation_bridge_enabled") +} diff --git a/backend/internal/service/content_moderation.go b/backend/internal/service/content_moderation.go new file mode 100644 index 00000000000..144222c2fd9 --- /dev/null +++ b/backend/internal/service/content_moderation.go @@ -0,0 +1,2048 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +const ( + ContentModerationModeOff = "off" + ContentModerationModeObserve = "observe" + ContentModerationModePreBlock = "pre_block" + + contentModerationAPIKeysModeAppend = "append" + contentModerationAPIKeysModeReplace = "replace" + + ContentModerationActionAllow = "allow" + ContentModerationActionBlock = "block" + ContentModerationActionHashBlock = "hash_block" + ContentModerationActionError = "error" + + ContentModerationProtocolAnthropicMessages = "anthropic_messages" + ContentModerationProtocolOpenAIResponses = "openai_responses" + ContentModerationProtocolOpenAIChat = "openai_chat_completions" + ContentModerationProtocolGemini = "gemini" + ContentModerationProtocolOpenAIImages = "openai_images" + + defaultContentModerationBaseURL = "https://api.openai.com" + defaultContentModerationModel = "omni-moderation-latest" + defaultContentModerationTimeoutMS = 3000 + maxContentModerationTimeoutMS = 30000 + maxModerationInputRunes = 12000 + maxModerationExcerptRunes = 240 + + defaultContentModerationWorkerCount = 4 + maxContentModerationWorkerCount = 32 + defaultContentModerationQueueSize = 32768 + maxContentModerationQueueSize = 100000 + defaultContentModerationBanThreshold = 10 + defaultContentModerationViolationWindowHours = 720 + defaultContentModerationBlockHTTPStatus = http.StatusForbidden + defaultContentModerationBlockMessage = "内容审计命中风险规则,请调整输入后重试" + defaultContentModerationRetryCount = 2 + maxContentModerationRetryCount = 5 + defaultContentModerationHitRetentionDays = 180 + defaultContentModerationNonHitRetentionDays = 3 + maxContentModerationRetentionDays = 3650 + maxContentModerationNonHitRetentionDays = 3 + contentModerationKeyRateLimitFreezeDuration = time.Minute + contentModerationKeyAuthFreezeDuration = 10 * time.Minute + contentModerationKeyHTTPErrorFreezeDuration = 10 * time.Second + maxContentModerationInputImages = 1 + maxContentModerationTestImages = maxContentModerationInputImages + maxContentModerationTestImageBytes = 8 * 1024 * 1024 + maxContentModerationTestImageDataURLBytes = 12 * 1024 * 1024 + + contentModerationCleanupInterval = 24 * time.Hour + contentModerationCleanupTimeout = 30 * time.Minute + contentModerationCleanupDelay = 5 * time.Minute +) + +var contentModerationCategoryOrder = []string{ + "harassment", + "harassment/threatening", + "hate", + "hate/threatening", + "illicit", + "illicit/violent", + "self-harm", + "self-harm/intent", + "self-harm/instructions", + "sexual", + "sexual/minors", + "violence", + "violence/graphic", +} + +func ContentModerationDefaultThresholds() map[string]float64 { + return map[string]float64{ + "harassment": 0.98, + "harassment/threatening": 0.90, + "hate": 0.65, + "hate/threatening": 0.65, + "illicit": 0.95, + "illicit/violent": 0.95, + "self-harm": 0.65, + "self-harm/intent": 0.85, + "self-harm/instructions": 0.65, + "sexual": 0.65, + "sexual/minors": 0.65, + "violence": 0.95, + "violence/graphic": 0.95, + } +} + +func ContentModerationCategories() []string { + out := make([]string, len(contentModerationCategoryOrder)) + copy(out, contentModerationCategoryOrder) + return out +} + +type ContentModerationConfig struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + APIKey string `json:"api_key,omitempty"` + APIKeys []string `json:"api_keys,omitempty"` + TimeoutMS int `json:"timeout_ms"` + SampleRate int `json:"sample_rate"` + AllGroups bool `json:"all_groups"` + GroupIDs []int64 `json:"group_ids"` + RecordNonHits bool `json:"record_non_hits"` + Thresholds map[string]float64 `json:"thresholds"` + WorkerCount int `json:"worker_count"` + QueueSize int `json:"queue_size"` + BlockStatus int `json:"block_status"` + BlockMessage string `json:"block_message"` + EmailOnHit bool `json:"email_on_hit"` + AutoBanEnabled bool `json:"auto_ban_enabled"` + BanThreshold int `json:"ban_threshold"` + ViolationWindowHours int `json:"violation_window_hours"` + RetryCount int `json:"retry_count"` + HitRetentionDays int `json:"hit_retention_days"` + NonHitRetentionDays int `json:"non_hit_retention_days"` + PreHashCheckEnabled bool `json:"pre_hash_check_enabled"` +} + +type ContentModerationConfigView struct { + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + APIKeyConfigured bool `json:"api_key_configured"` + APIKeyMasked string `json:"api_key_masked"` + APIKeyCount int `json:"api_key_count"` + APIKeyMasks []string `json:"api_key_masks"` + APIKeyStatuses []ContentModerationAPIKeyStatus `json:"api_key_statuses"` + TimeoutMS int `json:"timeout_ms"` + SampleRate int `json:"sample_rate"` + AllGroups bool `json:"all_groups"` + GroupIDs []int64 `json:"group_ids"` + RecordNonHits bool `json:"record_non_hits"` + WorkerCount int `json:"worker_count"` + QueueSize int `json:"queue_size"` + BlockStatus int `json:"block_status"` + BlockMessage string `json:"block_message"` + EmailOnHit bool `json:"email_on_hit"` + AutoBanEnabled bool `json:"auto_ban_enabled"` + BanThreshold int `json:"ban_threshold"` + ViolationWindowHours int `json:"violation_window_hours"` + RetryCount int `json:"retry_count"` + HitRetentionDays int `json:"hit_retention_days"` + NonHitRetentionDays int `json:"non_hit_retention_days"` + PreHashCheckEnabled bool `json:"pre_hash_check_enabled"` +} + +type ContentModerationAPIKeyStatus struct { + Index int `json:"index"` + KeyHash string `json:"key_hash"` + Masked string `json:"masked"` + Status string `json:"status"` + FailureCount int `json:"failure_count"` + SuccessCount int64 `json:"success_count"` + LastError string `json:"last_error"` + LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` + FrozenUntil *time.Time `json:"frozen_until,omitempty"` + LastLatencyMS int `json:"last_latency_ms"` + LastHTTPStatus int `json:"last_http_status"` + LastTested bool `json:"last_tested"` + Configured bool `json:"configured"` +} + +type TestContentModerationAPIKeysInput struct { + APIKeys []string `json:"api_keys"` + BaseURL string `json:"base_url"` + Model string `json:"model"` + TimeoutMS int `json:"timeout_ms"` + Prompt string `json:"prompt"` + Images []string `json:"images"` +} + +type TestContentModerationAPIKeysResult struct { + Items []ContentModerationAPIKeyStatus `json:"items"` + AuditResult *ContentModerationTestAuditResult `json:"audit_result,omitempty"` + ImageCount int `json:"image_count"` +} + +type ContentModerationTestAuditResult struct { + Flagged bool `json:"flagged"` + HighestCategory string `json:"highest_category"` + HighestScore float64 `json:"highest_score"` + CompositeScore float64 `json:"composite_score"` + CategoryScores map[string]float64 `json:"category_scores"` + Thresholds map[string]float64 `json:"thresholds"` +} + +type UpdateContentModerationConfigInput struct { + Enabled *bool `json:"enabled"` + Mode *string `json:"mode"` + BaseURL *string `json:"base_url"` + Model *string `json:"model"` + APIKey *string `json:"api_key"` + APIKeys *[]string `json:"api_keys"` + APIKeysMode string `json:"api_keys_mode"` + DeleteAPIKeyHashes *[]string `json:"delete_api_key_hashes"` + ClearAPIKey bool `json:"clear_api_key"` + TimeoutMS *int `json:"timeout_ms"` + SampleRate *int `json:"sample_rate"` + AllGroups *bool `json:"all_groups"` + GroupIDs *[]int64 `json:"group_ids"` + RecordNonHits *bool `json:"record_non_hits"` + WorkerCount *int `json:"worker_count"` + QueueSize *int `json:"queue_size"` + BlockStatus *int `json:"block_status"` + BlockMessage *string `json:"block_message"` + EmailOnHit *bool `json:"email_on_hit"` + AutoBanEnabled *bool `json:"auto_ban_enabled"` + BanThreshold *int `json:"ban_threshold"` + ViolationWindowHours *int `json:"violation_window_hours"` + RetryCount *int `json:"retry_count"` + HitRetentionDays *int `json:"hit_retention_days"` + NonHitRetentionDays *int `json:"non_hit_retention_days"` + PreHashCheckEnabled *bool `json:"pre_hash_check_enabled"` +} + +type ContentModerationCheckInput struct { + RequestID string + UserID int64 + UserEmail string + APIKeyID int64 + APIKeyName string + GroupID *int64 + GroupName string + Endpoint string + Provider string + Model string + Protocol string + Body []byte +} + +type ContentModerationInput struct { + Text string + Images []string +} + +func (in *ContentModerationInput) Normalize() { + if in == nil { + return + } + in.Text = trimRunes(normalizeContentModerationText(in.Text), maxModerationInputRunes) + in.Images = normalizeModerationImages(in.Images) +} + +func (in ContentModerationInput) IsEmpty() bool { + return strings.TrimSpace(in.Text) == "" && len(in.Images) == 0 +} + +func (in ContentModerationInput) ModerationInput() any { + images := limitContentModerationImages(in.Images) + if len(images) == 0 { + return in.Text + } + parts := make([]moderationAPIInputPart, 0, len(images)+1) + if strings.TrimSpace(in.Text) != "" { + parts = append(parts, moderationAPIInputPart{Type: "text", Text: in.Text}) + } + for _, image := range images { + parts = append(parts, moderationAPIInputPart{ + Type: "image_url", + ImageURL: &moderationAPIImageURLRef{URL: image}, + }) + } + return parts +} + +func (in ContentModerationInput) ExcerptText() string { + return in.Text +} + +func (in ContentModerationInput) Hash() string { + h := sha256.New() + _, _ = h.Write([]byte("text:")) + _, _ = h.Write([]byte(in.Text)) + for _, image := range in.Images { + imageHash := sha256.Sum256([]byte(image)) + _, _ = h.Write([]byte("\nimage:")) + _, _ = h.Write([]byte(hex.EncodeToString(imageHash[:]))) + } + return hex.EncodeToString(h.Sum(nil)) +} + +type ContentModerationDecision struct { + Allowed bool `json:"allowed"` + Blocked bool `json:"blocked"` + Flagged bool `json:"flagged"` + Message string `json:"message"` + StatusCode int `json:"status_code"` + InputHash string `json:"input_hash,omitempty"` + HighestCategory string `json:"highest_category"` + HighestScore float64 `json:"highest_score"` + CategoryScores map[string]float64 `json:"category_scores"` + Action string `json:"action"` +} + +type ContentModerationLog struct { + ID int64 `json:"id"` + RequestID string `json:"request_id"` + UserID *int64 `json:"user_id,omitempty"` + UserEmail string `json:"user_email"` + APIKeyID *int64 `json:"api_key_id,omitempty"` + APIKeyName string `json:"api_key_name"` + GroupID *int64 `json:"group_id,omitempty"` + GroupName string `json:"group_name"` + Endpoint string `json:"endpoint"` + Provider string `json:"provider"` + Model string `json:"model"` + Mode string `json:"mode"` + Action string `json:"action"` + Flagged bool `json:"flagged"` + HighestCategory string `json:"highest_category"` + HighestScore float64 `json:"highest_score"` + CategoryScores map[string]float64 `json:"category_scores"` + ThresholdSnapshot map[string]float64 `json:"threshold_snapshot"` + InputExcerpt string `json:"input_excerpt"` + UpstreamLatencyMS *int `json:"upstream_latency_ms,omitempty"` + Error string `json:"error"` + ViolationCount int `json:"violation_count"` + AutoBanned bool `json:"auto_banned"` + EmailSent bool `json:"email_sent"` + UserStatus string `json:"user_status"` + QueueDelayMS *int `json:"queue_delay_ms,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ContentModerationLogFilter struct { + Pagination pagination.PaginationParams + Result string + GroupID *int64 + Endpoint string + Search string + From *time.Time + To *time.Time +} + +type ContentModerationCleanupResult struct { + DeletedHit int64 `json:"deleted_hit"` + DeletedNonHit int64 `json:"deleted_non_hit"` + FinishedAt time.Time `json:"finished_at"` +} + +type ContentModerationRuntimeStatus struct { + Enabled bool `json:"enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` + Mode string `json:"mode"` + WorkerCount int `json:"worker_count"` + MaxWorkers int `json:"max_workers"` + ActiveWorkers int `json:"active_workers"` + IdleWorkers int `json:"idle_workers"` + QueueSize int `json:"queue_size"` + QueueLength int `json:"queue_length"` + QueueUsagePercent float64 `json:"queue_usage_percent"` + Enqueued int64 `json:"enqueued"` + Dropped int64 `json:"dropped"` + Processed int64 `json:"processed"` + Errors int64 `json:"errors"` + APIKeyStatuses []ContentModerationAPIKeyStatus `json:"api_key_statuses"` + FlaggedHashCount int64 `json:"flagged_hash_count"` + LastCleanupAt *time.Time `json:"last_cleanup_at,omitempty"` + LastCleanupDeletedHit int64 `json:"last_cleanup_deleted_hit"` + LastCleanupDeletedNonHit int64 `json:"last_cleanup_deleted_non_hit"` +} + +type ContentModerationUnbanUserResult struct { + UserID int64 `json:"user_id"` + Status string `json:"status"` +} + +type ContentModerationDeleteHashResult struct { + InputHash string `json:"input_hash"` + Deleted bool `json:"deleted"` +} + +type ContentModerationClearHashesResult struct { + Deleted int64 `json:"deleted"` +} + +type ContentModerationRepository interface { + CreateLog(ctx context.Context, log *ContentModerationLog) error + ListLogs(ctx context.Context, filter ContentModerationLogFilter) ([]ContentModerationLog, *pagination.PaginationResult, error) + CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) + CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*ContentModerationCleanupResult, error) +} + +type ContentModerationHashCache interface { + RecordFlaggedInputHash(ctx context.Context, inputHash string) error + HasFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) + DeleteFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) + ClearFlaggedInputHashes(ctx context.Context) (int64, error) + CountFlaggedInputHashes(ctx context.Context) (int64, error) +} + +type ContentModerationService struct { + settingRepo SettingRepository + repo ContentModerationRepository + hashCache ContentModerationHashCache + groupRepo GroupRepository + userRepo UserRepository + authCacheInvalidator APIKeyAuthCacheInvalidator + emailService *EmailService + httpClient *http.Client + asyncQueue chan contentModerationTask + workerCount int + apiKeyCursor atomic.Uint64 + asyncActive atomic.Int64 + asyncEnqueued atomic.Int64 + asyncDropped atomic.Int64 + asyncProcessed atomic.Int64 + asyncErrors atomic.Int64 + lastCleanupUnix atomic.Int64 + lastCleanupDeletedHit atomic.Int64 + lastCleanupDeletedNonHit atomic.Int64 + keyHealthMu sync.Mutex + keyHealth map[string]*contentModerationKeyHealth +} + +type contentModerationTask struct { + input ContentModerationCheckInput + content ContentModerationInput + inputHash string + enqueuedAt time.Time +} + +type contentModerationKeyHealth struct { + Hash string + Masked string + FailureCount int + SuccessCount int64 + LastError string + LastCheckedAt time.Time + FrozenUntil time.Time + LastLatencyMS int + LastHTTPStatus int + LastTested bool +} + +func NewContentModerationService( + settingRepo SettingRepository, + repo ContentModerationRepository, + hashCache ContentModerationHashCache, + groupRepo GroupRepository, + userRepo UserRepository, + authCacheInvalidator APIKeyAuthCacheInvalidator, + emailService *EmailService, +) *ContentModerationService { + svc := &ContentModerationService{ + settingRepo: settingRepo, + repo: repo, + hashCache: hashCache, + groupRepo: groupRepo, + userRepo: userRepo, + authCacheInvalidator: authCacheInvalidator, + emailService: emailService, + httpClient: &http.Client{}, + workerCount: maxContentModerationWorkerCount, + asyncQueue: make(chan contentModerationTask, maxContentModerationQueueSize), + keyHealth: make(map[string]*contentModerationKeyHealth), + } + if settingRepo != nil && repo != nil { + for i := 0; i < svc.workerCount; i++ { + go svc.worker(i) + } + go svc.cleanupWorker() + } + return svc +} + +func (s *ContentModerationService) GetConfig(ctx context.Context) (*ContentModerationConfigView, error) { + cfg, err := s.loadConfig(ctx) + if err != nil { + return nil, err + } + return s.configView(cfg), nil +} + +func (s *ContentModerationService) UpdateConfig(ctx context.Context, input UpdateContentModerationConfigInput) (*ContentModerationConfigView, error) { + cfg, err := s.loadConfig(ctx) + if err != nil { + return nil, err + } + if input.Enabled != nil { + cfg.Enabled = *input.Enabled + } + if input.Mode != nil { + cfg.Mode = strings.TrimSpace(*input.Mode) + } + if input.BaseURL != nil { + cfg.BaseURL = strings.TrimSpace(*input.BaseURL) + } + if input.Model != nil { + cfg.Model = strings.TrimSpace(*input.Model) + } + if input.TimeoutMS != nil { + cfg.TimeoutMS = *input.TimeoutMS + } + if input.SampleRate != nil { + cfg.SampleRate = *input.SampleRate + } + if input.WorkerCount != nil { + cfg.WorkerCount = *input.WorkerCount + } + if input.QueueSize != nil { + cfg.QueueSize = *input.QueueSize + } + if input.BlockStatus != nil { + cfg.BlockStatus = *input.BlockStatus + } + if input.BlockMessage != nil { + cfg.BlockMessage = strings.TrimSpace(*input.BlockMessage) + } + if input.EmailOnHit != nil { + cfg.EmailOnHit = *input.EmailOnHit + } + if input.AutoBanEnabled != nil { + cfg.AutoBanEnabled = *input.AutoBanEnabled + } + if input.BanThreshold != nil { + cfg.BanThreshold = *input.BanThreshold + } + if input.ViolationWindowHours != nil { + cfg.ViolationWindowHours = *input.ViolationWindowHours + } + if input.RetryCount != nil { + cfg.RetryCount = *input.RetryCount + } + if input.HitRetentionDays != nil { + cfg.HitRetentionDays = *input.HitRetentionDays + } + if input.NonHitRetentionDays != nil { + cfg.NonHitRetentionDays = *input.NonHitRetentionDays + } + if input.PreHashCheckEnabled != nil { + cfg.PreHashCheckEnabled = *input.PreHashCheckEnabled + } + if input.AllGroups != nil { + cfg.AllGroups = *input.AllGroups + } + if input.GroupIDs != nil { + cfg.GroupIDs = normalizeInt64IDs(*input.GroupIDs) + } + if input.RecordNonHits != nil { + cfg.RecordNonHits = *input.RecordNonHits + } + if input.ClearAPIKey { + cfg.APIKey = "" + cfg.APIKeys = []string{} + } else { + apiKeysMode := normalizeContentModerationAPIKeysMode(input.APIKeysMode) + if input.DeleteAPIKeyHashes != nil && apiKeysMode != contentModerationAPIKeysModeReplace { + cfg.APIKeys = deleteModerationAPIKeysByHash(cfg.apiKeys(), *input.DeleteAPIKeyHashes) + cfg.APIKey = "" + } + if input.APIKeys != nil { + if apiKeysMode == contentModerationAPIKeysModeReplace { + cfg.APIKeys = normalizeModerationAPIKeys(*input.APIKeys) + } else { + cfg.APIKeys = normalizeModerationAPIKeys(append(cfg.apiKeys(), *input.APIKeys...)) + } + cfg.APIKey = "" + } + if input.APIKey != nil && strings.TrimSpace(*input.APIKey) != "" { + cfg.APIKeys = normalizeModerationAPIKeys(append(cfg.APIKeys, *input.APIKey)) + cfg.APIKey = "" + } + } + if err := s.validateConfig(ctx, cfg); err != nil { + return nil, err + } + cfg.normalize() + raw, err := json.Marshal(cfg) + if err != nil { + return nil, fmt.Errorf("marshal content moderation config: %w", err) + } + if err := s.settingRepo.Set(ctx, SettingKeyContentModerationConfig, string(raw)); err != nil { + return nil, fmt.Errorf("save content moderation config: %w", err) + } + return s.configView(cfg), nil +} + +func (s *ContentModerationService) TestAPIKeys(ctx context.Context, input TestContentModerationAPIKeysInput) (*TestContentModerationAPIKeysResult, error) { + cfg, err := s.loadConfig(ctx) + if err != nil { + return nil, err + } + keys := normalizeModerationAPIKeys(input.APIKeys) + configured := false + if len(keys) == 0 { + keys = cfg.apiKeys() + configured = true + } + if strings.TrimSpace(input.BaseURL) != "" { + cfg.BaseURL = input.BaseURL + } + if strings.TrimSpace(input.Model) != "" { + cfg.Model = input.Model + } + if input.TimeoutMS > 0 { + cfg.TimeoutMS = input.TimeoutMS + } + cfg.normalize() + testInput, imageCount, err := buildModerationTestInput(input.Prompt, input.Images) + if err != nil { + return nil, err + } + auditOnly := contentModerationTestHasAuditInput(input.Prompt, input.Images) + if configured && auditOnly { + key, ok := s.nextUsableAPIKey(cfg) + if !ok { + return &TestContentModerationAPIKeysResult{ + Items: s.apiKeyStatuses(keys), + ImageCount: imageCount, + }, nil + } + keys = []string{key} + } + if len(keys) == 0 { + return &TestContentModerationAPIKeysResult{Items: []ContentModerationAPIKeyStatus{}, ImageCount: imageCount}, nil + } + items := make([]ContentModerationAPIKeyStatus, 0, len(keys)) + var auditResult *ContentModerationTestAuditResult + for idx, key := range keys { + start := time.Now() + httpStatus := 0 + result, err := s.callModerationOnceWithInput(ctx, cfg, key, testInput, &httpStatus) + latency := int(time.Since(start).Milliseconds()) + keyHash := moderationAPIKeyHash(key) + if err != nil { + s.markAPIKeyError(key, err.Error(), latency, httpStatus) + } else { + s.markAPIKeySuccess(key, latency, httpStatus) + if auditResult == nil { + auditResult = buildContentModerationTestAuditResult(result, cfg.Thresholds) + } + } + status := s.apiKeyStatusForHash(idx, keyHash, maskSecretTail(key), configured) + status.LastTested = true + items = append(items, status) + } + return &TestContentModerationAPIKeysResult{Items: items, AuditResult: auditResult, ImageCount: imageCount}, nil +} + +func (s *ContentModerationService) Check(ctx context.Context, input ContentModerationCheckInput) (*ContentModerationDecision, error) { + allow := &ContentModerationDecision{Allowed: true, Action: ContentModerationActionAllow} + if s == nil || s.settingRepo == nil || s.repo == nil { + slog.Info("content_moderation.skip_unavailable", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol) + return allow, nil + } + if !s.isRiskControlEnabled(ctx) { + slog.Info("content_moderation.skip_feature_disabled", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol) + return allow, nil + } + cfg, err := s.loadConfig(ctx) + if err != nil { + slog.Warn("content_moderation.skip_config_load_failed", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "error", err) + return allow, nil + } + inScope := cfg.includesGroup(input.GroupID) + slog.Info("content_moderation.config_loaded", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "group_name", input.GroupName, + "endpoint", input.Endpoint, + "provider", input.Provider, + "protocol", input.Protocol, + "model", input.Model, + "enabled", cfg.Enabled, + "mode", cfg.Mode, + "all_groups", cfg.AllGroups, + "configured_group_ids", cfg.GroupIDs, + "in_scope", inScope, + "sample_rate", cfg.SampleRate, + "api_key_count", len(cfg.apiKeys()), + "pre_hash_check_enabled", cfg.PreHashCheckEnabled, + "record_non_hits", cfg.RecordNonHits) + if !cfg.Enabled { + slog.Info("content_moderation.skip_config_disabled", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol) + return allow, nil + } + if cfg.Mode == ContentModerationModeOff { + slog.Info("content_moderation.skip_mode_off", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol) + return allow, nil + } + if !inScope { + slog.Info("content_moderation.skip_group_out_of_scope", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "group_name", input.GroupName, + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "all_groups", cfg.AllGroups, + "configured_group_ids", cfg.GroupIDs) + return allow, nil + } + content := ExtractContentModerationInput(input.Protocol, input.Body) + if content.IsEmpty() { + slog.Info("content_moderation.skip_empty_input", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "body_bytes", len(input.Body)) + return allow, nil + } + content.Normalize() + slog.Info("content_moderation.input_extracted", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "text_runes", len([]rune(content.Text)), + "image_count", len(content.Images)) + hashText := content.Hash() + if cfg.PreHashCheckEnabled && s.hashCache != nil { + matched, err := s.hashCache.HasFlaggedInputHash(ctx, hashText) + if err != nil { + slog.Warn("content_moderation.hash_check_failed", "user_id", input.UserID, "endpoint", input.Endpoint, "error", err) + } + if matched { + slog.Info("content_moderation.hash_block", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "input_hash", hashText) + message := cfg.BlockMessage + if message != "" { + message = fmt.Sprintf("%s(hash: %s)", message, hashText) + } + return &ContentModerationDecision{ + Allowed: false, + Blocked: true, + Flagged: true, + Message: message, + StatusCode: cfg.BlockStatus, + InputHash: hashText, + Action: ContentModerationActionHashBlock, + }, nil + } + } + if !cfg.shouldSample(hashText) { + slog.Info("content_moderation.skip_sample_rate", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "sample_rate", cfg.SampleRate) + return allow, nil + } + if len(cfg.apiKeys()) == 0 { + slog.Warn("content_moderation.skip_no_audit_api_keys", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol) + return allow, nil + } + if cfg.Mode == ContentModerationModeObserve { + slog.Info("content_moderation.enqueue_observe", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "queue_len", len(s.asyncQueue)) + s.enqueueAsync(input, cfg, content, hashText) + return allow, nil + } + + return s.checkSync(ctx, input, cfg, content, hashText, nil, true), nil +} + +func (s *ContentModerationService) checkSync(ctx context.Context, input ContentModerationCheckInput, cfg *ContentModerationConfig, content ContentModerationInput, hashText string, queueDelay *int, allowBlock bool) *ContentModerationDecision { + allow := &ContentModerationDecision{Allowed: true, Action: ContentModerationActionAllow} + start := time.Now() + result, err := s.callModeration(ctx, cfg, content.ModerationInput()) + latency := int(time.Since(start).Milliseconds()) + if err != nil { + slog.Warn("content_moderation.audit_api_failed", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "mode", cfg.Mode, + "allow_block", allowBlock, + "queue_delay_ms", queueDelay, + "latency_ms", latency, + "error", err) + if queueDelay != nil { + s.asyncErrors.Add(1) + } + if cfg.RecordNonHits { + log := s.buildLog(input, cfg, ContentModerationActionError, false, "", 0, nil, content.ExcerptText(), &latency, queueDelay, err.Error()) + _ = s.repo.CreateLog(ctx, log) + } + return allow + } + + flagged, highestCategory, highestScore := evaluateModerationScores(result.CategoryScores, cfg.Thresholds) + action := ContentModerationActionAllow + blocked := false + if allowBlock && flagged && cfg.Mode == ContentModerationModePreBlock { + action = ContentModerationActionBlock + blocked = true + } + slog.Info("content_moderation.audit_result", + "user_id", input.UserID, + "api_key_id", input.APIKeyID, + "group_id", contentModerationLogGroupID(input.GroupID), + "group_name", input.GroupName, + "endpoint", input.Endpoint, + "protocol", input.Protocol, + "mode", cfg.Mode, + "allow_block", allowBlock, + "flagged", flagged, + "blocked", blocked, + "action", action, + "highest_category", highestCategory, + "highest_score", highestScore, + "latency_ms", latency, + "queue_delay_ms", queueDelay) + if flagged || cfg.RecordNonHits { + log := s.buildLog(input, cfg, action, flagged, highestCategory, highestScore, result.CategoryScores, content.ExcerptText(), &latency, queueDelay, "") + if flagged && s.hashCache != nil { + if err := s.hashCache.RecordFlaggedInputHash(ctx, hashText); err != nil { + slog.Warn("content_moderation.record_hash_failed", "user_id", input.UserID, "endpoint", input.Endpoint, "error", err) + } + } + s.applyFlaggedSideEffects(ctx, cfg, log) + _ = s.repo.CreateLog(ctx, log) + } + if blocked { + return &ContentModerationDecision{ + Allowed: false, + Blocked: true, + Flagged: true, + Message: cfg.BlockMessage, + StatusCode: cfg.BlockStatus, + HighestCategory: highestCategory, + HighestScore: highestScore, + CategoryScores: result.CategoryScores, + Action: action, + } + } + return &ContentModerationDecision{ + Allowed: true, + Flagged: flagged, + Message: "", + HighestCategory: highestCategory, + HighestScore: highestScore, + CategoryScores: result.CategoryScores, + Action: action, + } +} + +func (s *ContentModerationService) enqueueAsync(input ContentModerationCheckInput, cfg *ContentModerationConfig, content ContentModerationInput, hashText string) { + if s == nil || s.asyncQueue == nil { + return + } + queueSize := defaultContentModerationQueueSize + if cfg != nil && cfg.QueueSize > 0 { + queueSize = cfg.QueueSize + } + if len(s.asyncQueue) >= queueSize { + slog.Warn("content_moderation.async_queue_full", "user_id", input.UserID, "endpoint", input.Endpoint, "queue_size", queueSize) + s.asyncDropped.Add(1) + return + } + task := contentModerationTask{ + input: input, + content: content, + inputHash: hashText, + enqueuedAt: time.Now(), + } + select { + case s.asyncQueue <- task: + s.asyncEnqueued.Add(1) + default: + slog.Warn("content_moderation.async_queue_full", "user_id", input.UserID, "endpoint", input.Endpoint) + s.asyncDropped.Add(1) + } +} + +func (s *ContentModerationService) worker(id int) { + for { + ctx, cancel := context.WithTimeout(context.Background(), maxContentModerationTimeoutMS*time.Millisecond+10*time.Second) + cfg, err := s.loadConfig(ctx) + if err != nil || !cfg.Enabled || cfg.Mode == ContentModerationModeOff || len(cfg.apiKeys()) == 0 || id >= cfg.WorkerCount { + cancel() + time.Sleep(time.Second) + continue + } + task, ok := s.dequeueAsyncTask(ctx, time.Second) + if !ok { + cancel() + continue + } + func() { + defer cancel() + defer func() { + if r := recover(); r != nil { + slog.Error("content_moderation.worker_panic", "worker_id", id, "recover", r) + } + }() + if !cfg.includesGroup(task.input.GroupID) { + return + } + s.asyncActive.Add(1) + defer s.asyncActive.Add(-1) + queueDelay := int(time.Since(task.enqueuedAt).Milliseconds()) + _ = s.checkSync(ctx, task.input, cfg, task.content, task.inputHash, &queueDelay, false) + s.asyncProcessed.Add(1) + }() + } +} + +func (s *ContentModerationService) dequeueAsyncTask(ctx context.Context, idleWait time.Duration) (contentModerationTask, bool) { + var zero contentModerationTask + if s == nil || s.asyncQueue == nil { + return zero, false + } + if idleWait <= 0 { + idleWait = time.Second + } + timer := time.NewTimer(idleWait) + defer timer.Stop() + select { + case task, ok := <-s.asyncQueue: + return task, ok + case <-ctx.Done(): + return zero, false + case <-timer.C: + return zero, false + } +} + +func (s *ContentModerationService) ListLogs(ctx context.Context, filter ContentModerationLogFilter) ([]ContentModerationLog, *pagination.PaginationResult, error) { + if filter.Pagination.Page <= 0 { + filter.Pagination.Page = 1 + } + if filter.Pagination.PageSize <= 0 { + filter.Pagination.PageSize = 20 + } + if filter.Pagination.PageSize > 100 { + filter.Pagination.PageSize = 100 + } + if filter.Pagination.SortOrder == "" { + filter.Pagination.SortOrder = pagination.SortOrderDesc + } + return s.repo.ListLogs(ctx, filter) +} + +func (s *ContentModerationService) UnbanUser(ctx context.Context, userID int64) (*ContentModerationUnbanUserResult, error) { + if s == nil || s.userRepo == nil { + return nil, infraerrors.InternalServer("CONTENT_MODERATION_USER_REPOSITORY_UNAVAILABLE", "用户仓储不可用") + } + if userID <= 0 { + return nil, infraerrors.BadRequest("INVALID_USER_ID", "用户 ID 无效") + } + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + return nil, infraerrors.NotFound("USER_NOT_FOUND", "用户不存在") + } + return nil, fmt.Errorf("get content moderation unban user: %w", err) + } + if user.Status != StatusActive { + user.Status = StatusActive + if err := s.userRepo.Update(ctx, user); err != nil { + return nil, fmt.Errorf("update content moderation unban user: %w", err) + } + } + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) + } + return &ContentModerationUnbanUserResult{ + UserID: userID, + Status: StatusActive, + }, nil +} + +func (s *ContentModerationService) DeleteFlaggedInputHash(ctx context.Context, inputHash string) (*ContentModerationDeleteHashResult, error) { + inputHash = normalizeContentModerationHash(inputHash) + if inputHash == "" { + return nil, infraerrors.BadRequest("INVALID_CONTENT_MODERATION_HASH", "风险输入哈希无效") + } + if s == nil || s.hashCache == nil { + return nil, infraerrors.InternalServer("CONTENT_MODERATION_HASH_CACHE_UNAVAILABLE", "内容审计哈希缓存不可用") + } + deleted, err := s.hashCache.DeleteFlaggedInputHash(ctx, inputHash) + if err != nil { + return nil, fmt.Errorf("delete content moderation flagged hash: %w", err) + } + return &ContentModerationDeleteHashResult{ + InputHash: inputHash, + Deleted: deleted, + }, nil +} + +func (s *ContentModerationService) ClearFlaggedInputHashes(ctx context.Context) (*ContentModerationClearHashesResult, error) { + if s == nil || s.hashCache == nil { + return nil, infraerrors.InternalServer("CONTENT_MODERATION_HASH_CACHE_UNAVAILABLE", "内容审计哈希缓存不可用") + } + deleted, err := s.hashCache.ClearFlaggedInputHashes(ctx) + if err != nil { + return nil, fmt.Errorf("clear content moderation flagged hashes: %w", err) + } + return &ContentModerationClearHashesResult{Deleted: deleted}, nil +} + +func (s *ContentModerationService) GetStatus(ctx context.Context) (*ContentModerationRuntimeStatus, error) { + if s == nil { + return &ContentModerationRuntimeStatus{}, nil + } + cfg, err := s.loadConfig(ctx) + if err != nil { + return nil, err + } + riskEnabled := s.isRiskControlEnabled(ctx) + active := int(s.asyncActive.Load()) + if active < 0 { + active = 0 + } + if active > cfg.WorkerCount { + active = cfg.WorkerCount + } + queueLength := 0 + if s.asyncQueue != nil { + queueLength = len(s.asyncQueue) + } + queueUsage := 0.0 + if cfg.QueueSize > 0 { + queueUsage = float64(queueLength) * 100 / float64(cfg.QueueSize) + } + var flaggedHashCount int64 + if s.hashCache != nil { + if n, err := s.hashCache.CountFlaggedInputHashes(ctx); err == nil { + flaggedHashCount = n + } else { + slog.Warn("content_moderation.hash_count_failed", "error", err) + } + } + var lastCleanupAt *time.Time + if unix := s.lastCleanupUnix.Load(); unix > 0 { + t := time.Unix(unix, 0) + lastCleanupAt = &t + } + return &ContentModerationRuntimeStatus{ + Enabled: cfg.Enabled, + RiskControlEnabled: riskEnabled, + Mode: cfg.Mode, + WorkerCount: cfg.WorkerCount, + MaxWorkers: maxContentModerationWorkerCount, + ActiveWorkers: active, + IdleWorkers: cfg.WorkerCount - active, + QueueSize: cfg.QueueSize, + QueueLength: queueLength, + QueueUsagePercent: queueUsage, + Enqueued: s.asyncEnqueued.Load(), + Dropped: s.asyncDropped.Load(), + Processed: s.asyncProcessed.Load(), + Errors: s.asyncErrors.Load(), + APIKeyStatuses: s.apiKeyStatuses(cfg.apiKeys()), + FlaggedHashCount: flaggedHashCount, + LastCleanupAt: lastCleanupAt, + LastCleanupDeletedHit: s.lastCleanupDeletedHit.Load(), + LastCleanupDeletedNonHit: s.lastCleanupDeletedNonHit.Load(), + }, nil +} + +func (s *ContentModerationService) cleanupWorker() { + timer := time.NewTimer(contentModerationCleanupDelay) + defer timer.Stop() + for { + <-timer.C + s.runCleanupOnce() + timer.Reset(contentModerationCleanupInterval) + } +} + +func (s *ContentModerationService) runCleanupOnce() { + if s == nil || s.repo == nil || s.settingRepo == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), contentModerationCleanupTimeout) + defer cancel() + cfg, err := s.loadConfig(ctx) + if err != nil { + slog.Warn("content_moderation.cleanup_load_config_failed", "error", err) + return + } + now := time.Now() + hitBefore := now.AddDate(0, 0, -cfg.HitRetentionDays) + nonHitBefore := now.AddDate(0, 0, -cfg.NonHitRetentionDays) + result, err := s.repo.CleanupExpiredLogs(ctx, hitBefore, nonHitBefore) + if err != nil { + slog.Warn("content_moderation.cleanup_failed", "error", err) + return + } + if result == nil { + return + } + s.lastCleanupUnix.Store(result.FinishedAt.Unix()) + s.lastCleanupDeletedHit.Store(result.DeletedHit) + s.lastCleanupDeletedNonHit.Store(result.DeletedNonHit) +} + +func (s *ContentModerationService) loadConfig(ctx context.Context) (*ContentModerationConfig, error) { + cfg := defaultContentModerationConfig() + raw, err := s.settingRepo.GetValue(ctx, SettingKeyContentModerationConfig) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + cfg.normalize() + return cfg, nil + } + return nil, fmt.Errorf("get content moderation config: %w", err) + } + if strings.TrimSpace(raw) == "" { + cfg.normalize() + return cfg, nil + } + if err := json.Unmarshal([]byte(raw), cfg); err != nil { + return nil, infraerrors.BadRequest("INVALID_CONTENT_MODERATION_CONFIG", "内容审计配置不是有效 JSON") + } + cfg.normalize() + return cfg, nil +} + +func (s *ContentModerationService) isRiskControlEnabled(ctx context.Context) bool { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyRiskControlEnabled) + if err != nil { + return false + } + return raw == "true" +} + +func (s *ContentModerationService) validateConfig(ctx context.Context, cfg *ContentModerationConfig) error { + if cfg == nil { + return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_CONFIG", "内容审计配置不能为空") + } + cfg.normalize() + switch cfg.Mode { + case ContentModerationModeOff, ContentModerationModeObserve, ContentModerationModePreBlock: + default: + return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_MODE", "内容审计模式无效") + } + if _, err := url.ParseRequestURI(cfg.BaseURL); err != nil { + return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BASE_URL", "OpenAI Base URL 无效") + } + if cfg.BlockStatus < 400 || cfg.BlockStatus > 599 { + return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BLOCK_STATUS", "拦截 HTTP 状态码必须在 400-599 之间") + } + if !cfg.AllGroups && len(cfg.GroupIDs) > 0 && s.groupRepo != nil { + for _, groupID := range cfg.GroupIDs { + if _, err := s.groupRepo.GetByIDLite(ctx, groupID); err != nil { + return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_GROUP", fmt.Sprintf("审计分组不存在: %d", groupID)) + } + } + } + return nil +} + +func (s *ContentModerationService) callModeration(ctx context.Context, cfg *ContentModerationConfig, input any) (*moderationAPIResult, error) { + attempts := cfg.RetryCount + 1 + if attempts <= 0 { + attempts = 1 + } + if attempts > maxContentModerationRetryCount+1 { + attempts = maxContentModerationRetryCount + 1 + } + var lastErr error + for attempt := 0; attempt < attempts; attempt++ { + key, ok := s.nextUsableAPIKey(cfg) + if !ok { + lastErr = errors.New("no moderation api key available") + break + } + start := time.Now() + httpStatus := 0 + result, err := s.callModerationOnceWithInput(ctx, cfg, key, input, &httpStatus) + latency := int(time.Since(start).Milliseconds()) + if err == nil { + s.markAPIKeySuccess(key, latency, httpStatus) + return result, nil + } + s.markAPIKeyError(key, err.Error(), latency, httpStatus) + lastErr = err + if httpStatus == http.StatusBadRequest { + break + } + if attempt == attempts-1 { + break + } + wait := time.Duration(100*(attempt+1)) * time.Millisecond + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(wait): + } + } + return nil, lastErr +} + +func (s *ContentModerationService) callModerationOnceWithInput(ctx context.Context, cfg *ContentModerationConfig, apiKey string, input any, httpStatus *int) (*moderationAPIResult, error) { + base := strings.TrimRight(cfg.BaseURL, "/") + endpoint, err := url.JoinPath(base, "/v1/moderations") + if err != nil { + return nil, err + } + payload := moderationAPIRequest{ + Model: cfg.Model, + Input: input, + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + timeout := time.Duration(cfg.TimeoutMS) * time.Millisecond + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, endpoint, bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("Content-Type", "application/json") + + client := s.httpClient + if client == nil { + client = http.DefaultClient + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if httpStatus != nil { + *httpStatus = resp.StatusCode + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("moderation api status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var out moderationAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + if len(out.Results) == 0 { + return nil, errors.New("moderation api returned empty results") + } + return &out.Results[0], nil +} + +func (s *ContentModerationService) buildLog(input ContentModerationCheckInput, cfg *ContentModerationConfig, action string, flagged bool, highestCategory string, highestScore float64, scores map[string]float64, text string, latency *int, queueDelay *int, errText string) *ContentModerationLog { + var userID *int64 + if input.UserID > 0 { + userID = &input.UserID + } + var apiKeyID *int64 + if input.APIKeyID > 0 { + apiKeyID = &input.APIKeyID + } + return &ContentModerationLog{ + RequestID: input.RequestID, + UserID: userID, + UserEmail: input.UserEmail, + APIKeyID: apiKeyID, + APIKeyName: input.APIKeyName, + GroupID: cloneInt64Ptr(input.GroupID), + GroupName: input.GroupName, + Endpoint: input.Endpoint, + Provider: input.Provider, + Model: input.Model, + Mode: cfg.Mode, + Action: action, + Flagged: flagged, + HighestCategory: highestCategory, + HighestScore: highestScore, + CategoryScores: cloneFloatMap(scores), + ThresholdSnapshot: cloneFloatMap(cfg.Thresholds), + InputExcerpt: trimRunes(redactContentModerationSecrets(text), maxModerationExcerptRunes), + UpstreamLatencyMS: latency, + QueueDelayMS: queueDelay, + Error: errText, + } +} + +func (s *ContentModerationService) applyFlaggedSideEffects(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) { + if s == nil || cfg == nil || log == nil || !log.Flagged || log.UserID == nil || *log.UserID <= 0 { + return + } + count := 1 + if s.repo != nil && cfg.ViolationWindowHours > 0 { + since := time.Now().Add(-time.Duration(cfg.ViolationWindowHours) * time.Hour) + if n, err := s.repo.CountFlaggedByUserSince(ctx, *log.UserID, since); err == nil { + count = n + 1 + } + } + log.ViolationCount = count + autoBanJustApplied := false + if cfg.AutoBanEnabled && cfg.BanThreshold > 0 && count >= cfg.BanThreshold && s.userRepo != nil { + user, err := s.userRepo.GetByID(ctx, *log.UserID) + if err != nil { + slog.Warn("content_moderation.ban_get_user_failed", "user_id", *log.UserID, "error", err) + return + } + if user.Status != StatusDisabled { + user.Status = StatusDisabled + if err := s.userRepo.Update(ctx, user); err != nil { + slog.Warn("content_moderation.ban_update_user_failed", "user_id", *log.UserID, "error", err) + return + } + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, *log.UserID) + } + autoBanJustApplied = true + } + log.AutoBanned = true + } + + if s.emailService == nil || strings.TrimSpace(log.UserEmail) == "" { + return + } + emailSent := false + if cfg.EmailOnHit { + if err := s.sendViolationEmail(ctx, cfg, log); err != nil { + slog.Warn("content_moderation.email_failed", "user_id", *log.UserID, "email", log.UserEmail, "error", err) + } else { + emailSent = true + } + } + if autoBanJustApplied { + if err := s.sendAccountDisabledEmail(ctx, cfg, log); err != nil { + slog.Warn("content_moderation.ban_email_failed", "user_id", *log.UserID, "email", log.UserEmail, "error", err) + } else { + emailSent = true + } + } + log.EmailSent = emailSent +} + +func (s *ContentModerationService) sendViolationEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error { + siteName := s.siteName(ctx) + subject := fmt.Sprintf("[%s] 账户风控提醒 / Risk Control Notice", sanitizeEmailHeader(siteName)) + body := buildContentModerationViolationEmailBody(siteName, log, cfg) + return s.emailService.SendEmail(ctx, log.UserEmail, subject, body) +} + +func (s *ContentModerationService) sendAccountDisabledEmail(ctx context.Context, cfg *ContentModerationConfig, log *ContentModerationLog) error { + siteName := s.siteName(ctx) + subject := fmt.Sprintf("[%s] 账户已被禁用 / Account Disabled", sanitizeEmailHeader(siteName)) + body := buildContentModerationAccountDisabledEmailBody(siteName, log, cfg) + return s.emailService.SendEmail(ctx, log.UserEmail, subject, body) +} + +func (s *ContentModerationService) siteName(ctx context.Context) string { + if s == nil || s.settingRepo == nil { + return "Sub2API" + } + name, err := s.settingRepo.GetValue(ctx, SettingKeySiteName) + if err != nil || strings.TrimSpace(name) == "" { + return "Sub2API" + } + return strings.TrimSpace(name) +} + +func defaultContentModerationConfig() *ContentModerationConfig { + return &ContentModerationConfig{ + Enabled: false, + Mode: ContentModerationModePreBlock, + BaseURL: defaultContentModerationBaseURL, + Model: defaultContentModerationModel, + TimeoutMS: defaultContentModerationTimeoutMS, + SampleRate: 100, + AllGroups: true, + GroupIDs: []int64{}, + RecordNonHits: false, + Thresholds: ContentModerationDefaultThresholds(), + WorkerCount: defaultContentModerationWorkerCount, + QueueSize: defaultContentModerationQueueSize, + BlockStatus: defaultContentModerationBlockHTTPStatus, + BlockMessage: defaultContentModerationBlockMessage, + EmailOnHit: true, + AutoBanEnabled: true, + BanThreshold: defaultContentModerationBanThreshold, + ViolationWindowHours: defaultContentModerationViolationWindowHours, + RetryCount: defaultContentModerationRetryCount, + HitRetentionDays: defaultContentModerationHitRetentionDays, + NonHitRetentionDays: defaultContentModerationNonHitRetentionDays, + PreHashCheckEnabled: false, + } +} + +func (cfg *ContentModerationConfig) normalize() { + if cfg.APIKey != "" { + cfg.APIKeys = normalizeModerationAPIKeys(append(cfg.APIKeys, cfg.APIKey)) + cfg.APIKey = "" + } else { + cfg.APIKeys = normalizeModerationAPIKeys(cfg.APIKeys) + } + if cfg.Mode == "" { + cfg.Mode = ContentModerationModePreBlock + } + if cfg.BaseURL == "" { + cfg.BaseURL = defaultContentModerationBaseURL + } + cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + if cfg.Model == "" { + cfg.Model = defaultContentModerationModel + } + cfg.Model = strings.TrimSpace(cfg.Model) + if cfg.TimeoutMS <= 0 { + cfg.TimeoutMS = defaultContentModerationTimeoutMS + } + if cfg.TimeoutMS > maxContentModerationTimeoutMS { + cfg.TimeoutMS = maxContentModerationTimeoutMS + } + if cfg.SampleRate < 0 { + cfg.SampleRate = 0 + } + if cfg.SampleRate > 100 { + cfg.SampleRate = 100 + } + if cfg.WorkerCount <= 0 { + cfg.WorkerCount = defaultContentModerationWorkerCount + } + if cfg.WorkerCount > maxContentModerationWorkerCount { + cfg.WorkerCount = maxContentModerationWorkerCount + } + if cfg.QueueSize <= 0 { + cfg.QueueSize = defaultContentModerationQueueSize + } + if cfg.QueueSize > maxContentModerationQueueSize { + cfg.QueueSize = maxContentModerationQueueSize + } + if strings.TrimSpace(cfg.BlockMessage) == "" { + cfg.BlockMessage = defaultContentModerationBlockMessage + } + cfg.BlockMessage = strings.TrimSpace(cfg.BlockMessage) + if cfg.BlockStatus <= 0 { + cfg.BlockStatus = defaultContentModerationBlockHTTPStatus + } + if cfg.BanThreshold <= 0 { + cfg.BanThreshold = defaultContentModerationBanThreshold + } + if cfg.ViolationWindowHours <= 0 { + cfg.ViolationWindowHours = defaultContentModerationViolationWindowHours + } + if cfg.RetryCount < 0 { + cfg.RetryCount = 0 + } + if cfg.RetryCount > maxContentModerationRetryCount { + cfg.RetryCount = maxContentModerationRetryCount + } + if cfg.HitRetentionDays <= 0 { + cfg.HitRetentionDays = defaultContentModerationHitRetentionDays + } + if cfg.HitRetentionDays > maxContentModerationRetentionDays { + cfg.HitRetentionDays = maxContentModerationRetentionDays + } + if cfg.NonHitRetentionDays <= 0 { + cfg.NonHitRetentionDays = defaultContentModerationNonHitRetentionDays + } + if cfg.NonHitRetentionDays > maxContentModerationNonHitRetentionDays { + cfg.NonHitRetentionDays = maxContentModerationNonHitRetentionDays + } + cfg.GroupIDs = normalizeInt64IDs(cfg.GroupIDs) + cfg.Thresholds = mergeContentModerationThresholds(ContentModerationDefaultThresholds(), cfg.Thresholds) +} + +func (cfg *ContentModerationConfig) includesGroup(groupID *int64) bool { + if cfg.AllGroups { + return true + } + if groupID == nil { + return false + } + for _, id := range cfg.GroupIDs { + if id == *groupID { + return true + } + } + return false +} + +func contentModerationLogGroupID(groupID *int64) int64 { + if groupID == nil { + return 0 + } + return *groupID +} + +func (cfg *ContentModerationConfig) shouldSample(hashText string) bool { + if cfg.SampleRate >= 100 { + return true + } + if cfg.SampleRate <= 0 { + return false + } + raw, err := hex.DecodeString(hashText) + if err != nil || len(raw) < 2 { + return true + } + return int(binary.BigEndian.Uint16(raw[:2])%100) < cfg.SampleRate +} + +func (cfg *ContentModerationConfig) apiKeys() []string { + if cfg == nil { + return nil + } + return normalizeModerationAPIKeys(cfg.APIKeys) +} + +func (s *ContentModerationService) nextUsableAPIKey(cfg *ContentModerationConfig) (string, bool) { + keys := cfg.apiKeys() + if len(keys) == 0 { + return "", false + } + now := time.Now() + for i := 0; i < len(keys); i++ { + idx := int(s.apiKeyCursor.Add(1)-1) % len(keys) + key := keys[idx] + if !s.isAPIKeyFrozen(key, now) { + return key, true + } + } + return "", false +} + +func (s *ContentModerationService) isAPIKeyFrozen(key string, now time.Time) bool { + hash := moderationAPIKeyHash(key) + if hash == "" || s == nil { + return false + } + s.keyHealthMu.Lock() + defer s.keyHealthMu.Unlock() + state := s.keyHealth[hash] + return state != nil && state.FrozenUntil.After(now) +} + +func (s *ContentModerationService) markAPIKeySuccess(key string, latencyMS int, httpStatus int) { + hash := moderationAPIKeyHash(key) + if hash == "" || s == nil { + return + } + s.keyHealthMu.Lock() + defer s.keyHealthMu.Unlock() + state := s.ensureAPIKeyHealthLocked(hash, maskSecretTail(key)) + state.FailureCount = 0 + state.SuccessCount++ + state.LastError = "" + state.LastCheckedAt = time.Now() + state.FrozenUntil = time.Time{} + state.LastLatencyMS = latencyMS + state.LastHTTPStatus = httpStatus + state.LastTested = true +} + +func (s *ContentModerationService) markAPIKeyError(key string, errText string, latencyMS int, httpStatus int) { + hash := moderationAPIKeyHash(key) + if hash == "" || s == nil { + return + } + s.keyHealthMu.Lock() + defer s.keyHealthMu.Unlock() + state := s.ensureAPIKeyHealthLocked(hash, maskSecretTail(key)) + if contentModerationFreezeDurationForHTTPStatus(httpStatus) > 0 { + state.FailureCount++ + } + state.LastError = trimRunes(errText, 180) + state.LastCheckedAt = time.Now() + state.LastLatencyMS = latencyMS + state.LastHTTPStatus = httpStatus + state.LastTested = true + if freezeDuration := contentModerationFreezeDurationForHTTPStatus(httpStatus); freezeDuration > 0 { + state.FrozenUntil = time.Now().Add(freezeDuration) + } +} + +func contentModerationFreezeDurationForHTTPStatus(httpStatus int) time.Duration { + switch httpStatus { + case 0, http.StatusBadRequest: + return 0 + case http.StatusUnauthorized, http.StatusForbidden: + return contentModerationKeyAuthFreezeDuration + case http.StatusTooManyRequests, 529: + return contentModerationKeyRateLimitFreezeDuration + default: + return contentModerationKeyHTTPErrorFreezeDuration + } +} + +func (s *ContentModerationService) ensureAPIKeyHealthLocked(hash string, masked string) *contentModerationKeyHealth { + if s.keyHealth == nil { + s.keyHealth = make(map[string]*contentModerationKeyHealth) + } + state := s.keyHealth[hash] + if state == nil { + state = &contentModerationKeyHealth{Hash: hash} + s.keyHealth[hash] = state + } + if strings.TrimSpace(masked) != "" { + state.Masked = masked + } + return state +} + +func (s *ContentModerationService) configView(cfg *ContentModerationConfig) *ContentModerationConfigView { + keys := cfg.apiKeys() + masks := make([]string, 0, len(keys)) + for _, key := range keys { + masks = append(masks, maskSecretTail(key)) + } + apiKeyMasked := "" + if len(masks) > 0 { + apiKeyMasked = masks[0] + } + return &ContentModerationConfigView{ + Enabled: cfg.Enabled, + Mode: cfg.Mode, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + APIKeyConfigured: len(keys) > 0, + APIKeyMasked: apiKeyMasked, + APIKeyCount: len(keys), + APIKeyMasks: masks, + APIKeyStatuses: s.apiKeyStatuses(keys), + TimeoutMS: cfg.TimeoutMS, + SampleRate: cfg.SampleRate, + AllGroups: cfg.AllGroups, + GroupIDs: append([]int64(nil), cfg.GroupIDs...), + RecordNonHits: cfg.RecordNonHits, + WorkerCount: cfg.WorkerCount, + QueueSize: cfg.QueueSize, + BlockStatus: cfg.BlockStatus, + BlockMessage: cfg.BlockMessage, + EmailOnHit: cfg.EmailOnHit, + AutoBanEnabled: cfg.AutoBanEnabled, + BanThreshold: cfg.BanThreshold, + ViolationWindowHours: cfg.ViolationWindowHours, + RetryCount: cfg.RetryCount, + HitRetentionDays: cfg.HitRetentionDays, + NonHitRetentionDays: cfg.NonHitRetentionDays, + PreHashCheckEnabled: cfg.PreHashCheckEnabled, + } +} + +func (s *ContentModerationService) apiKeyStatuses(keys []string) []ContentModerationAPIKeyStatus { + out := make([]ContentModerationAPIKeyStatus, 0, len(keys)) + for idx, key := range keys { + out = append(out, s.apiKeyStatusForHash(idx, moderationAPIKeyHash(key), maskSecretTail(key), true)) + } + return out +} + +func (s *ContentModerationService) apiKeyStatusForHash(index int, hash string, masked string, configured bool) ContentModerationAPIKeyStatus { + status := ContentModerationAPIKeyStatus{ + Index: index, + KeyHash: hash, + Masked: masked, + Status: "unknown", + Configured: configured, + } + if hash == "" || s == nil { + return status + } + now := time.Now() + s.keyHealthMu.Lock() + defer s.keyHealthMu.Unlock() + state := s.keyHealth[hash] + if state == nil { + return status + } + status.FailureCount = state.FailureCount + status.SuccessCount = state.SuccessCount + status.LastError = state.LastError + status.LastLatencyMS = state.LastLatencyMS + status.LastHTTPStatus = state.LastHTTPStatus + status.LastTested = state.LastTested + if !state.LastCheckedAt.IsZero() { + t := state.LastCheckedAt + status.LastCheckedAt = &t + } + if state.FrozenUntil.After(now) { + t := state.FrozenUntil + status.FrozenUntil = &t + status.Status = "frozen" + return status + } + if state.LastError != "" { + status.Status = "error" + return status + } + if state.SuccessCount > 0 || state.LastTested { + status.Status = "ok" + } + return status +} + +func moderationAPIKeyHash(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) +} + +func buildModerationTestInput(prompt string, images []string) (any, int, error) { + prompt = trimRunes(normalizeContentModerationText(prompt), maxModerationInputRunes) + normalizedImages := make([]string, 0, len(images)) + for _, image := range images { + image = strings.TrimSpace(image) + if image == "" { + continue + } + if len(normalizedImages) >= maxContentModerationTestImages { + return nil, 0, infraerrors.BadRequest("TOO_MANY_MODERATION_TEST_IMAGES", fmt.Sprintf("最多上传 %d 张测试图片", maxContentModerationTestImages)) + } + if err := validateModerationTestImageDataURL(image); err != nil { + return nil, 0, err + } + normalizedImages = append(normalizedImages, image) + } + if prompt == "" && len(normalizedImages) == 0 { + return "hello", 0, nil + } + if len(normalizedImages) == 0 { + return prompt, 0, nil + } + parts := make([]moderationAPIInputPart, 0, len(normalizedImages)+1) + if prompt != "" { + parts = append(parts, moderationAPIInputPart{Type: "text", Text: prompt}) + } + for _, image := range normalizedImages { + parts = append(parts, moderationAPIInputPart{ + Type: "image_url", + ImageURL: &moderationAPIImageURLRef{URL: image}, + }) + } + return parts, len(normalizedImages), nil +} + +func contentModerationTestHasAuditInput(prompt string, images []string) bool { + if normalizeContentModerationText(prompt) != "" { + return true + } + for _, image := range images { + if strings.TrimSpace(image) != "" { + return true + } + } + return false +} + +func validateModerationTestImageDataURL(value string) error { + if len(value) > maxContentModerationTestImageDataURLBytes { + return infraerrors.BadRequest("MODERATION_TEST_IMAGE_TOO_LARGE", "测试图片不能超过 8MB") + } + if !strings.HasPrefix(value, "data:image/") { + return infraerrors.BadRequest("INVALID_MODERATION_TEST_IMAGE", "测试图片必须是 data:image/* base64") + } + parts := strings.SplitN(value, ",", 2) + if len(parts) != 2 || !strings.Contains(parts[0], ";base64") { + return infraerrors.BadRequest("INVALID_MODERATION_TEST_IMAGE", "测试图片必须是 base64 data URL") + } + raw, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return infraerrors.BadRequest("INVALID_MODERATION_TEST_IMAGE", "测试图片 base64 无效") + } + if len(raw) > maxContentModerationTestImageBytes { + return infraerrors.BadRequest("MODERATION_TEST_IMAGE_TOO_LARGE", "测试图片不能超过 8MB") + } + return nil +} + +func buildContentModerationTestAuditResult(result *moderationAPIResult, thresholds map[string]float64) *ContentModerationTestAuditResult { + if result == nil { + return nil + } + scores := make(map[string]float64, len(result.CategoryScores)) + for category, score := range result.CategoryScores { + scores[category] = score + } + thresholdSnapshot := mergeContentModerationThresholds(ContentModerationDefaultThresholds(), thresholds) + flagged, highestCategory, highestScore := evaluateModerationScores(scores, thresholdSnapshot) + compositeScore := highestScore + return &ContentModerationTestAuditResult{ + Flagged: flagged, + HighestCategory: highestCategory, + HighestScore: highestScore, + CompositeScore: compositeScore, + CategoryScores: scores, + Thresholds: thresholdSnapshot, + } +} + +type moderationAPIRequest struct { + Model string `json:"model"` + Input any `json:"input"` +} + +type moderationAPIInputPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *moderationAPIImageURLRef `json:"image_url,omitempty"` +} + +type moderationAPIImageURLRef struct { + URL string `json:"url"` +} + +type moderationAPIResponse struct { + Results []moderationAPIResult `json:"results"` +} + +type moderationAPIResult struct { + Flagged bool `json:"flagged"` + CategoryScores map[string]float64 `json:"category_scores"` +} + +func evaluateModerationScores(scores map[string]float64, thresholds map[string]float64) (bool, string, float64) { + flagged := false + highestCategory := "" + highestScore := 0.0 + for _, category := range contentModerationCategoryOrder { + score := scores[category] + if score > highestScore || highestCategory == "" { + highestScore = score + highestCategory = category + } + if score >= thresholds[category] { + flagged = true + } + } + for category, score := range scores { + if score > highestScore || highestCategory == "" { + highestScore = score + highestCategory = category + } + } + return flagged, highestCategory, highestScore +} + +func mergeContentModerationThresholds(base map[string]float64, override map[string]float64) map[string]float64 { + out := cloneFloatMap(base) + if out == nil { + out = map[string]float64{} + } + for _, category := range contentModerationCategoryOrder { + if v, ok := override[category]; ok { + if v < 0 { + v = 0 + } + if v > 1 { + v = 1 + } + out[category] = v + } + } + return out +} + +func normalizeInt64IDs(ids []int64) []int64 { + if len(ids) == 0 { + return []int64{} + } + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + +func normalizeModerationAPIKeys(keys []string) []string { + if len(keys) == 0 { + return []string{} + } + seen := make(map[string]struct{}, len(keys)) + out := make([]string, 0, len(keys)) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + return out +} + +func deleteModerationAPIKeysByHash(keys []string, hashes []string) []string { + keys = normalizeModerationAPIKeys(keys) + deleteHashes := make(map[string]struct{}, len(hashes)) + for _, hash := range hashes { + hash = normalizeContentModerationHash(hash) + if hash != "" { + deleteHashes[hash] = struct{}{} + } + } + if len(deleteHashes) == 0 { + return keys + } + out := make([]string, 0, len(keys)) + for _, key := range keys { + if _, ok := deleteHashes[moderationAPIKeyHash(key)]; ok { + continue + } + out = append(out, key) + } + return out +} + +func normalizeContentModerationAPIKeysMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case contentModerationAPIKeysModeReplace: + return contentModerationAPIKeysModeReplace + default: + return contentModerationAPIKeysModeAppend + } +} + +func normalizeContentModerationHash(inputHash string) string { + inputHash = strings.ToLower(strings.TrimSpace(inputHash)) + if len(inputHash) != sha256.Size*2 { + return "" + } + if _, err := hex.DecodeString(inputHash); err != nil { + return "" + } + return inputHash +} + +func cloneFloatMap(in map[string]float64) map[string]float64 { + if in == nil { + return map[string]float64{} + } + out := make(map[string]float64, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func cloneInt64Ptr(in *int64) *int64 { + if in == nil { + return nil + } + v := *in + return &v +} + +func trimRunes(text string, max int) string { + if max <= 0 { + return "" + } + runes := []rune(text) + if len(runes) <= max { + return text + } + return string(runes[:max]) +} + +func maskSecretTail(secret string) string { + secret = strings.TrimSpace(secret) + if secret == "" { + return "" + } + if len(secret) <= 4 { + return "****" + } + return strings.Repeat("*", 8) + secret[len(secret)-4:] +} diff --git a/backend/internal/service/content_moderation_email.go b/backend/internal/service/content_moderation_email.go new file mode 100644 index 00000000000..e462ff88f0f --- /dev/null +++ b/backend/internal/service/content_moderation_email.go @@ -0,0 +1,117 @@ +package service + +import ( + "fmt" + "html" + "strings" + "time" +) + +func buildContentModerationViolationEmailBody(siteName string, log *ContentModerationLog, cfg *ContentModerationConfig) string { + if log == nil { + return "" + } + userName := strings.TrimSpace(log.UserEmail) + if userName == "" && log.UserID != nil { + userName = fmt.Sprintf("UID %d", *log.UserID) + } + threshold := cfg.BanThreshold + if threshold <= 0 { + threshold = defaultContentModerationBanThreshold + } + statusBlock := "" + if log.AutoBanned { + statusBlock = `
账户当前处于封禁状态,所有 API 请求将被拒绝
` + } + return fmt.Sprintf(` + + +
+
+
+
Risk Control / 风控提醒
+

账户触发内容审计规则

+

尊敬的用户 %s,您的 API 请求在内容审计中触发平台风控策略。详情如下。

+
+

触发详情

+ + + + + + +
触发时间%s
触发来源内容审核
所属分组%s
命中类别%s / %.3f
累计触发次数%d 次(阈值 %d)
+
+ %s +

此邮件由 %s 自动发送,请勿回复。

+
+
+ +`, + html.EscapeString(userName), + html.EscapeString(time.Now().Format("2006-01-02 15:04:05")), + html.EscapeString(defaultContentModerationString(log.GroupName, "-")), + html.EscapeString(defaultContentModerationString(log.HighestCategory, "-")), + log.HighestScore, + log.ViolationCount, + threshold, + statusBlock, + html.EscapeString(siteName), + ) +} + +func buildContentModerationAccountDisabledEmailBody(siteName string, log *ContentModerationLog, cfg *ContentModerationConfig) string { + if log == nil { + return "" + } + userName := strings.TrimSpace(log.UserEmail) + if userName == "" && log.UserID != nil { + userName = fmt.Sprintf("UID %d", *log.UserID) + } + threshold := cfg.BanThreshold + if threshold <= 0 { + threshold = defaultContentModerationBanThreshold + } + return fmt.Sprintf(` + + +
+
+
+
Risk Control / 账户封禁
+

账户已被自动禁用

+

尊敬的用户 %s,您的账户在计数周期内多次触发平台风控策略,系统已自动禁用该账户。详情如下。

+
+

封禁详情

+ + + + + + +
封禁时间%s
触发来源内容审核
所属分组%s
命中类别%s / %.3f
累计触发次数%d 次(阈值 %d)
+
+
账户当前处于封禁状态,所有 API 请求将被拒绝
+

如需申诉或恢复账号,请联系平台管理员处理。

+

此邮件由 %s 自动发送,请勿回复。

+
+
+ +`, + html.EscapeString(userName), + html.EscapeString(time.Now().Format("2006-01-02 15:04:05")), + html.EscapeString(defaultContentModerationString(log.GroupName, "-")), + html.EscapeString(defaultContentModerationString(log.HighestCategory, "-")), + log.HighestScore, + log.ViolationCount, + threshold, + html.EscapeString(siteName), + ) +} + +func defaultContentModerationString(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return strings.TrimSpace(value) +} diff --git a/backend/internal/service/content_moderation_input.go b/backend/internal/service/content_moderation_input.go new file mode 100644 index 00000000000..67df397d32a --- /dev/null +++ b/backend/internal/service/content_moderation_input.go @@ -0,0 +1,320 @@ +package service + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" + + "github.com/tidwall/gjson" +) + +func ExtractContentModerationText(protocol string, body []byte) string { + return ExtractContentModerationInput(protocol, body).Text +} + +func ExtractContentModerationInput(protocol string, body []byte) ContentModerationInput { + if len(body) == 0 || !gjson.ValidBytes(body) { + return ContentModerationInput{} + } + var parts []string + var images []string + switch protocol { + case ContentModerationProtocolAnthropicMessages: + collectLastAnthropicUserMessage(gjson.GetBytes(body, "messages"), &parts, &images) + case ContentModerationProtocolOpenAIChat: + collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images) + case ContentModerationProtocolOpenAIResponses: + collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images) + case ContentModerationProtocolGemini: + collectLastGeminiContent(gjson.GetBytes(body, "contents"), &parts, &images) + case ContentModerationProtocolOpenAIImages: + addModerationText(&parts, gjson.GetBytes(body, "prompt").String()) + collectContentValue(gjson.GetBytes(body, "images"), &parts, &images) + default: + collectLastResponsesInput(gjson.GetBytes(body, "input"), &parts, &images) + collectLastRoleMessage(gjson.GetBytes(body, "messages"), "user", &parts, &images) + collectLastGeminiContent(gjson.GetBytes(body, "contents"), &parts, &images) + } + out := ContentModerationInput{ + Text: normalizeContentModerationText(strings.Join(parts, "\n")), + Images: normalizeModerationImages(images), + } + out.Normalize() + return out +} + +func collectLastRoleMessage(messages gjson.Result, role string, parts *[]string, images *[]string) { + if !messages.IsArray() { + return + } + var lastParts []string + var lastImages []string + messages.ForEach(func(_, msg gjson.Result) bool { + if strings.ToLower(strings.TrimSpace(msg.Get("role").String())) == role { + var candidate []string + var candidateImages []string + collectContentValue(msg.Get("content"), &candidate, &candidateImages) + if normalizeContentModerationText(strings.Join(candidate, "\n")) != "" || len(candidateImages) > 0 { + lastParts = candidate + lastImages = candidateImages + } + } + return true + }) + *parts = append(*parts, lastParts...) + *images = append(*images, lastImages...) +} + +func collectLastAnthropicUserMessage(messages gjson.Result, parts *[]string, images *[]string) { + if !messages.IsArray() { + return + } + var lastParts []string + var lastImages []string + messages.ForEach(func(_, msg gjson.Result) bool { + if strings.ToLower(strings.TrimSpace(msg.Get("role").String())) == "user" { + var candidate []string + var candidateImages []string + collectAnthropicUserContentValue(msg.Get("content"), &candidate, &candidateImages) + if normalizeContentModerationText(strings.Join(candidate, "\n")) != "" || len(candidateImages) > 0 { + lastParts = candidate + lastImages = candidateImages + } + } + return true + }) + *parts = append(*parts, lastParts...) + *images = append(*images, lastImages...) +} + +func collectAnthropicUserContentValue(value gjson.Result, parts *[]string, images *[]string) { + switch { + case !value.Exists(): + return + case value.Type == gjson.String: + if !isAnthropicSystemReminderText(value.String()) { + addModerationText(parts, value.String()) + } + case value.IsArray(): + value.ForEach(func(_, item gjson.Result) bool { + collectAnthropicUserContentValue(item, parts, images) + return true + }) + case value.IsObject(): + typ := strings.ToLower(strings.TrimSpace(value.Get("type").String())) + switch typ { + case "", "text", "input_text", "message": + if value.Get("text").Exists() && !isAnthropicSystemReminderText(value.Get("text").String()) { + addModerationText(parts, value.Get("text").String()) + } + if value.Get("content").Exists() { + collectAnthropicUserContentValue(value.Get("content"), parts, images) + } + case "image_url", "input_image", "image": + collectContentValue(value, parts, images) + } + } +} + +func isAnthropicSystemReminderText(text string) bool { + return strings.HasPrefix(strings.TrimSpace(text), "") +} + +func collectLastResponsesInput(input gjson.Result, parts *[]string, images *[]string) { + switch { + case !input.Exists(): + return + case input.Type == gjson.String: + addModerationText(parts, input.String()) + case input.IsArray(): + var last gjson.Result + input.ForEach(func(_, item gjson.Result) bool { + if isResponsesUserTextItem(item) { + last = item + } + return true + }) + if last.Exists() { + collectContentValue(last.Get("content"), parts, images) + if last.Get("type").String() == "input_text" || last.Get("text").Exists() { + collectContentValue(last, parts, images) + } + } + case input.IsObject(): + if isResponsesUserTextItem(input) { + collectContentValue(input.Get("content"), parts, images) + if input.Get("type").String() == "input_text" || input.Get("text").Exists() { + collectContentValue(input, parts, images) + } + } + } +} + +func isResponsesUserTextItem(item gjson.Result) bool { + role := strings.ToLower(strings.TrimSpace(item.Get("role").String())) + if role == "user" { + return responseItemHasModerationText(item) + } + if role != "" { + return false + } + return responseItemHasModerationText(item) +} + +func responseItemHasModerationText(item gjson.Result) bool { + var parts []string + var images []string + collectContentValue(item.Get("content"), &parts, &images) + if item.Get("type").String() == "input_text" || item.Get("text").Exists() { + collectContentValue(item, &parts, &images) + } + return normalizeContentModerationText(strings.Join(parts, "\n")) != "" || len(images) > 0 +} + +func collectLastGeminiContent(contents gjson.Result, parts *[]string, images *[]string) { + if !contents.IsArray() { + return + } + var lastParts []string + var lastImages []string + contents.ForEach(func(_, content gjson.Result) bool { + role := strings.ToLower(strings.TrimSpace(content.Get("role").String())) + if role == "" || role == "user" { + var candidate []string + var candidateImages []string + if arr := content.Get("parts"); arr.IsArray() { + arr.ForEach(func(_, part gjson.Result) bool { + addModerationText(&candidate, part.Get("text").String()) + addGeminiModerationImage(&candidateImages, part) + return true + }) + } + if normalizeContentModerationText(strings.Join(candidate, "\n")) != "" || len(candidateImages) > 0 { + lastParts = candidate + lastImages = candidateImages + } + } + return true + }) + *parts = append(*parts, lastParts...) + *images = append(*images, lastImages...) +} + +func collectContentValue(value gjson.Result, parts *[]string, images *[]string) { + switch { + case !value.Exists(): + return + case value.Type == gjson.String: + addModerationText(parts, value.String()) + case value.IsArray(): + value.ForEach(func(_, item gjson.Result) bool { + collectContentValue(item, parts, images) + return true + }) + case value.IsObject(): + typ := strings.ToLower(strings.TrimSpace(value.Get("type").String())) + addModerationImage(images, value.Get("image_url.url").String()) + addModerationImage(images, value.Get("image_url").String()) + addModerationImage(images, value.Get("url").String()) + addModerationImageData(images, value.Get("source.media_type").String(), value.Get("source.data").String()) + addModerationImageData(images, value.Get("source.mediaType").String(), value.Get("source.data").String()) + addModerationImageData(images, value.Get("media_type").String(), value.Get("data").String()) + addModerationImageData(images, value.Get("mime_type").String(), value.Get("data").String()) + addModerationImageData(images, value.Get("mimeType").String(), value.Get("data").String()) + addModerationImage(images, value.Get("source.data").String()) + addModerationImage(images, value.Get("data").String()) + addModerationImage(images, value.Get("base64").String()) + switch typ { + case "", "text", "input_text", "message": + if value.Get("text").Exists() { + addModerationText(parts, value.Get("text").String()) + } + if value.Get("content").Exists() { + collectContentValue(value.Get("content"), parts, images) + } + case "image_url", "input_image", "image": + } + } +} + +func addGeminiModerationImage(images *[]string, part gjson.Result) { + if inlineData := part.Get("inline_data"); inlineData.IsObject() { + mimeType := strings.TrimSpace(inlineData.Get("mime_type").String()) + data := strings.TrimSpace(inlineData.Get("data").String()) + if mimeType != "" && data != "" { + addModerationImage(images, fmt.Sprintf("data:%s;base64,%s", mimeType, data)) + } + } + if inlineData := part.Get("inlineData"); inlineData.IsObject() { + mimeType := strings.TrimSpace(inlineData.Get("mimeType").String()) + data := strings.TrimSpace(inlineData.Get("data").String()) + if mimeType != "" && data != "" { + addModerationImage(images, fmt.Sprintf("data:%s;base64,%s", mimeType, data)) + } + } + addModerationImage(images, part.Get("file_data.file_uri").String()) + addModerationImage(images, part.Get("fileData.fileUri").String()) +} + +func addModerationImageData(images *[]string, mimeType string, data string) { + mimeType = strings.TrimSpace(mimeType) + data = strings.TrimSpace(data) + if mimeType == "" || data == "" { + return + } + addModerationImage(images, fmt.Sprintf("data:%s;base64,%s", mimeType, data)) +} + +func addModerationImage(images *[]string, image string) { + image = strings.TrimSpace(image) + if image == "" { + return + } + if strings.HasPrefix(image, "data:") || strings.HasPrefix(image, "http://") || strings.HasPrefix(image, "https://") { + *images = append(*images, image) + } +} + +func normalizeModerationImages(images []string) []string { + out := make([]string, 0, len(images)) + seen := make(map[string]struct{}, len(images)) + for _, image := range images { + image = strings.TrimSpace(image) + if image == "" { + continue + } + if _, ok := seen[image]; ok { + continue + } + seen[image] = struct{}{} + out = append(out, image) + } + return out +} + +func limitContentModerationImages(images []string) []string { + if len(images) <= maxContentModerationInputImages { + return images + } + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(images)))) + if err != nil { + return images[:maxContentModerationInputImages] + } + return []string{images[int(idx.Int64())]} +} + +func addModerationText(parts *[]string, text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + if strings.Contains(text, "") { + return + } + *parts = append(*parts, text) +} + +func normalizeContentModerationText(text string) string { + return strings.Join(strings.Fields(strings.TrimSpace(text)), " ") +} diff --git a/backend/internal/service/content_moderation_redact.go b/backend/internal/service/content_moderation_redact.go new file mode 100644 index 00000000000..473c8178190 --- /dev/null +++ b/backend/internal/service/content_moderation_redact.go @@ -0,0 +1,37 @@ +package service + +import ( + "regexp" + "strings" +) + +var contentModerationSecretPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)\bhttps?://[^\s"'<>,。;、]+`), + regexp.MustCompile(`(?i)\b((?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|token|session|cookie|set[_-]?cookie|authorization|bearer|password|passwd|pwd|secret|client[_-]?secret|private[_-]?key)\s*[:=]\s*)(["']?)[^"'\s,;,。;、]{6,}`), + regexp.MustCompile(`(?i)\b(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}`), + regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`), + regexp.MustCompile(`(?i)\b(?:sk|sk-proj|sk-ant|sess|rk|pk|ak|api|key|token|secret)[_-][A-Za-z0-9._~+/=-]{12,}\b`), + regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`), + regexp.MustCompile(`\b[A-Za-z0-9_-]{48,}\b`), + regexp.MustCompile(`\b[A-Za-z0-9+/]{48,}={0,2}\b`), + regexp.MustCompile(`\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b`), +} + +func redactContentModerationSecrets(text string) string { + text = strings.TrimSpace(text) + if text == "" { + return "" + } + out := text + for idx, pattern := range contentModerationSecretPatterns { + switch idx { + case 1: + out = pattern.ReplaceAllString(out, `${1}${2}[已脱敏]`) + case 2: + out = pattern.ReplaceAllString(out, `${1}[已脱敏]`) + default: + out = pattern.ReplaceAllString(out, `[已脱敏]`) + } + } + return out +} diff --git a/backend/internal/service/content_moderation_test.go b/backend/internal/service/content_moderation_test.go new file mode 100644 index 00000000000..cef5127e6bd --- /dev/null +++ b/backend/internal/service/content_moderation_test.go @@ -0,0 +1,1006 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/stretchr/testify/require" +) + +type contentModerationTestSettingRepo struct { + values map[string]string +} + +func (r *contentModerationTestSettingRepo) Get(ctx context.Context, key string) (*Setting, error) { + if value, ok := r.values[key]; ok { + return &Setting{Key: key, Value: value}, nil + } + return nil, ErrSettingNotFound +} + +func (r *contentModerationTestSettingRepo) GetValue(ctx context.Context, key string) (string, error) { + if value, ok := r.values[key]; ok { + return value, nil + } + return "", ErrSettingNotFound +} + +func (r *contentModerationTestSettingRepo) Set(ctx context.Context, key, value string) error { + if r.values == nil { + r.values = map[string]string{} + } + r.values[key] = value + return nil +} + +func (r *contentModerationTestSettingRepo) GetMultiple(ctx context.Context, keys []string) (map[string]string, error) { + out := map[string]string{} + for _, key := range keys { + if value, ok := r.values[key]; ok { + out[key] = value + } + } + return out, nil +} + +func (r *contentModerationTestSettingRepo) SetMultiple(ctx context.Context, settings map[string]string) error { + if r.values == nil { + r.values = map[string]string{} + } + for key, value := range settings { + r.values[key] = value + } + return nil +} + +func (r *contentModerationTestSettingRepo) GetAll(ctx context.Context) (map[string]string, error) { + out := make(map[string]string, len(r.values)) + for key, value := range r.values { + out[key] = value + } + return out, nil +} + +func (r *contentModerationTestSettingRepo) Delete(ctx context.Context, key string) error { + delete(r.values, key) + return nil +} + +type contentModerationTestRepo struct { + logs []ContentModerationLog +} + +func (r *contentModerationTestRepo) CreateLog(ctx context.Context, log *ContentModerationLog) error { + if log != nil { + r.logs = append(r.logs, *log) + } + return nil +} + +func (r *contentModerationTestRepo) ListLogs(ctx context.Context, filter ContentModerationLogFilter) ([]ContentModerationLog, *pagination.PaginationResult, error) { + return nil, nil, nil +} + +func (r *contentModerationTestRepo) CountFlaggedByUserSince(ctx context.Context, userID int64, since time.Time) (int, error) { + return 0, nil +} + +func (r *contentModerationTestRepo) CleanupExpiredLogs(ctx context.Context, hitBefore time.Time, nonHitBefore time.Time) (*ContentModerationCleanupResult, error) { + return &ContentModerationCleanupResult{}, nil +} + +type contentModerationTestHashCache struct { + hashes map[string]struct{} + recorded []string + checked []string + deleted []string + hasResult bool + hasResultUsed bool +} + +type contentModerationTestUserRepo struct { + user *User + updated []User +} + +func (r *contentModerationTestUserRepo) Create(ctx context.Context, user *User) error { + panic("unexpected Create call") +} + +func (r *contentModerationTestUserRepo) GetByID(ctx context.Context, id int64) (*User, error) { + if r.user == nil { + return nil, ErrUserNotFound + } + clone := *r.user + return &clone, nil +} + +func (r *contentModerationTestUserRepo) GetByEmail(ctx context.Context, email string) (*User, error) { + panic("unexpected GetByEmail call") +} + +func (r *contentModerationTestUserRepo) GetFirstAdmin(ctx context.Context) (*User, error) { + panic("unexpected GetFirstAdmin call") +} + +func (r *contentModerationTestUserRepo) Update(ctx context.Context, user *User) error { + if user == nil { + return nil + } + clone := *user + r.updated = append(r.updated, clone) + r.user = &clone + return nil +} + +func (r *contentModerationTestUserRepo) Delete(ctx context.Context, id int64) error { + panic("unexpected Delete call") +} + +func (r *contentModerationTestUserRepo) GetUserAvatar(ctx context.Context, userID int64) (*UserAvatar, error) { + panic("unexpected GetUserAvatar call") +} + +func (r *contentModerationTestUserRepo) UpsertUserAvatar(ctx context.Context, userID int64, input UpsertUserAvatarInput) (*UserAvatar, error) { + panic("unexpected UpsertUserAvatar call") +} + +func (r *contentModerationTestUserRepo) DeleteUserAvatar(ctx context.Context, userID int64) error { + panic("unexpected DeleteUserAvatar call") +} + +func (r *contentModerationTestUserRepo) List(ctx context.Context, params pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) { + panic("unexpected List call") +} + +func (r *contentModerationTestUserRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters UserListFilters) ([]User, *pagination.PaginationResult, error) { + panic("unexpected ListWithFilters call") +} + +func (r *contentModerationTestUserRepo) GetLatestUsedAtByUserIDs(ctx context.Context, userIDs []int64) (map[int64]*time.Time, error) { + panic("unexpected GetLatestUsedAtByUserIDs call") +} + +func (r *contentModerationTestUserRepo) GetLatestUsedAtByUserID(ctx context.Context, userID int64) (*time.Time, error) { + panic("unexpected GetLatestUsedAtByUserID call") +} + +func (r *contentModerationTestUserRepo) UpdateUserLastActiveAt(ctx context.Context, userID int64, activeAt time.Time) error { + panic("unexpected UpdateUserLastActiveAt call") +} + +func (r *contentModerationTestUserRepo) UpdateBalance(ctx context.Context, id int64, amount float64) error { + panic("unexpected UpdateBalance call") +} + +func (r *contentModerationTestUserRepo) DeductBalance(ctx context.Context, id int64, amount float64) error { + panic("unexpected DeductBalance call") +} + +func (r *contentModerationTestUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount int) error { + panic("unexpected UpdateConcurrency call") +} + +func (r *contentModerationTestUserRepo) BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) { + panic("unexpected BatchSetConcurrency call") +} + +func (r *contentModerationTestUserRepo) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) { + panic("unexpected BatchAddConcurrency call") +} + +func (r *contentModerationTestUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { + panic("unexpected ExistsByEmail call") +} + +func (r *contentModerationTestUserRepo) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) { + panic("unexpected RemoveGroupFromAllowedGroups call") +} + +func (r *contentModerationTestUserRepo) AddGroupToAllowedGroups(ctx context.Context, userID int64, groupID int64) error { + panic("unexpected AddGroupToAllowedGroups call") +} + +func (r *contentModerationTestUserRepo) RemoveGroupFromUserAllowedGroups(ctx context.Context, userID int64, groupID int64) error { + panic("unexpected RemoveGroupFromUserAllowedGroups call") +} + +func (r *contentModerationTestUserRepo) ListUserAuthIdentities(ctx context.Context, userID int64) ([]UserAuthIdentityRecord, error) { + panic("unexpected ListUserAuthIdentities call") +} + +func (r *contentModerationTestUserRepo) UnbindUserAuthProvider(ctx context.Context, userID int64, provider string) error { + panic("unexpected UnbindUserAuthProvider call") +} + +func (r *contentModerationTestUserRepo) UpdateTotpSecret(ctx context.Context, userID int64, encryptedSecret *string) error { + panic("unexpected UpdateTotpSecret call") +} + +func (r *contentModerationTestUserRepo) EnableTotp(ctx context.Context, userID int64) error { + panic("unexpected EnableTotp call") +} + +func (r *contentModerationTestUserRepo) DisableTotp(ctx context.Context, userID int64) error { + panic("unexpected DisableTotp call") +} + +type contentModerationTestAuthCacheInvalidator struct { + userIDs []int64 +} + +func (i *contentModerationTestAuthCacheInvalidator) InvalidateAuthCacheByKey(ctx context.Context, key string) { +} + +func (i *contentModerationTestAuthCacheInvalidator) InvalidateAuthCacheByUserID(ctx context.Context, userID int64) { + i.userIDs = append(i.userIDs, userID) +} + +func (i *contentModerationTestAuthCacheInvalidator) InvalidateAuthCacheByGroupID(ctx context.Context, groupID int64) { +} + +func (c *contentModerationTestHashCache) RecordFlaggedInputHash(ctx context.Context, inputHash string) error { + if c.hashes == nil { + c.hashes = map[string]struct{}{} + } + c.hashes[inputHash] = struct{}{} + c.recorded = append(c.recorded, inputHash) + return nil +} + +func (c *contentModerationTestHashCache) HasFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) { + c.checked = append(c.checked, inputHash) + if c.hasResultUsed { + return c.hasResult, nil + } + _, ok := c.hashes[inputHash] + return ok, nil +} + +func (c *contentModerationTestHashCache) DeleteFlaggedInputHash(ctx context.Context, inputHash string) (bool, error) { + c.deleted = append(c.deleted, inputHash) + if c.hashes == nil { + return false, nil + } + if _, ok := c.hashes[inputHash]; !ok { + return false, nil + } + delete(c.hashes, inputHash) + return true, nil +} + +func (c *contentModerationTestHashCache) ClearFlaggedInputHashes(ctx context.Context) (int64, error) { + deleted := int64(len(c.hashes)) + c.hashes = map[string]struct{}{} + return deleted, nil +} + +func (c *contentModerationTestHashCache) CountFlaggedInputHashes(ctx context.Context) (int64, error) { + return int64(len(c.hashes)), nil +} + +func TestBuildContentModerationLog_RedactsInputExcerpt(t *testing.T) { + svc := &ContentModerationService{} + cfg := defaultContentModerationConfig() + input := ContentModerationCheckInput{ + RequestID: "req-1", + Endpoint: "/v1/chat/completions", + Provider: "openai", + } + + log := svc.buildLog(input, cfg, ContentModerationActionAllow, true, "sexual", 0.8, map[string]float64{"sexual": 0.8}, "hello sk-proj-1234567890abcdef", nil, nil, "") + + require.NotContains(t, log.InputExcerpt, "sk-proj-1234567890abcdef") + require.Contains(t, log.InputExcerpt, "[已脱敏]") +} + +func TestRedactContentModerationSecrets_LongHexAndTokens(t *testing.T) { + input := "你哈市多大事cf5bbdc4cd508f3aaf0d2070d529d4a4ac29099f8ecc357f696df28e1df91554 token=abc123456789xyz Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signaturepart https://example.com/private/path?token=abc123" + + out := redactContentModerationSecrets(input) + + require.NotContains(t, out, "cf5bbdc4cd508f3aaf0d2070d529d4a4ac29099f8ecc357f696df28e1df91554") + require.NotContains(t, out, "abc123456789xyz") + require.NotContains(t, out, "eyJhbGciOiJIUzI1NiJ9") + require.NotContains(t, out, "https://example.com/private/path") + require.Contains(t, out, "[已脱敏]") +} + +func TestContentModerationConfigNormalize_NonHitRetentionMaxThreeDays(t *testing.T) { + cfg := defaultContentModerationConfig() + cfg.NonHitRetentionDays = 30 + + cfg.normalize() + + require.Equal(t, 3, cfg.NonHitRetentionDays) +} + +func TestContentModerationUpdateConfig_AppendsAndDeletesAPIKeys(t *testing.T) { + cfg := defaultContentModerationConfig() + cfg.APIKeys = []string{"sk-old-a", "sk-old-b"} + rawCfg, err := json.Marshal(cfg) + require.NoError(t, err) + + repo := &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyContentModerationConfig: string(rawCfg), + }} + svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil) + deleteHashes := []string{moderationAPIKeyHash("sk-old-a")} + addKeys := []string{"sk-new-c", "sk-old-b"} + + view, err := svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{ + APIKeys: &addKeys, + DeleteAPIKeyHashes: &deleteHashes, + }) + + require.NoError(t, err) + require.Equal(t, 2, view.APIKeyCount) + require.Equal(t, []string{maskSecretTail("sk-old-b"), maskSecretTail("sk-new-c")}, view.APIKeyMasks) + + var saved ContentModerationConfig + require.NoError(t, json.Unmarshal([]byte(repo.values[SettingKeyContentModerationConfig]), &saved)) + require.Equal(t, []string{"sk-old-b", "sk-new-c"}, saved.apiKeys()) +} + +func TestContentModerationUpdateConfig_ReplacesAPIKeysWhenRequested(t *testing.T) { + cfg := defaultContentModerationConfig() + cfg.APIKeys = []string{"sk-old-a", "sk-old-b"} + rawCfg, err := json.Marshal(cfg) + require.NoError(t, err) + + repo := &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyContentModerationConfig: string(rawCfg), + }} + svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil) + deleteHashes := []string{moderationAPIKeyHash("sk-old-a")} + replaceKeys := []string{"sk-new-only"} + + view, err := svc.UpdateConfig(context.Background(), UpdateContentModerationConfigInput{ + APIKeys: &replaceKeys, + APIKeysMode: contentModerationAPIKeysModeReplace, + DeleteAPIKeyHashes: &deleteHashes, + }) + + require.NoError(t, err) + require.Equal(t, 1, view.APIKeyCount) + require.Equal(t, []string{maskSecretTail("sk-new-only")}, view.APIKeyMasks) + + var saved ContentModerationConfig + require.NoError(t, json.Unmarshal([]byte(repo.values[SettingKeyContentModerationConfig]), &saved)) + require.Equal(t, []string{"sk-new-only"}, saved.apiKeys()) +} + +func TestExtractContentModerationInput_AnthropicImageSourceOnlyParticipatesInMemory(t *testing.T) { + body := []byte(`{ + "messages": [ + {"role":"user","content":"old"}, + {"role":"assistant","content":"ok"}, + {"role":"user","content":[ + {"type":"text","text":"检查这张图"}, + {"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}} + ]} + ] + }`) + + input := ExtractContentModerationInput(ContentModerationProtocolAnthropicMessages, body) + require.Equal(t, "检查这张图", input.Text) + require.Equal(t, []string{"data:image/png;base64,aGVsbG8="}, input.Images) + + log := (&ContentModerationService{}).buildLog(ContentModerationCheckInput{}, defaultContentModerationConfig(), ContentModerationActionAllow, false, "", 0, nil, input.ExcerptText(), nil, nil, "") + require.Equal(t, "检查这张图", log.InputExcerpt) + require.NotContains(t, log.InputExcerpt, "aGVsbG8=") +} + +func TestExtractContentModerationInput_AnthropicKeepsEphemeralUserTextAndSkipsSystemReminders(t *testing.T) { + body := []byte(`{ + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "工具说明"}, + {"type": "text", "text": "Ainder>\n\n"}, + {"type": "text", "text": "hid", "cache_control": {"type": "ephemeral"}} + ] + } + ] + }`) + + input := ExtractContentModerationInput(ContentModerationProtocolAnthropicMessages, body) + + require.Equal(t, "hid", input.Text) + require.Empty(t, input.Images) +} + +func TestExtractContentModerationInput_OpenAIChatUsesLastUserMessage(t *testing.T) { + body := []byte(`{ + "model":"gpt-5.5", + "messages":[ + {"role":"system","content":"system prompt"}, + {"role":"user","content":"old user"}, + {"role":"assistant","content":"ok"}, + {"role":"user","content":[{"type":"text","text":"latest user"},{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]} + ] + }`) + + input := ExtractContentModerationInput(ContentModerationProtocolOpenAIChat, body) + + require.Equal(t, "latest user", input.Text) + require.Equal(t, []string{"https://example.com/a.png"}, input.Images) + require.NotContains(t, input.Text, "old user") + require.NotContains(t, input.Text, "system prompt") +} + +func TestExtractContentModerationInput_OpenAIImagesIncludesPromptAndImages(t *testing.T) { + body := []byte(`{ + "prompt":"replace background", + "images":[ + {"image_url":"https://example.com/source.png"}, + {"image_url":"data:image/png;base64,aGVsbG8="} + ] + }`) + + input := ExtractContentModerationInput(ContentModerationProtocolOpenAIImages, body) + + require.Equal(t, "replace background", input.Text) + require.Equal(t, []string{"https://example.com/source.png", "data:image/png;base64,aGVsbG8="}, input.Images) +} + +func TestContentModerationInput_NormalizeKeepsImagesAndModerationInputSamplesOneImage(t *testing.T) { + images := []string{ + "data:image/png;base64,Zmlyc3Q=", + "data:image/png;base64,c2Vjb25k", + } + input := ContentModerationInput{ + Text: "check image", + Images: append([]string(nil), images...), + } + input.Normalize() + + require.Equal(t, images, input.Images) + + parts, ok := input.ModerationInput().([]moderationAPIInputPart) + require.True(t, ok) + require.Len(t, parts, 2) + require.Equal(t, "text", parts[0].Type) + require.Equal(t, "image_url", parts[1].Type) + require.NotNil(t, parts[1].ImageURL) + require.Contains(t, images, parts[1].ImageURL.URL) +} + +func TestBuildModerationTestInputRejectsMultipleImages(t *testing.T) { + _, _, err := buildModerationTestInput("check image", []string{ + "data:image/png;base64,Zmlyc3Q=", + "data:image/png;base64,c2Vjb25k", + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "最多上传 1 张测试图片") +} + +func TestExtractContentModerationInput_OpenAIResponsesCodexPayloadUsesLastUserMessage(t *testing.T) { + body := []byte(`{ + "model":"gpt-5.5", + "instructions":"instructions.....", + "input":[ + {"type":"message","role":"developer","content":[{"type":"input_text","text":"developer permissions sk-proj-1234567890abcdef"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"first user prompt"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"last user prompt"}]} + ], + "prompt_cache_key":"cache-key" + }`) + + input := ExtractContentModerationInput(ContentModerationProtocolOpenAIResponses, body) + + require.Equal(t, "last user prompt", input.Text) + require.Empty(t, input.Images) + require.NotContains(t, input.Text, "developer permissions") + require.NotContains(t, input.Text, "first user prompt") +} + +func TestContentModerationCheck_OpenAIResponsesRecordsNonHitForCodexPayload(t *testing.T) { + var moderationRequest moderationAPIRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/moderations", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&moderationRequest)) + _ = json.NewEncoder(w).Encode(moderationAPIResponse{ + Results: []moderationAPIResult{{ + CategoryScores: map[string]float64{"sexual": 0.01}, + }}, + }) + })) + defer server.Close() + + cfg := defaultContentModerationConfig() + cfg.Enabled = true + cfg.Mode = ContentModerationModePreBlock + cfg.BaseURL = server.URL + cfg.APIKeys = []string{"sk-test"} + cfg.RecordNonHits = true + rawCfg, err := json.Marshal(cfg) + require.NoError(t, err) + + repo := &contentModerationTestRepo{} + svc := NewContentModerationService( + &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", + SettingKeyContentModerationConfig: string(rawCfg), + }}, + repo, + &contentModerationTestHashCache{}, + nil, + nil, + nil, + nil, + ) + + body := []byte(`{ + "model":"gpt-5.5", + "input":[ + {"type":"message","role":"developer","content":[{"type":"input_text","text":"developer instructions should not be audited"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"first user prompt"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"last user prompt"}]} + ] + }`) + decision, err := svc.Check(context.Background(), ContentModerationCheckInput{ + UserID: 1001, + Endpoint: "/responses", + Provider: "openai", + Model: "gpt-5.5", + Protocol: ContentModerationProtocolOpenAIResponses, + Body: body, + }) + + require.NoError(t, err) + require.False(t, decision.Blocked) + require.Len(t, repo.logs, 1) + require.False(t, repo.logs[0].Flagged) + require.Equal(t, ContentModerationActionAllow, repo.logs[0].Action) + require.Equal(t, "/responses", repo.logs[0].Endpoint) + require.Equal(t, "last user prompt", repo.logs[0].InputExcerpt) + require.Equal(t, "last user prompt", moderationRequest.Input) +} + +func TestContentModerationCheck_PreBlockBlocksCodexResponsesLatestUserInput(t *testing.T) { + var moderationRequest moderationAPIRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/v1/moderations", r.URL.Path) + require.NoError(t, json.NewDecoder(r.Body).Decode(&moderationRequest)) + _ = json.NewEncoder(w).Encode(moderationAPIResponse{ + Results: []moderationAPIResult{{ + CategoryScores: map[string]float64{"sexual": 0.9}, + }}, + }) + })) + defer server.Close() + + cfg := defaultContentModerationConfig() + cfg.Enabled = true + cfg.Mode = ContentModerationModePreBlock + cfg.BaseURL = server.URL + cfg.APIKeys = []string{"sk-test"} + cfg.BlockStatus = http.StatusUnavailableForLegalReasons + cfg.BlockMessage = "内容审计测试阻断" + rawCfg, err := json.Marshal(cfg) + require.NoError(t, err) + + repo := &contentModerationTestRepo{} + svc := NewContentModerationService( + &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", + SettingKeyContentModerationConfig: string(rawCfg), + }}, + repo, + &contentModerationTestHashCache{}, + nil, + nil, + nil, + nil, + ) + + body := []byte(`{ + "model":"gpt-5.5", + "instructions":"instructions.....", + "input":[ + {"type":"message","role":"developer","content":[{"type":"input_text","text":"developer instructions should not be audited"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"environment context"}]}, + {"type":"message","role":"user","content":[{"type":"input_text","text":"latest blocked prompt"}]} + ] + }`) + decision, err := svc.Check(context.Background(), ContentModerationCheckInput{ + UserID: 1001, + Endpoint: "/responses", + Provider: "openai", + Model: "gpt-5.5", + Protocol: ContentModerationProtocolOpenAIResponses, + Body: body, + }) + + require.NoError(t, err) + require.True(t, decision.Blocked) + require.Equal(t, ContentModerationActionBlock, decision.Action) + require.Equal(t, http.StatusUnavailableForLegalReasons, decision.StatusCode) + require.Equal(t, "内容审计测试阻断", decision.Message) + require.Len(t, repo.logs, 1) + require.True(t, repo.logs[0].Flagged) + require.Equal(t, ContentModerationActionBlock, repo.logs[0].Action) + require.Equal(t, ContentModerationModePreBlock, repo.logs[0].Mode) + require.Equal(t, "latest blocked prompt", repo.logs[0].InputExcerpt) + require.Equal(t, "latest blocked prompt", moderationRequest.Input) +} + +func TestBuildContentModerationTestAuditResult_UsesConfiguredThresholdsOnly(t *testing.T) { + result := buildContentModerationTestAuditResult(&moderationAPIResult{ + Flagged: true, + CategoryScores: map[string]float64{ + "harassment": 0.65, + }, + }, nil) + + require.NotNil(t, result) + require.False(t, result.Flagged) + require.Equal(t, "harassment", result.HighestCategory) + require.Equal(t, 0.65, result.HighestScore) + require.Equal(t, 0.65, result.CompositeScore) + require.Equal(t, 0.98, result.Thresholds["harassment"]) +} + +func TestContentModerationCallModeration_400DoesNotFreezeAPIKey(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"message":"Number of images (5) exceeds maximum of 1","type":"invalid_request_error","param":"input","code":"too_many_images"}}`)) + })) + defer server.Close() + + cfg := defaultContentModerationConfig() + cfg.BaseURL = server.URL + cfg.APIKeys = []string{"sk-test"} + cfg.RetryCount = 5 + svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil) + + _, err := svc.callModeration(context.Background(), cfg, "hello") + + require.Error(t, err) + require.Equal(t, 1, requestCount) + status := svc.apiKeyStatusForHash(0, moderationAPIKeyHash("sk-test"), maskSecretTail("sk-test"), true) + require.Equal(t, "error", status.Status) + require.Equal(t, http.StatusBadRequest, status.LastHTTPStatus) + require.Zero(t, status.FailureCount) + require.Nil(t, status.FrozenUntil) +} + +func TestContentModerationCallModeration_FreezesByHTTPStatus(t *testing.T) { + tests := []struct { + name string + statusCode int + minFreeze time.Duration + maxFreeze time.Duration + }{ + {name: "401 freezes ten minutes", statusCode: http.StatusUnauthorized, minFreeze: 9*time.Minute + 55*time.Second, maxFreeze: 10*time.Minute + time.Second}, + {name: "403 freezes ten minutes", statusCode: http.StatusForbidden, minFreeze: 9*time.Minute + 55*time.Second, maxFreeze: 10*time.Minute + time.Second}, + {name: "429 freezes one minute", statusCode: http.StatusTooManyRequests, minFreeze: 55 * time.Second, maxFreeze: time.Minute + time.Second}, + {name: "529 freezes one minute", statusCode: 529, minFreeze: 55 * time.Second, maxFreeze: time.Minute + time.Second}, + {name: "500 freezes ten seconds", statusCode: http.StatusInternalServerError, minFreeze: 5 * time.Second, maxFreeze: 11 * time.Second}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(`{"error":{"message":"upstream error"}}`)) + })) + defer server.Close() + + cfg := defaultContentModerationConfig() + cfg.BaseURL = server.URL + cfg.APIKeys = []string{"sk-test"} + cfg.RetryCount = 0 + svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil) + + _, err := svc.callModeration(context.Background(), cfg, "hello") + + require.Error(t, err) + status := svc.apiKeyStatusForHash(0, moderationAPIKeyHash("sk-test"), maskSecretTail("sk-test"), true) + require.Equal(t, "frozen", status.Status) + require.Equal(t, tt.statusCode, status.LastHTTPStatus) + require.Equal(t, 1, status.FailureCount) + require.NotNil(t, status.FrozenUntil) + remaining := time.Until(*status.FrozenUntil) + require.GreaterOrEqual(t, remaining, tt.minFreeze) + require.LessOrEqual(t, remaining, tt.maxFreeze) + }) + } +} + +func TestContentModerationTestAPIKeys_400DoesNotFreezeAPIKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"message":"invalid moderation request"}}`)) + })) + defer server.Close() + + svc := NewContentModerationService( + &contentModerationTestSettingRepo{values: map[string]string{}}, + nil, + nil, + nil, + nil, + nil, + nil, + ) + result, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{ + APIKeys: []string{"sk-test"}, + BaseURL: server.URL, + Prompt: "hello", + }) + + require.NoError(t, err) + require.Len(t, result.Items, 1) + require.Equal(t, "error", result.Items[0].Status) + require.Equal(t, http.StatusBadRequest, result.Items[0].LastHTTPStatus) + require.Zero(t, result.Items[0].FailureCount) + require.Nil(t, result.Items[0].FrozenUntil) +} + +func TestContentModerationCheck_PreHashUsesRedisHashCache(t *testing.T) { + cfg := defaultContentModerationConfig() + cfg.Enabled = true + cfg.PreHashCheckEnabled = true + cfg.APIKeys = []string{"sk-test"} + cfg.BlockStatus = http.StatusConflict + cfg.BlockMessage = "命中历史风险输入" + rawCfg, err := json.Marshal(cfg) + require.NoError(t, err) + + hashCache := &contentModerationTestHashCache{hashes: map[string]struct{}{}} + content := ContentModerationInput{Text: "blocked prompt"} + content.Normalize() + hashCache.hashes[content.Hash()] = struct{}{} + + svc := NewContentModerationService( + &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", + SettingKeyContentModerationConfig: string(rawCfg), + }}, + &contentModerationTestRepo{}, + hashCache, + nil, + nil, + nil, + nil, + ) + + decision, err := svc.Check(context.Background(), ContentModerationCheckInput{ + Protocol: ContentModerationProtocolOpenAIChat, + Body: []byte(`{"messages":[{"role":"user","content":"blocked prompt"}]}`), + }) + require.NoError(t, err) + require.True(t, decision.Blocked) + require.Equal(t, ContentModerationActionHashBlock, decision.Action) + require.Equal(t, http.StatusConflict, decision.StatusCode) + require.Equal(t, content.Hash(), decision.InputHash) + require.Contains(t, decision.Message, "命中历史风险输入") + require.Contains(t, decision.Message, content.Hash()) + require.Len(t, hashCache.checked, 1) +} + +func TestContentModerationCheck_PreBlockFlaggedWritesRedisHashCache(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + _ = json.NewEncoder(w).Encode(moderationAPIResponse{ + Results: []moderationAPIResult{{ + CategoryScores: map[string]float64{"sexual": 0.9}, + }}, + }) + })) + defer server.Close() + + cfg := defaultContentModerationConfig() + cfg.Enabled = true + cfg.Mode = ContentModerationModePreBlock + cfg.PreHashCheckEnabled = true + cfg.BaseURL = server.URL + cfg.APIKeys = []string{"sk-test"} + cfg.BlockStatus = http.StatusConflict + cfg.BlockMessage = "命中风险输入" + rawCfg, err := json.Marshal(cfg) + require.NoError(t, err) + + repo := &contentModerationTestRepo{} + hashCache := &contentModerationTestHashCache{} + svc := NewContentModerationService( + &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", + SettingKeyContentModerationConfig: string(rawCfg), + }}, + repo, + hashCache, + nil, + nil, + nil, + nil, + ) + + body := []byte(`{"messages":[{"role":"user","content":"repeat blocked prompt"}]}`) + decision, err := svc.Check(context.Background(), ContentModerationCheckInput{ + Protocol: ContentModerationProtocolOpenAIChat, + Body: body, + }) + require.NoError(t, err) + require.True(t, decision.Blocked) + require.Equal(t, ContentModerationActionBlock, decision.Action) + require.Equal(t, 1, requestCount) + require.Len(t, hashCache.recorded, 1) + require.Len(t, repo.logs, 1) + + decision, err = svc.Check(context.Background(), ContentModerationCheckInput{ + Protocol: ContentModerationProtocolOpenAIChat, + Body: body, + }) + require.NoError(t, err) + require.True(t, decision.Blocked) + require.Equal(t, ContentModerationActionHashBlock, decision.Action) + require.Equal(t, hashCache.recorded[0], decision.InputHash) + require.Equal(t, 1, requestCount) + require.Len(t, repo.logs, 1) +} + +func TestContentModerationDeleteFlaggedInputHash_NormalizesAndDeletes(t *testing.T) { + existingHash := strings.Repeat("a", 64) + hashCache := &contentModerationTestHashCache{hashes: map[string]struct{}{ + existingHash: {}, + }} + svc := &ContentModerationService{hashCache: hashCache} + + result, err := svc.DeleteFlaggedInputHash(context.Background(), strings.ToUpper(existingHash)) + + require.NoError(t, err) + require.Equal(t, existingHash, result.InputHash) + require.True(t, result.Deleted) + require.NotContains(t, hashCache.hashes, existingHash) + require.Equal(t, []string{existingHash}, hashCache.deleted) + + result, err = svc.DeleteFlaggedInputHash(context.Background(), existingHash) + + require.NoError(t, err) + require.Equal(t, existingHash, result.InputHash) + require.False(t, result.Deleted) +} + +func TestContentModerationClearFlaggedInputHashesAndStatusCount(t *testing.T) { + cfg := defaultContentModerationConfig() + cfg.Enabled = true + rawCfg, err := json.Marshal(cfg) + require.NoError(t, err) + + hashCache := &contentModerationTestHashCache{hashes: map[string]struct{}{ + strings.Repeat("a", 64): {}, + strings.Repeat("b", 64): {}, + }} + svc := &ContentModerationService{ + settingRepo: &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", + SettingKeyContentModerationConfig: string(rawCfg), + }}, + hashCache: hashCache, + keyHealth: make(map[string]*contentModerationKeyHealth), + } + + status, err := svc.GetStatus(context.Background()) + require.NoError(t, err) + require.Equal(t, int64(2), status.FlaggedHashCount) + + result, err := svc.ClearFlaggedInputHashes(context.Background()) + require.NoError(t, err) + require.Equal(t, int64(2), result.Deleted) + + status, err = svc.GetStatus(context.Background()) + require.NoError(t, err) + require.Equal(t, int64(0), status.FlaggedHashCount) +} + +func TestContentModerationCheck_AsyncFlaggedWritesRedisHashCache(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(moderationAPIResponse{ + Results: []moderationAPIResult{{ + CategoryScores: map[string]float64{"sexual": 0.9}, + }}, + }) + })) + defer server.Close() + + cfg := defaultContentModerationConfig() + cfg.Enabled = true + cfg.Mode = ContentModerationModeObserve + cfg.BaseURL = server.URL + cfg.APIKeys = []string{"sk-test"} + rawCfg, err := json.Marshal(cfg) + require.NoError(t, err) + + repo := &contentModerationTestRepo{} + hashCache := &contentModerationTestHashCache{} + svc := NewContentModerationService( + &contentModerationTestSettingRepo{values: map[string]string{ + SettingKeyRiskControlEnabled: "true", + SettingKeyContentModerationConfig: string(rawCfg), + }}, + repo, + hashCache, + nil, + nil, + nil, + nil, + ) + + decision := svc.checkSync(context.Background(), ContentModerationCheckInput{ + Protocol: ContentModerationProtocolOpenAIChat, + Body: []byte(`{"messages":[{"role":"user","content":"bad prompt"}]}`), + }, cfg, ContentModerationInput{Text: "bad prompt"}, strings.Repeat("b", 64), contentModerationIntPtr(25), false) + + require.False(t, decision.Blocked) + require.Len(t, hashCache.recorded, 1) + require.Len(t, repo.logs, 1) +} + +func TestBuildContentModerationAccountDisabledEmailBody_ContainsBanDetails(t *testing.T) { + userID := int64(1001) + cfg := defaultContentModerationConfig() + cfg.BanThreshold = 10 + body := buildContentModerationAccountDisabledEmailBody("Sub2API ", &ContentModerationLog{ + UserID: &userID, + UserEmail: "user@example.com", + GroupName: "vip_2", + HighestCategory: "sexual", + HighestScore: 0.926, + ViolationCount: 10, + }, cfg) + + require.Contains(t, body, "账户已被自动禁用") + require.Contains(t, body, "封禁详情") + require.Contains(t, body, "账户当前处于封禁状态,所有 API 请求将被拒绝") + require.Contains(t, body, "10 次(阈值 10)") + require.Contains(t, body, "sexual / 0.926") + require.Contains(t, body, "Sub2API <Admin>") +} + +func TestContentModerationUnbanUser_ActivatesUserAndInvalidatesAuthCache(t *testing.T) { + userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusDisabled}} + invalidator := &contentModerationTestAuthCacheInvalidator{} + repo := &contentModerationTestRepo{} + svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil) + + result, err := svc.UnbanUser(context.Background(), 1001) + + require.NoError(t, err) + require.Equal(t, int64(1001), result.UserID) + require.Equal(t, StatusActive, result.Status) + require.Len(t, userRepo.updated, 1) + require.Equal(t, StatusActive, userRepo.updated[0].Status) + require.Equal(t, []int64{1001}, invalidator.userIDs) +} + +func TestContentModerationUnbanUser_ActiveUserOnlyInvalidatesAuthCache(t *testing.T) { + userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusActive}} + invalidator := &contentModerationTestAuthCacheInvalidator{} + repo := &contentModerationTestRepo{} + svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil) + + result, err := svc.UnbanUser(context.Background(), 1001) + + require.NoError(t, err) + require.Equal(t, StatusActive, result.Status) + require.Empty(t, userRepo.updated) + require.Equal(t, []int64{1001}, invalidator.userIDs) +} + +func contentModerationIntPtr(v int) *int { + return &v +} diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 632ebf5fc8c..946c7e986de 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -107,6 +107,12 @@ const ( SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) + SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路 + SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON) + SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款 + SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox + SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用) + SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容) // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 @@ -173,6 +179,18 @@ const ( SettingKeyOIDCConnectUserInfoIDPath = "oidc_connect_userinfo_id_path" SettingKeyOIDCConnectUserInfoUsernamePath = "oidc_connect_userinfo_username_path" + // GitHub / Google 邮箱快捷登录设置 + SettingKeyGitHubOAuthEnabled = "github_oauth_enabled" + SettingKeyGitHubOAuthClientID = "github_oauth_client_id" + SettingKeyGitHubOAuthClientSecret = "github_oauth_client_secret" + SettingKeyGitHubOAuthRedirectURL = "github_oauth_redirect_url" + SettingKeyGitHubOAuthFrontendRedirectURL = "github_oauth_frontend_redirect_url" + SettingKeyGoogleOAuthEnabled = "google_oauth_enabled" + SettingKeyGoogleOAuthClientID = "google_oauth_client_id" + SettingKeyGoogleOAuthClientSecret = "google_oauth_client_secret" + SettingKeyGoogleOAuthRedirectURL = "google_oauth_redirect_url" + SettingKeyGoogleOAuthFrontendRedirectURL = "google_oauth_frontend_redirect_url" + // OEM设置 SettingKeySiteName = "site_name" // 网站名称 SettingKeySiteLogo = "site_logo" // 网站Logo (base64) @@ -216,6 +234,16 @@ const ( SettingKeyAuthSourceDefaultWeChatSubscriptions = "auth_source_default_wechat_subscriptions" SettingKeyAuthSourceDefaultWeChatGrantOnSignup = "auth_source_default_wechat_grant_on_signup" SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind = "auth_source_default_wechat_grant_on_first_bind" + SettingKeyAuthSourceDefaultGitHubBalance = "auth_source_default_github_balance" + SettingKeyAuthSourceDefaultGitHubConcurrency = "auth_source_default_github_concurrency" + SettingKeyAuthSourceDefaultGitHubSubscriptions = "auth_source_default_github_subscriptions" + SettingKeyAuthSourceDefaultGitHubGrantOnSignup = "auth_source_default_github_grant_on_signup" + SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind = "auth_source_default_github_grant_on_first_bind" + SettingKeyAuthSourceDefaultGoogleBalance = "auth_source_default_google_balance" + SettingKeyAuthSourceDefaultGoogleConcurrency = "auth_source_default_google_concurrency" + SettingKeyAuthSourceDefaultGoogleSubscriptions = "auth_source_default_google_subscriptions" + SettingKeyAuthSourceDefaultGoogleGrantOnSignup = "auth_source_default_google_grant_on_signup" + SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind = "auth_source_default_google_grant_on_first_bind" SettingKeyForceEmailOnThirdPartySignup = "force_email_on_third_party_signup" // 管理员 API Key @@ -280,6 +308,15 @@ const ( // sidebar entry is hidden. Defaults to false (opt-in feature). SettingKeyAvailableChannelsEnabled = "available_channels_enabled" + // SettingKeyImageGenerationEnabled is a DB-backed soft switch for the user-facing + // image generation tool. Defaults to false because OpenAI image models and pricing + // must be configured before exposing the entry point. + SettingKeyImageGenerationEnabled = "image_generation_enabled" + + // SettingKeyChatCompletionEnabled is a DB-backed soft switch for the user-facing + // chat completion tool. Defaults to false because deployments should opt in. + SettingKeyChatCompletionEnabled = "chat_completion_enabled" + // ========================= // Overload Cooldown (529) // ========================= @@ -287,6 +324,9 @@ const ( // SettingKeyOverloadCooldownSettings stores JSON config for 529 overload cooldown handling. SettingKeyOverloadCooldownSettings = "overload_cooldown_settings" + // SettingKeyRateLimit429CooldownSettings stores JSON config for 429 fallback cooldown handling. + SettingKeyRateLimit429CooldownSettings = "rate_limit_429_cooldown_settings" + // ========================= // Stream Timeout Handling // ========================= diff --git a/backend/internal/service/gateway_beta_test.go b/backend/internal/service/gateway_beta_test.go index ecaffe21c9d..6919c148f3b 100644 --- a/backend/internal/service/gateway_beta_test.go +++ b/backend/internal/service/gateway_beta_test.go @@ -124,6 +124,24 @@ func TestMergeAnthropicBetaDropping_DroppedBetas(t *testing.T) { require.Contains(t, got, "fast-mode-2026-02-01") } +func TestFullClaudeCodeMimicryBetas_DoesNotDefaultRedactThinking(t *testing.T) { + required := claude.FullClaudeCodeMimicryBetas() + + require.NotContains(t, required, claude.BetaRedactThinking) + require.Contains(t, required, claude.BetaClaudeCode) + require.Contains(t, required, claude.BetaOAuth) + require.Contains(t, required, claude.BetaInterleavedThinking) +} + +func TestMergeAnthropicBetaDropping_PreservesIncomingRedactThinking(t *testing.T) { + required := claude.FullClaudeCodeMimicryBetas() + incoming := claude.BetaRedactThinking + + got := mergeAnthropicBetaDropping(required, incoming, droppedBetaSet()) + + require.Contains(t, got, claude.BetaRedactThinking) +} + func TestDroppedBetaSet(t *testing.T) { // Base set contains DroppedBetas (now empty — filtering moved to configurable beta policy) base := droppedBetaSet() diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 67d19720f41..3a003bd23cb 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -5343,6 +5343,12 @@ func (s *GatewayService) handleStreamingResponseAnthropicAPIKeyPassthrough( flusher.Flush() } if !sawTerminalEvent { + if clientDisconnected && streamInterval > 0 { + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) >= streamInterval { + return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: true}, fmt.Errorf("stream usage incomplete after timeout") + } + } return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, fmt.Errorf("stream usage incomplete: missing terminal event") } return &streamingResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnected}, nil @@ -8367,6 +8373,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage groupDefault := apiKey.Group.RateMultiplier multiplier = s.getUserGroupRateMultiplier(ctx, user.ID, *apiKey.GroupID, groupDefault) } + imageMultiplier := resolveImageRateMultiplier(apiKey, multiplier) // 确定计费模型 billingModel := forwardResultBillingModel(result.Model, result.UpstreamModel) @@ -8384,7 +8391,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage } // 计算费用 - cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, opts) + cost := s.calculateRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, imageMultiplier, opts) // 判断计费方式:订阅模式 vs 余额模式 isSubscriptionBilling := subscription != nil && apiKey.Group != nil && apiKey.Group.IsSubscriptionType() @@ -8396,7 +8403,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage // 创建使用日志 accountRateMultiplier := account.BillingRateMultiplier() usageLog := s.buildRecordUsageLog(ctx, input, result, apiKey, user, account, subscription, - requestedModel, multiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts) + requestedModel, multiplier, imageMultiplier, accountRateMultiplier, billingType, cacheTTLOverridden, cost, opts) // 计算账号统计定价费用(使用最终上游模型匹配自定义规则) if apiKey.GroupID != nil { @@ -8450,11 +8457,12 @@ func (s *GatewayService) calculateRecordUsageCost( apiKey *APIKey, billingModel string, multiplier float64, + imageMultiplier float64, opts *recordUsageOpts, ) *CostBreakdown { // 图片生成计费 if result.ImageCount > 0 { - return s.calculateImageCost(ctx, result, apiKey, billingModel, multiplier) + return s.calculateImageCost(ctx, result, apiKey, billingModel, imageMultiplier) } // Token 计费 @@ -8495,7 +8503,8 @@ func (s *GatewayService) calculateImageCost( Model: billingModel, GroupID: &gid, Tokens: tokens, - RequestCount: 1, + RequestCount: result.ImageCount, + SizeTier: result.ImageSize, RateMultiplier: multiplier, Resolver: s.resolver, Resolved: resolved, @@ -8580,6 +8589,7 @@ func (s *GatewayService) buildRecordUsageLog( subscription *UserSubscription, requestedModel string, multiplier float64, + imageMultiplier float64, accountRateMultiplier float64, billingType int8, cacheTTLOverridden bool, @@ -8624,6 +8634,9 @@ func (s *GatewayService) buildRecordUsageLog( SubscriptionID: optionalSubscriptionID(subscription), CreatedAt: time.Now(), } + if result.ImageCount > 0 { + usageLog.RateMultiplier = imageMultiplier + } if cost != nil { usageLog.InputCost = cost.InputCost usageLog.OutputCost = cost.OutputCost diff --git a/backend/internal/service/group.go b/backend/internal/service/group.go index bb4c5aa1ba1..f61553522af 100644 --- a/backend/internal/service/group.go +++ b/backend/internal/service/group.go @@ -26,9 +26,12 @@ type Group struct { DefaultValidityDays int // 图片生成计费配置(antigravity 和 gemini 平台使用) - ImagePrice1K *float64 - ImagePrice2K *float64 - ImagePrice4K *float64 + AllowImageGeneration bool + ImageRateIndependent bool + ImageRateMultiplier float64 + ImagePrice1K *float64 + ImagePrice2K *float64 + ImagePrice4K *float64 // Claude Code 客户端限制 ClaudeCodeOnly bool diff --git a/backend/internal/service/group_service.go b/backend/internal/service/group_service.go index 87174e03764..93078aa6325 100644 --- a/backend/internal/service/group_service.go +++ b/backend/internal/service/group_service.go @@ -45,19 +45,25 @@ type GroupSortOrderUpdate struct { // CreateGroupRequest 创建分组请求 type CreateGroupRequest struct { - Name string `json:"name"` - Description string `json:"description"` - RateMultiplier float64 `json:"rate_multiplier"` - IsExclusive bool `json:"is_exclusive"` + Name string `json:"name"` + Description string `json:"description"` + RateMultiplier float64 `json:"rate_multiplier"` + IsExclusive bool `json:"is_exclusive"` + AllowImageGeneration bool `json:"allow_image_generation"` + ImageRateIndependent bool `json:"image_rate_independent"` + ImageRateMultiplier *float64 `json:"image_rate_multiplier"` } // UpdateGroupRequest 更新分组请求 type UpdateGroupRequest struct { - Name *string `json:"name"` - Description *string `json:"description"` - RateMultiplier *float64 `json:"rate_multiplier"` - IsExclusive *bool `json:"is_exclusive"` - Status *string `json:"status"` + Name *string `json:"name"` + Description *string `json:"description"` + RateMultiplier *float64 `json:"rate_multiplier"` + IsExclusive *bool `json:"is_exclusive"` + Status *string `json:"status"` + AllowImageGeneration *bool `json:"allow_image_generation"` + ImageRateIndependent *bool `json:"image_rate_independent"` + ImageRateMultiplier *float64 `json:"image_rate_multiplier"` } // GroupService 分组管理服务 @@ -76,6 +82,13 @@ func NewGroupService(groupRepo GroupRepository, authCacheInvalidator APIKeyAuthC // Create 创建分组 func (s *GroupService) Create(ctx context.Context, req CreateGroupRequest) (*Group, error) { + imageRateMultiplier := 1.0 + if req.ImageRateMultiplier != nil { + if *req.ImageRateMultiplier < 0 { + return nil, fmt.Errorf("image_rate_multiplier must be >= 0") + } + imageRateMultiplier = *req.ImageRateMultiplier + } // 检查名称是否已存在 exists, err := s.groupRepo.ExistsByName(ctx, req.Name) if err != nil { @@ -87,13 +100,16 @@ func (s *GroupService) Create(ctx context.Context, req CreateGroupRequest) (*Gro // 创建分组 group := &Group{ - Name: req.Name, - Description: req.Description, - Platform: PlatformAnthropic, - RateMultiplier: req.RateMultiplier, - IsExclusive: req.IsExclusive, - Status: StatusActive, - SubscriptionType: SubscriptionTypeStandard, + Name: req.Name, + Description: req.Description, + Platform: PlatformAnthropic, + RateMultiplier: req.RateMultiplier, + IsExclusive: req.IsExclusive, + Status: StatusActive, + SubscriptionType: SubscriptionTypeStandard, + AllowImageGeneration: req.AllowImageGeneration, + ImageRateIndependent: req.ImageRateIndependent, + ImageRateMultiplier: imageRateMultiplier, } if err := s.groupRepo.Create(ctx, group); err != nil { @@ -165,6 +181,18 @@ func (s *GroupService) Update(ctx context.Context, id int64, req UpdateGroupRequ if req.Status != nil { group.Status = *req.Status } + if req.AllowImageGeneration != nil { + group.AllowImageGeneration = *req.AllowImageGeneration + } + if req.ImageRateIndependent != nil { + group.ImageRateIndependent = *req.ImageRateIndependent + } + if req.ImageRateMultiplier != nil { + if *req.ImageRateMultiplier < 0 { + return nil, fmt.Errorf("image_rate_multiplier must be >= 0") + } + group.ImageRateMultiplier = *req.ImageRateMultiplier + } if err := s.groupRepo.Update(ctx, group); err != nil { return nil, fmt.Errorf("update group: %w", err) diff --git a/backend/internal/service/image_billing_multiplier.go b/backend/internal/service/image_billing_multiplier.go new file mode 100644 index 00000000000..23ec5ac104b --- /dev/null +++ b/backend/internal/service/image_billing_multiplier.go @@ -0,0 +1,11 @@ +package service + +func resolveImageRateMultiplier(apiKey *APIKey, effectiveGroupMultiplier float64) float64 { + if apiKey != nil && apiKey.Group != nil && apiKey.Group.ImageRateIndependent { + if apiKey.Group.ImageRateMultiplier < 0 { + return 0 + } + return apiKey.Group.ImageRateMultiplier + } + return effectiveGroupMultiplier +} diff --git a/backend/internal/service/image_generation_intent.go b/backend/internal/service/image_generation_intent.go new file mode 100644 index 00000000000..b6ef106509d --- /dev/null +++ b/backend/internal/service/image_generation_intent.go @@ -0,0 +1,220 @@ +package service + +import ( + "encoding/json" + "strings" + + "github.com/tidwall/gjson" +) + +const ( + openAIResponsesEndpoint = "/v1/responses" + openAIResponsesCompactEndpoint = "/v1/responses/compact" + imageGenerationPermissionMessage = "Image generation is not enabled for this group" +) + +// ImageGenerationPermissionMessage returns the stable end-user error text for disabled groups. +func ImageGenerationPermissionMessage() string { + return imageGenerationPermissionMessage +} + +// GroupAllowsImageGeneration preserves ungrouped-key behavior and enforces the flag when a group is present. +func GroupAllowsImageGeneration(group *Group) bool { + return group == nil || group.AllowImageGeneration +} + +// IsImageGenerationIntent classifies requests that can produce generated images. +func IsImageGenerationIntent(endpoint string, requestedModel string, body []byte) bool { + if IsImageGenerationEndpoint(endpoint) { + return true + } + if isOpenAIImageGenerationModel(requestedModel) { + return true + } + if len(body) == 0 || !gjson.ValidBytes(body) { + return false + } + if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); isOpenAIImageGenerationModel(model) { + return true + } + if openAIJSONToolsContainImageGeneration(gjson.GetBytes(body, "tools")) { + return true + } + return openAIJSONToolChoiceSelectsImageGeneration(gjson.GetBytes(body, "tool_choice")) +} + +// IsImageGenerationIntentMap is the map-backed variant used after service-side request mutation. +func IsImageGenerationIntentMap(endpoint string, requestedModel string, reqBody map[string]any) bool { + if IsImageGenerationEndpoint(endpoint) { + return true + } + if isOpenAIImageGenerationModel(requestedModel) { + return true + } + if reqBody == nil { + return false + } + if isOpenAIImageGenerationModel(firstNonEmptyString(reqBody["model"])) { + return true + } + if hasOpenAIImageGenerationTool(reqBody) { + return true + } + return openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) +} + +// IsImageGenerationEndpoint identifies dedicated generated-image endpoints. +func IsImageGenerationEndpoint(endpoint string) bool { + switch normalizeImageGenerationEndpoint(endpoint) { + case "/v1/images/generations", "/v1/images/edits", "/images/generations", "/images/edits": + return true + default: + return false + } +} + +func normalizeImageGenerationEndpoint(endpoint string) string { + endpoint = strings.TrimSpace(strings.ToLower(endpoint)) + if endpoint == "" { + return "" + } + endpoint = strings.TrimPrefix(endpoint, "https://api.openai.com") + if idx := strings.IndexByte(endpoint, '?'); idx >= 0 { + endpoint = endpoint[:idx] + } + return strings.TrimRight(endpoint, "/") +} + +func openAIJSONToolsContainImageGeneration(tools gjson.Result) bool { + if !tools.IsArray() { + return false + } + found := false + tools.ForEach(func(_, item gjson.Result) bool { + if strings.TrimSpace(item.Get("type").String()) == "image_generation" { + found = true + return false + } + return true + }) + return found +} + +func openAIJSONToolChoiceSelectsImageGeneration(choice gjson.Result) bool { + if !choice.Exists() { + return false + } + if choice.Type == gjson.String { + return strings.TrimSpace(choice.String()) == "image_generation" + } + if !choice.IsObject() { + return false + } + if strings.TrimSpace(choice.Get("type").String()) == "image_generation" { + return true + } + if strings.TrimSpace(choice.Get("tool.type").String()) == "image_generation" { + return true + } + if strings.TrimSpace(choice.Get("function.name").String()) == "image_generation" { + return true + } + return false +} + +func openAIAnyToolChoiceSelectsImageGeneration(choice any) bool { + switch v := choice.(type) { + case string: + return strings.TrimSpace(v) == "image_generation" + case map[string]any: + if strings.TrimSpace(firstNonEmptyString(v["type"])) == "image_generation" { + return true + } + if tool, ok := v["tool"].(map[string]any); ok && strings.TrimSpace(firstNonEmptyString(tool["type"])) == "image_generation" { + return true + } + if fn, ok := v["function"].(map[string]any); ok && strings.TrimSpace(firstNonEmptyString(fn["name"])) == "image_generation" { + return true + } + } + return false +} + +func getAPIKeyFromContext(c interface{ Get(string) (any, bool) }) *APIKey { + if c == nil { + return nil + } + v, exists := c.Get("api_key") + if !exists { + return nil + } + apiKey, _ := v.(*APIKey) + return apiKey +} + +func apiKeyGroup(apiKey *APIKey) *Group { + if apiKey == nil { + return nil + } + return apiKey.Group +} + +func cloneRequestMapForImageIntent(body []byte) map[string]any { + if len(body) == 0 { + return nil + } + var out map[string]any + if err := json.Unmarshal(body, &out); err != nil { + return nil + } + return out +} + +func resolveOpenAIResponsesImageBillingConfig(reqBody map[string]any, fallbackModel string) (string, string, error) { + imageModel := "" + imageSize := "" + hasImageTool := false + if reqBody != nil { + rawTools, _ := reqBody["tools"].([]any) + for _, rawTool := range rawTools { + toolMap, ok := rawTool.(map[string]any) + if !ok || strings.TrimSpace(firstNonEmptyString(toolMap["type"])) != "image_generation" { + continue + } + hasImageTool = true + imageModel = strings.TrimSpace(firstNonEmptyString(toolMap["model"])) + imageSize = strings.TrimSpace(firstNonEmptyString(toolMap["size"])) + break + } + if imageSize == "" { + imageSize = strings.TrimSpace(firstNonEmptyString(reqBody["size"])) + } + } + if imageModel == "" && reqBody != nil { + bodyModel := strings.TrimSpace(firstNonEmptyString(reqBody["model"])) + if isOpenAIImageBillingModelAlias(bodyModel) || !hasImageTool { + imageModel = bodyModel + } + } + if imageModel == "" && hasImageTool { + imageModel = "gpt-image-2" + } + if imageModel == "" { + imageModel = strings.TrimSpace(fallbackModel) + } + sizeTier := normalizeOpenAIImageSizeTier(imageSize) + return imageModel, sizeTier, nil +} + +func resolveOpenAIResponsesImageBillingConfigFromBody(body []byte, fallbackModel string) (string, string, error) { + reqBody := cloneRequestMapForImageIntent(body) + return resolveOpenAIResponsesImageBillingConfig(reqBody, fallbackModel) +} + +func isOpenAIImageBillingModelAlias(model string) bool { + normalized := strings.ToLower(strings.TrimSpace(model)) + if normalized == "" { + return false + } + return isOpenAIImageGenerationModel(normalized) || strings.Contains(normalized, "image") +} diff --git a/backend/internal/service/image_generation_intent_test.go b/backend/internal/service/image_generation_intent_test.go new file mode 100644 index 00000000000..5e7bec79b7f --- /dev/null +++ b/backend/internal/service/image_generation_intent_test.go @@ -0,0 +1,184 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsImageGenerationIntent(t *testing.T) { + tests := []struct { + name string + endpoint string + model string + body []byte + want bool + }{ + { + name: "images endpoint", + endpoint: "/v1/images/generations", + body: []byte(`{"model":"gpt-image-2"}`), + want: true, + }, + { + name: "image model", + endpoint: "/v1/responses", + model: "gpt-image-2", + body: []byte(`{"model":"gpt-image-2"}`), + want: true, + }, + { + name: "image tool", + endpoint: "/v1/responses", + model: "gpt-5.4", + body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`), + want: true, + }, + { + name: "image tool choice", + endpoint: "/v1/responses", + model: "gpt-5.4", + body: []byte(`{"model":"gpt-5.4","tool_choice":{"type":"image_generation"}}`), + want: true, + }, + { + name: "required tool choice alone is text", + endpoint: "/v1/responses", + model: "gpt-5.4", + body: []byte(`{"model":"gpt-5.4","tool_choice":"required"}`), + want: false, + }, + { + name: "text only gpt 5.4", + endpoint: "/v1/responses", + model: "gpt-5.4", + body: []byte(`{"model":"gpt-5.4","input":"write code"}`), + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, IsImageGenerationIntent(tt.endpoint, tt.model, tt.body)) + }) + } +} + +func TestResolveOpenAIResponsesImageBillingConfigUsesCurrentBodyModel(t *testing.T) { + imageModel, imageSize, err := resolveOpenAIResponsesImageBillingConfigFromBody( + []byte(`{"model":"mapped-image-model","tools":[{"type":"image_generation","size":"1024x1024"}]}`), + "requested-model", + ) + require.NoError(t, err) + require.Equal(t, "mapped-image-model", imageModel) + require.Equal(t, "1K", imageSize) +} + +func TestResolveOpenAIResponsesImageBillingConfigToolModelWins(t *testing.T) { + imageModel, imageSize, err := resolveOpenAIResponsesImageBillingConfigFromBody( + []byte(`{"model":"mapped-text-model","tools":[{"type":"image_generation","model":"gpt-image-2","size":"1536x1024"}]}`), + "requested-model", + ) + require.NoError(t, err) + require.Equal(t, "gpt-image-2", imageModel) + require.Equal(t, "2K", imageSize) +} + +func TestResolveOpenAIResponsesImageBillingConfigSupportsOfficialAndCustomSizes(t *testing.T) { + tests := []struct { + name string + body []byte + wantTier string + }{ + { + name: "official 2k landscape", + body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","model":"gpt-image-2","size":"2048x1152"}]}`), + wantTier: "2K", + }, + { + name: "official 4k landscape", + body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","model":"gpt-image-2","size":"3840x2160"}]}`), + wantTier: "4K", + }, + { + name: "custom valid 2k", + body: []byte(`{"model":"gpt-5.5","tools":[{"type":"image_generation","model":"gpt-image-2","size":"1280x768"}]}`), + wantTier: "2K", + }, + { + name: "default image tool model supports flexible size", + body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","size":"2048x1152"}]}`), + wantTier: "2K", + }, + { + name: "top level image size is moved into billing", + body: []byte(`{"model":"gpt-image-2","size":"2048x2048","tools":[{"type":"image_generation","model":"gpt-image-2"}]}`), + wantTier: "2K", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + imageModel, imageSize, err := resolveOpenAIResponsesImageBillingConfigFromBody(tt.body, "requested-model") + require.NoError(t, err) + require.NotEmpty(t, imageModel) + require.Equal(t, tt.wantTier, imageSize) + }) + } +} + +func TestResolveOpenAIResponsesImageBillingConfigDoesNotRejectUnknownSizes(t *testing.T) { + imageModel, imageSize, err := resolveOpenAIResponsesImageBillingConfigFromBody( + []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation","model":"gpt-image-1.5","size":"2048x1152"}]}`), + "requested-model", + ) + require.NoError(t, err) + require.Equal(t, "gpt-image-1.5", imageModel) + require.Equal(t, "2K", imageSize) +} + +func TestOpenAIImageOutputCounterDeduplicatesFinalImages(t *testing.T) { + counter := newOpenAIImageOutputCounter() + counter.AddSSEData([]byte(`{"type":"response.image_generation_call.partial_image","partial_image_b64":"abc"}`)) + counter.AddSSEData([]byte(`{"type":"response.output_item.done","item":{"id":"ig_1","type":"image_generation_call","result":"final-a"}}`)) + counter.AddSSEData([]byte(`{"type":"response.completed","response":{"output":[{"id":"ig_1","type":"image_generation_call","result":"final-a"},{"id":"ig_2","type":"image_generation_call","result":"final-b"}]}}`)) + require.Equal(t, 2, counter.Count()) +} + +func TestOpenAIImageOutputCounterCountsImagesAPIStreamShapes(t *testing.T) { + counter := newOpenAIImageOutputCounter() + counter.AddSSEData([]byte(`{"type":"image_generation.completed","id":"ig_complete","b64_json":"final-a"}`)) + counter.AddSSEData([]byte(`{"type":"response.output_item.done","item":{"id":"ig_item","type":"image_generation_call","result":"final-b"}}`)) + counter.AddSSEData([]byte(`{"type":"response.completed","response":{"output":[{"id":"ig_done","type":"image_generation_call","result":"final-c"}]}}`)) + require.Equal(t, 3, counter.Count()) + + dataCounter := newOpenAIImageOutputCounter() + dataCounter.AddSSEData([]byte(`{"data":[{"b64_json":"a"},{"b64_json":"b"}]}`)) + dataCounter.AddSSEData([]byte(`{"data":[{"b64_json":"a"},{"b64_json":"b"},{"b64_json":"c"}]}`)) + require.Equal(t, 3, dataCounter.Count()) +} + +func TestOpenAIImageOutputCounterCountsMultilineSSEDataPayload(t *testing.T) { + counter := newOpenAIImageOutputCounter() + counter.AddSSEData([]byte("{\"type\":\"image_generation.completed\",\n\"b64_json\":\"final-a\"}")) + require.Equal(t, 1, counter.Count()) +} + +func TestOpenAIImageOutputCounterCountsMultilineSSEBodyPayload(t *testing.T) { + counter := newOpenAIImageOutputCounter() + counter.AddSSEBody( + "data: {\"type\":\"image_generation.completed\",\n" + + "data: \"b64_json\":\"final-a\"}\n\n" + + "data: [DONE]\n\n", + ) + require.Equal(t, 1, counter.Count()) +} + +func TestOpenAIImageOutputCounterFallsBackForInvalidMultilineSSEBody(t *testing.T) { + counter := newOpenAIImageOutputCounter() + counter.AddSSEBody( + "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"final-a\"}\n" + + "data: {\"type\":\"image_generation.completed\",\"b64_json\":\"final-b\"}\n\n", + ) + require.Equal(t, 2, counter.Count()) +} diff --git a/backend/internal/service/image_output_accounting.go b/backend/internal/service/image_output_accounting.go new file mode 100644 index 00000000000..219c0c59609 --- /dev/null +++ b/backend/internal/service/image_output_accounting.go @@ -0,0 +1,149 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + + "github.com/tidwall/gjson" +) + +type openAIImageOutputCounter struct { + seen map[string]struct{} + count int + maxDataCount int +} + +func newOpenAIImageOutputCounter() *openAIImageOutputCounter { + return &openAIImageOutputCounter{seen: make(map[string]struct{})} +} + +func (c *openAIImageOutputCounter) Count() int { + if c == nil { + return 0 + } + if c.maxDataCount > c.count { + return c.maxDataCount + } + return c.count +} + +func (c *openAIImageOutputCounter) AddJSONResponse(body []byte) { + if c == nil || len(body) == 0 || !gjson.ValidBytes(body) { + return + } + c.addDataArray(gjson.GetBytes(body, "data")) + c.addOutputArray(gjson.GetBytes(body, "output")) + c.addOutputArray(gjson.GetBytes(body, "response.output")) +} + +func (c *openAIImageOutputCounter) AddSSEData(data []byte) { + if c == nil || len(data) == 0 || strings.TrimSpace(string(data)) == "[DONE]" || !gjson.ValidBytes(data) { + return + } + root := gjson.ParseBytes(data) + c.addDataArray(root.Get("data")) + eventType := strings.TrimSpace(root.Get("type").String()) + switch eventType { + case "response.output_item.done": + c.addImageOutputItem(root.Get("item")) + case "response.completed", "response.done": + c.addOutputArray(root.Get("response.output")) + case "image_generation.completed": + if item := root.Get("item"); item.Exists() { + c.addImageOutputItem(item) + return + } + if output := root.Get("output"); output.Exists() { + c.addImageOutputItem(output) + return + } + c.addImageOutputItem(root) + } +} + +func (c *openAIImageOutputCounter) AddSSEBody(body string) { + if c == nil || strings.TrimSpace(body) == "" { + return + } + forEachOpenAISSEDataPayload(body, c.AddSSEData) +} + +func (c *openAIImageOutputCounter) addDataArray(data gjson.Result) { + if !data.IsArray() { + return + } + count := len(data.Array()) + if count > c.maxDataCount { + c.maxDataCount = count + } +} + +func (c *openAIImageOutputCounter) addOutputArray(output gjson.Result) { + if !output.IsArray() { + return + } + output.ForEach(func(_, item gjson.Result) bool { + c.addImageOutputItem(item) + return true + }) +} + +func (c *openAIImageOutputCounter) addImageOutputItem(item gjson.Result) { + if !item.Exists() || !item.IsObject() { + return + } + itemType := strings.TrimSpace(item.Get("type").String()) + if itemType != "" && itemType != "image_generation_call" && itemType != "image_generation.completed" { + return + } + if strings.Contains(strings.ToLower(item.Raw), "partial_image") { + return + } + result := strings.TrimSpace(item.Get("result").String()) + if result == "" { + result = strings.TrimSpace(item.Get("b64_json").String()) + } + if result == "" { + result = strings.TrimSpace(item.Get("url").String()) + } + if result == "" && itemType != "image_generation.completed" { + return + } + key := strings.TrimSpace(item.Get("id").String()) + if key == "" { + key = strings.TrimSpace(item.Get("call_id").String()) + } + if key == "" { + key = hashOpenAIImageOutputResult(result) + } + if key == "" { + return + } + if _, exists := c.seen[key]; exists { + return + } + c.seen[key] = struct{}{} + c.count++ +} + +func hashOpenAIImageOutputResult(result string) string { + result = strings.TrimSpace(result) + if result == "" { + return "" + } + sum := sha256.Sum256([]byte(result)) + return hex.EncodeToString(sum[:]) +} + +func countOpenAIResponseImageOutputsFromJSONBytes(body []byte) int { + counter := newOpenAIImageOutputCounter() + counter.AddJSONResponse(body) + return counter.Count() +} + +func countOpenAIImageOutputsFromSSEBody(body string) int { + counter := newOpenAIImageOutputCounter() + counter.AddSSEBody(body) + return counter.Count() +} diff --git a/backend/internal/service/image_task.go b/backend/internal/service/image_task.go new file mode 100644 index 00000000000..7328cffd889 --- /dev/null +++ b/backend/internal/service/image_task.go @@ -0,0 +1,239 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + ImageTaskStatusPending = "pending" + ImageTaskStatusRunning = "running" + ImageTaskStatusSucceeded = "succeeded" + ImageTaskStatusFailed = "failed" + ImageTaskStatusExpired = "expired" + + defaultImageTaskTTL = time.Hour +) + +var ( + ErrImageTaskNotFound = errors.New("image task not found") + ErrImageTaskExpired = errors.New("image task expired") +) + +type ImageTask struct { + TaskID string + UserID int64 + APIKeyID int64 + Status string + Endpoint string + Model string + Prompt string + FilePath string + MimeType string + ByteSize int64 + ErrorMessage string + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time +} + +type ImageTaskRepository interface { + Create(ctx context.Context, task *ImageTask) error + GetByTaskID(ctx context.Context, taskID string) (*ImageTask, error) + MarkRunning(ctx context.Context, taskID string) error + MarkSucceeded(ctx context.Context, taskID, filePath, mimeType string, byteSize int64, expiresAt time.Time) error + MarkFailed(ctx context.Context, taskID, message string, expiresAt time.Time) error + DeleteExpired(ctx context.Context, now time.Time) ([]ImageTask, error) + MarkStaleRunningFailed(ctx context.Context, message string) error +} + +type ImageTaskService struct { + repo ImageTaskRepository + storageDir string + ttl time.Duration + now func() time.Time + startOnce sync.Once +} + +func NewImageTaskService(repo ImageTaskRepository) *ImageTaskService { + return NewImageTaskServiceWithOptions(repo, "", 0) +} + +func NewImageTaskServiceWithOptions(repo ImageTaskRepository, storageDir string, ttl time.Duration) *ImageTaskService { + if ttl <= 0 { + ttl = defaultImageTaskTTL + } + if strings.TrimSpace(storageDir) == "" { + storageDir = filepath.Join(os.TempDir(), "sub2api-image-tasks") + } + return &ImageTaskService{ + repo: repo, + storageDir: storageDir, + ttl: ttl, + now: time.Now, + } +} + +func (s *ImageTaskService) Start(timingWheel *TimingWheelService) { + if s == nil || timingWheel == nil { + return + } + s.startOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _ = s.MarkStaleRunningFailed(ctx) + _, _ = s.CleanupExpired(ctx) + cancel() + timingWheel.ScheduleRecurring("image_task_cleanup", 5*time.Minute, func() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _, _ = s.CleanupExpired(ctx) + }) + }) +} + +func (s *ImageTaskService) CreatePending(ctx context.Context, userID, apiKeyID int64, endpoint, model, prompt string) (*ImageTask, error) { + if s == nil || s.repo == nil { + return nil, errors.New("image task service is unavailable") + } + now := s.now() + task := &ImageTask{ + TaskID: newImageTaskID(), + UserID: userID, + APIKeyID: apiKeyID, + Status: ImageTaskStatusPending, + Endpoint: strings.TrimSpace(endpoint), + Model: strings.TrimSpace(model), + Prompt: strings.TrimSpace(prompt), + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(s.ttl), + } + if err := s.repo.Create(ctx, task); err != nil { + return nil, err + } + return task, nil +} + +func (s *ImageTaskService) GetForUser(ctx context.Context, taskID string, userID int64) (*ImageTask, error) { + task, err := s.repo.GetByTaskID(ctx, strings.TrimSpace(taskID)) + if err != nil { + return nil, err + } + if task == nil || task.UserID != userID { + return nil, ErrImageTaskNotFound + } + if s.now().After(task.ExpiresAt) { + task.Status = ImageTaskStatusExpired + return task, nil + } + return task, nil +} + +func (s *ImageTaskService) GetForAPIKey(ctx context.Context, taskID string, userID, apiKeyID int64) (*ImageTask, error) { + task, err := s.GetForUser(ctx, taskID, userID) + if err != nil { + return nil, err + } + if task.APIKeyID != apiKeyID { + return nil, ErrImageTaskNotFound + } + return task, nil +} + +func (s *ImageTaskService) MarkRunning(ctx context.Context, taskID string) error { + return s.repo.MarkRunning(ctx, taskID) +} + +func (s *ImageTaskService) SaveResult(ctx context.Context, taskID string, data []byte, mimeType string) (*ImageTask, error) { + if len(data) == 0 { + return nil, errors.New("image result is empty") + } + task, err := s.repo.GetByTaskID(ctx, taskID) + if err != nil { + return nil, err + } + if err := os.MkdirAll(s.storageDir, 0o755); err != nil { + return nil, fmt.Errorf("create image task storage: %w", err) + } + mimeType = normalizeImageTaskMimeType(mimeType) + filePath := filepath.Join(s.storageDir, taskID+imageTaskExtension(mimeType)) + if err := os.WriteFile(filePath, data, 0o600); err != nil { + return nil, fmt.Errorf("write image task file: %w", err) + } + expiresAt := s.now().Add(s.ttl) + if err := s.repo.MarkSucceeded(ctx, taskID, filePath, mimeType, int64(len(data)), expiresAt); err != nil { + _ = os.Remove(filePath) + return nil, err + } + task.Status = ImageTaskStatusSucceeded + task.FilePath = filePath + task.MimeType = mimeType + task.ByteSize = int64(len(data)) + task.ExpiresAt = expiresAt + return task, nil +} + +func (s *ImageTaskService) MarkFailed(ctx context.Context, taskID string, err error) error { + message := "image generation failed" + if err != nil && strings.TrimSpace(err.Error()) != "" { + message = err.Error() + } + return s.repo.MarkFailed(ctx, taskID, message, s.now().Add(s.ttl)) +} + +func (s *ImageTaskService) CleanupExpired(ctx context.Context) (int, error) { + tasks, err := s.repo.DeleteExpired(ctx, s.now()) + if err != nil { + return 0, err + } + for _, task := range tasks { + if strings.TrimSpace(task.FilePath) != "" { + _ = os.Remove(task.FilePath) + } + } + return len(tasks), nil +} + +func (s *ImageTaskService) MarkStaleRunningFailed(ctx context.Context) error { + if s == nil || s.repo == nil { + return nil + } + return s.repo.MarkStaleRunningFailed(ctx, "server restarted before task completed") +} + +func newImageTaskID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("img_%d", time.Now().UnixNano()) + } + return "img_" + hex.EncodeToString(b[:]) +} + +func normalizeImageTaskMimeType(mimeType string) string { + mimeType = strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) + switch mimeType { + case "image/jpeg", "image/png", "image/webp": + return mimeType + default: + return "image/png" + } +} + +func imageTaskExtension(mimeType string) string { + switch normalizeImageTaskMimeType(mimeType) { + case "image/jpeg": + return ".jpg" + case "image/webp": + return ".webp" + default: + return ".png" + } +} diff --git a/backend/internal/service/image_task_test.go b/backend/internal/service/image_task_test.go new file mode 100644 index 00000000000..8adc10c32f2 --- /dev/null +++ b/backend/internal/service/image_task_test.go @@ -0,0 +1,166 @@ +package service + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +type imageTaskRepoMemory struct { + tasks map[string]*ImageTask +} + +func newImageTaskRepoMemory() *imageTaskRepoMemory { + return &imageTaskRepoMemory{tasks: make(map[string]*ImageTask)} +} + +func (r *imageTaskRepoMemory) Create(_ context.Context, task *ImageTask) error { + cp := *task + r.tasks[task.TaskID] = &cp + return nil +} + +func (r *imageTaskRepoMemory) GetByTaskID(_ context.Context, taskID string) (*ImageTask, error) { + task, ok := r.tasks[taskID] + if !ok { + return nil, ErrImageTaskNotFound + } + cp := *task + return &cp, nil +} + +func (r *imageTaskRepoMemory) MarkRunning(_ context.Context, taskID string) error { + task, ok := r.tasks[taskID] + if !ok { + return ErrImageTaskNotFound + } + task.Status = ImageTaskStatusRunning + return nil +} + +func (r *imageTaskRepoMemory) MarkSucceeded(_ context.Context, taskID, filePath, mimeType string, byteSize int64, expiresAt time.Time) error { + task, ok := r.tasks[taskID] + if !ok { + return ErrImageTaskNotFound + } + task.Status = ImageTaskStatusSucceeded + task.FilePath = filePath + task.MimeType = mimeType + task.ByteSize = byteSize + task.ExpiresAt = expiresAt + return nil +} + +func (r *imageTaskRepoMemory) MarkFailed(_ context.Context, taskID, message string, expiresAt time.Time) error { + task, ok := r.tasks[taskID] + if !ok { + return ErrImageTaskNotFound + } + task.Status = ImageTaskStatusFailed + task.ErrorMessage = message + task.ExpiresAt = expiresAt + return nil +} + +func (r *imageTaskRepoMemory) DeleteExpired(_ context.Context, now time.Time) ([]ImageTask, error) { + var deleted []ImageTask + for id, task := range r.tasks { + if !now.Before(task.ExpiresAt) { + deleted = append(deleted, *task) + delete(r.tasks, id) + } + } + return deleted, nil +} + +func (r *imageTaskRepoMemory) MarkStaleRunningFailed(_ context.Context, message string) error { + for _, task := range r.tasks { + if task.Status == ImageTaskStatusPending || task.Status == ImageTaskStatusRunning { + task.Status = ImageTaskStatusFailed + task.ErrorMessage = message + } + } + return nil +} + +func TestImageTaskServiceCreatePendingReturnsTaskID(t *testing.T) { + repo := newImageTaskRepoMemory() + svc := NewImageTaskServiceWithOptions(repo, t.TempDir(), time.Hour) + now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC) + svc.now = func() time.Time { return now } + + task, err := svc.CreatePending(context.Background(), 7, 9, "/v1/images/generations", "gpt-image-2", "draw a cat") + require.NoError(t, err) + require.NotEmpty(t, task.TaskID) + require.Equal(t, ImageTaskStatusPending, task.Status) + require.Equal(t, now.Add(time.Hour), task.ExpiresAt) +} + +func TestImageTaskServiceSaveResultWritesTemporaryFileAndMarksSucceeded(t *testing.T) { + repo := newImageTaskRepoMemory() + dir := t.TempDir() + svc := NewImageTaskServiceWithOptions(repo, dir, time.Hour) + + task, err := svc.CreatePending(context.Background(), 7, 9, "/v1/images/generations", "gpt-image-2", "draw a cat") + require.NoError(t, err) + + saved, err := svc.SaveResult(context.Background(), task.TaskID, []byte("png-bytes"), "image/png") + require.NoError(t, err) + require.Equal(t, ImageTaskStatusSucceeded, saved.Status) + require.Equal(t, filepath.Join(dir, task.TaskID+".png"), saved.FilePath) + require.Equal(t, int64(9), saved.ByteSize) + + got, err := os.ReadFile(saved.FilePath) + require.NoError(t, err) + require.Equal(t, []byte("png-bytes"), got) +} + +func TestImageTaskServiceGetForUserHidesOtherUsersTasks(t *testing.T) { + repo := newImageTaskRepoMemory() + svc := NewImageTaskServiceWithOptions(repo, t.TempDir(), time.Hour) + task, err := svc.CreatePending(context.Background(), 7, 9, "/v1/images/generations", "gpt-image-2", "draw a cat") + require.NoError(t, err) + + _, err = svc.GetForUser(context.Background(), task.TaskID, 8) + require.ErrorIs(t, err, ErrImageTaskNotFound) +} + +func TestImageTaskServiceGetForAPIKeyRequiresSameAPIKey(t *testing.T) { + repo := newImageTaskRepoMemory() + svc := NewImageTaskServiceWithOptions(repo, t.TempDir(), time.Hour) + task, err := svc.CreatePending(context.Background(), 7, 9, "/v1/images/generations", "gpt-image-2", "draw a cat") + require.NoError(t, err) + + _, err = svc.GetForAPIKey(context.Background(), task.TaskID, 7, 10) + require.ErrorIs(t, err, ErrImageTaskNotFound) + + got, err := svc.GetForAPIKey(context.Background(), task.TaskID, 7, 9) + require.NoError(t, err) + require.Equal(t, task.TaskID, got.TaskID) +} + +func TestImageTaskServiceCleanupExpiredDeletesFilesAndRecords(t *testing.T) { + repo := newImageTaskRepoMemory() + dir := t.TempDir() + svc := NewImageTaskServiceWithOptions(repo, dir, time.Hour) + now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC) + svc.now = func() time.Time { return now } + task, err := svc.CreatePending(context.Background(), 7, 9, "/v1/images/generations", "gpt-image-2", "draw a cat") + require.NoError(t, err) + saved, err := svc.SaveResult(context.Background(), task.TaskID, []byte("png-bytes"), "image/png") + require.NoError(t, err) + + svc.now = func() time.Time { return now.Add(time.Hour + time.Second) } + deleted, err := svc.CleanupExpired(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, deleted) + require.NoFileExists(t, saved.FilePath) + + _, err = repo.GetByTaskID(context.Background(), task.TaskID) + require.True(t, errors.Is(err, ErrImageTaskNotFound)) +} diff --git a/backend/internal/service/openai_account_scheduler.go b/backend/internal/service/openai_account_scheduler.go index 7a0a6636ec8..ec369a1f5b4 100644 --- a/backend/internal/service/openai_account_scheduler.go +++ b/backend/internal/service/openai_account_scheduler.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "hash/fnv" + "log/slog" "math" "sort" "strconv" @@ -345,7 +346,7 @@ func (s *defaultOpenAIAccountScheduler) selectBySessionHash( _ = s.service.deleteStickySessionAccountID(ctx, req.GroupID, sessionHash) return nil, nil } - if !s.isAccountRequestCompatible(account, req) { + if !s.isAccountRequestCompatible(ctx, account, req) { return nil, nil } if !s.isAccountTransportCompatible(account, req.RequiredTransport) { @@ -621,7 +622,7 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( fmt.Sprintf("Privacy not set, required by group [%s]", schedGroup.Name)) continue } - if !s.isAccountRequestCompatible(account, req) { + if !s.isAccountRequestCompatible(ctx, account, req) { continue } if !s.isAccountTransportCompatible(account, req.RequiredTransport) { @@ -822,11 +823,11 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( for i := 0; i < len(selectionOrder); i++ { candidate := selectionOrder[i] fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.RequestedModel, false) - if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) { + if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) { continue } fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, fresh, req.RequestedModel, false) - if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) { + if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) { continue } if req.RequireCompact && openAICompactSupportTier(fresh) == 0 { @@ -853,11 +854,11 @@ func (s *defaultOpenAIAccountScheduler) selectByLoadBalance( // WaitPlan.MaxConcurrency 使用 Concurrency(非 EffectiveLoadFactor),因为 WaitPlan 控制的是 Redis 实际并发槽位等待。 for _, candidate := range selectionOrder { fresh := s.service.resolveFreshSchedulableOpenAIAccount(ctx, candidate.account, req.RequestedModel, false) - if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) { + if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) { continue } fresh = s.service.recheckSelectedOpenAIAccountFromDB(ctx, fresh, req.RequestedModel, false) - if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(fresh, req) { + if fresh == nil || !s.isAccountTransportCompatible(fresh, req.RequiredTransport) || !s.isAccountRequestCompatible(ctx, fresh, req) { continue } if req.RequireCompact && openAICompactSupportTier(fresh) == 0 { @@ -888,13 +889,18 @@ func (s *defaultOpenAIAccountScheduler) isAccountTransportCompatible(account *Ac return s.service.isOpenAIAccountTransportCompatible(account, requiredTransport) } -func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatible(account *Account, req OpenAIAccountScheduleRequest) bool { +func (s *defaultOpenAIAccountScheduler) isAccountRequestCompatible(ctx context.Context, account *Account, req OpenAIAccountScheduleRequest) bool { if account == nil { return false } if req.RequestedModel != "" && !account.IsModelSupported(req.RequestedModel) { return false } + if req.GroupID != nil && s != nil && s.service != nil && + s.service.needsUpstreamChannelRestrictionCheck(ctx, req.GroupID) && + s.service.isUpstreamModelRestrictedByChannel(ctx, *req.GroupID, account, req.RequestedModel, req.RequireCompact) { + return false + } return account.SupportsOpenAIImageCapability(req.RequiredImageCapability) } @@ -1035,7 +1041,7 @@ func (s *OpenAIGatewayService) SelectAccountWithSchedulerForImages( } // 如果要求 native 能力(如指定了模型)但没有可用的 APIKey 账号,回退到 basic(OAuth 账号) if requiredCapability == OpenAIImagesCapabilityNative { - return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, requestedModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, OpenAIImagesCapabilityBasic, false) + return s.selectAccountWithScheduler(ctx, groupID, "", sessionHash, openAIImagesResponsesMainModel, excludedIDs, OpenAIUpstreamTransportHTTPSSE, OpenAIImagesCapabilityBasic, false) } return selection, decision, err } @@ -1106,6 +1112,13 @@ func (s *OpenAIGatewayService) selectAccountWithScheduler( } } + if s.checkChannelPricingRestriction(ctx, groupID, requestedModel) { + slog.Warn("channel pricing restriction blocked request", + "group_id", derefGroupID(groupID), + "model", requestedModel) + return nil, decision, fmt.Errorf("%w supporting model: %s (channel pricing restriction)", ErrNoAvailableAccounts, requestedModel) + } + var stickyAccountID int64 if sessionHash != "" && s.cache != nil { if accountID, err := s.getStickySessionAccountID(ctx, groupID, sessionHash); err == nil && accountID > 0 { diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index 0950ee54767..eef3dd7e428 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -393,6 +393,51 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_Require require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) } +func TestOpenAIGatewayService_SelectAccountWithSchedulerForImages_NativeFallsBackToOAuthWithoutImageModelMapping(t *testing.T) { + resetOpenAIAdvancedSchedulerSettingCacheForTest() + + ctx := context.Background() + groupID := int64(10110) + accounts := []Account{ + { + ID: 36031, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Priority: 0, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + openAIImagesResponsesMainModel: openAIImagesResponsesMainModel, + }, + }, + }, + } + cfg := &config.Config{} + cfg.Gateway.Scheduling.LoadBatchEnabled = false + svc := &OpenAIGatewayService{ + accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts}, + cache: &schedulerTestGatewayCache{}, + cfg: cfg, + concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}), + } + + selection, decision, err := svc.SelectAccountWithSchedulerForImages( + ctx, + &groupID, + "", + "gpt-image-2", + nil, + OpenAIImagesCapabilityNative, + ) + require.NoError(t, err) + require.NotNil(t, selection) + require.NotNil(t, selection.Account) + require.Equal(t, int64(36031), selection.Account.ID) + require.Equal(t, openAIAccountScheduleLayerLoadBalance, decision.Layer) +} + func TestOpenAIGatewayService_SelectAccountWithScheduler_EnabledUsesAdvancedPreviousResponseRouting(t *testing.T) { resetOpenAIAdvancedSchedulerSettingCacheForTest() diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index de98b50d1fe..a3b69dee5fd 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -69,6 +69,13 @@ type codexTransformResult struct { PromptCacheKey string } +type codexOAuthTransformOptions struct { + IsCodexCLI bool + IsCompact bool + SkipDefaultInstructions bool + PreserveToolCallIDs bool +} + const ( codexImageGenerationBridgeMarker = "" codexImageGenerationBridgeText = codexImageGenerationBridgeMarker + "\nWhen the user asks for raster image generation or editing, use the OpenAI Responses native `image_generation` tool attached to this request. The local Codex client may not expose an `image_gen` namespace, but that does not mean image generation is unavailable. Do not ask the user to switch to CLI fallback solely because `image_gen` is absent.\n" @@ -94,6 +101,13 @@ var openAICodexOAuthUnsupportedFields = append([]string{ }, openAIChatGPTInternalUnsupportedFields...) func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact bool) codexTransformResult { + return applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{ + IsCodexCLI: isCodexCLI, + IsCompact: isCompact, + }) +} + +func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuthTransformOptions) codexTransformResult { result := codexTransformResult{} // 工具续链需求会影响存储策略与 input 过滤逻辑。 needsToolContinuation := NeedsToolContinuation(reqBody) @@ -111,7 +125,7 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact result.NormalizedModel = normalizedModel } - if isCompact { + if opts.IsCompact { if _, ok := reqBody["store"]; ok { delete(reqBody, "store") result.Modified = true @@ -183,6 +197,10 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact if v, ok := reqBody["prompt_cache_key"].(string); ok { result.PromptCacheKey = strings.TrimSpace(v) + if isOpenAICompatMessagesBridgeRequestBody(reqBody) { + delete(reqBody, "prompt_cache_key") + result.Modified = true + } } // 提取 input 中 role:"system" 消息至 instructions(OAuth 上游不支持 system role)。 @@ -191,7 +209,7 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact } // instructions 处理逻辑:根据是否是 Codex CLI 分别调用不同方法 - if applyInstructions(reqBody, isCodexCLI) { + if !opts.SkipDefaultInstructions && applyInstructions(reqBody, opts.IsCodexCLI) { result.Modified = true } if isCodexSparkModel(normalizedModel) && applyCodexSparkImageUnsupportedInstructions(reqBody) { @@ -208,7 +226,10 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact input = normalizedInput result.Modified = true } - input = filterCodexInput(input, needsToolContinuation) + input = filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: needsToolContinuation, + PreserveCallIDs: opts.PreserveToolCallIDs, + }) reqBody["input"] = input result.Modified = true } else if inputStr, ok := reqBody["input"].(string); ok { @@ -485,12 +506,14 @@ func normalizeKnownCodexModel(model string) (string, bool) { return model, true } - modelID := model - if strings.Contains(modelID, "/") { - parts := strings.Split(modelID, "/") - modelID = parts[len(parts)-1] - } + modelID := lastOpenAIModelSegment(model) + if normalized := canonicalizeOpenAIModelAliasSpelling(modelID); normalized != "" { + modelID = normalized + } + if mapped := normalizeKnownOpenAICodexModel(modelID); mapped != "" { + return mapped, true + } key := codexModelLookupKey(modelID) if key == "" { return "", false @@ -851,7 +874,7 @@ func getNormalizedCodexModel(modelID string) string { } // extractTextFromContent extracts plain text from a content value that is either -// a Go string or a []any of content-part maps with type:"text". +// a Go string or a []any of text-like content-part maps. func extractTextFromContent(content any) string { switch v := content.(type) { case string: @@ -863,7 +886,8 @@ func extractTextFromContent(content any) string { if !ok { continue } - if t, _ := m["type"].(string); t == "text" { + switch t, _ := m["type"].(string); t { + case "text", "input_text", "output_text": if text, ok := m["text"].(string); ok { parts = append(parts, text) } @@ -917,6 +941,28 @@ func extractSystemMessagesFromInput(reqBody map[string]any) bool { return true } +func extractPromptLikeInstructionsFromInput(reqBody map[string]any) string { + input, ok := reqBody["input"].([]any) + if !ok || len(input) == 0 { + return "" + } + var texts []string + for _, item := range input { + m, ok := item.(map[string]any) + if !ok { + continue + } + role, _ := m["role"].(string) + switch role { + case "developer", "system": + if text := strings.TrimSpace(extractTextFromContent(m["content"])); text != "" { + texts = append(texts, text) + } + } + } + return strings.Join(texts, "\n\n") +} + // applyInstructions 处理 instructions 字段:仅在 instructions 为空时填充默认值。 func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool { if !isInstructionsEmpty(reqBody) { @@ -943,9 +989,20 @@ func isInstructionsEmpty(reqBody map[string]any) bool { return strings.TrimSpace(str) == "" } +type codexInputFilterOptions struct { + PreserveReferences bool + PreserveCallIDs bool +} + // filterCodexInput 按需过滤 item_reference 与 id。 // preserveReferences 为 true 时保持引用与 id,以满足续链请求对上下文的依赖。 func filterCodexInput(input []any, preserveReferences bool) []any { + return filterCodexInputWithOptions(input, codexInputFilterOptions{ + PreserveReferences: preserveReferences, + }) +} + +func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []any { filtered := make([]any, 0, len(input)) for _, item := range input { m, ok := item.(map[string]any) @@ -966,6 +1023,9 @@ func filterCodexInput(input []any, preserveReferences bool) []any { // 仅修正真正的 tool/function call 标识,避免误改普通 message/reasoning id; // 若 item_reference 指向 legacy call_* 标识,则仅修正该引用本身。 fixCallIDPrefix := func(id string) string { + if opts.PreserveCallIDs { + return id + } if id == "" || strings.HasPrefix(id, "fc") { return id } @@ -976,7 +1036,7 @@ func filterCodexInput(input []any, preserveReferences bool) []any { } if typ == "item_reference" { - if !preserveReferences { + if !opts.PreserveReferences { continue } newItem := make(map[string]any, len(m)) @@ -1044,7 +1104,7 @@ func filterCodexInput(input []any, preserveReferences bool) []any { } } - if !preserveReferences { + if !opts.PreserveReferences { ensureCopy() delete(newItem, "id") } diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 87bb71628e2..9c72760aa2b 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -44,6 +44,39 @@ func TestApplyCodexOAuthTransform_ToolContinuationPreservesInput(t *testing.T) { require.Equal(t, "fc1", second["call_id"]) } +func TestApplyCodexOAuthTransform_MessagesBridgePromptCacheKeyIsHeaderOnly(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.5", + "prompt_cache_key": "anthropic-metadata-session-1", + "input": []any{ + map[string]any{ + "type": "message", + "role": "developer", + "content": []any{ + map[string]any{ + "type": "input_text", + "text": openAICompatClaudeCodeTodoGuardMarker, + }, + }, + }, + map[string]any{ + "type": "message", + "role": "user", + "content": "hello", + }, + }, + } + + result := applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{ + SkipDefaultInstructions: true, + PreserveToolCallIDs: true, + }) + + require.Equal(t, "anthropic-metadata-session-1", result.PromptCacheKey) + require.True(t, result.Modified) + require.NotContains(t, reqBody, "prompt_cache_key") +} + func TestApplyCodexOAuthTransform_ToolContinuationPreservesNativeMessageAndReasoningIDs(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.2", @@ -804,15 +837,25 @@ func TestApplyCodexOAuthTransform_EmptyInput(t *testing.T) { func TestNormalizeCodexModel_Gpt53(t *testing.T) { cases := map[string]string{ "gpt-5.4": "gpt-5.4", + "gpt5.5": "gpt-5.5", + "openai/gpt5.5": "gpt-5.5", + "gpt5.4": "gpt-5.4", "gpt-5.4-high": "gpt-5.4", "gpt-5.4-chat-latest": "gpt-5.4", "gpt 5.4": "gpt-5.4", "gpt-5.4-mini": "gpt-5.4-mini", + "gpt5.4-mini": "gpt-5.4-mini", + "gpt5.4mini": "gpt-5.4-mini", "gpt 5.4 mini": "gpt-5.4-mini", "gpt-5.3": "gpt-5.3-codex", + "gpt5.3": "gpt-5.3-codex", "gpt-5.3-codex": "gpt-5.3-codex", + "gpt5.3-codex": "gpt-5.3-codex", + "gpt5.3codex": "gpt-5.3-codex", "gpt-5.3-codex-xhigh": "gpt-5.3-codex", "gpt-5.3-codex-spark": "gpt-5.3-codex-spark", + "gpt5.3-codex-spark": "gpt-5.3-codex-spark", + "gpt5.3codexspark": "gpt-5.3-codex-spark", "gpt 5.3 codex spark": "gpt-5.3-codex-spark", "gpt-5.3-codex-spark-high": "gpt-5.3-codex-spark", "gpt-5.3-codex-spark-xhigh": "gpt-5.3-codex-spark", diff --git a/backend/internal/service/openai_compat_model_test.go b/backend/internal/service/openai_compat_model_test.go index 840784bfc53..a897e219fd4 100644 --- a/backend/internal/service/openai_compat_model_test.go +++ b/backend/internal/service/openai_compat_model_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "fmt" "io" "net/http" "net/http/httptest" @@ -145,7 +146,10 @@ func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T Body: io.NopCloser(strings.NewReader(upstreamBody)), }} - svc := &OpenAIGatewayService{httpUpstream: upstream} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } account := &Account{ ID: 1, Name: "openai-oauth", @@ -179,6 +183,927 @@ func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T t.Logf("response body: %s", rec.Body.String()) } +func TestForwardAsAnthropic_InjectsPromptCacheKeyForAPIKeyMessagesDispatch(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":{"user_id":"claude-session-1"},"messages":[{"role":"user","content":"hello"}],"stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.3-codex","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7,"input_tokens_details":{"cached_tokens":3}}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_cache_key"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.3-codex") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "stable-cache-key", gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()) + require.Equal(t, "gpt-5.3-codex", gjson.GetBytes(upstream.lastBody, "model").String()) + require.Equal(t, 3, result.Usage.CacheReadInputTokens) +} + +func TestForwardAsAnthropic_AutoDerivesPromptCacheKeyWhenMessagesDispatchHasNoSessionID(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":"You are helpful.","messages":[{"role":"user","content":"open repo"}],"stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.3-codex","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7,"input_tokens_details":{"cached_tokens":3}}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_auto_cache_key"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.3-codex") + require.NoError(t, err) + require.NotNil(t, result) + cacheKey := gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String() + require.NotEmpty(t, cacheKey) + require.True(t, strings.HasPrefix(cacheKey, "anthropic-digest-")) + require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, cacheKey)), upstream.lastReq.Header.Get("session_id")) +} + +func TestForwardAsAnthropic_DoesNotAutoDerivePromptCacheKeyForNonCodexModel(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-4o","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_no_cache_key"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-4o") + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) + require.Empty(t, upstream.lastReq.Header.Get("session_id")) +} + +func TestForwardAsAnthropic_TrimsFullReplayOnlyForCodexCompatModels(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+3) + for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ { + messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`) + } + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`) + + run := func(t *testing.T, mappedModel string) []byte { + t.Helper() + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstreamBody := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"` + mappedModel + `","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`, + "", + "data: [DONE]", + "", + }, "\n") + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_trim"}}, + Body: io.NopCloser(strings.NewReader(upstreamBody)), + }} + + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", mappedModel) + require.NoError(t, err) + require.NotNil(t, result) + return upstream.lastBody + } + + codexBody := run(t, "gpt-5.3-codex") + require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+1), gjson.GetBytes(codexBody, "input.#").Int()) + require.Equal(t, "developer", gjson.GetBytes(codexBody, "input.0.role").String()) + require.Contains(t, gjson.GetBytes(codexBody, "input.0.content.0.text").String(), "") + require.Equal(t, "message-03", gjson.GetBytes(codexBody, "input.1.content.0.text").String()) + require.Equal(t, "message-14", gjson.GetBytes(codexBody, "input.12.content.0.text").String()) + + nonCompatBody := run(t, "gpt-4o") + require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+3), gjson.GetBytes(nonCompatBody, "input.#").Int()) + require.Equal(t, "message-00", gjson.GetBytes(nonCompatBody, "input.0.content.0.text").String()) +} + +func TestForwardAsAnthropic_OAuthCompatKeepsFullReplayForCacheGrowth(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+3) + for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ { + messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`) + } + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_trim", "gpt-5.4")} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+4), gjson.GetBytes(upstream.lastBody, "input.#").Int()) + require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String()) + require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String(), "") + require.Equal(t, "message-00", gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String()) + require.Equal(t, "message-14", gjson.GetBytes(upstream.lastBody, "input.15.content.0.text").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) +} + +func TestForwardAsAnthropic_AttachesPreviousResponseIDForCompatContinuation(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } + + firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":false}`) + upstream.resp = openAICompatSSECompletedResponse("resp_first", "gpt-5.3-codex") + firstRec := httptest.NewRecorder() + firstCtx, _ := gin.CreateTestContext(firstRec) + firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody)) + firstCtx.Request.Header.Set("Content-Type", "application/json") + + firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.3-codex") + require.NoError(t, err) + require.NotNil(t, firstResult) + require.Equal(t, "resp_first", firstResult.ResponseID) + require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) + + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + upstream.resp = openAICompatSSECompletedResponse("resp_second", "gpt-5.3-codex") + secondRec := httptest.NewRecorder() + secondCtx, _ := gin.CreateTestContext(secondRec) + secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody)) + secondCtx.Request.Header.Set("Content-Type", "application/json") + + secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.3-codex") + require.NoError(t, err) + require.NotNil(t, secondResult) + require.Equal(t, "resp_second", secondResult.ResponseID) + require.Equal(t, "resp_first", gjson.GetBytes(upstream.lastBody, "previous_response_id").String()) + require.Equal(t, int64(2), gjson.GetBytes(upstream.lastBody, "input.#").Int()) + require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String()) + require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String(), "") + require.Equal(t, "second", gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String()) +} + +func TestForwardAsAnthropic_ReplaysWithoutContinuationWhenPreviousResponseMissing(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } + + svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_missing") + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + upstream.responses = []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_prev_missing"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"code":"previous_response_not_found","message":"previous response not found"}}`)), + }, + openAICompatSSECompletedResponse("resp_replayed", "gpt-5.3-codex"), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody)) + c.Request.Header.Set("Content-Type", "application/json") + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, secondBody, "stable-cache-key", "gpt-5.3-codex") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "resp_replayed", result.ResponseID) + require.Len(t, upstream.requests, 2) + require.Equal(t, "resp_missing", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) + require.Equal(t, int64(4), gjson.GetBytes(upstream.bodies[1], "input.#").Int()) + require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[1], "input.0.role").String()) + require.Contains(t, gjson.GetBytes(upstream.bodies[1], "input.0.content.0.text").String(), "") + require.Equal(t, "first", gjson.GetBytes(upstream.bodies[1], "input.1.content.0.text").String()) + require.Equal(t, "second", gjson.GetBytes(upstream.bodies[1], "input.3.content.0.text").String()) +} + +func TestForwardAsAnthropic_DisablesAPIKeyContinuationWhenUpstreamRequiresWebSocketV2(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } + + svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_http_unsupported") + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + upstream.responses = []*http.Response{ + { + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_prev_http_unsupported"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is only supported on Responses WebSocket v2","type":"invalid_request_error"}}`)), + }, + openAICompatSSECompletedResponse("resp_replayed", "gpt-5.5"), + openAICompatSSECompletedResponse("resp_later", "gpt-5.5"), + } + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "resp_replayed", result.ResponseID) + require.Len(t, upstream.requests, 2) + require.Equal(t, "resp_http_unsupported", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) + + laterRec := httptest.NewRecorder() + laterCtx, _ := gin.CreateTestContext(laterRec) + laterCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + laterCtx.Request.Header.Set("Content-Type", "application/json") + + laterResult, err := svc.ForwardAsAnthropic(context.Background(), laterCtx, account, body, "stable-cache-key", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, laterResult) + require.Equal(t, "resp_later", laterResult.ResponseID) + require.Len(t, upstream.requests, 3) + require.False(t, gjson.GetBytes(upstream.bodies[2], "previous_response_id").Exists()) +} + +func TestForwardAsAnthropic_APIKeyMetadataSessionSurvivesChangingCacheControlAnchorAfterContinuationDisabled(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + metadata := `{"user_id":"{\"device_id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"account_uuid\":\"\",\"session_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}"}` + firstBody := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":16,"metadata":` + metadata + `,"system":[{"type":"text","text":"project docs","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"}],"stream":false}`) + messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+4) + messages = append(messages, `{"role":"user","content":[{"type":"text","text":"rewritten context","cache_control":{"type":"ephemeral"}}]}`) + for i := 1; i < openAICompatAnthropicReplayMaxTailMessages+4; i++ { + messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`) + } + secondBody := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":16,"metadata":` + metadata + `,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`) + + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + openAICompatSSECompletedResponse("resp_first", "gpt-5.4-mini"), + openAICompatSSECompletedResponse("resp_second", "gpt-5.4-mini"), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } + + firstRec := httptest.NewRecorder() + firstCtx, _ := gin.CreateTestContext(firstRec) + firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody)) + firstCtx.Request.Header.Set("Content-Type", "application/json") + + firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.4-mini") + require.NoError(t, err) + require.NotNil(t, firstResult) + firstKey := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String() + require.NotEmpty(t, firstKey) + require.True(t, strings.HasPrefix(firstKey, "anthropic-metadata-")) + + svc.disableOpenAICompatSessionContinuation(context.Background(), nil, account, firstKey) + + secondRec := httptest.NewRecorder() + secondCtx, _ := gin.CreateTestContext(secondRec) + secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody)) + secondCtx.Request.Header.Set("Content-Type", "application/json") + + secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.4-mini") + require.NoError(t, err) + require.NotNil(t, secondResult) + require.Len(t, upstream.requests, 2) + require.Equal(t, firstKey, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) + require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+5), gjson.GetBytes(upstream.bodies[1], "input.#").Int()) + require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[1], "input.0.role").String()) + require.Contains(t, gjson.GetBytes(upstream.bodies[1], "input.0.content.0.text").String(), "") + require.Equal(t, "rewritten context", gjson.GetBytes(upstream.bodies[1], "input.1.content.0.text").String()) + require.Equal(t, "message-15", gjson.GetBytes(upstream.bodies[1], "input.16.content.0.text").String()) +} + +func TestForwardAsAnthropic_DoesNotAttachPreviousResponseIDForOAuthCompat(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_next", "gpt-5.4")} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_oauth_prev") + + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.4") + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) +} + +func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + firstResp := openAICompatSSECompletedResponse("resp_oauth_first", "gpt-5.4") + firstResp.Header.Set("x-codex-turn-state", "turn_state_first") + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + firstResp, + openAICompatSSECompletedResponse("resp_oauth_second", "gpt-5.4"), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + + firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":false}`) + firstRec := httptest.NewRecorder() + firstCtx, _ := gin.CreateTestContext(firstRec) + firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody)) + firstCtx.Request.Header.Set("Content-Type", "application/json") + + firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.4") + require.NoError(t, err) + require.NotNil(t, firstResult) + require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) + require.Empty(t, upstream.requests[0].Header.Get("OpenAI-Beta")) + require.Empty(t, upstream.requests[0].Header.Get("originator")) + + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + secondRec := httptest.NewRecorder() + secondCtx, _ := gin.CreateTestContext(secondRec) + secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody)) + secondCtx.Request.Header.Set("Content-Type", "application/json") + + secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.4") + require.NoError(t, err) + require.NotNil(t, secondResult) + require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state")) + require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, "stable-cache-key")), upstream.requests[1].Header.Get("session_id")) + require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) + require.Empty(t, upstream.requests[1].Header.Get("OpenAI-Beta")) + require.Empty(t, upstream.requests[1].Header.Get("originator")) + require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) +} + +func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + firstResp := openAICompatSSECompletedResponse("resp_oauth_digest_first", "gpt-5.4") + firstResp.Header.Set("x-codex-turn-state", "turn_state_digest_first") + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + firstResp, + openAICompatSSECompletedResponse("resp_oauth_digest_second", "gpt-5.4"), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + + firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":false}`) + firstRec := httptest.NewRecorder() + firstCtx, _ := gin.CreateTestContext(firstRec) + firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody)) + firstCtx.Request.Header.Set("Content-Type", "application/json") + + firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.4") + require.NoError(t, err) + require.NotNil(t, firstResult) + firstSessionID := upstream.requests[0].Header.Get("session_id") + require.NotEmpty(t, firstSessionID) + require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) + require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists()) + + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + secondRec := httptest.NewRecorder() + secondCtx, _ := gin.CreateTestContext(secondRec) + secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody)) + secondCtx.Request.Header.Set("Content-Type", "application/json") + + secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.4") + require.NoError(t, err) + require.NotNil(t, secondResult) + require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id")) + require.Equal(t, "turn_state_digest_first", upstream.requests[1].Header.Get("x-codex-turn-state")) + require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) + require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) +} + +func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesDigestPrefixRewrite(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + firstResp := openAICompatSSECompletedResponse("resp_oauth_metadata_first", "gpt-5.5") + firstResp.Header.Set("x-codex-turn-state", "turn_state_metadata_first") + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + firstResp, + openAICompatSSECompletedResponse("resp_oauth_metadata_second", "gpt-5.5"), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + metadata := `{"user_id":"{\"device_id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"account_uuid\":\"\",\"session_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}"}` + + firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"messages":[{"role":"user","content":"first plan"}],"stream":false}`) + firstRec := httptest.NewRecorder() + firstCtx, _ := gin.CreateTestContext(firstRec) + firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody)) + firstCtx.Request.Header.Set("Content-Type", "application/json") + + firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, firstResult) + firstSessionID := upstream.requests[0].Header.Get("session_id") + require.NotEmpty(t, firstSessionID) + require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) + require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists()) + + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"messages":[{"role":"user","content":"rewritten plan"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + secondRec := httptest.NewRecorder() + secondCtx, _ := gin.CreateTestContext(secondRec) + secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody)) + secondCtx.Request.Header.Set("Content-Type", "application/json") + + secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, secondResult) + require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id")) + require.Equal(t, "turn_state_metadata_first", upstream.requests[1].Header.Get("x-codex-turn-state")) + require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) + require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) +} + +func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesChangingCacheControlAnchor(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + firstResp := openAICompatSSECompletedResponse("resp_oauth_cache_anchor_first", "gpt-5.5") + firstResp.Header.Set("x-codex-turn-state", "turn_state_cache_anchor_first") + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + firstResp, + openAICompatSSECompletedResponse("resp_oauth_cache_anchor_second", "gpt-5.5"), + }} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + metadata := `{"user_id":"{\"device_id\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"account_uuid\":\"\",\"session_id\":\"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\"}"}` + + firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"system":[{"type":"text","text":"anchor one","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"}],"stream":false}`) + firstRec := httptest.NewRecorder() + firstCtx, _ := gin.CreateTestContext(firstRec) + firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody)) + firstCtx.Request.Header.Set("Content-Type", "application/json") + + firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, firstResult) + firstSessionID := upstream.requests[0].Header.Get("session_id") + require.NotEmpty(t, firstSessionID) + require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state")) + require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists()) + + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"system":[{"type":"text","text":"anchor two","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + secondRec := httptest.NewRecorder() + secondCtx, _ := gin.CreateTestContext(secondRec) + secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody)) + secondCtx.Request.Header.Set("Content-Type", "application/json") + + secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, secondResult) + require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id")) + require.Equal(t, "turn_state_cache_anchor_first", upstream.requests[1].Header.Get("x-codex-turn-state")) + require.Empty(t, upstream.requests[1].Header.Get("conversation_id")) + require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists()) + require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists()) +} + +func TestForwardAsAnthropic_OAuthKeepsSystemAsDeveloperInput(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_system", "gpt-5.4")} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":[{"type":"text","text":"project instructions","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String()) + require.Equal(t, "input_text", gjson.GetBytes(upstream.lastBody, "input.0.content.0.type").String()) + require.Equal(t, "project instructions", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String()) + instructions := gjson.GetBytes(upstream.lastBody, "instructions") + require.True(t, instructions.Exists()) + require.Empty(t, instructions.String()) + require.Empty(t, upstream.requests[0].Header.Get("OpenAI-Beta")) + require.Empty(t, upstream.requests[0].Header.Get("originator")) +} + +func TestForwardAsAnthropic_OAuthAddsClaudeCodeTodoGuardForCompatModel(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_todo_guard", "gpt-5.5")} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":"project instructions","messages":[{"role":"user","content":"review files"}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.5") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String()) + require.Equal(t, "project instructions", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String()) + require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.1.role").String()) + require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String(), "") + require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.2.role").String()) +} + +func TestForwardAsAnthropic_OAuthPreservesClaudeCodeToolCallID(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_tool", "gpt-5.4")} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + } + + body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"list files"},{"role":"assistant","content":[{"type":"tool_use","id":"toolu_123","name":"Bash","input":{"command":"ls"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_123","content":"ok"}]}],"tools":[{"name":"Bash","description":"run shell","input_schema":{"type":"object","properties":{"command":{"type":"string"}}}}],"stream":false}`) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + + result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.4") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "toolu_123", gjson.GetBytes(upstream.lastBody, `input.#(type=="function_call").call_id`).String()) + require.Equal(t, "toolu_123", gjson.GetBytes(upstream.lastBody, `input.#(type=="function_call_output").call_id`).String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool()) + require.Equal(t, "medium", gjson.GetBytes(upstream.lastBody, "text.verbosity").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "tools.0.strict").Bool()) +} + +func TestForwardAsAnthropic_StoresStreamingResponseIDWithoutUsage(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{} + svc := &OpenAIGatewayService{ + httpUpstream: upstream, + cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}}, + } + account := &Account{ + ID: 1, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": "https://api.openai.com/v1", + }, + } + + firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":true}`) + upstream.resp = openAICompatSSEResponseWithoutUsage("resp_stream_first", "gpt-5.3-codex") + firstRec := httptest.NewRecorder() + firstCtx, _ := gin.CreateTestContext(firstRec) + firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody)) + firstCtx.Request.Header.Set("Content-Type", "application/json") + + firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.3-codex") + require.NoError(t, err) + require.NotNil(t, firstResult) + require.Equal(t, "resp_stream_first", firstResult.ResponseID) + + secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`) + upstream.resp = openAICompatSSECompletedResponse("resp_stream_second", "gpt-5.3-codex") + secondRec := httptest.NewRecorder() + secondCtx, _ := gin.CreateTestContext(secondRec) + secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody)) + secondCtx.Request.Header.Set("Content-Type", "application/json") + + secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.3-codex") + require.NoError(t, err) + require.NotNil(t, secondResult) + require.Equal(t, "resp_stream_first", gjson.GetBytes(upstream.lastBody, "previous_response_id").String()) +} + +func openAICompatSSECompletedResponse(responseID, model string) *http.Response { + body := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"` + responseID + `","object":"response","model":"` + model + `","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`, + "", + "data: [DONE]", + "", + }, "\n") + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_continuation"}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func openAICompatSSEResponseWithoutUsage(responseID, model string) *http.Response { + body := strings.Join([]string{ + `data: {"type":"response.completed","response":{"id":"` + responseID + `","object":"response","model":"` + model + `","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}]}}`, + "", + "data: [DONE]", + "", + }, "\n") + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_" + responseID}}, + Body: io.NopCloser(strings.NewReader(body)), + } +} + func TestForwardAsAnthropic_ForcedCodexInstructionsTemplatePrependsRenderedInstructions(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) diff --git a/backend/internal/service/openai_compat_prompt_cache_key.go b/backend/internal/service/openai_compat_prompt_cache_key.go index fcd27f1921b..de227ff17ab 100644 --- a/backend/internal/service/openai_compat_prompt_cache_key.go +++ b/backend/internal/service/openai_compat_prompt_cache_key.go @@ -1,7 +1,9 @@ package service import ( + "crypto/sha256" "encoding/json" + "fmt" "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" @@ -16,12 +18,8 @@ func shouldAutoInjectPromptCacheKeyForCompat(model string) bool { if !strings.Contains(trimmed, "gpt-5") && !strings.Contains(trimmed, "codex") { return false } - switch normalizeCodexModel(trimmed) { - case "gpt-5.4", "gpt-5.3-codex", "gpt-5.3-codex-spark": - return true - default: - return false - } + normalized := strings.TrimSpace(strings.ToLower(normalizeCodexModel(trimmed))) + return strings.HasPrefix(normalized, "gpt-5") || strings.Contains(normalized, "codex") } func deriveCompatPromptCacheKey(req *apicompat.ChatCompletionsRequest, mappedModel string) string { @@ -71,6 +69,102 @@ func deriveCompatPromptCacheKey(req *apicompat.ChatCompletionsRequest, mappedMod return compatPromptCacheKeyPrefix + hashSensitiveValueForLog(strings.Join(seedParts, "|")) } +func deriveAnthropicCompatPromptCacheKey(req *apicompat.AnthropicRequest, mappedModel string) string { + if req == nil { + return "" + } + if anchorKey := deriveAnthropicCacheControlPromptCacheKey(req); anchorKey != "" { + return anchorKey + } + + normalizedModel := normalizeCodexModel(strings.TrimSpace(mappedModel)) + if normalizedModel == "" { + normalizedModel = normalizeCodexModel(strings.TrimSpace(req.Model)) + } + if normalizedModel == "" { + normalizedModel = strings.TrimSpace(req.Model) + } + + seedParts := []string{"model=" + normalizedModel} + if req.OutputConfig != nil && strings.TrimSpace(req.OutputConfig.Effort) != "" { + seedParts = append(seedParts, "effort="+strings.TrimSpace(req.OutputConfig.Effort)) + } + if len(req.ToolChoice) > 0 { + seedParts = append(seedParts, "tool_choice="+normalizeCompatSeedJSON(req.ToolChoice)) + } + if len(req.Tools) > 0 { + if raw, err := json.Marshal(req.Tools); err == nil { + seedParts = append(seedParts, "tools="+normalizeCompatSeedJSON(raw)) + } + } + if len(req.System) > 0 { + seedParts = append(seedParts, "system="+normalizeCompatSeedJSON(req.System)) + } + + firstUserCaptured := false + for _, msg := range req.Messages { + if strings.TrimSpace(msg.Role) != "user" || firstUserCaptured { + continue + } + seedParts = append(seedParts, "first_user="+normalizeCompatSeedJSON(msg.Content)) + firstUserCaptured = true + } + + return compatPromptCacheKeyPrefix + hashSensitiveValueForLog(strings.Join(seedParts, "|")) +} + +func deriveAnthropicCacheControlPromptCacheKey(req *apicompat.AnthropicRequest) string { + if req == nil { + return "" + } + + var parts []string + var systemBlocks []apicompat.AnthropicContentBlock + if len(req.System) > 0 && json.Unmarshal(req.System, &systemBlocks) == nil { + for _, block := range systemBlocks { + if block.Type == "text" && + block.CacheControl != nil && + strings.TrimSpace(block.CacheControl.Type) == "ephemeral" && + strings.TrimSpace(block.Text) != "" { + parts = append(parts, "system:"+strings.TrimSpace(block.Text)) + } + } + } + + firstUserAnchor := "" + for _, msg := range req.Messages { + var blocks []apicompat.AnthropicContentBlock + if len(msg.Content) == 0 || json.Unmarshal(msg.Content, &blocks) != nil { + continue + } + role := strings.TrimSpace(msg.Role) + for _, block := range blocks { + if block.Type != "text" || + block.CacheControl == nil || + strings.TrimSpace(block.CacheControl.Type) != "ephemeral" || + strings.TrimSpace(block.Text) == "" { + continue + } + switch role { + case "user": + if firstUserAnchor == "" { + firstUserAnchor = strings.TrimSpace(block.Text) + } + case "assistant": + parts = append(parts, "assistant:"+strings.TrimSpace(block.Text)) + } + } + } + if firstUserAnchor != "" { + parts = append(parts, "user_anchor:"+firstUserAnchor) + } + if len(parts) == 0 { + return "" + } + sum := sha256.Sum256([]byte("anthropic-cache:" + strings.Join(parts, "\n"))) + return fmt.Sprintf("anthropic-cache-%x", sum[:16]) +} + func normalizeCompatSeedJSON(v json.RawMessage) string { if len(v) == 0 { return "" diff --git a/backend/internal/service/openai_compat_prompt_cache_key_test.go b/backend/internal/service/openai_compat_prompt_cache_key_test.go index 6ca3e85cd3b..3fe7db6ef69 100644 --- a/backend/internal/service/openai_compat_prompt_cache_key_test.go +++ b/backend/internal/service/openai_compat_prompt_cache_key_test.go @@ -2,6 +2,7 @@ package service import ( "encoding/json" + "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" @@ -14,7 +15,10 @@ func mustRawJSON(t *testing.T, s string) json.RawMessage { } func TestShouldAutoInjectPromptCacheKeyForCompat(t *testing.T) { + require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.5")) require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.4")) + require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.4-mini")) + require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.2")) require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.3")) require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.3-codex")) require.True(t, shouldAutoInjectPromptCacheKeyForCompat("gpt-5.3-codex-spark")) @@ -77,3 +81,57 @@ func TestDeriveCompatPromptCacheKey_UsesResolvedSparkFamily(t *testing.T) { require.NotEmpty(t, k1) require.Equal(t, k1, k2, "resolved spark family should derive a stable compat cache key") } + +func TestDeriveAnthropicCompatPromptCacheKey_StableAcrossLaterTurns(t *testing.T) { + base := &apicompat.AnthropicRequest{ + Model: "claude-sonnet-4-5", + System: mustRawJSON(t, `"You are helpful."`), + Messages: []apicompat.AnthropicMessage{ + {Role: "user", Content: mustRawJSON(t, `"Open repo"`)}, + }, + } + extended := &apicompat.AnthropicRequest{ + Model: "claude-sonnet-4-5", + System: mustRawJSON(t, `"You are helpful."`), + Messages: []apicompat.AnthropicMessage{ + {Role: "user", Content: mustRawJSON(t, `"Open repo"`)}, + {Role: "assistant", Content: mustRawJSON(t, `"Opened."`)}, + {Role: "user", Content: mustRawJSON(t, `"Run tests"`)}, + }, + } + + k1 := deriveAnthropicCompatPromptCacheKey(base, "gpt-5.3-codex") + k2 := deriveAnthropicCompatPromptCacheKey(extended, "gpt-5.3-codex") + require.NotEmpty(t, k1) + require.Equal(t, k1, k2, "cache key should stay stable as later Claude Code turns append history") +} + +func TestDeriveAnthropicCompatPromptCacheKey_UsesCacheControlAnchors(t *testing.T) { + base := &apicompat.AnthropicRequest{ + Model: "claude-sonnet-4-5", + System: mustRawJSON(t, `[ + {"type":"text","text":"project instructions","cache_control":{"type":"ephemeral"}} + ]`), + Messages: []apicompat.AnthropicMessage{ + {Role: "user", Content: mustRawJSON(t, `[ + {"type":"text","text":"repo anchor","cache_control":{"type":"ephemeral"}} + ]`)}, + }, + } + extended := &apicompat.AnthropicRequest{ + Model: base.Model, + System: base.System, + Messages: []apicompat.AnthropicMessage{ + base.Messages[0], + {Role: "assistant", Content: mustRawJSON(t, `[{"type":"text","text":"Opened."}]`)}, + {Role: "user", Content: mustRawJSON(t, `[{"type":"text","text":"Run tests"}]`)}, + }, + } + + k1 := deriveAnthropicCompatPromptCacheKey(base, "gpt-5.4") + k2 := deriveAnthropicCompatPromptCacheKey(extended, "gpt-5.4") + require.NotEmpty(t, k1) + require.Equal(t, k1, k2) + require.True(t, strings.HasPrefix(k1, "anthropic-cache-")) + require.False(t, strings.HasPrefix(k1, compatPromptCacheKeyPrefix)) +} diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 5f3bf5c1e49..aefa8fd23d2 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -40,12 +40,54 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if err := json.Unmarshal(body, &anthropicReq); err != nil { return nil, fmt.Errorf("parse anthropic request: %w", err) } + anthropicDigestReq := cloneAnthropicRequestForDigest(&anthropicReq) originalModel := anthropicReq.Model applyOpenAICompatModelNormalization(&anthropicReq) normalizedModel := anthropicReq.Model clientStream := anthropicReq.Stream // client's original stream preference - // 2. Convert Anthropic → Responses + // 2. Model mapping + billingModel := resolveOpenAIForwardModel(account, normalizedModel, defaultMappedModel) + upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) + promptCacheKey = strings.TrimSpace(promptCacheKey) + apiKeyID := getAPIKeyIDFromContext(c) + anthropicDigestChain := "" + anthropicMatchedDigestChain := "" + compatPromptCacheInjected := false + if promptCacheKey == "" && shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) { + promptCacheKey = promptCacheKeyFromAnthropicMetadataSession(&anthropicReq) + if promptCacheKey == "" { + promptCacheKey = deriveAnthropicCacheControlPromptCacheKey(&anthropicReq) + } + if promptCacheKey == "" { + anthropicDigestChain = buildOpenAICompatAnthropicDigestChain(anthropicDigestReq) + if reusedKey, matchedChain := s.findOpenAICompatAnthropicDigestPromptCacheKey(account, apiKeyID, anthropicDigestChain); reusedKey != "" { + promptCacheKey = reusedKey + anthropicMatchedDigestChain = matchedChain + } else { + promptCacheKey = promptCacheKeyFromAnthropicDigest(anthropicDigestChain) + } + } + compatPromptCacheInjected = promptCacheKey != "" + } + compatReplayTrimmed := false + compatReplayGuardEnabled := shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) + compatContinuationEnabled := openAICompatContinuationEnabled(account, upstreamModel) + previousResponseID := "" + if compatContinuationEnabled { + previousResponseID = s.getOpenAICompatSessionResponseID(ctx, c, account, promptCacheKey) + } + compatContinuationDisabled := compatContinuationEnabled && + s.isOpenAICompatSessionContinuationDisabled(ctx, c, account, promptCacheKey) + compatTurnState := "" + // OAuth/Plus relies on session_id + x-codex-turn-state; trimming to a + // sliding 12-message window makes the cached prefix stall at system/tools. + // Keep full replay there so upstream prompt caching can grow turn by turn. + if compatReplayGuardEnabled && account.Type != AccountTypeOAuth && previousResponseID == "" && !compatContinuationDisabled { + compatReplayTrimmed = applyAnthropicCompatFullReplayGuard(&anthropicReq) + } + + // 3. Convert Anthropic → Responses after compatibility-only replay guard. responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq) if err != nil { return nil, fmt.Errorf("convert anthropic to responses: %w", err) @@ -56,24 +98,50 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( responsesReq.Stream = true isStream := true - // 2b. Handle BetaFastMode → service_tier: "priority" + // 3b. Handle BetaFastMode → service_tier: "priority" if containsBetaToken(c.GetHeader("anthropic-beta"), claude.BetaFastMode) { responsesReq.ServiceTier = "priority" } - // 3. Model mapping - billingModel := resolveOpenAIForwardModel(account, normalizedModel, defaultMappedModel) - upstreamModel := normalizeOpenAIModelForUpstream(account, billingModel) responsesReq.Model = upstreamModel + if previousResponseID != "" { + responsesReq.PreviousResponseID = previousResponseID + trimAnthropicCompatResponsesInputToLatestTurn(responsesReq) + } + if compatReplayGuardEnabled && account.Type != AccountTypeOAuth { + appendOpenAICompatClaudeCodeTodoGuard(responsesReq) + } - logger.L().Debug("openai messages: model mapping applied", + logFields := []zap.Field{ zap.Int64("account_id", account.ID), zap.String("original_model", originalModel), zap.String("normalized_model", normalizedModel), zap.String("billing_model", billingModel), zap.String("upstream_model", upstreamModel), zap.Bool("stream", isStream), - ) + } + if compatPromptCacheInjected { + logFields = append(logFields, + zap.Bool("compat_prompt_cache_key_injected", true), + zap.String("compat_prompt_cache_key_sha256", hashSensitiveValueForLog(promptCacheKey)), + ) + } + if compatReplayTrimmed { + logFields = append(logFields, + zap.Bool("compat_full_replay_trimmed", true), + zap.Int("compat_messages_after_trim", len(anthropicReq.Messages)), + ) + } + if previousResponseID != "" { + logFields = append(logFields, + zap.Bool("compat_previous_response_id_attached", true), + zap.String("compat_previous_response_id", truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen)), + ) + } + if compatTurnState != "" { + logFields = append(logFields, zap.Bool("compat_turn_state_attached", true)) + } + logger.L().Debug("openai messages: model mapping applied", logFields...) // 4. Marshal Responses request body, then apply OAuth codex transform responsesBody, err := json.Marshal(responsesReq) @@ -86,7 +154,10 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( if err := json.Unmarshal(responsesBody, &reqBody); err != nil { return nil, fmt.Errorf("unmarshal for codex transform: %w", err) } - codexResult := applyCodexOAuthTransform(reqBody, false, false) + codexResult := applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{ + SkipDefaultInstructions: true, + PreserveToolCallIDs: true, + }) forcedTemplateText := "" if s.cfg != nil { forcedTemplateText = s.cfg.Gateway.ForcedCodexInstructionsTemplate @@ -96,6 +167,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( templateUpstreamModel = codexResult.NormalizedModel } existingInstructions, _ := reqBody["instructions"].(string) + if strings.TrimSpace(existingInstructions) == "" { + existingInstructions = extractPromptLikeInstructionsFromInput(reqBody) + } if _, err := applyForcedCodexInstructionsTemplate(reqBody, forcedTemplateText, forcedCodexInstructionsTemplateData{ ExistingInstructions: strings.TrimSpace(existingInstructions), OriginalModel: originalModel, @@ -105,13 +179,19 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( }); err != nil { return nil, err } + ensureCodexOAuthInstructionsField(reqBody) + if shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) { + appendOpenAICompatClaudeCodeTodoGuardToRequestBody(reqBody) + } if codexResult.NormalizedModel != "" { upstreamModel = codexResult.NormalizedModel } if codexResult.PromptCacheKey != "" { promptCacheKey = codexResult.PromptCacheKey - } else if promptCacheKey != "" { - reqBody["prompt_cache_key"] = promptCacheKey + } + delete(reqBody, "prompt_cache_key") + if shouldAutoInjectPromptCacheKeyForCompat(upstreamModel) { + compatTurnState = s.getOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey) } // OAuth codex transform forces stream=true upstream, so always use // the streaming response handler regardless of what the client asked. @@ -174,8 +254,25 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // Override session_id with a deterministic UUID derived from the isolated // session key, ensuring different API keys produce different upstream sessions. if promptCacheKey != "" { - apiKeyID := getAPIKeyIDFromContext(c) - upstreamReq.Header.Set("session_id", generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey))) + isolatedSessionID := generateSessionUUID(isolateOpenAISessionID(apiKeyID, promptCacheKey)) + upstreamReq.Header.Set("session_id", isolatedSessionID) + if upstreamReq.Header.Get("conversation_id") != "" { + upstreamReq.Header.Set("conversation_id", isolatedSessionID) + } + } + if account.Type == AccountTypeOAuth { + // Anthropic Messages compatibility uses the ChatGPT Codex SSE endpoint. + // Match airgate-openai's request shape: the SSE endpoint does not need + // the Responses experimental beta header, and forcing originator can make + // ChatGPT select a different internal continuation path. + upstreamReq.Header.Del("OpenAI-Beta") + upstreamReq.Header.Del("originator") + } + if account.Type == AccountTypeOAuth && promptCacheKey != "" && strings.TrimSpace(c.GetHeader("conversation_id")) == "" { + upstreamReq.Header.Del("conversation_id") + } + if compatTurnState != "" && upstreamReq.Header.Get("x-codex-turn-state") == "" { + upstreamReq.Header.Set("x-codex-turn-state", compatTurnState) } // 7. Send request @@ -208,6 +305,19 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( upstreamMsg := strings.TrimSpace(extractUpstreamErrorMessage(respBody)) upstreamMsg = sanitizeUpstreamErrorMessage(upstreamMsg) + if previousResponseID != "" && (isOpenAICompatPreviousResponseNotFound(resp.StatusCode, upstreamMsg, respBody) || isOpenAICompatPreviousResponseUnsupported(resp.StatusCode, upstreamMsg, respBody)) { + if isOpenAICompatPreviousResponseUnsupported(resp.StatusCode, upstreamMsg, respBody) { + s.disableOpenAICompatSessionContinuation(ctx, c, account, promptCacheKey) + } else { + s.deleteOpenAICompatSessionResponseID(ctx, c, account, promptCacheKey) + } + logger.L().Info("openai messages: previous_response_id unavailable, retrying without continuation", + zap.Int64("account_id", account.ID), + zap.String("previous_response_id", truncateOpenAIWSLogValue(previousResponseID, openAIWSIDValueMaxLen)), + zap.String("upstream_model", upstreamModel), + ) + return s.ForwardAsAnthropic(ctx, c, account, body, promptCacheKey, defaultMappedModel) + } if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMsg, respBody) { upstreamDetail := "" if s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody { @@ -240,6 +350,12 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return s.handleAnthropicErrorResponse(resp, c, account) } + if account.Type == AccountTypeOAuth && promptCacheKey != "" { + if turnState := strings.TrimSpace(resp.Header.Get("x-codex-turn-state")); turnState != "" { + s.bindOpenAICompatSessionTurnState(ctx, c, account, promptCacheKey, turnState) + } + } + // 9. Handle normal response // Upstream is always streaming; choose response format based on client preference. var result *OpenAIForwardResult @@ -253,6 +369,12 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // Propagate ServiceTier and ReasoningEffort to result for billing if handleErr == nil && result != nil { + if compatContinuationEnabled && promptCacheKey != "" && result.ResponseID != "" { + s.bindOpenAICompatSessionResponseID(ctx, c, account, promptCacheKey, result.ResponseID) + } + if promptCacheKey != "" && anthropicDigestChain != "" { + s.bindOpenAICompatAnthropicDigestPromptCacheKey(account, apiKeyID, anthropicDigestChain, promptCacheKey, anthropicMatchedDigestChain) + } if responsesReq.ServiceTier != "" { st := responsesReq.ServiceTier result.ServiceTier = &st @@ -273,6 +395,19 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( return result, handleErr } +func ensureCodexOAuthInstructionsField(reqBody map[string]any) { + if reqBody == nil { + return + } + if value, ok := reqBody["instructions"]; !ok || value == nil { + reqBody["instructions"] = "" + return + } + if _, ok := reqBody["instructions"].(string); !ok { + reqBody["instructions"] = "" + } +} + // handleAnthropicErrorResponse reads an upstream error and returns it in // Anthropic error format. func (s *OpenAIGatewayService) handleAnthropicErrorResponse( @@ -322,6 +457,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse( return &OpenAIForwardResult{ RequestID: requestID, + ResponseID: finalResponse.ID, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -505,6 +641,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( state := apicompat.NewResponsesEventToAnthropicState() state.Model = originalModel var usage OpenAIUsage + responseID := "" var firstTokenMs *int firstChunk := true clientDisconnected := false @@ -534,6 +671,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( resultWithUsage := func() *OpenAIForwardResult { return &OpenAIForwardResult{ RequestID: requestID, + ResponseID: responseID, Usage: usage, Model: originalModel, BillingModel: billingModel, @@ -563,8 +701,13 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse( // 仅按兼容转换器支持的终止事件提取 usage,避免无意扩大事件语义。 isTerminalEvent := isOpenAICompatResponsesTerminalEvent(event.Type) - if isTerminalEvent && event.Response != nil && event.Response.Usage != nil { - usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage) + if isTerminalEvent && event.Response != nil { + if id := strings.TrimSpace(event.Response.ID); id != "" { + responseID = id + } + if event.Response.Usage != nil { + usage = copyOpenAIUsageFromResponsesUsage(event.Response.Usage) + } } // Convert to Anthropic events diff --git a/backend/internal/service/openai_gateway_record_usage_test.go b/backend/internal/service/openai_gateway_record_usage_test.go index 4722c82dd93..3791c5a858f 100644 --- a/backend/internal/service/openai_gateway_record_usage_test.go +++ b/backend/internal/service/openai_gateway_record_usage_test.go @@ -52,6 +52,12 @@ func (s *openAIRecordUsageBillingRepoStub) Apply(ctx context.Context, cmd *Usage return &UsageBillingApplyResult{Applied: true}, nil } +func TestOpenAIGatewayServiceRecordUsage_RejectsNilInput(t *testing.T) { + svc := &OpenAIGatewayService{} + require.Error(t, svc.RecordUsage(context.Background(), nil)) + require.Error(t, svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{})) +} + type openAIRecordUsageUserRepoStub struct { UserRepository @@ -1081,6 +1087,101 @@ func TestOpenAIGatewayServiceRecordUsage_ChannelMappedOverridesBillingModelWhenM require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero") } +func TestOpenAIGatewayServiceRecordUsage_BillsCompactOpenAIModelAlias(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + usage := OpenAIUsage{InputTokens: 20, OutputTokens: 10} + + expectedCost, err := svc.billingService.CalculateCost("gpt-5.5", UsageTokens{ + InputTokens: 20, + OutputTokens: 10, + }, 1.1) + require.NoError(t, err) + + err = svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_compact_openai_alias", + Model: "gpt5.5", + UpstreamModel: "gpt-5.4", + Usage: usage, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.Equal(t, "gpt5.5", usageRepo.lastLog.Model) + require.NotNil(t, usageRepo.lastLog.UpstreamModel) + require.Equal(t, "gpt-5.4", *usageRepo.lastLog.UpstreamModel) + require.InDelta(t, expectedCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero") + require.InDelta(t, expectedCost.ActualCost, userRepo.lastAmount, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_FallsBackToUpstreamModelWhenPrimaryUnpriceable(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + usage := OpenAIUsage{InputTokens: 20, OutputTokens: 10} + + expectedCost, err := svc.billingService.CalculateCost("gpt-5.4", UsageTokens{ + InputTokens: 20, + OutputTokens: 10, + }, 1.1) + require.NoError(t, err) + + err = svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_unpriceable_primary_upstream_fallback", + Model: "not-priceable-alias", + BillingModel: "not-priceable-alias", + UpstreamModel: "gpt-5.4", + Usage: usage, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, expectedCost.ActualCost, usageRepo.lastLog.ActualCost, 1e-12) + require.True(t, usageRepo.lastLog.ActualCost > 0, "cost must not be zero") + require.InDelta(t, expectedCost.ActualCost, userRepo.lastAmount, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_ReturnsErrorWhenTokenModelCannotBePriced(t *testing.T) { + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + userRepo := &openAIRecordUsageUserRepoStub{} + subRepo := &openAIRecordUsageSubRepoStub{} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, userRepo, subRepo, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_unpriceable_without_upstream", + Model: "not-priceable-alias", + Usage: OpenAIUsage{InputTokens: 20, OutputTokens: 10}, + Duration: time.Second, + }, + APIKey: &APIKey{ID: 10}, + User: &User{ID: 20}, + Account: &Account{ID: 30}, + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "calculate OpenAI usage cost failed") + require.Equal(t, 0, usageRepo.calls) + require.Equal(t, 0, userRepo.deductCalls) + require.Equal(t, 0, subRepo.incrementCalls) +} + func TestOpenAIGatewayServiceRecordUsage_SubscriptionBillingSetsSubscriptionFields(t *testing.T) { usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} userRepo := &openAIRecordUsageUserRepoStub{} @@ -1209,3 +1310,278 @@ func TestOpenAIGatewayServiceRecordUsage_ImageUsesPerImageBillingEvenWithUsageTo require.InDelta(t, 0.0, usageRepo.lastLog.OutputCost, 1e-12) require.InDelta(t, 0.0, usageRepo.lastLog.ImageOutputCost, 1e-12) } + +func TestOpenAIGatewayServiceRecordUsage_ImageSharedMultiplierPreservesExistingBehavior(t *testing.T) { + imagePrice := 0.2 + groupID := int64(121) + + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_image_shared_multiplier", + Model: "gpt-image-2", + ImageCount: 1, + ImageSize: "1K", + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10121, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + RateMultiplier: 0.15, + ImageRateIndependent: false, + ImageRateMultiplier: 1, + ImagePrice1K: &imagePrice, + }, + }, + User: &User{ID: 20121}, + Account: &Account{ID: 30121}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, 0.2, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.03, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.15, usageRepo.lastLog.RateMultiplier, 1e-12) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode) +} + +func TestOpenAIGatewayServiceRecordUsage_ImageSharedMultiplierUsesUserGroupOverride(t *testing.T) { + imagePrice := 0.5 + userRate := 0.2 + groupID := int64(125) + + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest( + usageRepo, + &openAIRecordUsageUserRepoStub{}, + &openAIRecordUsageSubRepoStub{}, + &openAIUserGroupRateRepoStub{rate: &userRate}, + ) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_image_user_group_override", + Model: "gpt-image-2", + ImageCount: 1, + ImageSize: "1K", + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10125, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + RateMultiplier: 0.15, + ImageRateIndependent: false, + ImageRateMultiplier: 1, + ImagePrice1K: &imagePrice, + }, + }, + User: &User{ID: 20125}, + Account: &Account{ID: 30125}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, 0.5, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.1, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.2, usageRepo.lastLog.RateMultiplier, 1e-12) +} + +func TestOpenAIGatewayServiceRecordUsage_ImageIndependentMultiplierUsesImageRate(t *testing.T) { + imagePrice := 0.2 + groupID := int64(122) + + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_image_independent_multiplier", + Model: "gpt-image-2", + ImageCount: 1, + ImageSize: "1K", + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10122, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + RateMultiplier: 0.15, + ImageRateIndependent: true, + ImageRateMultiplier: 1, + ImagePrice1K: &imagePrice, + }, + }, + User: &User{ID: 20122}, + Account: &Account{ID: 30122}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, 0.2, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.2, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 1.0, usageRepo.lastLog.RateMultiplier, 1e-12) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode) +} + +func TestOpenAIGatewayServiceRecordUsage_ChannelImageBillingUsesImageCountAndSharedMultiplier(t *testing.T) { + groupID := int64(123) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAIImageChannelPricingResolverForTest(t, groupID, "gpt-image-2", 0.25) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_image_channel_shared", + Model: "gpt-image-2", + ImageCount: 3, + ImageSize: "1K", + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10123, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + RateMultiplier: 0.15, + ImageRateIndependent: false, + ImageRateMultiplier: 1, + }, + }, + User: &User{ID: 20123}, + Account: &Account{ID: 30123}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, 0.75, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.1125, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 0.15, usageRepo.lastLog.RateMultiplier, 1e-12) + require.Equal(t, 3, usageRepo.lastLog.ImageCount) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode) +} + +func TestOpenAIGatewayServiceRecordUsage_ChannelImageBillingUsesImageCountAndIndependentMultiplier(t *testing.T) { + groupID := int64(124) + usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} + svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) + svc.resolver = newOpenAIImageChannelPricingResolverForTest(t, groupID, "gpt-image-2", 0.25) + + err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ + Result: &OpenAIForwardResult{ + RequestID: "resp_image_channel_independent", + Model: "gpt-image-2", + ImageCount: 3, + ImageSize: "1K", + Duration: time.Second, + }, + APIKey: &APIKey{ + ID: 10124, + GroupID: i64p(groupID), + Group: &Group{ + ID: groupID, + RateMultiplier: 0.15, + ImageRateIndependent: true, + ImageRateMultiplier: 1, + }, + }, + User: &User{ID: 20124}, + Account: &Account{ID: 30124}, + }) + + require.NoError(t, err) + require.NotNil(t, usageRepo.lastLog) + require.InDelta(t, 0.75, usageRepo.lastLog.TotalCost, 1e-12) + require.InDelta(t, 0.75, usageRepo.lastLog.ActualCost, 1e-12) + require.InDelta(t, 1.0, usageRepo.lastLog.RateMultiplier, 1e-12) + require.Equal(t, 3, usageRepo.lastLog.ImageCount) + require.NotNil(t, usageRepo.lastLog.BillingMode) + require.Equal(t, string(BillingModeImage), *usageRepo.lastLog.BillingMode) +} + +func newOpenAIImageChannelPricingResolverForTest(t *testing.T, groupID int64, model string, price float64) *ModelPricingResolver { + t.Helper() + cache := newEmptyChannelCache() + cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: model}] = &ChannelModelPricing{ + BillingMode: BillingModeImage, + PerRequestPrice: &price, + } + cache.channelByGroupID[groupID] = &Channel{ID: groupID, Status: StatusActive} + cache.groupPlatform[groupID] = "" + cache.loadedAt = time.Now() + cs := &ChannelService{} + cs.cache.Store(cache) + return NewModelPricingResolver(cs, NewBillingService(&config.Config{}, nil)) +} + +func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesImageCount(t *testing.T) { + groupID := int64(126) + billingService := NewBillingService(&config.Config{}, nil) + svc := &GatewayService{ + billingService: billingService, + resolver: newOpenAIImageChannelPricingResolverForTest(t, groupID, "gemini-image", 0.25), + } + + cost := svc.calculateRecordUsageCost( + context.Background(), + &ForwardResult{Model: "gemini-image", ImageCount: 2, ImageSize: "1K"}, + &APIKey{GroupID: i64p(groupID), Group: &Group{ID: groupID}}, + "gemini-image", + 0.15, + 1.0, + nil, + ) + + require.NotNil(t, cost) + require.Equal(t, string(BillingModeImage), cost.BillingMode) + require.InDelta(t, 0.5, cost.TotalCost, 1e-12) + require.InDelta(t, 0.5, cost.ActualCost, 1e-12) +} + +func TestGatewayServiceCalculateRecordUsageCost_ChannelImageBillingUsesSizeTier(t *testing.T) { + groupID := int64(127) + defaultPrice := 0.10 + price4K := 0.40 + cache := newEmptyChannelCache() + cache.pricingByGroupModel[channelModelKey{groupID: groupID, model: "gemini-image"}] = &ChannelModelPricing{ + BillingMode: BillingModeImage, + PerRequestPrice: &defaultPrice, + Intervals: []PricingInterval{{ + TierLabel: "4K", + PerRequestPrice: &price4K, + }}, + } + cache.channelByGroupID[groupID] = &Channel{ID: groupID, Status: StatusActive} + cache.loadedAt = time.Now() + channelService := &ChannelService{} + channelService.cache.Store(cache) + + svc := &GatewayService{ + billingService: NewBillingService(&config.Config{}, nil), + resolver: NewModelPricingResolver(channelService, NewBillingService(&config.Config{}, nil)), + } + + cost := svc.calculateRecordUsageCost( + context.Background(), + &ForwardResult{Model: "gemini-image", ImageCount: 2, ImageSize: "4K"}, + &APIKey{GroupID: i64p(groupID), Group: &Group{ID: groupID}}, + "gemini-image", + 1.0, + 1.0, + nil, + ) + + require.NotNil(t, cost) + require.Equal(t, string(BillingModeImage), cost.BillingMode) + require.InDelta(t, 0.80, cost.TotalCost, 1e-12) + require.InDelta(t, 0.80, cost.ActualCost, 1e-12) +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index b818fa4ad48..e4430536652 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -211,9 +211,10 @@ type OpenAIUsage struct { // OpenAIForwardResult represents the result of forwarding type OpenAIForwardResult struct { - RequestID string - Usage OpenAIUsage - Model string // 原始模型(用于响应和日志显示) + RequestID string + ResponseID string + Usage OpenAIUsage + Model string // 原始模型(用于响应和日志显示) // BillingModel is the model used for cost calculation. // When non-empty, CalculateCost uses this instead of Model. // This is set by the Anthropic Messages conversion path where @@ -346,10 +347,12 @@ type OpenAIGatewayService struct { openaiWSPassthroughDialer openAIWSClientDialer openaiAccountStats *openAIAccountRuntimeStats - openaiWSFallbackUntil sync.Map // key: int64(accountID), value: time.Time - openaiWSRetryMetrics openAIWSRetryMetrics - responseHeaderFilter *responseheaders.CompiledHeaderFilter - codexSnapshotThrottle *accountWriteThrottle + openaiWSFallbackUntil sync.Map // key: int64(accountID), value: time.Time + openaiWSRetryMetrics openAIWSRetryMetrics + responseHeaderFilter *responseheaders.CompiledHeaderFilter + codexSnapshotThrottle *accountWriteThrottle + openaiCompatSessionResponses sync.Map + openaiCompatAnthropicDigestSessions sync.Map } // NewOpenAIGatewayService creates a new OpenAIGatewayService @@ -437,6 +440,21 @@ func (s *OpenAIGatewayService) ResolveChannelMappingAndRestrict(ctx context.Cont return s.channelService.ResolveChannelMappingAndRestrict(ctx, groupID, model) } +func (s *OpenAIGatewayService) isCodexImageGenerationBridgeEnabled(ctx context.Context, account *Account, apiKey *APIKey) bool { + if override := account.CodexImageGenerationBridgeOverride(); override != nil { + return *override + } + if s != nil && s.channelService != nil && apiKey != nil && apiKey.GroupID != nil { + ch, err := s.channelService.GetChannelForGroup(ctx, *apiKey.GroupID) + if err != nil { + slog.Warn("failed to resolve codex image generation bridge channel override", "group_id", *apiKey.GroupID, "error", err) + } else if override := ch.CodexImageGenerationBridgeOverride(PlatformOpenAI); override != nil { + return *override + } + } + return s != nil && s.cfg != nil && s.cfg.Gateway.CodexImageGenerationBridgeEnabled +} + func (s *OpenAIGatewayService) checkChannelPricingRestriction(ctx context.Context, groupID *int64, requestedModel string) bool { if groupID == nil || s.channelService == nil || requestedModel == "" { return false @@ -1992,6 +2010,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco originalBody := body reqModel, reqStream, promptCacheKey := extractOpenAIRequestMetaFromBody(body) originalModel := reqModel + compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body) + setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge) isCodexCLI := openai.IsCodexOfficialClientByHeaders(c.GetHeader("User-Agent"), c.GetHeader("originator")) || (s.cfg != nil && s.cfg.Gateway.ForceCodexCLI) wsDecision := s.getOpenAIWSProtocolResolver().Resolve(account) @@ -2049,6 +2069,22 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco promptCacheKey = strings.TrimSpace(v) } } + apiKey := getAPIKeyFromContext(c) + imageGenerationAllowed := GroupAllowsImageGeneration(nil) + if apiKey != nil { + imageGenerationAllowed = GroupAllowsImageGeneration(apiKey.Group) + } + codexImageGenerationBridgeEnabled := isCodexCLI && imageGenerationAllowed && s.isCodexImageGenerationBridgeEnabled(ctx, account, apiKey) + if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) && !imageGenerationAllowed { + setOpsUpstreamError(c, http.StatusForbidden, ImageGenerationPermissionMessage(), "") + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "type": "permission_error", + "message": ImageGenerationPermissionMessage(), + }, + }) + return nil, errors.New("image generation disabled for group") + } // Track if body needs re-serialization bodyModified := false @@ -2102,13 +2138,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } // 非透传模式下,instructions 为空时注入默认指令。 - if isInstructionsEmpty(reqBody) { + if isInstructionsEmpty(reqBody) && !compatMessagesBridge { reqBody["instructions"] = "You are a helpful coding assistant." bodyModified = true markPatchSet("instructions", "You are a helpful coding assistant.") } - if isCodexCLI && ensureOpenAIResponsesImageGenerationTool(reqBody) { + if codexImageGenerationBridgeEnabled && ensureOpenAIResponsesImageGenerationTool(reqBody) { bodyModified = true disablePatch() logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Injected /responses image_generation tool for Codex client") @@ -2119,7 +2155,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco disablePatch() logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Normalized /responses image_generation tool payload") } - if isCodexCLI && applyCodexImageGenerationBridgeInstructions(reqBody) { + if codexImageGenerationBridgeEnabled && applyCodexImageGenerationBridgeInstructions(reqBody) { bodyModified = true disablePatch() logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Added Codex image_generation bridge instructions") @@ -2134,7 +2170,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco markPatchSet("model", billingModel) } upstreamModel := billingModel - if normalizeOpenAIResponsesImageOnlyModel(reqBody) { + if imageGenerationAllowed && normalizeOpenAIResponsesImageOnlyModel(reqBody) { bodyModified = true disablePatch() if model, ok := reqBody["model"].(string); ok { @@ -2231,7 +2267,20 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } if account.Type == AccountTypeOAuth { - codexResult := applyCodexOAuthTransform(reqBody, isCodexCLI, isCompactRequest) + codexResult := codexTransformResult{} + if compatMessagesBridge { + codexResult = applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{ + IsCodexCLI: isCodexCLI, + IsCompact: isCompactRequest, + SkipDefaultInstructions: true, + PreserveToolCallIDs: true, + }) + ensureCodexOAuthInstructionsField(reqBody) + bodyModified = true + disablePatch() + } else { + codexResult = applyCodexOAuthTransform(reqBody, isCodexCLI, isCompactRequest) + } if codexResult.Modified { bodyModified = true disablePatch() @@ -2355,6 +2404,34 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } + if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) && !imageGenerationAllowed { + setOpsUpstreamError(c, http.StatusForbidden, ImageGenerationPermissionMessage(), "") + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "type": "permission_error", + "message": ImageGenerationPermissionMessage(), + }, + }) + return nil, errors.New("image generation disabled for group") + } + imageBillingModel := "" + imageSizeTier := "" + if IsImageGenerationIntentMap(openAIResponsesEndpoint, reqModel, reqBody) { + var imageCfgErr error + imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfig(reqBody, billingModel) + if imageCfgErr != nil { + setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "") + c.JSON(http.StatusBadRequest, gin.H{ + "error": gin.H{ + "type": "invalid_request_error", + "message": imageCfgErr.Error(), + "param": "size", + }, + }) + return nil, imageCfgErr + } + } + // Re-serialize body only if modified if bodyModified { serializedByPatch := false @@ -2592,6 +2669,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco wsAttempts, ) wsResult.UpstreamModel = upstreamModel + if wsResult.ImageCount > 0 { + wsResult.ImageSize = imageSizeTier + wsResult.BillingModel = imageBillingModel + } return wsResult, nil } s.writeOpenAIWSFallbackErrorResponse(c, account, wsErr) @@ -2695,6 +2776,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco // Handle normal response var usage *OpenAIUsage var firstTokenMs *int + imageCount := 0 if reqStream { streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel) if err != nil { @@ -2702,11 +2784,14 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } usage = streamResult.usage firstTokenMs = streamResult.firstTokenMs + imageCount = streamResult.imageCount } else { - usage, err = s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel) + nonStreamResult, err := s.handleNonStreamingResponse(ctx, resp, c, account, originalModel, upstreamModel) if err != nil { return nil, err } + usage = nonStreamResult.usage + imageCount = nonStreamResult.imageCount } // Extract and save Codex usage snapshot from response headers (for OAuth accounts) @@ -2723,7 +2808,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco reasoningEffort := extractOpenAIReasoningEffort(reqBody, originalModel) serviceTier := extractOpenAIServiceTier(reqBody) - return &OpenAIForwardResult{ + forwardResult := &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), Usage: *usage, Model: originalModel, @@ -2734,7 +2819,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco OpenAIWSMode: false, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, - }, nil + } + if imageCount > 0 { + forwardResult.ImageCount = imageCount + forwardResult.ImageSize = imageSizeTier + forwardResult.BillingModel = imageBillingModel + } + return forwardResult, nil } } @@ -2823,6 +2914,35 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } body = updatedBody + apiKey := getAPIKeyFromContext(c) + if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) && !GroupAllowsImageGeneration(apiKeyGroup(apiKey)) { + setOpsUpstreamError(c, http.StatusForbidden, ImageGenerationPermissionMessage(), "") + c.JSON(http.StatusForbidden, gin.H{ + "error": gin.H{ + "type": "permission_error", + "message": ImageGenerationPermissionMessage(), + }, + }) + return nil, errors.New("image generation disabled for group") + } + imageBillingModel := "" + imageSizeTier := "" + if IsImageGenerationIntent(openAIResponsesEndpoint, reqModel, body) { + var imageCfgErr error + imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfigFromBody(body, reqModel) + if imageCfgErr != nil { + setOpsUpstreamError(c, http.StatusBadRequest, imageCfgErr.Error(), "") + c.JSON(http.StatusBadRequest, gin.H{ + "error": gin.H{ + "type": "invalid_request_error", + "message": imageCfgErr.Error(), + "param": "size", + }, + }) + return nil, imageCfgErr + } + } + logger.LegacyPrintf("service.openai_gateway", "[OpenAI 自动透传] 命中自动透传分支: account=%d name=%s type=%s model=%s stream=%v", account.ID, @@ -2905,6 +3025,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( var usage *OpenAIUsage var firstTokenMs *int + imageCount := 0 if reqStream { result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel) if err != nil { @@ -2912,11 +3033,14 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( } usage = result.usage firstTokenMs = result.firstTokenMs + imageCount = result.imageCount } else { - usage, err = s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel) + result, err := s.handleNonStreamingResponsePassthrough(ctx, resp, c, reqModel, upstreamPassthroughModel) if err != nil { return nil, err } + usage = result.usage + imageCount = result.imageCount } if snapshot := ParseCodexRateLimitHeaders(resp.Header); snapshot != nil { @@ -2927,7 +3051,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( usage = &OpenAIUsage{} } - return &OpenAIForwardResult{ + forwardResult := &OpenAIForwardResult{ RequestID: resp.Header.Get("x-request-id"), Usage: *usage, Model: reqModel, @@ -2938,7 +3062,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( OpenAIWSMode: false, Duration: time.Since(startTime), FirstTokenMs: firstTokenMs, - }, nil + } + if imageCount > 0 { + forwardResult.ImageCount = imageCount + forwardResult.ImageSize = imageSizeTier + forwardResult.BillingModel = imageBillingModel + } + return forwardResult, nil } func logOpenAIPassthroughInstructionsRejected( @@ -3233,6 +3363,13 @@ func collectOpenAIPassthroughTimeoutHeaders(h http.Header) []string { type openaiStreamingResultPassthrough struct { usage *OpenAIUsage firstTokenMs *int + imageCount int +} + +type openaiNonStreamingResultPassthrough struct { + *OpenAIUsage + usage *OpenAIUsage + imageCount int } func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool { @@ -3369,6 +3506,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( } usage := &OpenAIUsage{} + imageCounter := newOpenAIImageOutputCounter() var firstTokenMs *int clientDisconnected := false sawDone := false @@ -3400,6 +3538,9 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( defer putSSEScannerBuf64K(scanBuf) needModelReplace := strings.TrimSpace(originalModel) != "" && strings.TrimSpace(mappedModel) != "" && strings.TrimSpace(originalModel) != strings.TrimSpace(mappedModel) + resultWithUsage := func() *openaiStreamingResultPassthrough { + return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs, imageCount: imageCounter.Count()} + } for scanner.Scan() { line := scanner.Text() @@ -3419,7 +3560,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( if eventType == "response.failed" { failedMessage = extractOpenAISSEErrorMessage(dataBytes) if !openAIStreamClientOutputStarted(c, clientOutputStarted) && openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) { - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, + return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage) } forceFlushFailedEvent = true @@ -3431,6 +3572,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( if openAIStreamEventIsTerminal(trimmedData) { sawTerminalEvent = true } + imageCounter.AddSSEData(dataBytes) lineStartsClientOutput = forceFlushFailedEvent || openAIStreamDataStartsClientOutput(trimmedData, eventType) if firstTokenMs == nil && lineStartsClientOutput && trimmedData != "[DONE]" { ms := int(time.Since(startTime).Milliseconds()) @@ -3460,28 +3602,28 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( } if err := scanner.Err(); err != nil { if sawTerminalEvent && !sawFailedEvent { - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, nil + return resultWithUsage(), nil } if sawFailedEvent { - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("upstream response failed: %s", failedMessage) + return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage) } if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream usage incomplete: %w", err) + return resultWithUsage(), fmt.Errorf("stream usage incomplete: %w", err) } if errors.Is(err, bufio.ErrTooLong) { logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] SSE line too long: account=%d max_size=%d error=%v", account.ID, maxLineSize, err) - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, err + return resultWithUsage(), err } if !openAIStreamClientOutputStarted(c, clientOutputStarted) { msg := "OpenAI stream disconnected before completion" if errText := strings.TrimSpace(err.Error()); errText != "" { msg += ": " + errText } - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, + return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, msg) } if clientDisconnected { - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream usage incomplete after disconnect: %w", err) + return resultWithUsage(), fmt.Errorf("stream usage incomplete after disconnect: %w", err) } logger.LegacyPrintf("service.openai_gateway", "[OpenAI passthrough] 流读取异常中断: account=%d request_id=%s err=%v", @@ -3489,10 +3631,10 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( upstreamRequestID, err, ) - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("stream read error: %w", err) + return resultWithUsage(), fmt.Errorf("stream read error: %w", err) } if sawFailedEvent { - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, fmt.Errorf("upstream response failed: %s", failedMessage) + return resultWithUsage(), fmt.Errorf("upstream response failed: %s", failedMessage) } if !clientDisconnected && !sawDone && !sawTerminalEvent && ctx.Err() == nil { logger.FromContext(ctx).With( @@ -3501,13 +3643,13 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough( zap.String("upstream_request_id", upstreamRequestID), ).Info("OpenAI passthrough 上游流在未收到 [DONE] 时结束,疑似断流") if !openAIStreamClientOutputStarted(c, clientOutputStarted) { - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, + return resultWithUsage(), s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, nil, "OpenAI stream ended before a terminal event") } - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, errors.New("stream usage incomplete: missing terminal event") + return resultWithUsage(), errors.New("stream usage incomplete: missing terminal event") } - return &openaiStreamingResultPassthrough{usage: usage, firstTokenMs: firstTokenMs}, nil + return resultWithUsage(), nil } func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( @@ -3516,7 +3658,7 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( c *gin.Context, originalModel string, mappedModel string, -) (*OpenAIUsage, error) { +) (*openaiNonStreamingResultPassthrough, error) { body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { return nil, err @@ -3553,14 +3695,18 @@ func (s *OpenAIGatewayService) handleNonStreamingResponsePassthrough( body = s.replaceModelInResponseBody(body, mappedModel, originalModel) } c.Data(resp.StatusCode, contentType, body) - return usage, nil + return &openaiNonStreamingResultPassthrough{ + OpenAIUsage: usage, + usage: usage, + imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), + }, nil } // handlePassthroughSSEToJSON converts an SSE response body into a JSON // response for the passthrough path. It mirrors handleSSEToJSON while // preserving passthrough payloads, except compact-only model remapping may // rewrite model fields back to the original requested model. -func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel string, mappedModel string) (*OpenAIUsage, error) { +func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel string, mappedModel string) (*openaiNonStreamingResultPassthrough, error) { bodyText := string(body) finalResponse, ok := extractCodexFinalResponse(bodyText) @@ -3611,7 +3757,11 @@ func (s *OpenAIGatewayService) handlePassthroughSSEToJSON(resp *http.Response, c } c.Data(resp.StatusCode, contentType, body) - return usage, nil + return &openaiNonStreamingResultPassthrough{ + OpenAIUsage: usage, + usage: usage, + imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), + }, nil } func writeOpenAIPassthroughResponseHeaders(dst http.Header, src http.Header, filter *responseheaders.CompiledHeaderFilter) { @@ -3715,12 +3865,19 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. } } if account.Type == AccountTypeOAuth { + compatMessagesBridge := isOpenAICompatMessagesBridgeContext(c) || isOpenAICompatMessagesBridgeBody(body) // 清除客户端透传的 session 头,后续用隔离后的值重新设置,防止跨用户会话碰撞。 + clientConversationID := strings.TrimSpace(req.Header.Get("conversation_id")) req.Header.Del("conversation_id") req.Header.Del("session_id") - req.Header.Set("OpenAI-Beta", "responses=experimental") - req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) + if compatMessagesBridge { + req.Header.Del("OpenAI-Beta") + req.Header.Del("originator") + } else { + req.Header.Set("OpenAI-Beta", "responses=experimental") + req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI)) + } apiKeyID := getAPIKeyIDFromContext(c) if isOpenAIResponsesCompactPath(c) { req.Header.Set("accept", "application/json") @@ -3734,8 +3891,10 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. } if promptCacheKey != "" { isolated := isolateOpenAISessionID(apiKeyID, promptCacheKey) - req.Header.Set("conversation_id", isolated) req.Header.Set("session_id", isolated) + if !compatMessagesBridge || clientConversationID != "" { + req.Header.Set("conversation_id", isolated) + } } } @@ -4025,6 +4184,13 @@ func (s *OpenAIGatewayService) handleCompatErrorResponse( type openaiStreamingResult struct { usage *OpenAIUsage firstTokenMs *int + imageCount int +} + +type openaiNonStreamingResult struct { + *OpenAIUsage + usage *OpenAIUsage + imageCount int } func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string) (*openaiStreamingResult, error) { @@ -4058,6 +4224,7 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp } usage := &OpenAIUsage{} + imageCounter := newOpenAIImageOutputCounter() var firstTokenMs *int scanner := bufio.NewScanner(resp.Body) maxLineSize := defaultMaxLineSize @@ -4136,7 +4303,7 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp needModelReplace := originalModel != mappedModel resultWithUsage := func() *openaiStreamingResult { - return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs} + return &openaiStreamingResult{usage: usage, firstTokenMs: firstTokenMs, imageCount: imageCounter.Count()} } finalizeStream := func() (*openaiStreamingResult, error) { if !sawTerminalEvent { @@ -4231,6 +4398,7 @@ func (s *OpenAIGatewayService) handleStreamingResponse(ctx context.Context, resp forceFlushFailedEvent = true sawFailedEvent = true } + imageCounter.AddSSEData(dataBytes) // Correct Codex tool calls if needed (apply_patch -> edit, etc.) if correctedData, corrected := s.toolCorrector.CorrectToolCallsInSSEBytes(dataBytes); corrected { @@ -4496,7 +4664,7 @@ func extractOpenAIUsageFromJSONBytes(body []byte) (OpenAIUsage, bool) { }, true } -func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*OpenAIUsage, error) { +func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { body, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError) if err != nil { return nil, err @@ -4542,7 +4710,11 @@ func (s *OpenAIGatewayService) handleNonStreamingResponse(ctx context.Context, r c.Data(resp.StatusCode, contentType, body) - return usage, nil + return &openaiNonStreamingResult{ + OpenAIUsage: usage, + usage: usage, + imageCount: countOpenAIResponseImageOutputsFromJSONBytes(body), + }, nil } func isEventStreamResponse(header http.Header) bool { @@ -4550,7 +4722,7 @@ func isEventStreamResponse(header http.Header) bool { return strings.Contains(contentType, "text/event-stream") } -func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*OpenAIUsage, error) { +func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Context, body []byte, originalModel, mappedModel string) (*openaiNonStreamingResult, error) { bodyText := string(body) finalResponse, ok := extractCodexFinalResponse(bodyText) @@ -4602,21 +4774,29 @@ func (s *OpenAIGatewayService) handleSSEToJSON(resp *http.Response, c *gin.Conte } c.Data(resp.StatusCode, contentType, body) - return usage, nil + return &openaiNonStreamingResult{ + OpenAIUsage: usage, + usage: usage, + imageCount: countOpenAIImageOutputsFromSSEBody(bodyText), + }, nil } func extractOpenAISSETerminalEvent(body string) (string, []byte, bool) { - lines := strings.Split(body, "\n") - for _, line := range lines { - data, ok := extractOpenAISSEDataLine(line) - if !ok || data == "" || data == "[DONE]" { - continue + var terminalType string + var terminalPayload []byte + forEachOpenAISSEDataPayload(body, func(data []byte) { + if terminalPayload != nil { + return } - eventType := strings.TrimSpace(gjson.Get(data, "type").String()) + eventType := strings.TrimSpace(gjson.GetBytes(data, "type").String()) switch eventType { case "response.completed", "response.done", "response.failed", "response.incomplete", "response.cancelled", "response.canceled": - return eventType, []byte(data), true + terminalType = eventType + terminalPayload = append([]byte(nil), data...) } + }) + if terminalPayload != nil { + return terminalType, terminalPayload, true } return "", nil, false } @@ -4651,21 +4831,20 @@ func (s *OpenAIGatewayService) writeOpenAINonStreamingProtocolError(resp *http.R } func extractCodexFinalResponse(body string) ([]byte, bool) { - lines := strings.Split(body, "\n") - for _, line := range lines { - data, ok := extractOpenAISSEDataLine(line) - if !ok { - continue - } - if data == "" || data == "[DONE]" { - continue + var finalResponse []byte + forEachOpenAISSEDataPayload(body, func(data []byte) { + if finalResponse != nil { + return } - eventType := gjson.Get(data, "type").String() + eventType := gjson.GetBytes(data, "type").String() if eventType == "response.done" || eventType == "response.completed" { - if response := gjson.Get(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" { - return []byte(response.Raw), true + if response := gjson.GetBytes(data, "response"); response.Exists() && response.Type == gjson.JSON && response.Raw != "" { + finalResponse = []byte(response.Raw) } } + }) + if finalResponse != nil { + return finalResponse, true } return nil, false } @@ -4677,21 +4856,15 @@ func reconstructResponseOutputFromSSE(bodyText string) ([]byte, bool) { acc := apicompat.NewBufferedResponseAccumulator() imageOutputs := make([]json.RawMessage, 0, 1) seenImages := make(map[string]struct{}) - lines := strings.Split(bodyText, "\n") - for _, line := range lines { - data, ok := extractOpenAISSEDataLine(line) - if !ok || data == "" || data == "[DONE]" { - continue - } - if imageOutput, ok := extractImageGenerationOutputFromSSEData([]byte(data), seenImages); ok { + forEachOpenAISSEDataPayload(bodyText, func(data []byte) { + if imageOutput, ok := extractImageGenerationOutputFromSSEData(data, seenImages); ok { imageOutputs = append(imageOutputs, imageOutput) } var event apicompat.ResponsesStreamEvent - if err := json.Unmarshal([]byte(data), &event); err != nil { - continue + if err := json.Unmarshal(data, &event); err == nil { + acc.ProcessEvent(&event) } - acc.ProcessEvent(&event) - } + }) if !acc.HasContent() && len(imageOutputs) == 0 { return nil, false } @@ -4744,17 +4917,9 @@ func extractImageGenerationOutputFromSSEData(data []byte, seen map[string]struct func (s *OpenAIGatewayService) parseSSEUsageFromBody(body string) *OpenAIUsage { usage := &OpenAIUsage{} - lines := strings.Split(body, "\n") - for _, line := range lines { - data, ok := extractOpenAISSEDataLine(line) - if !ok { - continue - } - if data == "" || data == "[DONE]" { - continue - } - s.parseSSEUsageBytes([]byte(data), usage) - } + forEachOpenAISSEDataPayload(body, func(data []byte) { + s.parseSSEUsageBytes(data, usage) + }) return usage } @@ -5036,8 +5201,14 @@ type OpenAIRecordUsageInput struct { // RecordUsage records usage and deducts balance func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRecordUsageInput) error { + if input == nil { + return errors.New("openai usage input is nil") + } result := input.Result - if s.rateLimitService != nil && input != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI { + if result == nil { + return errors.New("openai usage result is nil") + } + if s.rateLimitService != nil && input.Account != nil && input.Account.Platform == PlatformOpenAI { s.rateLimitService.ResetOpenAI403Counter(ctx, input.Account.ID) } @@ -5074,6 +5245,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec } multiplier = resolver.Resolve(ctx, user.ID, *apiKey.GroupID, apiKey.Group.RateMultiplier) } + imageMultiplier := resolveImageRateMultiplier(apiKey, multiplier) var cost *CostBreakdown var err error @@ -5087,13 +5259,21 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec if input.BillingModelSource == BillingModelSourceRequested && input.OriginalModel != "" { billingModel = input.OriginalModel } + billingModels := usageBillingModelCandidates( + billingModel, + result.BillingModel, + input.ChannelMappedModel, + input.OriginalModel, + result.UpstreamModel, + result.Model, + ) serviceTier := "" if result.ServiceTier != nil { serviceTier = strings.TrimSpace(*result.ServiceTier) } - cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModel, multiplier, tokens, serviceTier) + cost, err = s.calculateOpenAIRecordUsageCost(ctx, result, apiKey, billingModels, multiplier, imageMultiplier, tokens, serviceTier) if err != nil { - cost = &CostBreakdown{ActualCost: 0} + return err } // Determine billing type @@ -5143,7 +5323,11 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec usageLog.TotalCost = cost.TotalCost usageLog.ActualCost = cost.ActualCost } - usageLog.RateMultiplier = multiplier + if result.ImageCount > 0 { + usageLog.RateMultiplier = imageMultiplier + } else { + usageLog.RateMultiplier = multiplier + } usageLog.AccountRateMultiplier = &accountRateMultiplier usageLog.BillingType = billingType usageLog.Stream = result.Stream @@ -5224,14 +5408,45 @@ func (s *OpenAIGatewayService) calculateOpenAIRecordUsageCost( ctx context.Context, result *OpenAIForwardResult, apiKey *APIKey, - billingModel string, + billingModels []string, multiplier float64, + imageMultiplier float64, tokens UsageTokens, serviceTier string, ) (*CostBreakdown, error) { + billingModel := firstUsageBillingModel(billingModels) if result != nil && result.ImageCount > 0 { - return s.calculateOpenAIImageCost(ctx, billingModel, apiKey, result, multiplier), nil + return s.calculateOpenAIImageCost(ctx, billingModel, apiKey, result, imageMultiplier), nil + } + if len(billingModels) == 0 || billingModel == "" { + return nil, errors.New("openai usage billing model is empty") } + var lastErr error + for _, candidate := range billingModels { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + continue + } + cost, err := s.calculateOpenAIRecordUsageTokenCost(ctx, apiKey, candidate, multiplier, tokens, serviceTier) + if err == nil { + return cost, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = errors.New("no non-empty billing model candidates") + } + return nil, fmt.Errorf("calculate OpenAI usage cost failed for billing models %s: %w", strings.Join(billingModels, ","), lastErr) +} + +func (s *OpenAIGatewayService) calculateOpenAIRecordUsageTokenCost( + ctx context.Context, + apiKey *APIKey, + billingModel string, + multiplier float64, + tokens UsageTokens, + serviceTier string, +) (*CostBreakdown, error) { if s.resolver != nil && apiKey.Group != nil { gid := apiKey.Group.ID return s.billingService.CalculateCostUnified(CostInput{ @@ -5262,7 +5477,7 @@ func (s *OpenAIGatewayService) calculateOpenAIImageCost( Ctx: ctx, Model: billingModel, GroupID: &gid, - RequestCount: 1, + RequestCount: result.ImageCount, SizeTier: result.ImageSize, RateMultiplier: multiplier, Resolver: s.resolver, diff --git a/backend/internal/service/openai_gateway_service_test.go b/backend/internal/service/openai_gateway_service_test.go index b55f0d2ce8e..84a2fe714eb 100644 --- a/backend/internal/service/openai_gateway_service_test.go +++ b/backend/internal/service/openai_gateway_service_test.go @@ -1822,6 +1822,29 @@ func TestOpenAIBuildUpstreamRequestCompactForcesJSONAcceptForOAuth(t *testing.T) require.NotEmpty(t, req.Header.Get("Session_Id")) } +func TestOpenAIBuildUpstreamRequestOAuthMessagesBridgeUsesSessionOnly(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + body := []byte(`{"model":"gpt-5.5","prompt_cache_key":"anthropic-metadata-session-1","input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":""}]},{"type":"message","role":"user","content":"hello"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body)) + c.Request.Header.Set("OpenAI-Beta", "responses=experimental") + c.Request.Header.Set("originator", "codex_cli_rs") + + svc := &OpenAIGatewayService{} + account := &Account{ + Type: AccountTypeOAuth, + Credentials: map[string]any{"chatgpt_account_id": "chatgpt-acc"}, + } + + req, err := svc.buildUpstreamRequest(c.Request.Context(), c, account, body, "token", true, "anthropic-metadata-session-1", false) + require.NoError(t, err) + require.NotEmpty(t, req.Header.Get("Session_Id")) + require.Empty(t, req.Header.Get("Conversation_Id")) + require.Empty(t, req.Header.Get("OpenAI-Beta")) + require.Empty(t, req.Header.Get("originator")) +} + func TestOpenAIBuildUpstreamRequestPreservesCompactPathForAPIKeyBaseURL(t *testing.T) { gin.SetMode(gin.TestMode) rec := httptest.NewRecorder() diff --git a/backend/internal/service/openai_image_generation_controls_test.go b/backend/internal/service/openai_image_generation_controls_test.go new file mode 100644 index 00000000000..9ff8b5107b8 --- /dev/null +++ b/backend/internal/service/openai_image_generation_controls_test.go @@ -0,0 +1,378 @@ +package service + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestOpenAIGatewayServiceForward_RejectsDisabledImageGenerationIntents(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + body []byte + }{ + { + name: "image model", + body: []byte(`{"model":"gpt-image-2","input":"draw"}`), + }, + { + name: "image tool", + body: []byte(`{"model":"gpt-5.4","input":"draw","tools":[{"type":"image_generation"}]}`), + }, + { + name: "image tool choice", + body: []byte(`{"model":"gpt-5.4","input":"draw","tool_choice":{"type":"image_generation"}}`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{} + svc := newOpenAIImageGenerationControlTestService(upstream) + c, recorder := newOpenAIImageGenerationControlTestContext(false, "unit-test-agent/1.0") + account := newOpenAIImageGenerationControlTestAccount() + + result, err := svc.Forward(context.Background(), c, account, tt.body) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, http.StatusForbidden, recorder.Code) + require.Equal(t, "permission_error", gjson.GetBytes(recorder.Body.Bytes(), "error.type").String()) + require.Nil(t, upstream.lastReq, "disabled image request must not reach upstream") + }) + } +} + +func TestOpenAIGatewayServiceForward_DisabledGroupAllowsTextOnlyResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_text","model":"gpt-5.4","usage":{"input_tokens":3,"output_tokens":2}}`)), + }, + } + svc := newOpenAIImageGenerationControlTestService(upstream) + c, recorder := newOpenAIImageGenerationControlTestContext(false, "unit-test-agent/1.0") + account := newOpenAIImageGenerationControlTestAccount() + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`)) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 3, result.Usage.InputTokens) + require.Equal(t, 2, result.Usage.OutputTokens) + require.Equal(t, 0, result.ImageCount) + require.NotNil(t, upstream.lastReq) +} + +func TestOpenAIGatewayServiceForward_CodexImageInjectionRespectsGroupCapability(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + allowImages bool + bridgeEnabled bool + wantInjected bool + }{ + {name: "disabled group skips injection", allowImages: false, bridgeEnabled: true, wantInjected: false}, + {name: "enabled group skips injection by default", allowImages: true, bridgeEnabled: false, wantInjected: false}, + {name: "enabled group injects image tool when bridge enabled", allowImages: true, bridgeEnabled: true, wantInjected: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_codex","model":"gpt-5.4","usage":{"input_tokens":1,"output_tokens":1}}`)), + }, + } + svc := newOpenAIImageGenerationControlTestService(upstream) + svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = tt.bridgeEnabled + c, _ := newOpenAIImageGenerationControlTestContext(tt.allowImages, "codex_cli_rs/0.98.0") + account := newOpenAIImageGenerationControlTestAccount() + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`)) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + hasImageTool := gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists() + require.Equal(t, tt.wantInjected, hasImageTool) + instructions := gjson.GetBytes(upstream.lastBody, "instructions").String() + require.Equal(t, tt.wantInjected, strings.Contains(instructions, "image_generation")) + }) + } +} + +func TestOpenAIGatewayServiceForward_ExplicitImageToolWorksWithBridgeDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_explicit_image","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1}}`)), + }, + } + svc := newOpenAIImageGenerationControlTestService(upstream) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.98.0") + account := newOpenAIImageGenerationControlTestAccount() + body := []byte(`{"model":"gpt-5.4","input":"draw","stream":false,"tools":[{"type":"image_generation","format":"jpeg"}]}`) + + result, err := svc.Forward(context.Background(), c, account, body) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists()) + require.Equal(t, "jpeg", gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation").output_format`).String()) + require.False(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation").format`).Exists()) + instructions := gjson.GetBytes(upstream.lastBody, "instructions").String() + require.NotContains(t, instructions, "image_generation") +} + +func TestOpenAIGatewayServiceForward_ChannelBridgeOverrideEnablesCodexInjection(t *testing.T) { + gin.SetMode(gin.TestMode) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"id":"resp_channel_bridge","model":"gpt-5.4","usage":{"input_tokens":1,"output_tokens":1}}`)), + }, + } + svc := newOpenAIImageGenerationControlTestService(upstream) + groupID := int64(4242) + svc.channelService = newOpenAIImageGenerationControlChannelService(groupID, &Channel{ + ID: 9001, + Status: StatusActive, + FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }, + }) + c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.98.0") + account := newOpenAIImageGenerationControlTestAccount() + + result, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","input":"write code","stream":false}`)) + + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, upstream.lastReq) + require.True(t, gjson.GetBytes(upstream.lastBody, `tools.#(type=="image_generation")`).Exists()) + instructions := gjson.GetBytes(upstream.lastBody, "instructions").String() + require.Contains(t, instructions, "image_generation") +} + +func TestOpenAIGatewayService_CodexImageGenerationBridgeOverridePrecedence(t *testing.T) { + groupID := int64(4242) + + tests := []struct { + name string + global bool + channel *Channel + account *Account + want bool + }{ + { + name: "global default enables bridge", + global: true, + account: &Account{ + Platform: PlatformOpenAI, + }, + want: true, + }, + { + name: "channel true overrides disabled global", + global: false, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }}, + account: &Account{Platform: PlatformOpenAI}, + want: true, + }, + { + name: "channel false overrides enabled global", + global: true, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: false}, + }}, + account: &Account{Platform: PlatformOpenAI}, + want: false, + }, + { + name: "account false overrides channel and global true", + global: true, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: true}, + }}, + account: &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{featureKeyCodexImageGenerationBridge: false}, + }, + want: false, + }, + { + name: "nested account true overrides channel false", + global: false, + channel: &Channel{ID: 1, Status: StatusActive, FeaturesConfig: map[string]any{ + featureKeyCodexImageGenerationBridge: map[string]any{PlatformOpenAI: false}, + }}, + account: &Account{ + Platform: PlatformOpenAI, + Extra: map[string]any{ + PlatformOpenAI: map[string]any{"codex_image_generation_bridge_enabled": true}, + }, + }, + want: true, + }, + { + name: "non openai account extra is ignored", + global: false, + account: &Account{ + Platform: PlatformAnthropic, + Extra: map[string]any{featureKeyCodexImageGenerationBridge: true}, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := newOpenAIImageGenerationControlTestService(&httpUpstreamRecorder{}) + svc.cfg.Gateway.CodexImageGenerationBridgeEnabled = tt.global + if tt.channel != nil { + svc.channelService = newOpenAIImageGenerationControlChannelService(groupID, tt.channel) + } + apiKey := &APIKey{GroupID: &groupID} + + got := svc.isCodexImageGenerationBridgeEnabled(context.Background(), tt.account, apiKey) + + require.Equal(t, tt.want, got) + }) + } +} + +func TestOpenAIGatewayServiceHandleResponsesImageOutputs_NonStreaming(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := newOpenAIImageGenerationControlTestService(&httpUpstreamRecorder{}) + c, _ := newOpenAIImageGenerationControlTestContext(true, "unit-test-agent/1.0") + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{ + "id":"resp_image_json", + "model":"gpt-5.4", + "output":[{"id":"ig_json_1","type":"image_generation_call","result":"final-image"}], + "usage":{"input_tokens":7,"output_tokens":3,"output_tokens_details":{"image_tokens":2}} + }`)), + } + + result, err := svc.handleNonStreamingResponse(context.Background(), resp, c, &Account{ID: 1, Type: AccountTypeAPIKey}, "gpt-5.4", "gpt-5.4") + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.imageCount) + require.NotNil(t, result.usage) + require.Equal(t, 7, result.usage.InputTokens) + require.Equal(t, 3, result.usage.OutputTokens) + require.Equal(t, 2, result.usage.ImageOutputTokens) +} + +func TestOpenAIGatewayServiceHandleResponsesImageOutputs_Streaming(t *testing.T) { + gin.SetMode(gin.TestMode) + + svc := newOpenAIImageGenerationControlTestService(&httpUpstreamRecorder{}) + c, _ := newOpenAIImageGenerationControlTestContext(true, "unit-test-agent/1.0") + resp := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"ig_stream_1\",\"type\":\"image_generation_call\",\"result\":\"final-image\"}}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_image_stream\",\"model\":\"gpt-5.5\",\"output\":[{\"id\":\"ig_stream_1\",\"type\":\"image_generation_call\",\"result\":\"final-image\"}],\"usage\":{\"input_tokens\":11,\"output_tokens\":5,\"output_tokens_details\":{\"image_tokens\":4}}}}\n\n", + )), + } + + result, err := svc.handleStreamingResponse(context.Background(), resp, c, &Account{ID: 1}, time.Now(), "gpt-5.5", "gpt-5.5") + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.imageCount) + require.NotNil(t, result.usage) + require.Equal(t, 11, result.usage.InputTokens) + require.Equal(t, 5, result.usage.OutputTokens) + require.Equal(t, 4, result.usage.ImageOutputTokens) +} + +func newOpenAIImageGenerationControlTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService { + cfg := &config.Config{} + return &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: upstream, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + } +} + +func newOpenAIImageGenerationControlChannelService(groupID int64, ch *Channel) *ChannelService { + svc := &ChannelService{} + cache := newEmptyChannelCache() + if ch != nil { + cache.channelByGroupID[groupID] = ch + cache.byID[ch.ID] = ch + } + cache.loadedAt = time.Now() + svc.cache.Store(cache) + return svc +} + +func newOpenAIImageGenerationControlTestContext(allowImages bool, userAgent string) (*gin.Context, *httptest.ResponseRecorder) { + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + c.Request.Header.Set("User-Agent", userAgent) + groupID := int64(4242) + c.Set("api_key", &APIKey{ + ID: 2424, + GroupID: &groupID, + Group: &Group{ + ID: groupID, + AllowImageGeneration: allowImages, + RateMultiplier: 1, + ImageRateMultiplier: 1, + }, + }) + return c, recorder +} + +func newOpenAIImageGenerationControlTestAccount() *Account { + return &Account{ + ID: 5151, + Name: "openai-image-controls", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + }, + } +} diff --git a/backend/internal/service/openai_images.go b/backend/internal/service/openai_images.go index 3da76525fb7..dc4dad9bb05 100644 --- a/backend/internal/service/openai_images.go +++ b/backend/internal/service/openai_images.go @@ -13,9 +13,13 @@ import ( "mime" "mime/multipart" "net/http" + "net/http/httptest" "net/textproto" + "net/url" + "regexp" "strconv" "strings" + "sync/atomic" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" @@ -89,6 +93,69 @@ type OpenAIImagesRequest struct { bodyHash string } +func (r *OpenAIImagesRequest) ModerationBody() []byte { + if r == nil { + return nil + } + payload := map[string]any{} + if prompt := strings.TrimSpace(r.Prompt); prompt != "" { + payload["prompt"] = prompt + } + images := r.moderationImages() + if len(images) > 0 { + payload["images"] = images + } + if len(payload) == 0 { + return nil + } + body, err := json.Marshal(payload) + if err != nil { + return nil + } + return body +} + +func (r *OpenAIImagesRequest) moderationImages() []map[string]string { + if r == nil { + return nil + } + images := make([]map[string]string, 0, len(r.InputImageURLs)+len(r.Uploads)+1) + for _, imageURL := range r.InputImageURLs { + imageURL = strings.TrimSpace(imageURL) + if imageURL != "" { + images = append(images, map[string]string{"image_url": imageURL}) + } + } + for _, upload := range r.Uploads { + if dataURL := upload.ModerationDataURL(); dataURL != "" { + images = append(images, map[string]string{"image_url": dataURL}) + } + } + if maskURL := strings.TrimSpace(r.MaskImageURL); maskURL != "" { + images = append(images, map[string]string{"image_url": maskURL}) + } + if r.MaskUpload != nil { + if dataURL := r.MaskUpload.ModerationDataURL(); dataURL != "" { + images = append(images, map[string]string{"image_url": dataURL}) + } + } + return images +} + +func (u OpenAIImagesUpload) ModerationDataURL() string { + if len(u.Data) == 0 { + return "" + } + contentType := strings.TrimSpace(u.ContentType) + if contentType == "" { + contentType = http.DetectContentType(u.Data) + } + if !strings.HasPrefix(strings.ToLower(contentType), "image/") { + return "" + } + return fmt.Sprintf("data:%s;base64,%s", contentType, base64.StdEncoding.EncodeToString(u.Data)) +} + func (r *OpenAIImagesRequest) IsEdits() bool { return r != nil && r.Endpoint == openAIImagesEditsEndpoint } @@ -408,6 +475,10 @@ func validateOpenAIImagesModel(model string) error { func normalizeOpenAIImagesEndpointPath(path string) string { trimmed := strings.TrimSpace(path) switch { + case strings.Contains(trimmed, "/images/async/generations"): + return openAIImagesGenerationsEndpoint + case strings.Contains(trimmed, "/images/async/edits"): + return openAIImagesEditsEndpoint case strings.Contains(trimmed, "/images/generations"): return openAIImagesGenerationsEndpoint case strings.Contains(trimmed, "/images/edits"): @@ -468,14 +539,54 @@ func isOpenAINativeImageOption(name string) bool { } func normalizeOpenAIImageSizeTier(size string) string { - switch strings.ToLower(strings.TrimSpace(size)) { + trimmed := strings.TrimSpace(size) + normalized := strings.ToLower(trimmed) + switch normalized { + case "", "auto": + return "2K" case "1024x1024": return "1K" - case "1536x1024", "1024x1536", "1792x1024", "1024x1792", "", "auto": + case "1536x1024", "1024x1536", "1792x1024", "1024x1792", "2048x2048", "2048x1152", "1152x2048": return "2K" - default: + case "3072x2048", "2048x3072", "3840x2160", "2160x3840": + return "4K" + } + width, height, ok := parseOpenAIImageSizeDimensions(trimmed) + if !ok { return "2K" } + return classifyUnknownOpenAIImageSizeTier(width, height) +} + +const ( + openAIImage2KMaxPixels = 2560 * 1440 +) + +func parseOpenAIImageSizeDimensions(size string) (int, int, bool) { + trimmed := strings.TrimSpace(size) + parts := strings.Split(strings.ToLower(trimmed), "x") + if len(parts) != 2 { + return 0, 0, false + } + width, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return 0, 0, false + } + height, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return 0, 0, false + } + if width <= 0 || height <= 0 { + return 0, 0, false + } + return width, height, true +} + +func classifyUnknownOpenAIImageSizeTier(width int, height int) string { + if height > 0 && width > openAIImage2KMaxPixels/height { + return "4K" + } + return "2K" } func (s *OpenAIGatewayService) ForwardImages( @@ -499,6 +610,42 @@ func (s *OpenAIGatewayService) ForwardImages( } } +func (s *OpenAIGatewayService) ForwardImagesBuffered( + ctx context.Context, + account *Account, + body []byte, + parsed *OpenAIImagesRequest, + channelMappedModel string, +) (*OpenAIForwardResult, []OpenAIImageResultAsset, error) { + if parsed == nil { + return nil, nil, fmt.Errorf("parsed images request is required") + } + buffered := *parsed + buffered.Stream = false + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = &http.Request{ + Method: http.MethodPost, + URL: mustParseOpenAIImagesURL(buffered.Endpoint), + Header: make(http.Header), + } + if strings.TrimSpace(buffered.ContentType) != "" { + c.Request.Header.Set("Content-Type", buffered.ContentType) + } + result, err := s.ForwardImages(ctx, c, account, body, &buffered, channelMappedModel) + if err != nil { + return nil, nil, err + } + if rec.Code >= 400 { + return nil, nil, fmt.Errorf("upstream image request failed with status %d", rec.Code) + } + assets, err := ExtractOpenAIImageResultAssets(rec.Body.Bytes()) + if err != nil { + return nil, nil, fmt.Errorf("%w: %s", err, truncateOpenAIImageResponsePreview(rec.Body.String())) + } + return result, assets, nil +} + func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( ctx context.Context, c *gin.Context, @@ -535,11 +682,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( setOpsUpstreamRequestBody(c, forwardBody) } - token, _, err := s.GetAccessToken(ctx, account) + upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, parsed.Stream) + defer releaseUpstreamCtx() + + token, _, err := s.GetAccessToken(upstreamCtx, account) if err != nil { return nil, err } - upstreamReq, err := s.buildOpenAIImagesRequest(ctx, c, account, forwardBody, forwardContentType, token, parsed.Endpoint) + upstreamReq, err := s.buildOpenAIImagesRequest(upstreamCtx, c, account, forwardBody, forwardContentType, token, parsed.Endpoint) if err != nil { return nil, err } @@ -582,14 +732,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( Kind: "failover", Message: upstreamMsg, }) - s.handleFailoverSideEffects(ctx, resp, account) + s.handleFailoverSideEffects(upstreamCtx, resp, account) return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode), } } - return s.handleErrorResponse(ctx, resp, c, account, forwardBody) + return s.handleErrorResponse(upstreamCtx, resp, c, account, forwardBody) } defer func() { _ = resp.Body.Close() }() @@ -599,6 +749,20 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( if parsed.Stream && isEventStreamResponse(resp.Header) { streamUsage, streamCount, ttft, err := s.handleOpenAIImagesStreamingResponse(resp, c, startTime) if err != nil { + if streamCount > 0 { + return &OpenAIForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + Usage: streamUsage, + Model: requestModel, + UpstreamModel: upstreamModel, + Stream: parsed.Stream, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + FirstTokenMs: ttft, + ImageCount: streamCount, + ImageSize: parsed.SizeTier, + }, err + } return nil, err } usage = streamUsage @@ -628,6 +792,72 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesAPIKey( }, nil } +type OpenAIImageResultAsset struct { + Data []byte + MimeType string + RevisedPrompt string +} + +func ExtractOpenAIImageResultAssets(body []byte) ([]OpenAIImageResultAsset, error) { + if len(body) == 0 || !gjson.ValidBytes(body) { + return nil, fmt.Errorf("invalid image response body") + } + var assets []OpenAIImageResultAsset + for _, item := range gjson.GetBytes(body, "data").Array() { + raw := strings.TrimSpace(item.Get("b64_json").String()) + if raw == "" { + raw = strings.TrimSpace(item.Get("url").String()) + } + normalized := normalizeOpenAIImageBase64(raw) + if normalized == "" { + continue + } + data, err := base64.StdEncoding.DecodeString(normalized) + if err != nil { + return nil, err + } + assets = append(assets, OpenAIImageResultAsset{ + Data: data, + MimeType: inferImageMimeType(data), + RevisedPrompt: strings.TrimSpace(item.Get("revised_prompt").String()), + }) + } + if len(assets) == 0 { + return nil, fmt.Errorf("no inline images returned") + } + return assets, nil +} + +func truncateOpenAIImageResponsePreview(body string) string { + body = strings.TrimSpace(body) + if len(body) > 512 { + return body[:512] + } + return body +} + +func inferImageMimeType(data []byte) string { + if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" { + return "image/webp" + } + mimeType := http.DetectContentType(data) + if strings.HasPrefix(mimeType, "image/") { + return mimeType + } + return "image/png" +} + +var openAIImagesEndpointURLPattern = regexp.MustCompile(`^/v1/images/(generations|edits)$`) + +func mustParseOpenAIImagesURL(endpoint string) *url.URL { + endpoint = strings.TrimSpace(endpoint) + if !openAIImagesEndpointURLPattern.MatchString(endpoint) { + endpoint = openAIImagesGenerationsEndpoint + } + u, _ := url.Parse(endpoint) + return u +} + func (s *OpenAIGatewayService) buildOpenAIImagesRequest( ctx context.Context, c *gin.Context, @@ -807,66 +1037,205 @@ func (s *OpenAIGatewayService) handleOpenAIImagesStreamingResponse( return OpenAIUsage{}, 0, nil, fmt.Errorf("streaming is not supported by response writer") } - reader := bufio.NewReader(resp.Body) usage := OpenAIUsage{} - imageCount := 0 + imageCounter := newOpenAIImageOutputCounter() var firstTokenMs *int + clientDisconnected := false + lastDownstreamWriteAt := time.Now() var fallbackBody bytes.Buffer fallbackBytes := int64(0) fallbackLimit := resolveUpstreamResponseReadLimit(s.cfg) seenSSEData := false fallbackTooLarge := false + var sseData openAISSEDataAccumulator - for { - line, err := reader.ReadBytes('\n') - if len(line) > 0 { - if firstTokenMs == nil { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } + processSSEData := func(dataBytes []byte) { + seenSSEData = true + fallbackBody.Reset() + fallbackBytes = 0 + mergeOpenAIUsage(&usage, dataBytes) + imageCounter.AddSSEData(dataBytes) + } + + flushSSEEvent := func() { + sseData.Flush(processSSEData) + } + + processLine := func(line []byte) { + if len(line) == 0 { + return + } + if firstTokenMs == nil { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + if !clientDisconnected { if _, writeErr := c.Writer.Write(line); writeErr != nil { - return OpenAIUsage{}, 0, firstTokenMs, writeErr + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream client disconnected, continue draining upstream for billing") + } else { + flusher.Flush() + lastDownstreamWriteAt = time.Now() } - flusher.Flush() + } - if data, ok := extractOpenAISSEDataLine(strings.TrimRight(string(line), "\r\n")); ok { - if data != "" && data != "[DONE]" { - seenSSEData = true - fallbackBody.Reset() - fallbackBytes = 0 - dataBytes := []byte(data) - mergeOpenAIUsage(&usage, dataBytes) - if count := extractOpenAIImagesBillableCountFromJSONBytes(dataBytes); count > imageCount { - imageCount = count - } - } - } else if !seenSSEData && !fallbackTooLarge { - fallbackBytes += int64(len(line)) - if fallbackBytes <= fallbackLimit { - _, _ = fallbackBody.Write(line) - } else { - fallbackTooLarge = true - fallbackBody.Reset() - } + trimmedLine := strings.TrimRight(string(line), "\r\n") + if _, ok := extractOpenAISSEDataLine(trimmedLine); ok || strings.TrimSpace(trimmedLine) == "" { + sseData.AddLine(trimmedLine, processSSEData) + return + } + if !seenSSEData && !fallbackTooLarge { + fallbackBytes += int64(len(line)) + if fallbackBytes <= fallbackLimit { + _, _ = fallbackBody.Write(line) + } else { + fallbackTooLarge = true + fallbackBody.Reset() } } - if err == io.EOF { - break + } + + finalizeFallbackBody := func() { + if seenSSEData || fallbackBody.Len() == 0 { + return } - if err != nil { - return OpenAIUsage{}, 0, firstTokenMs, err + body := bytes.TrimSpace(fallbackBody.Bytes()) + if len(body) == 0 { + return } + mergeOpenAIUsage(&usage, body) + imageCounter.AddJSONResponse(body) } - if !seenSSEData && fallbackBody.Len() > 0 { - body := bytes.TrimSpace(fallbackBody.Bytes()) - if len(body) > 0 { - mergeOpenAIUsage(&usage, body) - if count := extractOpenAIImagesBillableCountFromJSONBytes(body); count > imageCount { - imageCount = count + + streamInterval := s.openAIImageStreamDataInterval() + keepaliveInterval := s.openAIImageStreamKeepaliveInterval() + if streamInterval <= 0 && keepaliveInterval <= 0 { + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadBytes('\n') + processLine(line) + if err == io.EOF { + break + } + if err != nil { + flushSSEEvent() + return usage, imageCounter.Count(), firstTokenMs, err + } + } + flushSSEEvent() + finalizeFallbackBody() + return usage, imageCounter.Count(), firstTokenMs, nil + } + + type readEvent struct { + line []byte + err error + } + events := make(chan readEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev readEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func() { + defer close(events) + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + } + if len(line) > 0 && !sendEvent(readEvent{line: line}) { + return + } + if err == io.EOF { + return + } + if err != nil { + _ = sendEvent(readEvent{err: err}) + return + } + } + }() + defer close(done) + + 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 + } + + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + + for { + select { + case ev, ok := <-events: + if !ok { + flushSSEEvent() + finalizeFallbackBody() + return usage, imageCounter.Count(), firstTokenMs, nil + } + if ev.err != nil { + flushSSEEvent() + return usage, imageCounter.Count(), firstTokenMs, ev.err + } + processLine(ev.line) + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + if clientDisconnected { + return usage, imageCounter.Count(), firstTokenMs, fmt.Errorf("image stream incomplete after timeout") + } + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream data interval timeout: interval=%s", streamInterval) + _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval))) + return usage, imageCounter.Count(), firstTokenMs, fmt.Errorf("image stream data interval timeout") + case <-keepaliveCh: + if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval { + continue } + if _, writeErr := io.WriteString(c.Writer, ":\n\n"); writeErr != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream client disconnected during keepalive, continue draining upstream for billing") + continue + } + flusher.Flush() + lastDownstreamWriteAt = time.Now() } } - return usage, imageCount, firstTokenMs, nil +} + +func (s *OpenAIGatewayService) openAIImageStreamDataInterval() time.Duration { + if s == nil || s.cfg == nil || s.cfg.Gateway.ImageStreamDataIntervalTimeout <= 0 { + return 0 + } + return time.Duration(s.cfg.Gateway.ImageStreamDataIntervalTimeout) * time.Second +} + +func (s *OpenAIGatewayService) openAIImageStreamKeepaliveInterval() time.Duration { + if s == nil || s.cfg == nil || s.cfg.Gateway.ImageStreamKeepaliveInterval <= 0 { + return 0 + } + return time.Duration(s.cfg.Gateway.ImageStreamKeepaliveInterval) * time.Second } func extractOpenAIImagesBillableCountFromJSONBytes(body []byte) int { @@ -913,14 +1282,7 @@ func mergeOpenAIUsage(dst *OpenAIUsage, body []byte) { } func extractOpenAIImageCountFromJSONBytes(body []byte) int { - if len(body) == 0 || !gjson.ValidBytes(body) { - return 0 - } - data := gjson.GetBytes(body, "data") - if data.Exists() && data.IsArray() { - return len(data.Array()) - } - return 0 + return countOpenAIResponseImageOutputsFromJSONBytes(body) } type openAIImagePointerInfo struct { @@ -1090,7 +1452,8 @@ func normalizeOpenAIImageBase64(raw string) string { } } raw = strings.TrimSpace(raw) - raw = strings.TrimRight(raw, "=") + strings.Repeat("=", (4-len(raw)%4)%4) + raw = strings.TrimRight(raw, "=") + raw += strings.Repeat("=", (4-len(raw)%4)%4) if raw == "" { return "" } diff --git a/backend/internal/service/openai_images_responses.go b/backend/internal/service/openai_images_responses.go index 64d995e138f..dd9489cece7 100644 --- a/backend/internal/service/openai_images_responses.go +++ b/backend/internal/service/openai_images_responses.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "strings" + "sync/atomic" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" @@ -361,21 +362,21 @@ func collectOpenAIImagesFromResponsesBody(body []byte) ([]openAIResponsesImageRe var ( fallbackResults []openAIResponsesImageResult fallbackSeen = make(map[string]struct{}) + finalResults []openAIResponsesImageResult + finalMeta openAIResponsesImageResult + collectErr error createdAt int64 usageRaw []byte foundFinal bool responseMeta openAIResponsesImageResult ) - for _, line := range bytes.Split(body, []byte("\n")) { - line = bytes.TrimRight(line, "\r") - data, ok := extractOpenAISSEDataLine(string(line)) - if !ok || data == "" || data == "[DONE]" { - continue + forEachOpenAISSEDataPayload(string(body), func(payload []byte) { + if collectErr != nil || len(finalResults) > 0 { + return } - payload := []byte(data) if !gjson.ValidBytes(payload) { - continue + return } if meta, eventCreatedAt, ok := extractOpenAIResponsesImageMetaFromLifecycleEvent(payload); ok { mergeOpenAIResponsesImageMeta(&responseMeta, meta) @@ -385,10 +386,18 @@ func collectOpenAIImagesFromResponsesBody(body []byte) ([]openAIResponsesImageRe } switch gjson.GetBytes(payload, "type").String() { + case "response.failed": + msg := strings.TrimSpace(extractOpenAISSEErrorMessage(payload)) + if msg == "" { + msg = "upstream response failed" + } + collectErr = fmt.Errorf("upstream response failed: %s", msg) + return case "response.output_item.done": result, itemID, ok, err := extractOpenAIImageFromResponsesOutputItemDone(payload) if err != nil { - return nil, 0, nil, openAIResponsesImageResult{}, false, err + collectErr = err + return } if ok { mergeOpenAIResponsesImageMeta(&result, responseMeta) @@ -397,7 +406,8 @@ func collectOpenAIImagesFromResponsesBody(body []byte) ([]openAIResponsesImageRe case "response.completed": results, completedAt, completedUsageRaw, firstMeta, err := extractOpenAIImagesFromResponsesCompleted(payload) if err != nil { - return nil, 0, nil, openAIResponsesImageResult{}, false, err + collectErr = err + return } foundFinal = true if completedAt > 0 { @@ -408,14 +418,24 @@ func collectOpenAIImagesFromResponsesBody(body []byte) ([]openAIResponsesImageRe } if len(results) > 0 { mergeOpenAIResponsesImageMeta(&firstMeta, responseMeta) - return results, createdAt, usageRaw, firstMeta, true, nil + finalResults = results + finalMeta = firstMeta + return } if len(fallbackResults) > 0 { firstMeta = fallbackResults[0] mergeOpenAIResponsesImageMeta(&firstMeta, responseMeta) - return fallbackResults, createdAt, usageRaw, firstMeta, true, nil + finalResults = fallbackResults + finalMeta = firstMeta + return } } + }) + if collectErr != nil { + return nil, 0, nil, openAIResponsesImageResult{}, false, collectErr + } + if len(finalResults) > 0 { + return finalResults, createdAt, usageRaw, finalMeta, true, nil } if len(fallbackResults) > 0 { @@ -505,6 +525,30 @@ func (s *OpenAIGatewayService) writeOpenAIImagesStreamEvent(c *gin.Context, flus return nil } +func (s *OpenAIGatewayService) tryWriteOpenAIImagesStreamEvent( + c *gin.Context, + flusher http.Flusher, + clientDisconnected *bool, + lastWriteAt *time.Time, + eventName string, + payload []byte, +) bool { + if clientDisconnected != nil && *clientDisconnected { + return false + } + if err := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); err != nil { + if clientDisconnected != nil { + *clientDisconnected = true + } + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images stream client disconnected, continue draining upstream for billing") + return false + } + if lastWriteAt != nil { + *lastWriteAt = time.Now() + } + return true +} + func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse( resp *http.Response, c *gin.Context, @@ -517,15 +561,9 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthNonStreamingResponse( } var usage OpenAIUsage - for _, line := range bytes.Split(body, []byte("\n")) { - line = bytes.TrimRight(line, "\r") - data, ok := extractOpenAISSEDataLine(string(line)) - if !ok || data == "" || data == "[DONE]" { - continue - } - dataBytes := []byte(data) - s.parseSSEUsageBytes(dataBytes, &usage) - } + forEachOpenAISSEDataPayload(string(body), func(data []byte) { + s.parseSSEUsageBytes(data, &usage) + }) results, createdAt, usageRaw, firstMeta, _, err := collectOpenAIImagesFromResponsesBody(body) if err != nil { return OpenAIUsage{}, 0, err @@ -570,7 +608,6 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( format = "b64_json" } - reader := bufio.NewReader(resp.Body) usage := OpenAIUsage{} imageCount := 0 var firstTokenMs *int @@ -579,141 +616,307 @@ func (s *OpenAIGatewayService) handleOpenAIImagesOAuthStreamingResponse( pendingSeen := make(map[string]struct{}) streamMeta := openAIResponsesImageResult{Model: strings.TrimSpace(fallbackModel)} var createdAt int64 + clientDisconnected := false + lastDownstreamWriteAt := time.Now() + var sseData openAISSEDataAccumulator + var processDataErr error + processDataDone := false - for { - line, err := reader.ReadBytes('\n') - if len(line) > 0 { - trimmedLine := strings.TrimRight(string(line), "\r\n") - data, ok := extractOpenAISSEDataLine(trimmedLine) - if ok && data != "" && data != "[DONE]" { - if firstTokenMs == nil { - ms := int(time.Since(startTime).Milliseconds()) - firstTokenMs = &ms - } - dataBytes := []byte(data) - s.parseSSEUsageBytes(dataBytes, &usage) - if gjson.ValidBytes(dataBytes) { - if meta, eventCreatedAt, ok := extractOpenAIResponsesImageMetaFromLifecycleEvent(dataBytes); ok { - mergeOpenAIResponsesImageMeta(&streamMeta, meta) - if eventCreatedAt > 0 { - createdAt = eventCreatedAt - } - } - switch gjson.GetBytes(dataBytes, "type").String() { - case "response.image_generation_call.partial_image": - b64 := strings.TrimSpace(gjson.GetBytes(dataBytes, "partial_image_b64").String()) - if b64 != "" { - eventName := streamPrefix + ".partial_image" - partialMeta := streamMeta - mergeOpenAIResponsesImageMeta(&partialMeta, openAIResponsesImageResult{ - OutputFormat: strings.TrimSpace(gjson.GetBytes(dataBytes, "output_format").String()), - Background: strings.TrimSpace(gjson.GetBytes(dataBytes, "background").String()), - }) - payload := buildOpenAIImagesStreamPartialPayload( - eventName, - b64, - gjson.GetBytes(dataBytes, "partial_image_index").Int(), - format, - createdAt, - partialMeta, - ) - if writeErr := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); writeErr != nil { - return OpenAIUsage{}, imageCount, firstTokenMs, writeErr - } - } - case "response.output_item.done": - img, itemID, ok, extractErr := extractOpenAIImageFromResponsesOutputItemDone(dataBytes) - if extractErr != nil { - _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error())) - return OpenAIUsage{}, imageCount, firstTokenMs, extractErr - } - if !ok { - break - } - mergeOpenAIResponsesImageMeta(&streamMeta, img) - mergeOpenAIResponsesImageMeta(&img, streamMeta) - key := openAIResponsesImageResultKey(itemID, img) - if _, exists := emitted[key]; exists { - break - } - if _, exists := pendingSeen[key]; exists { - break - } - pendingSeen[key] = struct{}{} - pendingResults = append(pendingResults, img) - case "response.completed": - results, _, usageRaw, firstMeta, extractErr := extractOpenAIImagesFromResponsesCompleted(dataBytes) - if extractErr != nil { - _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error())) - return OpenAIUsage{}, imageCount, firstTokenMs, extractErr - } - mergeOpenAIResponsesImageMeta(&streamMeta, firstMeta) - finalResults := make([]openAIResponsesImageResult, 0, len(results)+len(pendingResults)) - finalSeen := make(map[string]struct{}) - for _, img := range results { - mergeOpenAIResponsesImageMeta(&img, streamMeta) - appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img) - } - for _, img := range pendingResults { - mergeOpenAIResponsesImageMeta(&img, streamMeta) - appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img) - } - if len(finalResults) == 0 { - err = fmt.Errorf("upstream did not return image output") - _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(err.Error())) - return OpenAIUsage{}, imageCount, firstTokenMs, err - } - eventName := streamPrefix + ".completed" - for _, img := range finalResults { - key := openAIResponsesImageResultKey("", img) - if _, exists := emitted[key]; exists { - continue - } - payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, usageRaw) - if writeErr := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); writeErr != nil { - return OpenAIUsage{}, imageCount, firstTokenMs, writeErr - } - emitted[key] = struct{}{} - } - imageCount = len(emitted) - return usage, imageCount, firstTokenMs, nil - } + processData := func(dataBytes []byte) { + if processDataDone || processDataErr != nil { + return + } + if firstTokenMs == nil { + ms := int(time.Since(startTime).Milliseconds()) + firstTokenMs = &ms + } + s.parseSSEUsageBytes(dataBytes, &usage) + if !gjson.ValidBytes(dataBytes) { + return + } + if meta, eventCreatedAt, ok := extractOpenAIResponsesImageMetaFromLifecycleEvent(dataBytes); ok { + mergeOpenAIResponsesImageMeta(&streamMeta, meta) + if eventCreatedAt > 0 { + createdAt = eventCreatedAt + } + } + switch gjson.GetBytes(dataBytes, "type").String() { + case "response.image_generation_call.partial_image": + b64 := strings.TrimSpace(gjson.GetBytes(dataBytes, "partial_image_b64").String()) + if b64 == "" { + return + } + eventName := streamPrefix + ".partial_image" + partialMeta := streamMeta + mergeOpenAIResponsesImageMeta(&partialMeta, openAIResponsesImageResult{ + OutputFormat: strings.TrimSpace(gjson.GetBytes(dataBytes, "output_format").String()), + Background: strings.TrimSpace(gjson.GetBytes(dataBytes, "background").String()), + }) + payload := buildOpenAIImagesStreamPartialPayload( + eventName, + b64, + gjson.GetBytes(dataBytes, "partial_image_index").Int(), + format, + createdAt, + partialMeta, + ) + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload) + case "response.output_item.done": + img, itemID, ok, extractErr := extractOpenAIImageFromResponsesOutputItemDone(dataBytes) + if extractErr != nil { + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error())) + processDataErr = extractErr + processDataDone = true + return + } + if !ok { + return + } + mergeOpenAIResponsesImageMeta(&streamMeta, img) + mergeOpenAIResponsesImageMeta(&img, streamMeta) + key := openAIResponsesImageResultKey(itemID, img) + if _, exists := emitted[key]; exists { + return + } + if _, exists := pendingSeen[key]; exists { + return + } + pendingSeen[key] = struct{}{} + pendingResults = append(pendingResults, img) + case "response.completed": + results, _, usageRaw, firstMeta, extractErr := extractOpenAIImagesFromResponsesCompleted(dataBytes) + if extractErr != nil { + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(extractErr.Error())) + processDataErr = extractErr + processDataDone = true + return + } + mergeOpenAIResponsesImageMeta(&streamMeta, firstMeta) + finalResults := make([]openAIResponsesImageResult, 0, len(results)+len(pendingResults)) + finalSeen := make(map[string]struct{}) + for _, img := range results { + mergeOpenAIResponsesImageMeta(&img, streamMeta) + appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img) + } + for _, img := range pendingResults { + mergeOpenAIResponsesImageMeta(&img, streamMeta) + appendOpenAIResponsesImageResultDedup(&finalResults, finalSeen, "", img) + } + if len(finalResults) == 0 { + outputErr := fmt.Errorf("upstream did not return image output") + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(outputErr.Error())) + processDataErr = outputErr + processDataDone = true + return + } + eventName := streamPrefix + ".completed" + for _, img := range finalResults { + key := openAIResponsesImageResultKey("", img) + if _, exists := emitted[key]; exists { + continue } + payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, usageRaw) + emitted[key] = struct{}{} + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload) } + imageCount = len(emitted) + processDataDone = true } - if err == io.EOF { - break + } + + processLine := func(line []byte) (bool, error) { + if len(line) == 0 { + return false, nil } - if err != nil { - _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(err.Error())) - return OpenAIUsage{}, imageCount, firstTokenMs, err + sseData.AddLine(string(line), processData) + if processDataErr != nil { + return true, processDataErr } + return processDataDone, nil } - if imageCount > 0 { - return usage, imageCount, firstTokenMs, nil + flushData := func() (bool, error) { + sseData.Flush(processData) + if processDataErr != nil { + return true, processDataErr + } + return processDataDone, nil } - if len(pendingResults) > 0 { - eventName := streamPrefix + ".completed" - for _, img := range pendingResults { - mergeOpenAIResponsesImageMeta(&img, streamMeta) - key := openAIResponsesImageResultKey("", img) - if _, exists := emitted[key]; exists { - continue + + finalizePending := func() error { + if imageCount > 0 { + return nil + } + if len(pendingResults) > 0 { + eventName := streamPrefix + ".completed" + for _, img := range pendingResults { + mergeOpenAIResponsesImageMeta(&img, streamMeta) + key := openAIResponsesImageResultKey("", img) + if _, exists := emitted[key]; exists { + continue + } + payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, nil) + emitted[key] = struct{}{} + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, eventName, payload) + } + imageCount = len(emitted) + return nil + } + + streamErr := fmt.Errorf("stream disconnected before image generation completed") + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(streamErr.Error())) + return streamErr + } + + streamInterval := s.openAIImageStreamDataInterval() + keepaliveInterval := s.openAIImageStreamKeepaliveInterval() + if streamInterval <= 0 && keepaliveInterval <= 0 { + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadBytes('\n') + done, processErr := processLine(line) + if processErr != nil { + return usage, imageCount, firstTokenMs, processErr + } + if done { + return usage, imageCount, firstTokenMs, nil + } + if err == io.EOF { + break } - payload := buildOpenAIImagesStreamCompletedPayload(eventName, img, format, createdAt, nil) - if writeErr := s.writeOpenAIImagesStreamEvent(c, flusher, eventName, payload); writeErr != nil { - return OpenAIUsage{}, imageCount, firstTokenMs, writeErr + if err != nil { + if done, processErr := flushData(); processErr != nil { + return usage, imageCount, firstTokenMs, processErr + } else if done { + return usage, imageCount, firstTokenMs, nil + } + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(err.Error())) + return usage, imageCount, firstTokenMs, err } - emitted[key] = struct{}{} } - imageCount = len(emitted) + if done, processErr := flushData(); processErr != nil { + return usage, imageCount, firstTokenMs, processErr + } else if done { + return usage, imageCount, firstTokenMs, nil + } + if err := finalizePending(); err != nil { + return usage, imageCount, firstTokenMs, err + } return usage, imageCount, firstTokenMs, nil } - streamErr := fmt.Errorf("stream disconnected before image generation completed") - _ = s.writeOpenAIImagesStreamEvent(c, flusher, "error", buildOpenAIImagesStreamErrorBody(streamErr.Error())) - return OpenAIUsage{}, imageCount, firstTokenMs, streamErr + type readEvent struct { + line []byte + err error + } + events := make(chan readEvent, 16) + done := make(chan struct{}) + sendEvent := func(ev readEvent) bool { + select { + case events <- ev: + return true + case <-done: + return false + } + } + var lastReadAt int64 + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + go func() { + defer close(events) + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + atomic.StoreInt64(&lastReadAt, time.Now().UnixNano()) + } + if len(line) > 0 && !sendEvent(readEvent{line: line}) { + return + } + if err == io.EOF { + return + } + if err != nil { + _ = sendEvent(readEvent{err: err}) + return + } + } + }() + defer close(done) + + 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 + } + + var keepaliveTicker *time.Ticker + if keepaliveInterval > 0 { + keepaliveTicker = time.NewTicker(keepaliveInterval) + defer keepaliveTicker.Stop() + } + var keepaliveCh <-chan time.Time + if keepaliveTicker != nil { + keepaliveCh = keepaliveTicker.C + } + + for { + select { + case ev, ok := <-events: + if !ok { + if done, processErr := flushData(); processErr != nil { + return usage, imageCount, firstTokenMs, processErr + } else if done { + return usage, imageCount, firstTokenMs, nil + } + if err := finalizePending(); err != nil { + return usage, imageCount, firstTokenMs, err + } + return usage, imageCount, firstTokenMs, nil + } + if ev.err != nil { + if done, processErr := flushData(); processErr != nil { + return usage, imageCount, firstTokenMs, processErr + } else if done { + return usage, imageCount, firstTokenMs, nil + } + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(ev.err.Error())) + return usage, imageCount, firstTokenMs, ev.err + } + done, processErr := processLine(ev.line) + if processErr != nil { + return usage, imageCount, firstTokenMs, processErr + } + if done { + return usage, imageCount, firstTokenMs, nil + } + case <-intervalCh: + lastRead := time.Unix(0, atomic.LoadInt64(&lastReadAt)) + if time.Since(lastRead) < streamInterval { + continue + } + if clientDisconnected { + return usage, imageCount, firstTokenMs, fmt.Errorf("image stream incomplete after timeout") + } + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images responses stream data interval timeout: interval=%s", streamInterval) + s.tryWriteOpenAIImagesStreamEvent(c, flusher, &clientDisconnected, &lastDownstreamWriteAt, "error", buildOpenAIImagesStreamErrorBody(fmt.Sprintf("upstream image stream idle for %s", streamInterval))) + return usage, imageCount, firstTokenMs, fmt.Errorf("image stream data interval timeout") + case <-keepaliveCh: + if clientDisconnected || time.Since(lastDownstreamWriteAt) < keepaliveInterval { + continue + } + if _, writeErr := io.WriteString(c.Writer, ":\n\n"); writeErr != nil { + clientDisconnected = true + logger.LegacyPrintf("service.openai_gateway", "[OpenAI] Images responses stream client disconnected during keepalive, continue draining upstream for billing") + continue + } + flusher.Flush() + lastDownstreamWriteAt = time.Now() + } + } } func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( @@ -752,7 +955,10 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( ) } - token, _, err := s.GetAccessToken(ctx, account) + upstreamCtx, releaseUpstreamCtx := detachStreamUpstreamContext(ctx, parsed.Stream) + defer releaseUpstreamCtx() + + token, _, err := s.GetAccessToken(upstreamCtx, account) if err != nil { return nil, err } @@ -763,7 +969,7 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( } setOpsUpstreamRequestBody(c, responsesBody) - upstreamReq, err := s.buildUpstreamRequest(ctx, c, account, responsesBody, token, true, parsed.StickySessionSeed(), false) + upstreamReq, err := s.buildUpstreamRequest(upstreamCtx, c, account, responsesBody, token, true, parsed.StickySessionSeed(), false) if err != nil { return nil, err } @@ -808,14 +1014,14 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( Kind: "failover", Message: upstreamMsg, }) - s.handleFailoverSideEffects(ctx, resp, account) + s.handleFailoverSideEffects(upstreamCtx, resp, account) return nil, &UpstreamFailoverError{ StatusCode: resp.StatusCode, ResponseBody: respBody, RetryableOnSameAccount: account.IsPoolMode() && isPoolModeRetryableStatus(resp.StatusCode), } } - return s.handleErrorResponse(ctx, resp, c, account, responsesBody) + return s.handleErrorResponse(upstreamCtx, resp, c, account, responsesBody) } defer func() { _ = resp.Body.Close() }() @@ -827,6 +1033,20 @@ func (s *OpenAIGatewayService) forwardOpenAIImagesOAuth( if parsed.Stream { usage, imageCount, firstTokenMs, err = s.handleOpenAIImagesOAuthStreamingResponse(resp, c, startTime, parsed.ResponseFormat, openAIImagesStreamPrefix(parsed), requestModel) if err != nil { + if imageCount > 0 { + return &OpenAIForwardResult{ + RequestID: resp.Header.Get("x-request-id"), + Usage: usage, + Model: requestModel, + UpstreamModel: requestModel, + Stream: parsed.Stream, + ResponseHeaders: resp.Header.Clone(), + Duration: time.Since(startTime), + FirstTokenMs: firstTokenMs, + ImageCount: imageCount, + ImageSize: parsed.SizeTier, + }, err + } return nil, err } } else { diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index 681e0e8e9ec..a4fc6c4764f 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -3,6 +3,7 @@ package service import ( "bytes" "context" + "errors" "io" "mime/multipart" "net/http" @@ -17,6 +18,20 @@ import ( "github.com/tidwall/gjson" ) +type failingOpenAIImageWriter struct { + gin.ResponseWriter + failAfter int + writes int +} + +func (w *failingOpenAIImageWriter) Write(p []byte) (int, error) { + if w.writes >= w.failAfter { + return 0, errors.New("write failed: client disconnected") + } + w.writes++ + return w.ResponseWriter.Write(p) +} + func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSON(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024","quality":"high","stream":true}`) @@ -41,6 +56,41 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_JSON(t *testing.T) { require.False(t, parsed.Multipart) } +func TestOpenAIGatewayServiceParseOpenAIImagesRequest_AsyncGenerationAlias(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/images/async/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + require.NotNil(t, parsed) + require.Equal(t, "/v1/images/generations", parsed.Endpoint) +} + +func TestOpenAIGatewayServiceParseOpenAIImagesRequest_4KSizeTier(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a poster","size":"3072x2048","quality":"high"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + require.NotNil(t, parsed) + require.Equal(t, "3072x2048", parsed.Size) + require.Equal(t, "4K", parsed.SizeTier) +} + func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEdit(t *testing.T) { gin.SetMode(gin.TestMode) @@ -75,6 +125,145 @@ func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEdit(t *testing.T require.Equal(t, OpenAIImagesCapabilityNative, parsed.RequiredCapability) } +func TestOpenAIImagesRequestModerationBody_JSONEditIncludesInputImageURLs(t *testing.T) { + parsed := &OpenAIImagesRequest{ + Endpoint: openAIImagesEditsEndpoint, + Prompt: "replace background", + InputImageURLs: []string{"https://example.com/source.png"}, + MaskImageURL: "https://example.com/mask.png", + } + + input := ExtractContentModerationInput(ContentModerationProtocolOpenAIImages, parsed.ModerationBody()) + + require.Equal(t, "replace background", input.Text) + require.Equal(t, []string{"https://example.com/source.png", "https://example.com/mask.png"}, input.Images) +} + +func TestOpenAIImagesRequestModerationBody_MultipartEditIncludesUploadsInMemory(t *testing.T) { + parsed := &OpenAIImagesRequest{ + Endpoint: openAIImagesEditsEndpoint, + Prompt: "replace background", + Uploads: []OpenAIImagesUpload{{ + FieldName: "image", + FileName: "source.png", + ContentType: "image/png", + Data: []byte("fake-image-bytes"), + }}, + MaskUpload: &OpenAIImagesUpload{ + FieldName: "mask", + FileName: "mask.png", + ContentType: "image/png", + Data: []byte("fake-mask-bytes"), + }, + } + + input := ExtractContentModerationInput(ContentModerationProtocolOpenAIImages, parsed.ModerationBody()) + + require.Equal(t, "replace background", input.Text) + require.Equal(t, []string{ + "data:image/png;base64,ZmFrZS1pbWFnZS1ieXRlcw==", + "data:image/png;base64,ZmFrZS1tYXNrLWJ5dGVz", + }, input.Images) + + log := (&ContentModerationService{}).buildLog(ContentModerationCheckInput{}, defaultContentModerationConfig(), ContentModerationActionAllow, false, "", 0, nil, input.ExcerptText(), nil, nil, "") + require.Equal(t, "replace background", log.InputExcerpt) + require.NotContains(t, log.InputExcerpt, "ZmFrZS") +} + +func TestOpenAIGatewayServiceParseOpenAIImagesRequest_NormalizesOfficialAndCustomSizes(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + size string + wantTier string + }{ + {size: "1024x1024", wantTier: "1K"}, + {size: "1536x1024", wantTier: "2K"}, + {size: "1024x1536", wantTier: "2K"}, + {size: "2048x2048", wantTier: "2K"}, + {size: "2048x1152", wantTier: "2K"}, + {size: "3840x2160", wantTier: "4K"}, + {size: "2160x3840", wantTier: "4K"}, + {size: "1024X768", wantTier: "2K"}, + {size: "1280x768", wantTier: "2K"}, + {size: "2560x1440", wantTier: "2K"}, + {size: "2560x1600", wantTier: "4K"}, + {size: "auto", wantTier: "2K"}, + } + + svc := &OpenAIGatewayService{} + for _, tt := range tests { + t.Run(tt.size, func(t *testing.T) { + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"` + tt.size + `"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + require.NotNil(t, parsed) + require.Equal(t, tt.size, parsed.Size) + require.Equal(t, tt.wantTier, parsed.SizeTier) + }) + } +} + +func TestOpenAIGatewayServiceParseOpenAIImagesRequest_UnknownSizesDoNotBlockPassthrough(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + size string + wantTier string + }{ + {size: "2048x1153", wantTier: "2K"}, + {size: "4096x1024", wantTier: "4K"}, + {size: "3840x1024", wantTier: "4K"}, + {size: "512x512", wantTier: "2K"}, + {size: "invalid", wantTier: "2K"}, + {size: "999999999999999999999999999x2", wantTier: "2K"}, + } + + svc := &OpenAIGatewayService{} + for _, tt := range tests { + t.Run(tt.size, func(t *testing.T) { + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"` + tt.size + `"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + require.NotNil(t, parsed) + require.Equal(t, tt.size, parsed.Size) + require.Equal(t, tt.wantTier, parsed.SizeTier) + }) + } +} + +func TestOpenAIGatewayServiceParseOpenAIImagesRequest_LegacyImageModelUnknownSizePassthrough(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-1.5","prompt":"draw a cat","size":"2048x1152"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + require.NotNil(t, parsed) + require.Equal(t, "2048x1152", parsed.Size) + require.Equal(t, "2K", parsed.SizeTier) +} + func TestOpenAIGatewayServiceParseOpenAIImagesRequest_MultipartEditWithMaskAndNativeOptions(t *testing.T) { gin.SetMode(gin.TestMode) @@ -391,6 +580,53 @@ func TestOpenAIGatewayServiceForwardImages_OAuthUsesResponsesAPI(t *testing.T) { require.Equal(t, "draw a cat", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String()) } +func TestOpenAIGatewayServiceForwardImagesBuffered_OAuthExtractsImageAsset(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024","quality":"high","response_format":"b64_json"}`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/images/async/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "X-Request-Id": []string{"req_img_buffered"}, + }, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000000,\"usage\":{\"input_tokens\":11,\"output_tokens\":22,\"output_tokens_details\":{\"image_tokens\":7}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"aGVsbG8=\",\"revised_prompt\":\"draw a cat\",\"output_format\":\"png\",\"quality\":\"high\",\"size\":\"1024x1024\"}]}}\n\n" + + "data: [DONE]\n\n", + )), + }, + } + svc.httpUpstream = upstream + + account := &Account{ + ID: 1, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token-123", + }, + } + + result, assets, err := svc.ForwardImagesBuffered(context.Background(), account, body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, assets, 1) + require.Equal(t, []byte("hello"), assets[0].Data) + require.Equal(t, "image/png", assets[0].MimeType) +} + func TestOpenAIGatewayServiceForwardImages_APIKeyGenerationUsesConfiguredV1BaseURL(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","response_format":"b64_json"}`) @@ -543,6 +779,57 @@ func TestOpenAIGatewayServiceForwardImages_APIKeyStreamRawJSONEventStreamFallbac require.Equal(t, "ZmluYWw=", gjson.Get(rec.Body.String(), "data.0.b64_json").String()) } +func TestOpenAIGatewayServiceForwardImages_APIKeyStreamMultilineSSEDataBillsImage(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "X-Request-Id": []string{"req_img_stream_multiline"}, + }, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"image_generation.completed\",\n" + + "data: \"usage\":{\"input_tokens\":10,\"output_tokens\":18,\"output_tokens_details\":{\"image_tokens\":8}},\n" + + "data: \"b64_json\":\"ZmluYWw=\",\"output_format\":\"png\"}\n\n" + + "data: [DONE]\n\n", + )), + }, + }, + } + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + account := &Account{ + ID: 8, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-api-key", + }, + } + + result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Stream) + require.Equal(t, 1, result.ImageCount) + require.Equal(t, 10, result.Usage.InputTokens) + require.Equal(t, 18, result.Usage.OutputTokens) + require.Equal(t, 8, result.Usage.ImageOutputTokens) +} + func TestExtractOpenAIImagesBillableCountFromJSONBytes_CompletedEvent(t *testing.T) { body := []byte(`{"type":"image_generation.completed","b64_json":"ZmluYWw=","usage":{"input_tokens":10,"output_tokens":18}}`) @@ -686,6 +973,61 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingTransformsEvents(t *tes require.False(t, gjson.Get(completed.Data, "revised_prompt").Exists()) } +func TestOpenAIGatewayServiceForwardImages_APIKeyStreamingDrainsAfterClientDisconnect(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Writer = &failingOpenAIImageWriter{ResponseWriter: c.Writer, failAfter: 1} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{ + Gateway: config.GatewayConfig{ + ImageStreamDataIntervalTimeout: 1, + ImageStreamKeepaliveInterval: 0, + }, + }, + httpUpstream: &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "X-Request-Id": []string{"req_img_stream_disconnect_apikey"}, + }, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"image_generation.partial_image\",\"b64_json\":\"cGFydGlhbA==\"}\n\n" + + "data: {\"type\":\"image_generation.completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":4,\"output_tokens_details\":{\"image_tokens\":2}},\"b64_json\":\"ZmluYWw=\",\"output_format\":\"png\"}\n\n" + + "data: [DONE]\n\n", + )), + }, + }, + } + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + account := &Account{ + ID: 8, + Name: "openai-apikey", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-api-key", + }, + } + + result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, result.ImageCount) + require.Equal(t, 3, result.Usage.InputTokens) + require.Equal(t, 4, result.Usage.OutputTokens) + require.Equal(t, 2, result.Usage.ImageOutputTokens) +} + func TestOpenAIGatewayServiceForwardImages_OAuthEditsMultipartUsesResponsesAPI(t *testing.T) { gin.SetMode(gin.TestMode) @@ -901,6 +1243,35 @@ func TestCollectOpenAIImagesFromResponsesBody_FallsBackToOutputItemDone(t *testi require.JSONEq(t, `{"images":1}`, string(usageRaw)) } +func TestCollectOpenAIImagesFromResponsesBody_ResponseFailedReturnsMessage(t *testing.T) { + body := []byte( + "data: {\"type\":\"response.created\",\"response\":{\"created_at\":1710000004}}\n\n" + + "data: {\"type\":\"response.failed\",\"error\":{\"type\":\"safety_error\",\"message\":\"This prompt may violate our content policy.\"}}\n\n" + + "data: [DONE]\n\n", + ) + + results, _, _, _, _, err := collectOpenAIImagesFromResponsesBody(body) + require.Nil(t, results) + require.EqualError(t, err, "upstream response failed: This prompt may violate our content policy.") +} + +func TestCollectOpenAIImagesFromResponsesBody_MultilineSSE(t *testing.T) { + body := []byte( + "data: {\"type\":\"response.completed\",\n" + + "data: \"response\":{\"created_at\":1710000010,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\",\"output_format\":\"png\"}]}}\n\n" + + "data: [DONE]\n\n", + ) + + results, createdAt, usageRaw, firstMeta, foundFinal, err := collectOpenAIImagesFromResponsesBody(body) + require.NoError(t, err) + require.True(t, foundFinal) + require.Equal(t, int64(1710000010), createdAt) + require.Len(t, results, 1) + require.Equal(t, "ZmluYWw=", results[0].Result) + require.Equal(t, "png", firstMeta.OutputFormat) + require.JSONEq(t, `{"images":1}`, string(usageRaw)) +} + func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesOutputItemDoneFallback(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`) @@ -957,3 +1328,116 @@ func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesOutputItemDoneFa require.JSONEq(t, `{"images":1}`, gjson.Get(completed.Data, "usage").Raw) require.NotContains(t, rec.Body.String(), "event: error") } + +func TestOpenAIGatewayServiceForwardImages_OAuthStreamingHandlesMultilineSSE(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"b64_json"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + + svc := &OpenAIGatewayService{} + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + svc.httpUpstream = &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "X-Request-Id": []string{"req_img_stream_multiline_oauth"}, + }, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.completed\",\n" + + "data: \"response\":{\"created_at\":1710000011,\"usage\":{\"input_tokens\":6,\"output_tokens\":10,\"output_tokens_details\":{\"image_tokens\":5}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"TXVsdGlsaW5l\",\"output_format\":\"png\"}]}}\n\n" + + "data: [DONE]\n\n", + )), + }, + } + + account := &Account{ + ID: 11, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token-123", + }, + } + + result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Stream) + require.Equal(t, 1, result.ImageCount) + require.Equal(t, 6, result.Usage.InputTokens) + require.Equal(t, 10, result.Usage.OutputTokens) + require.Equal(t, 5, result.Usage.ImageOutputTokens) + events := parseOpenAIImageTestSSEEvents(rec.Body.String()) + completed, ok := findOpenAIImageTestSSEEvent(events, "image_generation.completed") + require.True(t, ok) + require.Equal(t, "TXVsdGlsaW5l", gjson.Get(completed.Data, "b64_json").String()) + require.JSONEq(t, `{"images":1}`, gjson.Get(completed.Data, "usage").Raw) + require.NotContains(t, rec.Body.String(), "event: error") +} + +func TestOpenAIGatewayServiceForwardImages_OAuthStreamingDrainsAfterClientDisconnect(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","stream":true,"response_format":"url"}`) + + req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Writer = &failingOpenAIImageWriter{ResponseWriter: c.Writer, failAfter: 1} + + svc := &OpenAIGatewayService{ + cfg: &config.Config{ + Gateway: config.GatewayConfig{ + ImageStreamDataIntervalTimeout: 1, + ImageStreamKeepaliveInterval: 0, + }, + }, + } + parsed, err := svc.ParseOpenAIImagesRequest(c, body) + require.NoError(t, err) + + upstream := &httpUpstreamRecorder{ + resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + "X-Request-Id": []string{"req_img_stream_disconnect_oauth"}, + }, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.image_generation_call.partial_image\",\"partial_image_b64\":\"cGFydGlhbA==\",\"partial_image_index\":0,\"output_format\":\"png\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{\"created_at\":1710000009,\"usage\":{\"input_tokens\":5,\"output_tokens\":9,\"output_tokens_details\":{\"image_tokens\":4}},\"tool_usage\":{\"image_gen\":{\"images\":1}},\"output\":[{\"type\":\"image_generation_call\",\"result\":\"ZmluYWw=\",\"output_format\":\"png\"}]}}\n\n" + + "data: [DONE]\n\n", + )), + }, + } + svc.httpUpstream = upstream + + account := &Account{ + ID: 9, + Name: "openai-oauth", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "token-123", + }, + } + + result, err := svc.ForwardImages(context.Background(), c, account, body, parsed, "") + require.NoError(t, err) + require.NotNil(t, result) + require.True(t, result.Stream) + require.Equal(t, 1, result.ImageCount) + require.Equal(t, 5, result.Usage.InputTokens) + require.Equal(t, 9, result.Usage.OutputTokens) + require.Equal(t, 4, result.Usage.ImageOutputTokens) +} diff --git a/backend/internal/service/openai_messages_bridge.go b/backend/internal/service/openai_messages_bridge.go new file mode 100644 index 00000000000..d67b4b1e918 --- /dev/null +++ b/backend/internal/service/openai_messages_bridge.go @@ -0,0 +1,57 @@ +package service + +import ( + "bytes" + "strings" + + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +const openAICompatMessagesBridgeContextKey = "openai_compat_messages_bridge" + +func isOpenAICompatMessagesBridgeBody(body []byte) bool { + if len(body) == 0 { + return false + } + if bytes.Contains(body, []byte(openAICompatClaudeCodeTodoGuardMarker)) { + return true + } + return isOpenAICompatMessagesBridgePromptCacheKey(gjson.GetBytes(body, "prompt_cache_key").String()) +} + +func isOpenAICompatMessagesBridgeRequestBody(reqBody map[string]any) bool { + if reqBody == nil { + return false + } + if input, ok := reqBody["input"].([]any); ok && inputContainsText(input, openAICompatClaudeCodeTodoGuardMarker) { + return true + } + return isOpenAICompatMessagesBridgePromptCacheKey(firstNonEmptyString(reqBody["prompt_cache_key"])) +} + +func isOpenAICompatMessagesBridgePromptCacheKey(key string) bool { + key = strings.TrimSpace(key) + return strings.HasPrefix(key, "anthropic-metadata-") || + strings.HasPrefix(key, "anthropic-cache-") || + strings.HasPrefix(key, "anthropic-digest-") +} + +func setOpenAICompatMessagesBridgeContext(c *gin.Context, enabled bool) { + if c == nil || !enabled { + return + } + c.Set(openAICompatMessagesBridgeContextKey, true) +} + +func isOpenAICompatMessagesBridgeContext(c *gin.Context) bool { + if c == nil { + return false + } + value, ok := c.Get(openAICompatMessagesBridgeContextKey) + if !ok { + return false + } + enabled, ok := value.(bool) + return ok && enabled +} diff --git a/backend/internal/service/openai_messages_continuation.go b/backend/internal/service/openai_messages_continuation.go new file mode 100644 index 00000000000..57d0478459b --- /dev/null +++ b/backend/internal/service/openai_messages_continuation.go @@ -0,0 +1,277 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/gin-gonic/gin" + "github.com/tidwall/gjson" +) + +type openAICompatSessionResponseBinding struct { + ResponseID string + TurnState string + ContinuationDisabled bool + ExpiresAt time.Time +} + +func openAICompatContinuationEnabled(account *Account, model string) bool { + if account == nil || account.Type != AccountTypeAPIKey { + return false + } + return shouldAutoInjectPromptCacheKeyForCompat(model) +} + +func trimAnthropicCompatResponsesInputToLatestTurn(req *apicompat.ResponsesRequest) { + if req == nil || len(req.Input) == 0 { + return + } + + var items []apicompat.ResponsesInputItem + if err := json.Unmarshal(req.Input, &items); err != nil || len(items) == 0 { + return + } + + start := len(items) - 1 + for start > 0 && items[start].Type == "function_call_output" { + start-- + } + trimmed := append([]apicompat.ResponsesInputItem(nil), items[start:]...) + if len(trimmed) == len(items) { + return + } + if input, err := json.Marshal(trimmed); err == nil { + req.Input = input + } +} + +func isOpenAICompatPreviousResponseNotFound(statusCode int, upstreamMsg string, upstreamBody []byte) bool { + if statusCode != http.StatusBadRequest && statusCode != http.StatusNotFound { + return false + } + check := func(s string) bool { + lower := strings.ToLower(strings.TrimSpace(s)) + return strings.Contains(lower, "previous_response_not_found") || + (strings.Contains(lower, "previous response") && strings.Contains(lower, "not found")) || + (strings.Contains(lower, "unsupported parameter") && strings.Contains(lower, "previous_response_id")) + } + if check(upstreamMsg) || check(string(upstreamBody)) { + return true + } + return check(gjson.GetBytes(upstreamBody, "error.code").String()) || + check(gjson.GetBytes(upstreamBody, "error.message").String()) +} + +func isOpenAICompatPreviousResponseUnsupported(statusCode int, upstreamMsg string, upstreamBody []byte) bool { + if statusCode != http.StatusBadRequest { + return false + } + check := func(s string) bool { + lower := strings.ToLower(strings.TrimSpace(s)) + if !strings.Contains(lower, "previous_response_id") { + return false + } + return strings.Contains(lower, "unsupported parameter") || + strings.Contains(lower, "only supported on responses websocket") || + strings.Contains(lower, "not supported") + } + if check(upstreamMsg) || check(string(upstreamBody)) { + return true + } + return check(gjson.GetBytes(upstreamBody, "error.code").String()) || + check(gjson.GetBytes(upstreamBody, "error.message").String()) +} + +func openAICompatSessionResponseKey(c *gin.Context, account *Account, promptCacheKey string) string { + key := strings.TrimSpace(promptCacheKey) + if account == nil || key == "" { + return "" + } + apiKeyID := int64(0) + if c != nil { + apiKeyID = getAPIKeyIDFromContext(c) + } + return strings.Join([]string{ + strconv.FormatInt(account.ID, 10), + strconv.FormatInt(apiKeyID, 10), + key, + }, "\x00") +} + +func (s *OpenAIGatewayService) getOpenAICompatSessionResponseID(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) string { + if s == nil { + return "" + } + key := openAICompatSessionResponseKey(c, account, promptCacheKey) + if key == "" { + return "" + } + raw, ok := s.openaiCompatSessionResponses.Load(key) + if !ok { + return "" + } + binding, ok := raw.(openAICompatSessionResponseBinding) + if !ok { + s.openaiCompatSessionResponses.Delete(key) + return "" + } + if !binding.ExpiresAt.IsZero() && time.Now().After(binding.ExpiresAt) { + s.openaiCompatSessionResponses.Delete(key) + return "" + } + if binding.ContinuationDisabled { + return "" + } + if strings.TrimSpace(binding.ResponseID) == "" { + s.openaiCompatSessionResponses.Delete(key) + return "" + } + return strings.TrimSpace(binding.ResponseID) +} + +func (s *OpenAIGatewayService) bindOpenAICompatSessionResponseID(_ context.Context, c *gin.Context, account *Account, promptCacheKey, responseID string) { + if s == nil { + return + } + key := openAICompatSessionResponseKey(c, account, promptCacheKey) + id := strings.TrimSpace(responseID) + if key == "" || id == "" { + return + } + binding := openAICompatSessionResponseBinding{ + ResponseID: id, + ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()), + } + if raw, ok := s.openaiCompatSessionResponses.Load(key); ok { + if existing, ok := raw.(openAICompatSessionResponseBinding); ok { + if existing.ContinuationDisabled { + existing.ResponseID = "" + existing.ExpiresAt = time.Now().Add(s.openAIWSResponseStickyTTL()) + s.openaiCompatSessionResponses.Store(key, existing) + return + } + binding.TurnState = existing.TurnState + } + } + s.openaiCompatSessionResponses.Store(key, binding) +} + +func (s *OpenAIGatewayService) deleteOpenAICompatSessionResponseID(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) { + if s == nil { + return + } + key := openAICompatSessionResponseKey(c, account, promptCacheKey) + if key == "" { + return + } + raw, ok := s.openaiCompatSessionResponses.Load(key) + if !ok { + return + } + binding, ok := raw.(openAICompatSessionResponseBinding) + if !ok { + s.openaiCompatSessionResponses.Delete(key) + return + } + binding.ResponseID = "" + if strings.TrimSpace(binding.TurnState) == "" && !binding.ContinuationDisabled { + s.openaiCompatSessionResponses.Delete(key) + return + } + binding.ExpiresAt = time.Now().Add(s.openAIWSResponseStickyTTL()) + s.openaiCompatSessionResponses.Store(key, binding) +} + +func (s *OpenAIGatewayService) disableOpenAICompatSessionContinuation(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) { + if s == nil { + return + } + key := openAICompatSessionResponseKey(c, account, promptCacheKey) + if key == "" { + return + } + binding := openAICompatSessionResponseBinding{ + ContinuationDisabled: true, + ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()), + } + if raw, ok := s.openaiCompatSessionResponses.Load(key); ok { + if existing, ok := raw.(openAICompatSessionResponseBinding); ok { + binding.TurnState = existing.TurnState + } + } + s.openaiCompatSessionResponses.Store(key, binding) +} + +func (s *OpenAIGatewayService) isOpenAICompatSessionContinuationDisabled(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) bool { + if s == nil { + return false + } + key := openAICompatSessionResponseKey(c, account, promptCacheKey) + if key == "" { + return false + } + raw, ok := s.openaiCompatSessionResponses.Load(key) + if !ok { + return false + } + binding, ok := raw.(openAICompatSessionResponseBinding) + if !ok { + s.openaiCompatSessionResponses.Delete(key) + return false + } + if !binding.ExpiresAt.IsZero() && time.Now().After(binding.ExpiresAt) { + s.openaiCompatSessionResponses.Delete(key) + return false + } + return binding.ContinuationDisabled +} + +func (s *OpenAIGatewayService) getOpenAICompatSessionTurnState(_ context.Context, c *gin.Context, account *Account, promptCacheKey string) string { + if s == nil { + return "" + } + key := openAICompatSessionResponseKey(c, account, promptCacheKey) + if key == "" { + return "" + } + raw, ok := s.openaiCompatSessionResponses.Load(key) + if !ok { + return "" + } + binding, ok := raw.(openAICompatSessionResponseBinding) + if !ok || strings.TrimSpace(binding.TurnState) == "" { + return "" + } + if !binding.ExpiresAt.IsZero() && time.Now().After(binding.ExpiresAt) { + s.openaiCompatSessionResponses.Delete(key) + return "" + } + return strings.TrimSpace(binding.TurnState) +} + +func (s *OpenAIGatewayService) bindOpenAICompatSessionTurnState(_ context.Context, c *gin.Context, account *Account, promptCacheKey, turnState string) { + if s == nil { + return + } + key := openAICompatSessionResponseKey(c, account, promptCacheKey) + state := strings.TrimSpace(turnState) + if key == "" || state == "" { + return + } + binding := openAICompatSessionResponseBinding{ + TurnState: state, + ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()), + } + if raw, ok := s.openaiCompatSessionResponses.Load(key); ok { + if existing, ok := raw.(openAICompatSessionResponseBinding); ok { + binding.ResponseID = existing.ResponseID + binding.ContinuationDisabled = existing.ContinuationDisabled + } + } + s.openaiCompatSessionResponses.Store(key, binding) +} diff --git a/backend/internal/service/openai_messages_digest_session.go b/backend/internal/service/openai_messages_digest_session.go new file mode 100644 index 00000000000..44a49d1e399 --- /dev/null +++ b/backend/internal/service/openai_messages_digest_session.go @@ -0,0 +1,135 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" +) + +type openAICompatAnthropicDigestBinding struct { + PromptCacheKey string + ExpiresAt time.Time +} + +func buildOpenAICompatAnthropicDigestChain(req *apicompat.AnthropicRequest) string { + if req == nil { + return "" + } + + parts := make([]string, 0, len(req.Messages)+1) + if len(req.System) > 0 && strings.TrimSpace(string(req.System)) != "" && strings.TrimSpace(string(req.System)) != "null" { + parts = append(parts, "s:"+shortHash(req.System)) + } + for _, msg := range req.Messages { + content := msg.Content + if len(content) == 0 || strings.TrimSpace(string(content)) == "" { + continue + } + prefix := "u" + if strings.TrimSpace(msg.Role) == "assistant" { + prefix = "a" + } + parts = append(parts, prefix+":"+shortHash(content)) + } + return strings.Join(parts, "-") +} + +func openAICompatAnthropicDigestNamespace(account *Account, cAPIKeyID int64) string { + if account == nil || account.ID <= 0 { + return "" + } + return fmt.Sprintf("%d|%d|", account.ID, cAPIKeyID) +} + +func (s *OpenAIGatewayService) findOpenAICompatAnthropicDigestPromptCacheKey(account *Account, cAPIKeyID int64, digestChain string) (promptCacheKey string, matchedChain string) { + if s == nil || digestChain == "" { + return "", "" + } + ns := openAICompatAnthropicDigestNamespace(account, cAPIKeyID) + if ns == "" { + return "", "" + } + chain := digestChain + for { + if raw, ok := s.openaiCompatAnthropicDigestSessions.Load(ns + chain); ok { + if binding, ok := raw.(openAICompatAnthropicDigestBinding); ok { + if binding.ExpiresAt.IsZero() || time.Now().Before(binding.ExpiresAt) { + if key := strings.TrimSpace(binding.PromptCacheKey); key != "" { + return key, chain + } + } + } + s.openaiCompatAnthropicDigestSessions.Delete(ns + chain) + } + i := strings.LastIndex(chain, "-") + if i < 0 { + return "", "" + } + chain = chain[:i] + } +} + +func (s *OpenAIGatewayService) bindOpenAICompatAnthropicDigestPromptCacheKey(account *Account, cAPIKeyID int64, digestChain, promptCacheKey, oldDigestChain string) { + if s == nil || digestChain == "" || strings.TrimSpace(promptCacheKey) == "" { + return + } + ns := openAICompatAnthropicDigestNamespace(account, cAPIKeyID) + if ns == "" { + return + } + binding := openAICompatAnthropicDigestBinding{ + PromptCacheKey: strings.TrimSpace(promptCacheKey), + ExpiresAt: time.Now().Add(s.openAIWSResponseStickyTTL()), + } + s.openaiCompatAnthropicDigestSessions.Store(ns+digestChain, binding) + if oldDigestChain != "" && oldDigestChain != digestChain { + s.openaiCompatAnthropicDigestSessions.Delete(ns + oldDigestChain) + } +} + +func promptCacheKeyFromAnthropicDigest(digestChain string) string { + if strings.TrimSpace(digestChain) == "" { + return "" + } + return "anthropic-digest-" + hashSensitiveValueForLog(digestChain) +} + +func promptCacheKeyFromAnthropicMetadataSession(req *apicompat.AnthropicRequest) string { + if req == nil || len(req.Metadata) == 0 { + return "" + } + var metadata struct { + UserID string `json:"user_id"` + } + if err := json.Unmarshal(req.Metadata, &metadata); err != nil { + return "" + } + parsed := ParseMetadataUserID(metadata.UserID) + if parsed == nil || strings.TrimSpace(parsed.SessionID) == "" { + return "" + } + seed := strings.Join([]string{ + "anthropic-metadata", + strings.TrimSpace(parsed.DeviceID), + strings.TrimSpace(parsed.AccountUUID), + strings.TrimSpace(parsed.SessionID), + }, "|") + return "anthropic-metadata-" + hashSensitiveValueForLog(seed) +} + +func cloneAnthropicRequestForDigest(req *apicompat.AnthropicRequest) *apicompat.AnthropicRequest { + if req == nil { + return nil + } + cp := *req + if len(req.System) > 0 { + cp.System = append(json.RawMessage(nil), req.System...) + } + if len(req.Messages) > 0 { + cp.Messages = append([]apicompat.AnthropicMessage(nil), req.Messages...) + } + return &cp +} diff --git a/backend/internal/service/openai_messages_replay_guard.go b/backend/internal/service/openai_messages_replay_guard.go new file mode 100644 index 00000000000..2ad9b6bc462 --- /dev/null +++ b/backend/internal/service/openai_messages_replay_guard.go @@ -0,0 +1,90 @@ +package service + +import ( + "encoding/json" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" +) + +const openAICompatAnthropicReplayMaxTailMessages = 12 + +func applyAnthropicCompatFullReplayGuard(req *apicompat.AnthropicRequest) bool { + if req == nil || len(req.Messages) <= openAICompatAnthropicReplayMaxTailMessages { + return false + } + + start := len(req.Messages) - openAICompatAnthropicReplayMaxTailMessages + start = expandAnthropicCompatTrimBoundary(req.Messages, start) + if start <= 0 { + return false + } + + req.Messages = append([]apicompat.AnthropicMessage(nil), req.Messages[start:]...) + return true +} + +func expandAnthropicCompatTrimBoundary(messages []apicompat.AnthropicMessage, start int) int { + if start <= 0 || start >= len(messages) { + return start + } + + toolUseIndex := make(map[string]int) + toolResultIndex := make(map[string]int) + for i, msg := range messages { + uses, results := anthropicCompatMessageToolIDs(msg) + for _, id := range uses { + if _, exists := toolUseIndex[id]; !exists { + toolUseIndex[id] = i + } + } + for _, id := range results { + if _, exists := toolResultIndex[id]; !exists { + toolResultIndex[id] = i + } + } + } + + for { + next := start + for i := start; i < len(messages); i++ { + uses, results := anthropicCompatMessageToolIDs(messages[i]) + for _, id := range results { + if useIdx, ok := toolUseIndex[id]; ok && useIdx < next { + next = useIdx + } + } + for _, id := range uses { + if resultIdx, ok := toolResultIndex[id]; ok && resultIdx < next { + next = resultIdx + } + } + } + if next == start { + return start + } + start = next + } +} + +func anthropicCompatMessageToolIDs(msg apicompat.AnthropicMessage) ([]string, []string) { + var blocks []apicompat.AnthropicContentBlock + if err := json.Unmarshal(msg.Content, &blocks); err != nil { + return nil, nil + } + + uses := make([]string, 0, 1) + results := make([]string, 0, 1) + for _, block := range blocks { + switch block.Type { + case "tool_use": + if block.ID != "" { + uses = append(uses, block.ID) + } + case "tool_result": + if block.ToolUseID != "" { + results = append(results, block.ToolUseID) + } + } + } + return uses, results +} diff --git a/backend/internal/service/openai_messages_replay_guard_test.go b/backend/internal/service/openai_messages_replay_guard_test.go new file mode 100644 index 00000000000..6176beeca73 --- /dev/null +++ b/backend/internal/service/openai_messages_replay_guard_test.go @@ -0,0 +1,58 @@ +package service + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" + "github.com/stretchr/testify/require" +) + +func TestApplyAnthropicCompatFullReplayGuard_TrimsOldMessages(t *testing.T) { + t.Parallel() + + req := &apicompat.AnthropicRequest{Messages: make([]apicompat.AnthropicMessage, 0, openAICompatAnthropicReplayMaxTailMessages+3)} + for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ { + req.Messages = append(req.Messages, apicompat.AnthropicMessage{ + Role: "user", + Content: json.RawMessage(fmt.Sprintf(`"message-%02d"`, i)), + }) + } + + trimmed := applyAnthropicCompatFullReplayGuard(req) + + require.True(t, trimmed) + require.Len(t, req.Messages, openAICompatAnthropicReplayMaxTailMessages) + require.JSONEq(t, `"message-03"`, string(req.Messages[0].Content)) + require.JSONEq(t, `"message-14"`, string(req.Messages[len(req.Messages)-1].Content)) +} + +func TestApplyAnthropicCompatFullReplayGuard_KeepsToolBoundaryIntact(t *testing.T) { + t.Parallel() + + req := &apicompat.AnthropicRequest{Messages: make([]apicompat.AnthropicMessage, 0, openAICompatAnthropicReplayMaxTailMessages+3)} + for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ { + role := "user" + content := json.RawMessage(fmt.Sprintf(`"message-%02d"`, i)) + if i == 1 { + role = "assistant" + content = json.RawMessage(`[{"type":"tool_use","id":"toolu_keep","name":"Read","input":{"file_path":"main.go"}}]`) + } + if i == 3 { + content = json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_keep","content":"ok"}]`) + } + req.Messages = append(req.Messages, apicompat.AnthropicMessage{ + Role: role, + Content: content, + }) + } + + trimmed := applyAnthropicCompatFullReplayGuard(req) + + require.True(t, trimmed) + require.Len(t, req.Messages, openAICompatAnthropicReplayMaxTailMessages+2) + require.Equal(t, "assistant", req.Messages[0].Role) + require.Contains(t, string(req.Messages[0].Content), `"toolu_keep"`) + require.Contains(t, string(req.Messages[2].Content), `"tool_result"`) +} diff --git a/backend/internal/service/openai_messages_todo_guard.go b/backend/internal/service/openai_messages_todo_guard.go new file mode 100644 index 00000000000..96fc90cbe11 --- /dev/null +++ b/backend/internal/service/openai_messages_todo_guard.go @@ -0,0 +1,121 @@ +package service + +import ( + "encoding/json" + "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" +) + +const ( + openAICompatClaudeCodeTodoGuardMarker = "" + openAICompatClaudeCodeTodoGuardText = openAICompatClaudeCodeTodoGuardMarker + "\nWhen using Claude Code todo or task tracking tools, keep the visible task list consistent. Do not send final or summary text while any item remains in_progress. Before finishing, asking the user to choose, or reporting a blocker, update the todo list so completed work is completed and deferred work is pending/open; leave an item in_progress only when active work will continue in the same turn.\n" +) + +func appendOpenAICompatClaudeCodeTodoGuard(req *apicompat.ResponsesRequest) bool { + if req == nil || len(req.Input) == 0 { + return false + } + + var items []apicompat.ResponsesInputItem + if err := json.Unmarshal(req.Input, &items); err != nil { + return false + } + if len(items) == 0 || responsesInputItemsContainText(items, openAICompatClaudeCodeTodoGuardMarker) { + return false + } + + content, err := json.Marshal([]apicompat.ResponsesContentPart{{ + Type: "input_text", + Text: openAICompatClaudeCodeTodoGuardText, + }}) + if err != nil { + return false + } + + guard := apicompat.ResponsesInputItem{ + Type: "message", + Role: "developer", + Content: content, + } + + insertAt := 0 + for insertAt < len(items) && items[insertAt].Type == "message" && items[insertAt].Role == "developer" { + insertAt++ + } + + items = append(items, apicompat.ResponsesInputItem{}) + copy(items[insertAt+1:], items[insertAt:]) + items[insertAt] = guard + + input, err := json.Marshal(items) + if err != nil { + return false + } + req.Input = input + return true +} + +func appendOpenAICompatClaudeCodeTodoGuardToRequestBody(reqBody map[string]any) bool { + if reqBody == nil { + return false + } + + input, ok := reqBody["input"].([]any) + if !ok || len(input) == 0 || inputContainsText(input, openAICompatClaudeCodeTodoGuardMarker) { + return false + } + + guard := map[string]any{ + "type": "message", + "role": "developer", + "content": []any{ + map[string]any{ + "type": "input_text", + "text": openAICompatClaudeCodeTodoGuardText, + }, + }, + } + + insertAt := 0 + for insertAt < len(input) { + item, ok := input[insertAt].(map[string]any) + if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "message" || strings.TrimSpace(firstNonEmptyString(item["role"])) != "developer" { + break + } + insertAt++ + } + + input = append(input, nil) + copy(input[insertAt+1:], input[insertAt:]) + input[insertAt] = guard + reqBody["input"] = input + return true +} + +func responsesInputItemsContainText(items []apicompat.ResponsesInputItem, needle string) bool { + needle = strings.TrimSpace(needle) + if needle == "" { + return false + } + for _, item := range items { + if strings.Contains(string(item.Content), needle) { + return true + } + } + return false +} + +func inputContainsText(input []any, needle string) bool { + needle = strings.TrimSpace(needle) + if needle == "" { + return false + } + for _, item := range input { + b, err := json.Marshal(item) + if err == nil && strings.Contains(string(b), needle) { + return true + } + } + return false +} diff --git a/backend/internal/service/openai_model_alias.go b/backend/internal/service/openai_model_alias.go new file mode 100644 index 00000000000..2fa2c90efd1 --- /dev/null +++ b/backend/internal/service/openai_model_alias.go @@ -0,0 +1,137 @@ +package service + +import "strings" + +func lastOpenAIModelSegment(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if strings.Contains(model, "/") { + parts := strings.Split(model, "/") + model = parts[len(parts)-1] + } + return strings.TrimSpace(model) +} + +func canonicalizeOpenAIModelAliasSpelling(model string) string { + model = strings.ToLower(lastOpenAIModelSegment(model)) + if model == "" { + return "" + } + + normalized := strings.ReplaceAll(model, "_", "-") + normalized = strings.Join(strings.Fields(normalized), "-") + for strings.Contains(normalized, "--") { + normalized = strings.ReplaceAll(normalized, "--", "-") + } + + if strings.HasPrefix(normalized, "gpt5") { + normalized = "gpt-5" + strings.TrimPrefix(normalized, "gpt5") + } + if !strings.HasPrefix(normalized, "gpt-") && !strings.Contains(normalized, "codex") { + return "" + } + + replacements := []struct { + from string + to string + }{ + {"gpt-5.4mini", "gpt-5.4-mini"}, + {"gpt-5.4nano", "gpt-5.4-nano"}, + {"gpt-5.3-codexspark", "gpt-5.3-codex-spark"}, + {"gpt-5.3codexspark", "gpt-5.3-codex-spark"}, + {"gpt-5.3codex", "gpt-5.3-codex"}, + } + for _, replacement := range replacements { + normalized = strings.ReplaceAll(normalized, replacement.from, replacement.to) + } + return normalized +} + +func normalizeKnownOpenAICodexModel(model string) string { + normalized := canonicalizeOpenAIModelAliasSpelling(model) + if normalized == "" { + return "" + } + + if mapped := getNormalizedCodexModel(normalized); mapped != "" { + return mapped + } + if strings.HasSuffix(normalized, "-openai-compact") { + if mapped := getNormalizedCodexModel(strings.TrimSuffix(normalized, "-openai-compact")); mapped != "" { + return mapped + } + } + + switch { + case strings.Contains(normalized, "gpt-5.5"): + return "gpt-5.5" + case strings.Contains(normalized, "gpt-5.4-mini"): + return "gpt-5.4-mini" + case strings.Contains(normalized, "gpt-5.4-nano"): + return "gpt-5.4-nano" + case strings.Contains(normalized, "gpt-5.4"): + return "gpt-5.4" + case strings.Contains(normalized, "gpt-5.2"): + return "gpt-5.2" + case strings.Contains(normalized, "gpt-5.3-codex-spark"): + return "gpt-5.3-codex-spark" + case strings.Contains(normalized, "gpt-5.3-codex"): + return "gpt-5.3-codex" + case strings.Contains(normalized, "gpt-5.3"): + return "gpt-5.3-codex" + case strings.Contains(normalized, "codex"): + return "gpt-5.3-codex" + case strings.Contains(normalized, "gpt-5"): + return "gpt-5.4" + default: + return "" + } +} + +func appendUsageBillingModelCandidate(candidates []string, seen map[string]struct{}, model string) []string { + trimmed := strings.TrimSpace(model) + if trimmed == "" { + return candidates + } + add := func(candidate string) { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return + } + key := strings.ToLower(candidate) + if _, ok := seen[key]; ok { + return + } + seen[key] = struct{}{} + candidates = append(candidates, candidate) + } + + add(trimmed) + if canonical := canonicalizeOpenAIModelAliasSpelling(trimmed); canonical != "" { + add(canonical) + } + if normalized := normalizeKnownOpenAICodexModel(trimmed); normalized != "" { + add(normalized) + } + return candidates +} + +func usageBillingModelCandidates(primary string, alternates ...string) []string { + seen := make(map[string]struct{}, 1+len(alternates)) + candidates := appendUsageBillingModelCandidate(nil, seen, primary) + for _, alternate := range alternates { + candidates = appendUsageBillingModelCandidate(candidates, seen, alternate) + } + return candidates +} + +func firstUsageBillingModel(candidates []string) string { + for _, candidate := range candidates { + if trimmed := strings.TrimSpace(candidate); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/backend/internal/service/openai_model_mapping_test.go b/backend/internal/service/openai_model_mapping_test.go index 5c3e1ae0819..f087ac32beb 100644 --- a/backend/internal/service/openai_model_mapping_test.go +++ b/backend/internal/service/openai_model_mapping_test.go @@ -94,6 +94,15 @@ func TestResolveOpenAIForwardModel(t *testing.T) { defaultMappedModel: "gpt-5.4", expectedModel: "gpt-5.5", }, + { + name: "preserves compact-spelled gpt5.5 instead of group default", + account: &Account{ + Credentials: map[string]any{}, + }, + requestedModel: "gpt5.5", + defaultMappedModel: "gpt-5.4", + expectedModel: "gpt5.5", + }, { name: "preserves openai namespaced gpt-5.5 instead of group default", account: &Account{ diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index cc9fc572d8c..398cbb850b4 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -25,9 +25,12 @@ func f64p(v float64) *float64 { return &v } type httpUpstreamRecorder struct { lastReq *http.Request lastBody []byte + requests []*http.Request + bodies [][]byte - resp *http.Response - err error + resp *http.Response + responses []*http.Response + err error } func (u *httpUpstreamRecorder) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) { @@ -35,12 +38,19 @@ func (u *httpUpstreamRecorder) Do(req *http.Request, proxyURL string, accountID if req != nil && req.Body != nil { b, _ := io.ReadAll(req.Body) u.lastBody = b + u.bodies = append(u.bodies, append([]byte(nil), b...)) _ = req.Body.Close() req.Body = io.NopCloser(bytes.NewReader(b)) } + u.requests = append(u.requests, req) if u.err != nil { return nil, u.err } + if len(u.responses) > 0 { + resp := u.responses[0] + u.responses = u.responses[1:] + return resp, nil + } return u.resp, nil } @@ -91,6 +101,50 @@ func TestOpenAIGatewayService_ResponsesUnknownModelDoesNotFallbackToGPT54(t *tes require.True(t, rec.Code >= http.StatusBadRequest) } +func TestOpenAIGatewayService_OAuthMessagesBridgeDoesNotInjectDefaultInstructions(t *testing.T) { + gin.SetMode(gin.TestMode) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + originalBody := []byte(`{"model":"gpt-5.5","stream":true,"prompt_cache_key":"anthropic-metadata-session-1","input":[{"type":"message","role":"developer","content":[{"type":"input_text","text":""}]},{"type":"message","role":"user","content":"hello"}]}`) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(originalBody)) + c.Request.Header.Set("Content-Type", "application/json") + + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusBadRequest, + Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_bridge"}}, + Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"bridge stop"}}`)), + }} + svc := &OpenAIGatewayService{ + cfg: &config.Config{}, + httpUpstream: upstream, + } + account := &Account{ + ID: 123, + Name: "acc", + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Concurrency: 1, + Credentials: map[string]any{ + "access_token": "oauth-token", + "chatgpt_account_id": "chatgpt-acc", + }, + Status: StatusActive, + Schedulable: true, + } + + result, err := svc.Forward(context.Background(), c, account, originalBody) + require.Error(t, err) + require.Nil(t, result) + require.NotNil(t, upstream.lastReq) + require.Equal(t, "", gjson.GetBytes(upstream.lastBody, "instructions").String()) + require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists()) + require.NotEmpty(t, upstream.lastReq.Header.Get("Session_Id")) + require.Empty(t, upstream.lastReq.Header.Get("Conversation_Id")) + require.Empty(t, upstream.lastReq.Header.Get("OpenAI-Beta")) + require.Empty(t, upstream.lastReq.Header.Get("originator")) +} + type openAIPassthroughFailoverRepo struct { stubOpenAIAccountRepo rateLimitCalls []time.Time diff --git a/backend/internal/service/openai_oauth_service_refresh_test.go b/backend/internal/service/openai_oauth_service_refresh_test.go index a31eb8cb97b..84b68ea643a 100644 --- a/backend/internal/service/openai_oauth_service_refresh_test.go +++ b/backend/internal/service/openai_oauth_service_refresh_test.go @@ -52,3 +52,47 @@ func TestOpenAIOAuthService_RefreshAccountToken_NoRefreshTokenUsesExistingAccess require.Equal(t, "client-id-1", info.ClientID) require.Zero(t, atomic.LoadInt32(&client.refreshCalls), "existing access token should be reused without calling refresh") } + +func TestOpenAITokenRefresher_NeedsRefresh_SkipsAccountWithoutRefreshToken(t *testing.T) { + refresher := NewOpenAITokenRefresher(nil, nil) + expiresAt := time.Now().Add(time.Minute).UTC().Format(time.RFC3339) + + withoutRT := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "access-token", + "expires_at": expiresAt, + }, + } + require.False(t, refresher.NeedsRefresh(withoutRT, 5*time.Minute)) + + withRT := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "access-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, + }, + } + require.True(t, refresher.NeedsRefresh(withRT, 5*time.Minute)) +} + +func TestOpenAITokenProvider_NoRefreshTokenExpiredAccessTokenReturnsError(t *testing.T) { + provider := NewOpenAITokenProvider(nil, nil, nil) + expiresAt := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "expired-access-token", + "expires_at": expiresAt, + }, + } + + token, err := provider.GetAccessToken(context.Background(), account) + require.Error(t, err) + require.Empty(t, token) + require.Contains(t, err.Error(), "refresh_token is missing") +} diff --git a/backend/internal/service/openai_sse_data.go b/backend/internal/service/openai_sse_data.go new file mode 100644 index 00000000000..61b813b601d --- /dev/null +++ b/backend/internal/service/openai_sse_data.go @@ -0,0 +1,70 @@ +package service + +import ( + "strings" + + "github.com/tidwall/gjson" +) + +type openAISSEDataAccumulator struct { + lines []string +} + +func (a *openAISSEDataAccumulator) AddLine(line string, fn func([]byte)) { + if fn == nil { + return + } + trimmedLine := strings.TrimRight(line, "\r\n") + if data, ok := extractOpenAISSEDataLine(trimmedLine); ok { + a.lines = append(a.lines, data) + return + } + if strings.TrimSpace(trimmedLine) == "" { + a.Flush(fn) + } +} + +func (a *openAISSEDataAccumulator) Flush(fn func([]byte)) { + if fn == nil || len(a.lines) == 0 { + return + } + emitOpenAISSEDataPayloads(a.lines, fn) + a.lines = a.lines[:0] +} + +func forEachOpenAISSEDataPayload(body string, fn func([]byte)) { + if fn == nil || strings.TrimSpace(body) == "" { + return + } + var acc openAISSEDataAccumulator + for _, line := range strings.Split(body, "\n") { + acc.AddLine(line, fn) + } + acc.Flush(fn) +} + +func emitOpenAISSEDataPayloads(lines []string, fn func([]byte)) { + if fn == nil || len(lines) == 0 { + return + } + if len(lines) == 1 { + emitOpenAISSEDataPayload(lines[0], fn) + return + } + joined := strings.Join(lines, "\n") + if gjson.Valid(joined) { + emitOpenAISSEDataPayload(joined, fn) + return + } + for _, line := range lines { + emitOpenAISSEDataPayload(line, fn) + } +} + +func emitOpenAISSEDataPayload(data string, fn func([]byte)) { + data = strings.TrimSpace(data) + if data == "" || data == "[DONE]" { + return + } + fn([]byte(data)) +} diff --git a/backend/internal/service/openai_token_provider.go b/backend/internal/service/openai_token_provider.go index e438588edb8..a680d45103e 100644 --- a/backend/internal/service/openai_token_provider.go +++ b/backend/internal/service/openai_token_provider.go @@ -152,6 +152,12 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou // 2) Refresh if needed (pre-expiry skew). expiresAt := account.GetCredentialAsTime("expires_at") needsRefresh := expiresAt == nil || time.Until(*expiresAt) <= openAITokenRefreshSkew + if needsRefresh && strings.TrimSpace(account.GetOpenAIRefreshToken()) == "" { + if expiresAt != nil && !time.Now().Before(*expiresAt) { + return "", errors.New("openai access_token expired and refresh_token is missing") + } + needsRefresh = false + } refreshFailed := false if needsRefresh && p.refreshAPI != nil && p.executor != nil { diff --git a/backend/internal/service/openai_token_provider_test.go b/backend/internal/service/openai_token_provider_test.go index e81fb465602..4b69db8ae45 100644 --- a/backend/internal/service/openai_token_provider_test.go +++ b/backend/internal/service/openai_token_provider_test.go @@ -424,8 +424,9 @@ func TestOpenAITokenProvider_CacheGetError(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -650,8 +651,9 @@ func TestOpenAITokenProvider_Real_LockFailedWait(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -819,8 +821,9 @@ func TestOpenAITokenProvider_Real_LockRace_PollingHitsCache(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -848,8 +851,9 @@ func TestOpenAITokenProvider_Real_LockRace_ContextCanceled(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } @@ -875,8 +879,9 @@ func TestOpenAITokenProvider_RuntimeMetrics_LockWaitHitAndSnapshot(t *testing.T) Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } cacheKey := OpenAITokenCacheKey(account) @@ -911,8 +916,9 @@ func TestOpenAITokenProvider_RuntimeMetrics_LockAcquireFailure(t *testing.T) { Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{ - "access_token": "fallback-token", - "expires_at": expiresAt, + "access_token": "fallback-token", + "refresh_token": "refresh-token", + "expires_at": expiresAt, }, } diff --git a/backend/internal/service/openai_ws_forwarder.go b/backend/internal/service/openai_ws_forwarder.go index 201073e0fb6..372f420fad9 100644 --- a/backend/internal/service/openai_ws_forwarder.go +++ b/backend/internal/service/openai_ws_forwarder.go @@ -223,6 +223,7 @@ type OpenAIWSIngressHooks struct { // 的 reasoning effort 后缀推导,禁止用于上游请求或计费模型。 InitialRequestModel string BeforeTurn func(turn int) error + BeforeRequest func(turn int, payload []byte, originalModel string) error AfterTurn func(turn int, result *OpenAIForwardResult, turnErr error) } @@ -1990,6 +1991,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( } usage := &OpenAIUsage{} + imageCounter := newOpenAIImageOutputCounter() var firstTokenMs *int responseID := "" var finalResponse []byte @@ -2171,6 +2173,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( if openAIWSEventShouldParseUsage(eventType) { parseOpenAIWSResponseUsageFromCompletedEvent(message, usage) } + imageCounter.AddSSEData(message) if eventType == "error" { errCodeRaw, errTypeRaw, errMsgRaw := parseOpenAIWSErrorEventFields(message) @@ -2343,6 +2346,7 @@ func (s *OpenAIGatewayService) forwardOpenAIWSV2( Usage: *usage, Model: originalModel, UpstreamModel: mappedModel, + ImageCount: imageCounter.Count(), ServiceTier: extractOpenAIServiceTier(reqBody), ReasoningEffort: extractOpenAIReasoningEffort(reqBody, originalModel), Stream: reqStream, @@ -2449,6 +2453,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( promptCacheKey string previousResponseID string originalModel string + imageBillingModel string + imageSizeTier string payloadBytes int } @@ -2546,6 +2552,19 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } normalized = next } + imageIntent := IsImageGenerationIntent(openAIResponsesEndpoint, originalModel, normalized) + if imageIntent && !GroupAllowsImageGeneration(apiKeyGroup(getAPIKeyFromContext(c))) { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, ImageGenerationPermissionMessage(), nil) + } + imageBillingModel := "" + imageSizeTier := "" + if imageIntent { + var imageCfgErr error + imageBillingModel, imageSizeTier, imageCfgErr = resolveOpenAIResponsesImageBillingConfigFromBody(normalized, originalModel) + if imageCfgErr != nil { + return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, imageCfgErr.Error(), imageCfgErr) + } + } // Apply OpenAI Fast Policy on the response.create frame using the same // evaluator/normalize/scope rules as the HTTP entrypoints. This is the @@ -2591,6 +2610,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( promptCacheKey: promptCacheKey, previousResponseID: previousResponseID, originalModel: originalModel, + imageBillingModel: imageBillingModel, + imageSizeTier: imageSizeTier, payloadBytes: len(normalized), }, nil } @@ -2792,7 +2813,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return payload, nil } - sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string) (*OpenAIForwardResult, error) { + sendAndRelay := func(turn int, lease *openAIWSConnLease, payload []byte, payloadBytes int, originalModel string, imageBillingModel string, imageSizeTier string) (*OpenAIForwardResult, error) { if lease == nil { return nil, errors.New("upstream websocket lease is nil") } @@ -2817,6 +2838,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( responseID := "" usage := OpenAIUsage{} + imageCounter := newOpenAIImageOutputCounter() var firstTokenMs *int reqStream := openAIWSPayloadBoolFromRaw(payload, "stream", true) turnPreviousResponseID := openAIWSPayloadStringFromRaw(payload, "previous_response_id") @@ -2938,6 +2960,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if openAIWSEventShouldParseUsage(eventType) { parseOpenAIWSResponseUsageFromCompletedEvent(upstreamMessage, &usage) } + imageCounter.AddSSEData(upstreamMessage) if !clientDisconnected { if needModelReplace && len(mappedModelBytes) > 0 && openAIWSEventMayContainModel(eventType) && bytes.Contains(upstreamMessage, mappedModelBytes) { @@ -2997,7 +3020,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( clientDisconnected, ) } - return &OpenAIForwardResult{ + imageCount := imageCounter.Count() + result := &OpenAIForwardResult{ RequestID: responseID, Usage: usage, Model: originalModel, @@ -3009,13 +3033,21 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ResponseHeaders: lease.HandshakeHeaders(), Duration: time.Since(turnStart), FirstTokenMs: firstTokenMs, - }, nil + } + if imageCount > 0 { + result.ImageCount = imageCount + result.ImageSize = imageSizeTier + result.BillingModel = imageBillingModel + } + return result, nil } } } currentPayload := firstPayload.payloadRaw currentOriginalModel := firstPayload.originalModel + currentImageBillingModel := firstPayload.imageBillingModel + currentImageSizeTier := firstPayload.imageSizeTier currentPayloadBytes := firstPayload.payloadBytes isStrictAffinityTurn := func(payload []byte) bool { if !storeDisabled { @@ -3104,6 +3136,12 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( if turnPrevRecoveryTried || !s.openAIWSIngressPreviousResponseRecoveryEnabled() { return false } + // 携带 function_call_output 的请求不能丢弃 previous_response_id: + // 上游 API 需要 response chain 来匹配 tool_result 与之前的 tool_use, + // 丢弃后会导致 "No tool call found for function call output" 400 错误。 + if gjson.GetBytes(currentPayload, `input.#(type=="function_call_output")`).Exists() { + return false + } if isStrictAffinityTurn(currentPayload) { // Layer 2:严格亲和链路命中 previous_response_not_found 时,降级为“去掉 previous_response_id 后重放一次”。 // 该错误说明续链锚点已失效,继续 strict fail-close 只会直接中断本轮请求。 @@ -3185,6 +3223,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( return true } for { + if turn > 1 && !skipBeforeTurn && hooks != nil && hooks.BeforeRequest != nil { + if err := hooks.BeforeRequest(turn, currentPayload, currentOriginalModel); err != nil { + return err + } + } if !skipBeforeTurn && hooks != nil && hooks.BeforeTurn != nil { if err := hooks.BeforeTurn(turn); err != nil { return err @@ -3370,7 +3413,11 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( truncateOpenAIWSLogValue(pingErr.Error(), openAIWSLogValueMaxLen), ) if forcePreferredConn { - if !turnPrevRecoveryTried && currentPreviousResponseID != "" { + // 携带 function_call_output 的请求不能丢弃 previous_response_id: + // 上游 API 需要 response chain 来匹配 tool_result 与之前的 tool_use, + // 丢弃后会导致 "No tool call found for function call output" 400 错误。 + hasFCOutput := gjson.GetBytes(currentPayload, `input.#(type=="function_call_output")`).Exists() + if !turnPrevRecoveryTried && currentPreviousResponseID != "" && !hasFCOutput { updatedPayload, removed, dropErr := dropPreviousResponseIDFromRawPayload(currentPayload) if dropErr != nil || !removed { reason := "not_removed" @@ -3460,7 +3507,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( ) } - result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel) + result, relayErr := sendAndRelay(turn, sessionLease, currentPayload, currentPayloadBytes, currentOriginalModel, currentImageBillingModel, currentImageSizeTier) if relayErr != nil { lastTurnClean = false if recoverIngressPrevResponseNotFound(relayErr, turn, connID) { @@ -3582,6 +3629,8 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( } currentPayload = nextPayload.payloadRaw currentOriginalModel = nextPayload.originalModel + currentImageBillingModel = nextPayload.imageBillingModel + currentImageSizeTier = nextPayload.imageSizeTier currentPayloadBytes = nextPayload.payloadBytes storeDisabled = s.isOpenAIWSStoreDisabledInRequestRaw(currentPayload, account) if !storeDisabled { diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go index 7a76c38573d..cd8165330ea 100644 --- a/backend/internal/service/openai_ws_forwarder_success_test.go +++ b/backend/internal/service/openai_ws_forwarder_success_test.go @@ -171,6 +171,127 @@ func TestOpenAIGatewayService_Forward_WSv2_SuccessAndBindSticky(t *testing.T) { require.Equal(t, "resp_new_1", gjson.GetBytes(responseBody, "id").String()) } +func TestOpenAIGatewayService_Forward_WSv2_ImageGenerationCountsOutputs(t *testing.T) { + gin.SetMode(gin.TestMode) + + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Errorf("upgrade websocket failed: %v", err) + return + } + defer func() { + _ = conn.Close() + }() + + var request map[string]any + if err := conn.ReadJSON(&request); err != nil { + t.Errorf("read ws request failed: %v", err) + return + } + + if err := conn.WriteJSON(map[string]any{ + "type": "response.output_item.done", + "item": map[string]any{ + "id": "ig_ws_1", + "type": "image_generation_call", + "result": "final-image", + }, + }); err != nil { + t.Errorf("write response.output_item.done failed: %v", err) + return + } + if err := conn.WriteJSON(map[string]any{ + "type": "response.completed", + "response": map[string]any{ + "id": "resp_ws_image_1", + "model": "gpt-5.4", + "output": []any{ + map[string]any{ + "id": "ig_ws_1", + "type": "image_generation_call", + "result": "final-image", + }, + }, + "usage": map[string]any{ + "input_tokens": 9, + "output_tokens": 4, + }, + }, + }); err != nil { + t.Errorf("write response.completed failed: %v", err) + return + } + })) + defer wsServer.Close() + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) + groupID := int64(1010) + c.Set("api_key", &APIKey{ + GroupID: &groupID, + Group: &Group{ + ID: groupID, + AllowImageGeneration: true, + }, + }) + + 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 + cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8 + cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3 + cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 5 + cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3 + + svc := &OpenAIGatewayService{ + cfg: cfg, + httpUpstream: &httpUpstreamRecorder{}, + cache: &stubGatewayCache{}, + openaiWSResolver: NewOpenAIWSProtocolResolver(cfg), + toolCorrector: NewCodexToolCorrector(), + } + + account := &Account{ + ID: 10, + Name: "openai-ws-image", + Platform: PlatformOpenAI, + Type: AccountTypeAPIKey, + Status: StatusActive, + Schedulable: true, + Concurrency: 1, + Credentials: map[string]any{ + "api_key": "sk-test", + "base_url": wsServer.URL, + }, + Extra: map[string]any{ + "responses_websockets_v2_enabled": true, + }, + } + + body := []byte(`{"model":"gpt-5.4","stream":false,"input":"draw","tools":[{"type":"image_generation","model":"gpt-image-2","size":"1024x1024"}],"tool_choice":{"type":"image_generation"}}`) + result, err := svc.Forward(context.Background(), c, account, body) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, "resp_ws_image_1", result.RequestID) + require.Equal(t, 1, result.ImageCount) + require.Equal(t, "1K", result.ImageSize) + require.Equal(t, "gpt-image-2", result.BillingModel) + require.Equal(t, 9, result.Usage.InputTokens) + require.Equal(t, 4, result.Usage.OutputTokens) + require.True(t, result.OpenAIWSMode) + require.Equal(t, "resp_ws_image_1", gjson.GetBytes(rec.Body.Bytes(), "id").String()) +} + func requestToJSONString(payload map[string]any) string { if len(payload) == 0 { return "{}" diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 8bc17d4227a..e27607259b4 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -387,6 +387,19 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( if msgType != coderws.MessageText { return payload, nil, nil } + if strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" && hooks != nil && hooks.BeforeRequest != nil { + turnNo := int(completedTurns.Load()) + 1 + if turnNo < 2 { + turnNo = 2 + } + requestModel := usageMeta.requestModelForFrame(payload) + if requestModel == "" { + requestModel = capturedSessionModel + } + if err := hooks.BeforeRequest(turnNo, payload, requestModel); err != nil { + return payload, nil, err + } + } // 在评估策略前先刷新 capturedSessionModel:客户端可能通过 // session.update 修改 session-level model(Realtime / // Responses WS 协议允许),如果不刷新就会出现 diff --git a/backend/internal/service/ops_cleanup_executor.go b/backend/internal/service/ops_cleanup_executor.go new file mode 100644 index 00000000000..63a7367f4ce --- /dev/null +++ b/backend/internal/service/ops_cleanup_executor.go @@ -0,0 +1,164 @@ +package service + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +const ( + opsCleanupDefaultSchedule = "0 2 * * *" + opsCleanupBatchSize = 5000 + opsCleanupCronStopTimeout = 3 * time.Second + opsCleanupRunTimeout = 30 * time.Minute + opsCleanupHeartbeatTimeout = 2 * time.Second +) + +type opsCleanupTarget struct { + retentionDays int + table string + timeCol string + castDate bool + counter *int64 +} + +type opsCleanupDeletedCounts struct { + errorLogs int64 + retryAttempts int64 + alertEvents int64 + systemLogs int64 + logAudits int64 + systemMetrics int64 + hourlyPreagg int64 + dailyPreagg int64 +} + +func (c opsCleanupDeletedCounts) String() string { + return fmt.Sprintf( + "error_logs=%d retry_attempts=%d alert_events=%d system_logs=%d log_audits=%d system_metrics=%d hourly_preagg=%d daily_preagg=%d", + c.errorLogs, + c.retryAttempts, + c.alertEvents, + c.systemLogs, + c.logAudits, + c.systemMetrics, + c.hourlyPreagg, + c.dailyPreagg, + ) +} + +// opsCleanupPlan 把"保留天数"翻译成具体的清理动作。 +// - days < 0 → 跳过该项清理(ok=false),保留兼容老数据 +// - days == 0 → TRUNCATE TABLE(O(1) 全清),truncate=true +// - days > 0 → 批量 DELETE 早于 now-N天 的行,cutoff = now - N 天 +func opsCleanupPlan(now time.Time, days int) (cutoff time.Time, truncate, ok bool) { + if days < 0 { + return time.Time{}, false, false + } + if days == 0 { + return time.Time{}, true, true + } + return now.AddDate(0, 0, -days), false, true +} + +func opsCleanupRunOne( + ctx context.Context, + db *sql.DB, + truncate bool, + cutoff time.Time, + table, timeCol string, + castDate bool, + batchSize int, +) (int64, error) { + if truncate { + return truncateOpsTable(ctx, db, table) + } + return deleteOldRowsByID(ctx, db, table, timeCol, cutoff, batchSize, castDate) +} + +func deleteOldRowsByID( + ctx context.Context, + db *sql.DB, + table string, + timeColumn string, + cutoff time.Time, + batchSize int, + castCutoffToDate bool, +) (int64, error) { + if db == nil { + return 0, nil + } + if batchSize <= 0 { + batchSize = opsCleanupBatchSize + } + + where := fmt.Sprintf("%s < $1", timeColumn) + if castCutoffToDate { + where = fmt.Sprintf("%s < $1::date", timeColumn) + } + + q := fmt.Sprintf(` +WITH batch AS ( + SELECT id FROM %s + WHERE %s + ORDER BY id + LIMIT $2 +) +DELETE FROM %s +WHERE id IN (SELECT id FROM batch) +`, table, where, table) + + var total int64 + for { + res, err := db.ExecContext(ctx, q, cutoff, batchSize) + if err != nil { + if isMissingRelationError(err) { + return total, nil + } + return total, err + } + affected, err := res.RowsAffected() + if err != nil { + return total, err + } + total += affected + if affected == 0 { + break + } + } + return total, nil +} + +// truncateOpsTable 用 TRUNCATE TABLE 清空指定表,先 SELECT COUNT(*) 取得清空前行数用于 heartbeat。 +func truncateOpsTable(ctx context.Context, db *sql.DB, table string) (int64, error) { + if db == nil { + return 0, nil + } + var count int64 + if err := db.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&count); err != nil { + if isMissingRelationError(err) { + return 0, nil + } + return 0, fmt.Errorf("count %s: %w", table, err) + } + if count == 0 { + return 0, nil + } + if _, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s", table)); err != nil { + if isMissingRelationError(err) { + return 0, nil + } + return 0, fmt.Errorf("truncate %s: %w", table, err) + } + return count, nil +} + +func isMissingRelationError(err error) bool { + if err == nil { + return false + } + s := strings.ToLower(err.Error()) + return strings.Contains(s, "does not exist") && strings.Contains(s, "relation") +} diff --git a/backend/internal/service/ops_cleanup_overlay_test.go b/backend/internal/service/ops_cleanup_overlay_test.go new file mode 100644 index 00000000000..f751a42627c --- /dev/null +++ b/backend/internal/service/ops_cleanup_overlay_test.go @@ -0,0 +1,257 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" +) + +// makeOverlayService 构造一个没有 cron / db 的 cleanup service,仅用来测试 effective overlay。 +func makeOverlayService(repo SettingRepository, base config.OpsCleanupConfig) *OpsCleanupService { + cfg := &config.Config{} + cfg.Ops.Cleanup = base + return &OpsCleanupService{ + cfg: cfg, + settingRepo: repo, + } +} + +func writeAdvancedSettings(t *testing.T, repo *runtimeSettingRepoStub, dr OpsDataRetentionSettings) { + t.Helper() + adv := OpsAdvancedSettings{DataRetention: dr} + raw, err := json.Marshal(adv) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := repo.Set(context.Background(), SettingKeyOpsAdvancedSettings, string(raw)); err != nil { + t.Fatalf("set: %v", err) + } +} + +func TestComputeEffective_FallbackToCfgWhenSettingsAbsent(t *testing.T) { + repo := newRuntimeSettingRepoStub() + base := config.OpsCleanupConfig{ + Enabled: false, + Schedule: "0 2 * * *", + ErrorLogRetentionDays: 30, + MinuteMetricsRetentionDays: 30, + HourlyMetricsRetentionDays: 30, + } + svc := makeOverlayService(repo, base) + + svc.computeEffectiveLocked(context.Background()) + + if svc.effective != base { + t.Fatalf("expected effective == cfg base, got %#v", svc.effective) + } +} + +func TestComputeEffective_SettingsOverridesAll(t *testing.T) { + repo := newRuntimeSettingRepoStub() + writeAdvancedSettings(t, repo, OpsDataRetentionSettings{ + CleanupEnabled: true, + CleanupSchedule: "0 * * * *", + ErrorLogRetentionDays: 0, + MinuteMetricsRetentionDays: 7, + HourlyMetricsRetentionDays: 14, + }) + base := config.OpsCleanupConfig{ + Enabled: false, + Schedule: "0 2 * * *", + ErrorLogRetentionDays: 30, + MinuteMetricsRetentionDays: 30, + HourlyMetricsRetentionDays: 30, + } + svc := makeOverlayService(repo, base) + + svc.computeEffectiveLocked(context.Background()) + + want := config.OpsCleanupConfig{ + Enabled: true, + Schedule: "0 * * * *", + ErrorLogRetentionDays: 0, + MinuteMetricsRetentionDays: 7, + HourlyMetricsRetentionDays: 14, + } + if svc.effective != want { + t.Fatalf("effective mismatch:\nwant %#v\n got %#v", want, svc.effective) + } +} + +func TestComputeEffective_EmptyScheduleFallbackToCfg(t *testing.T) { + repo := newRuntimeSettingRepoStub() + writeAdvancedSettings(t, repo, OpsDataRetentionSettings{ + CleanupEnabled: true, + CleanupSchedule: " ", // 空白被 trim 后视为空 + ErrorLogRetentionDays: 5, + MinuteMetricsRetentionDays: 5, + HourlyMetricsRetentionDays: 5, + }) + base := config.OpsCleanupConfig{ + Enabled: false, + Schedule: "0 2 * * *", + ErrorLogRetentionDays: 30, + MinuteMetricsRetentionDays: 30, + HourlyMetricsRetentionDays: 30, + } + svc := makeOverlayService(repo, base) + + svc.computeEffectiveLocked(context.Background()) + + if svc.effective.Schedule != "0 2 * * *" { + t.Fatalf("expected schedule fallback to cfg, got %q", svc.effective.Schedule) + } + if !svc.effective.Enabled { + t.Fatalf("expected enabled=true from settings") + } + if svc.effective.ErrorLogRetentionDays != 5 { + t.Fatalf("expected retention=5 from settings, got %d", svc.effective.ErrorLogRetentionDays) + } +} + +func TestComputeEffective_NegativeRetentionFallsBackToCfg(t *testing.T) { + repo := newRuntimeSettingRepoStub() + writeAdvancedSettings(t, repo, OpsDataRetentionSettings{ + CleanupEnabled: true, + CleanupSchedule: "0 * * * *", + ErrorLogRetentionDays: -1, + MinuteMetricsRetentionDays: -1, + HourlyMetricsRetentionDays: -1, + }) + base := config.OpsCleanupConfig{ + Enabled: false, + Schedule: "0 2 * * *", + ErrorLogRetentionDays: 30, + MinuteMetricsRetentionDays: 60, + HourlyMetricsRetentionDays: 90, + } + svc := makeOverlayService(repo, base) + + svc.computeEffectiveLocked(context.Background()) + + if svc.effective.ErrorLogRetentionDays != 30 || + svc.effective.MinuteMetricsRetentionDays != 60 || + svc.effective.HourlyMetricsRetentionDays != 90 { + t.Fatalf("expected retention fallback to cfg, got %#v", svc.effective) + } +} + +func TestComputeEffective_BadJSONFallsBackToCfg(t *testing.T) { + repo := newRuntimeSettingRepoStub() + if err := repo.Set(context.Background(), SettingKeyOpsAdvancedSettings, "{not json"); err != nil { + t.Fatalf("set: %v", err) + } + base := config.OpsCleanupConfig{ + Enabled: true, + Schedule: "0 3 * * *", + ErrorLogRetentionDays: 30, + MinuteMetricsRetentionDays: 30, + HourlyMetricsRetentionDays: 30, + } + svc := makeOverlayService(repo, base) + + svc.computeEffectiveLocked(context.Background()) + + if svc.effective != base { + t.Fatalf("expected fallback to cfg on bad JSON, got %#v", svc.effective) + } +} + +// 验证 OpsService.UpdateOpsAdvancedSettings 写入后会调用 cleanupReloader.Reload。 +type fakeCleanupReloader struct { + calls int + last context.Context + err error +} + +func (f *fakeCleanupReloader) Reload(ctx context.Context) error { + f.calls++ + f.last = ctx + return f.err +} + +func TestUpdateOpsAdvancedSettings_TriggersReload(t *testing.T) { + repo := newRuntimeSettingRepoStub() + reloader := &fakeCleanupReloader{} + svc := &OpsService{settingRepo: repo} + svc.SetCleanupReloader(reloader) + + cfg := defaultOpsAdvancedSettings() + cfg.DataRetention.CleanupEnabled = true + cfg.DataRetention.CleanupSchedule = "0 * * * *" + cfg.DataRetention.ErrorLogRetentionDays = 3 + cfg.DataRetention.MinuteMetricsRetentionDays = 3 + cfg.DataRetention.HourlyMetricsRetentionDays = 3 + + if _, err := svc.UpdateOpsAdvancedSettings(context.Background(), cfg); err != nil { + t.Fatalf("update: %v", err) + } + if reloader.calls != 1 { + t.Fatalf("expected reloader.Reload called once, got %d", reloader.calls) + } +} + +func TestReload_BeforeStart_IsNoop(t *testing.T) { + svc := &OpsCleanupService{} + if err := svc.Reload(context.Background()); err != nil { + t.Fatalf("Reload before Start should return nil, got %v", err) + } +} + +func TestReload_AfterStop_IsNoop(t *testing.T) { + svc := &OpsCleanupService{started: true, stopped: true} + if err := svc.Reload(context.Background()); err != nil { + t.Fatalf("Reload after Stop should return nil, got %v", err) + } +} + +func TestUpdateOpsAdvancedSettings_NilReloader_NoPanic(t *testing.T) { + repo := newRuntimeSettingRepoStub() + svc := &OpsService{settingRepo: repo} + // cleanupReloader intentionally nil + + cfg := defaultOpsAdvancedSettings() + cfg.DataRetention.ErrorLogRetentionDays = 7 + + // should not panic + if _, err := svc.UpdateOpsAdvancedSettings(context.Background(), cfg); err != nil { + t.Fatalf("update with nil reloader: %v", err) + } +} + +func TestStart_IdempotentSecondCall(t *testing.T) { + svc := &OpsCleanupService{started: true} + svc.Start() // second call should be noop, not panic +} + +func TestRefreshEffectiveBeforeRun_UpdatesSnapshot(t *testing.T) { + repo := newRuntimeSettingRepoStub() + base := config.OpsCleanupConfig{ + Enabled: true, + Schedule: "0 2 * * *", + ErrorLogRetentionDays: 30, + } + svc := makeOverlayService(repo, base) + svc.computeEffectiveLocked(context.Background()) + + if svc.effective.ErrorLogRetentionDays != 30 { + t.Fatalf("initial retention should be 30, got %d", svc.effective.ErrorLogRetentionDays) + } + + // simulate UI change + writeAdvancedSettings(t, repo, OpsDataRetentionSettings{ + CleanupEnabled: true, + CleanupSchedule: "0 * * * *", + ErrorLogRetentionDays: 7, + }) + + svc.refreshEffectiveBeforeRun(context.Background()) + snap := svc.snapshotEffective() + if snap.ErrorLogRetentionDays != 7 { + t.Fatalf("after refresh, retention should be 7, got %d", snap.ErrorLogRetentionDays) + } +} diff --git a/backend/internal/service/ops_cleanup_service.go b/backend/internal/service/ops_cleanup_service.go index 44ec1ad17db..60a690f3960 100644 --- a/backend/internal/service/ops_cleanup_service.go +++ b/backend/internal/service/ops_cleanup_service.go @@ -3,6 +3,8 @@ package service import ( "context" "database/sql" + "encoding/json" + "errors" "fmt" "strings" "sync" @@ -45,13 +47,18 @@ type OpsCleanupService struct { redisClient *redis.Client cfg *config.Config channelMonitorSvc *ChannelMonitorService + settingRepo SettingRepository instanceID string - cron *cron.Cron - - startOnce sync.Once - stopOnce sync.Once + // mu 守护 cron 实例切换 + effective 配置切换。 + // 这里不再用 startOnce/stopOnce,是因为 Reload 需要"停旧 cron 重启新 cron", + // 而 Once 一旦触发就无法再次执行;改为 started/stopped 布尔配合 mu。 + mu sync.Mutex + cron *cron.Cron + started bool + stopped bool + effective config.OpsCleanupConfig warnNoRedisOnce sync.Once } @@ -62,6 +69,7 @@ func NewOpsCleanupService( redisClient *redis.Client, cfg *config.Config, channelMonitorSvc *ChannelMonitorService, + settingRepo SettingRepository, ) *OpsCleanupService { return &OpsCleanupService{ opsRepo: opsRepo, @@ -69,10 +77,13 @@ func NewOpsCleanupService( redisClient: redisClient, cfg: cfg, channelMonitorSvc: channelMonitorSvc, + settingRepo: settingRepo, instanceID: uuid.NewString(), } } +// Start 首次启动 cron 调度。Enabled / Schedule 由 effective 配置决定(settings 优先 cfg)。 +// 重复调用幂等。 func (s *OpsCleanupService) Start() { if s == nil { return @@ -80,54 +91,169 @@ func (s *OpsCleanupService) Start() { if s.cfg != nil && !s.cfg.Ops.Enabled { return } - if s.cfg != nil && !s.cfg.Ops.Cleanup.Enabled { - logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] not started (disabled)") - return - } if s.opsRepo == nil || s.db == nil { logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] not started (missing deps)") return } - s.startOnce.Do(func() { - schedule := "0 2 * * *" - if s.cfg != nil && strings.TrimSpace(s.cfg.Ops.Cleanup.Schedule) != "" { - schedule = strings.TrimSpace(s.cfg.Ops.Cleanup.Schedule) - } + s.mu.Lock() + defer s.mu.Unlock() + if s.started || s.stopped { + return + } + s.started = true + if err := s.applyScheduleLocked(context.Background()); err != nil { + logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] not started: %v", err) + } +} - loc := time.Local - if s.cfg != nil && strings.TrimSpace(s.cfg.Timezone) != "" { - if parsed, err := time.LoadLocation(strings.TrimSpace(s.cfg.Timezone)); err == nil && parsed != nil { - loc = parsed - } - } +// Stop 关闭 cron。幂等。 +func (s *OpsCleanupService) Stop() { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.stopped { + return + } + s.stopped = true + s.stopCronLocked() +} - c := cron.New(cron.WithParser(opsCleanupCronParser), cron.WithLocation(loc)) - _, err := c.AddFunc(schedule, func() { s.runScheduled() }) - if err != nil { - logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] not started (invalid schedule=%q): %v", schedule, err) - return +// stopCronLocked 停掉当前 cron 实例(带 3s 超时)。调用方持锁。 +func (s *OpsCleanupService) stopCronLocked() { + if s.cron == nil { + return + } + ctx := s.cron.Stop() + select { + case <-ctx.Done(): + case <-time.After(opsCleanupCronStopTimeout): + logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cron stop timed out") + } + s.cron = nil +} + +// applyScheduleLocked 重新计算 effective 配置并按其 schedule 重建 cron。调用方持锁。 +// 若 effective.Enabled=false(用户在 UI 关闭清理),停旧 cron 后直接返回,不创建新 cron。 +func (s *OpsCleanupService) applyScheduleLocked(ctx context.Context) error { + s.computeEffectiveLocked(ctx) + s.stopCronLocked() + + if !s.effective.Enabled { + logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cron disabled by settings") + return nil + } + + schedule := strings.TrimSpace(s.effective.Schedule) + if schedule == "" { + schedule = opsCleanupDefaultSchedule + } + + loc := time.Local + if s.cfg != nil && strings.TrimSpace(s.cfg.Timezone) != "" { + if parsed, err := time.LoadLocation(strings.TrimSpace(s.cfg.Timezone)); err == nil && parsed != nil { + loc = parsed } - s.cron = c - s.cron.Start() - logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] started (schedule=%q tz=%s)", schedule, loc.String()) - }) + } + + c := cron.New(cron.WithParser(opsCleanupCronParser), cron.WithLocation(loc)) + if _, err := c.AddFunc(schedule, func() { s.runScheduled() }); err != nil { + return fmt.Errorf("invalid schedule %q: %w", schedule, err) + } + c.Start() + s.cron = c + logger.LegacyPrintf("service.ops_cleanup", + "[OpsCleanup] scheduled (schedule=%q tz=%s retention_days=err:%d/min:%d/hour:%d)", + schedule, loc.String(), + s.effective.ErrorLogRetentionDays, + s.effective.MinuteMetricsRetentionDays, + s.effective.HourlyMetricsRetentionDays, + ) + return nil } -func (s *OpsCleanupService) Stop() { +// Reload 重新读取 ops_advanced_settings.data_retention 并按新配置重建 cron。 +// 适用于 admin 在 UI 修改清理设置后立即生效(schedule / enabled 改动需要 Reload; +// retention 改动 runScheduled 顶部也会刷新,下一次触发即生效)。 +// 若 service 还未 Start 或已 Stop,Reload 不做任何事。 +func (s *OpsCleanupService) Reload(ctx context.Context) error { if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() + if !s.started || s.stopped { + return nil + } + return s.applyScheduleLocked(ctx) +} + +// computeEffectiveLocked 计算"生效配置"并写入 s.effective。调用方持锁。 +// +// 优先级:UI 写入的 settings.ops_advanced_settings.data_retention(权威)覆盖 cfg.Ops.Cleanup 的副本。 +// - Enabled:settings 直接覆盖 +// - Schedule:settings 非空时覆盖,否则保留 cfg +// - *RetentionDays:settings >=0 时覆盖(包括 0=TRUNCATE),<0 沿用 cfg +// +// 若 settings 表无该 key(ErrSettingNotFound)或解析失败,整体 fallback 到 cfg.Ops.Cleanup。 +func (s *OpsCleanupService) computeEffectiveLocked(ctx context.Context) { + base := config.OpsCleanupConfig{} + if s.cfg != nil { + base = s.cfg.Ops.Cleanup + } + defer func() { s.effective = base }() + + if s.settingRepo == nil { return } - s.stopOnce.Do(func() { - if s.cron != nil { - ctx := s.cron.Stop() - select { - case <-ctx.Done(): - case <-time.After(3 * time.Second): - logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cron stop timed out") - } + if ctx == nil { + ctx = context.Background() + } + raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsAdvancedSettings) + if err != nil { + if !errors.Is(err, ErrSettingNotFound) { + logger.LegacyPrintf("service.ops_cleanup", + "[OpsCleanup] read advanced settings failed, using cfg: %v", err) } - }) + return + } + var adv OpsAdvancedSettings + if err := json.Unmarshal([]byte(raw), &adv); err != nil { + logger.LegacyPrintf("service.ops_cleanup", + "[OpsCleanup] parse advanced settings failed, using cfg: %v", err) + return + } + dr := adv.DataRetention + base.Enabled = dr.CleanupEnabled + if sched := strings.TrimSpace(dr.CleanupSchedule); sched != "" { + base.Schedule = sched + } + if dr.ErrorLogRetentionDays >= 0 { + base.ErrorLogRetentionDays = dr.ErrorLogRetentionDays + } + if dr.MinuteMetricsRetentionDays >= 0 { + base.MinuteMetricsRetentionDays = dr.MinuteMetricsRetentionDays + } + if dr.HourlyMetricsRetentionDays >= 0 { + base.HourlyMetricsRetentionDays = dr.HourlyMetricsRetentionDays + } +} + +// snapshotEffective 取一份 effective 副本(runCleanupOnce 等读路径使用)。 +func (s *OpsCleanupService) snapshotEffective() config.OpsCleanupConfig { + s.mu.Lock() + defer s.mu.Unlock() + return s.effective +} + +// refreshEffectiveBeforeRun 在 cron 触发时刷新 effective,让 retention 改动当次即生效。 +// schedule 改动不影响当次(cron 调度由库管理,需要 Reload 才换 schedule)。 +func (s *OpsCleanupService) refreshEffectiveBeforeRun(ctx context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + s.computeEffectiveLocked(ctx) } func (s *OpsCleanupService) runScheduled() { @@ -135,9 +261,12 @@ func (s *OpsCleanupService) runScheduled() { return } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + ctx, cancel := context.WithTimeout(context.Background(), opsCleanupRunTimeout) defer cancel() + // 让 retention 改动当次生效(schedule/enabled 改动需要 Reload)。 + s.refreshEffectiveBeforeRun(ctx) + release, ok := s.tryAcquireLeaderLock(ctx) if !ok { return @@ -159,124 +288,36 @@ func (s *OpsCleanupService) runScheduled() { logger.LegacyPrintf("service.ops_cleanup", "[OpsCleanup] cleanup complete: %s", counts) } -type opsCleanupDeletedCounts struct { - errorLogs int64 - retryAttempts int64 - alertEvents int64 - systemLogs int64 - logAudits int64 - systemMetrics int64 - hourlyPreagg int64 - dailyPreagg int64 -} - -func (c opsCleanupDeletedCounts) String() string { - return fmt.Sprintf( - "error_logs=%d retry_attempts=%d alert_events=%d system_logs=%d log_audits=%d system_metrics=%d hourly_preagg=%d daily_preagg=%d", - c.errorLogs, - c.retryAttempts, - c.alertEvents, - c.systemLogs, - c.logAudits, - c.systemMetrics, - c.hourlyPreagg, - c.dailyPreagg, - ) -} - -// opsCleanupPlan 把"保留天数"翻译成具体的清理动作。 -// - days < 0 → 跳过该项清理(ok=false),保留兼容老数据 -// - days == 0 → TRUNCATE TABLE(O(1) 全清),truncate=true -// - days > 0 → 批量 DELETE 早于 now-N天 的行,cutoff = now - N 天 -// -// 之所以 days==0 走 TRUNCATE 而非"now+24h cutoff + DELETE": -// - 速度从 O(N) 降到 O(1),对百万行级表毫秒完成 -// - 无 WAL 写入、无后续 VACUUM 压力 -// - 这些 ops 表只有 cleanup 任务自己写,TRUNCATE 的 ACCESS EXCLUSIVE 锁影响可忽略 -func opsCleanupPlan(now time.Time, days int) (cutoff time.Time, truncate, ok bool) { - if days < 0 { - return time.Time{}, false, false - } - if days == 0 { - return time.Time{}, true, true - } - return now.AddDate(0, 0, -days), false, true -} - func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDeletedCounts, error) { out := opsCleanupDeletedCounts{} if s == nil || s.db == nil || s.cfg == nil { return out, nil } - batchSize := 5000 - + effective := s.snapshotEffective() now := time.Now().UTC() - // runOne 把"truncate? cutoff? batched delete?"封装到一处, - // 让三组清理(错误日志类 / 分钟指标 / 小时+日预聚合)调用方只关心表名和列名。 - runOne := func(truncate bool, cutoff time.Time, table, timeCol string, castDate bool) (int64, error) { - if truncate { - return truncateOpsTable(ctx, s.db, table) - } - return deleteOldRowsByID(ctx, s.db, table, timeCol, cutoff, batchSize, castDate) - } - - // Error-like tables: error logs / retry attempts / alert events / system logs / cleanup audits. - if cutoff, truncate, ok := opsCleanupPlan(now, s.cfg.Ops.Cleanup.ErrorLogRetentionDays); ok { - n, err := runOne(truncate, cutoff, "ops_error_logs", "created_at", false) - if err != nil { - return out, err - } - out.errorLogs = n - - n, err = runOne(truncate, cutoff, "ops_retry_attempts", "created_at", false) - if err != nil { - return out, err - } - out.retryAttempts = n - - n, err = runOne(truncate, cutoff, "ops_alert_events", "created_at", false) - if err != nil { - return out, err - } - out.alertEvents = n - - n, err = runOne(truncate, cutoff, "ops_system_logs", "created_at", false) - if err != nil { - return out, err - } - out.systemLogs = n - - n, err = runOne(truncate, cutoff, "ops_system_log_cleanup_audits", "created_at", false) - if err != nil { - return out, err - } - out.logAudits = n - } - - // Minute-level metrics snapshots. - if cutoff, truncate, ok := opsCleanupPlan(now, s.cfg.Ops.Cleanup.MinuteMetricsRetentionDays); ok { - n, err := runOne(truncate, cutoff, "ops_system_metrics", "created_at", false) - if err != nil { - return out, err - } - out.systemMetrics = n - } - - // Pre-aggregation tables (hourly/daily). - if cutoff, truncate, ok := opsCleanupPlan(now, s.cfg.Ops.Cleanup.HourlyMetricsRetentionDays); ok { - n, err := runOne(truncate, cutoff, "ops_metrics_hourly", "bucket_start", false) - if err != nil { - return out, err + targets := []opsCleanupTarget{ + {effective.ErrorLogRetentionDays, "ops_error_logs", "created_at", false, &out.errorLogs}, + {effective.ErrorLogRetentionDays, "ops_retry_attempts", "created_at", false, &out.retryAttempts}, + {effective.ErrorLogRetentionDays, "ops_alert_events", "created_at", false, &out.alertEvents}, + {effective.ErrorLogRetentionDays, "ops_system_logs", "created_at", false, &out.systemLogs}, + {effective.ErrorLogRetentionDays, "ops_system_log_cleanup_audits", "created_at", false, &out.logAudits}, + {effective.MinuteMetricsRetentionDays, "ops_system_metrics", "created_at", false, &out.systemMetrics}, + {effective.HourlyMetricsRetentionDays, "ops_metrics_hourly", "bucket_start", false, &out.hourlyPreagg}, + {effective.HourlyMetricsRetentionDays, "ops_metrics_daily", "bucket_date", true, &out.dailyPreagg}, + } + + for _, t := range targets { + cutoff, truncate, ok := opsCleanupPlan(now, t.retentionDays) + if !ok { + continue } - out.hourlyPreagg = n - - n, err = runOne(truncate, cutoff, "ops_metrics_daily", "bucket_date", true) + n, err := opsCleanupRunOne(ctx, s.db, truncate, cutoff, t.table, t.timeCol, t.castDate, opsCleanupBatchSize) if err != nil { return out, err } - out.dailyPreagg = n + *t.counter = n } // Channel monitor 每日维护(聚合昨日明细 + 软删过期明细/聚合)。 @@ -291,100 +332,6 @@ func (s *OpsCleanupService) runCleanupOnce(ctx context.Context) (opsCleanupDelet return out, nil } -func deleteOldRowsByID( - ctx context.Context, - db *sql.DB, - table string, - timeColumn string, - cutoff time.Time, - batchSize int, - castCutoffToDate bool, -) (int64, error) { - if db == nil { - return 0, nil - } - if batchSize <= 0 { - batchSize = 5000 - } - - where := fmt.Sprintf("%s < $1", timeColumn) - if castCutoffToDate { - where = fmt.Sprintf("%s < $1::date", timeColumn) - } - - q := fmt.Sprintf(` -WITH batch AS ( - SELECT id FROM %s - WHERE %s - ORDER BY id - LIMIT $2 -) -DELETE FROM %s -WHERE id IN (SELECT id FROM batch) -`, table, where, table) - - var total int64 - for { - res, err := db.ExecContext(ctx, q, cutoff, batchSize) - if err != nil { - // If ops tables aren't present yet (partial deployments), treat as no-op. - if isMissingRelationError(err) { - return total, nil - } - return total, err - } - affected, err := res.RowsAffected() - if err != nil { - return total, err - } - total += affected - if affected == 0 { - break - } - } - return total, nil -} - -// truncateOpsTable 用 TRUNCATE TABLE 清空指定表,先 SELECT COUNT(*) 取得清空前行数用于 heartbeat。 -// -// 与 deleteOldRowsByID 的差异: -// - 不可指定 WHERE 条件,仅用于 days==0 的"清空全部"语义 -// - O(1) 释放表的物理存储页,毫秒级完成,无 WAL 写入、无 VACUUM 压力 -// - 需要 ACCESS EXCLUSIVE 锁,但 ops 表只有清理任务自己写入,瞬间锁影响可忽略 -// -// 表不存在(部分部署)静默返回 0,与 deleteOldRowsByID 保持一致。 -func truncateOpsTable(ctx context.Context, db *sql.DB, table string) (int64, error) { - if db == nil { - return 0, nil - } - var count int64 - if err := db.QueryRowContext(ctx, fmt.Sprintf("SELECT COUNT(*) FROM %s", table)).Scan(&count); err != nil { - if isMissingRelationError(err) { - return 0, nil - } - return 0, fmt.Errorf("count %s: %w", table, err) - } - if count == 0 { - return 0, nil - } - if _, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s", table)); err != nil { - if isMissingRelationError(err) { - return 0, nil - } - return 0, fmt.Errorf("truncate %s: %w", table, err) - } - return count, nil -} - -// isMissingRelationError 判断 PG 报错是否为"表不存在",用于让清理任务在部分部署场景静默跳过。 -func isMissingRelationError(err error) bool { - if err == nil { - return false - } - s := strings.ToLower(err.Error()) - return strings.Contains(s, "does not exist") && strings.Contains(s, "relation") -} - func (s *OpsCleanupService) tryAcquireLeaderLock(ctx context.Context) (func(), bool) { if s == nil { return nil, false @@ -433,7 +380,7 @@ func (s *OpsCleanupService) recordHeartbeatSuccess(runAt time.Time, duration tim now := time.Now().UTC() durMs := duration.Milliseconds() result := truncateString(counts.String(), 2048) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), opsCleanupHeartbeatTimeout) defer cancel() _ = s.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{ JobName: opsCleanupJobName, @@ -451,7 +398,7 @@ func (s *OpsCleanupService) recordHeartbeatError(runAt time.Time, duration time. now := time.Now().UTC() durMs := duration.Milliseconds() msg := truncateString(err.Error(), 2048) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), opsCleanupHeartbeatTimeout) defer cancel() _ = s.opsRepo.UpsertJobHeartbeat(ctx, &OpsUpsertJobHeartbeatInput{ JobName: opsCleanupJobName, diff --git a/backend/internal/service/ops_service.go b/backend/internal/service/ops_service.go index cd3974a00f0..11afc6f9d28 100644 --- a/backend/internal/service/ops_service.go +++ b/backend/internal/service/ops_service.go @@ -54,6 +54,24 @@ type OpsService struct { geminiCompatService *GeminiMessagesCompatService antigravityGatewayService *AntigravityGatewayService systemLogSink *OpsSystemLogSink + + // cleanupReloader 由 wire 在 OpsCleanupService 构造完成后通过 SetCleanupReloader 注入。 + // 解耦避免 OpsService -> OpsCleanupService 的硬依赖(cleanup 也读 settings,会循环)。 + cleanupReloader CleanupReloader +} + +// CleanupReloader 由 OpsCleanupService 实现。 +// UpdateOpsAdvancedSettings 写入新配置后调用 Reload,让 schedule/enabled 改动立刻生效。 +type CleanupReloader interface { + Reload(ctx context.Context) error +} + +// SetCleanupReloader 由 wire 注入 cleanup hook(构造期循环依赖的解耦点)。 +func (s *OpsService) SetCleanupReloader(r CleanupReloader) { + if s == nil { + return + } + s.cleanupReloader = r } func NewOpsService( diff --git a/backend/internal/service/ops_settings.go b/backend/internal/service/ops_settings.go index ecc3a94b7db..68c1d9ddea4 100644 --- a/backend/internal/service/ops_settings.go +++ b/backend/internal/service/ops_settings.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "strings" "time" ) @@ -360,7 +361,7 @@ func defaultOpsAdvancedSettings() *OpsAdvancedSettings { return &OpsAdvancedSettings{ DataRetention: OpsDataRetentionSettings{ CleanupEnabled: false, - CleanupSchedule: "0 2 * * *", + CleanupSchedule: opsCleanupDefaultSchedule, ErrorLogRetentionDays: 30, MinuteMetricsRetentionDays: 30, HourlyMetricsRetentionDays: 30, @@ -385,7 +386,7 @@ func normalizeOpsAdvancedSettings(cfg *OpsAdvancedSettings) { } cfg.DataRetention.CleanupSchedule = strings.TrimSpace(cfg.DataRetention.CleanupSchedule) if cfg.DataRetention.CleanupSchedule == "" { - cfg.DataRetention.CleanupSchedule = "0 2 * * *" + cfg.DataRetention.CleanupSchedule = opsCleanupDefaultSchedule } // 保留天数:0 表示每次定时清理全部(清空所有),> 0 表示按天数保留; // 仅在拿到非法的负数时回填默认值,避免覆盖用户主动设的 0。 @@ -477,6 +478,14 @@ func (s *OpsService) UpdateOpsAdvancedSettings(ctx context.Context, cfg *OpsAdva return nil, err } + // notify cleanup service to reload schedule/enabled. + if s.cleanupReloader != nil { + if rerr := s.cleanupReloader.Reload(ctx); rerr != nil { + logger.LegacyPrintf("service.ops_settings", + "[OpsSettings] cleanup reload after advanced-settings update failed: %v", rerr) + } + } + updated := &OpsAdvancedSettings{} _ = json.Unmarshal(raw, updated) return updated, nil diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 4ae6d134ae2..f96684a4c3b 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -282,7 +282,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e case redeemActionRedeem: // Code exists but unused — skip creation, proceed to redeem } - if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil { + if _, err := s.redeemService.Redeem(ContextSkipRedeemAffiliate(ctx), o.UserID, o.RechargeCode); err != nil { return fmt.Errorf("redeem balance: %w", err) } if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil { diff --git a/backend/internal/service/payment_order_lifecycle_test.go b/backend/internal/service/payment_order_lifecycle_test.go index 8dfd2e7e01c..d8595715a53 100644 --- a/backend/internal/service/payment_order_lifecycle_test.go +++ b/backend/internal/service/payment_order_lifecycle_test.go @@ -208,6 +208,7 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) { nil, client, nil, + nil, ) registry := payment.NewRegistry() provider := &paymentOrderLifecycleQueryProvider{ @@ -308,6 +309,7 @@ func TestVerifyOrderByOutTradeNoRetriesZeroAmountPaidQueryOnce(t *testing.T) { nil, client, nil, + nil, ) registry := payment.NewRegistry() provider := &paymentOrderLifecycleQueryProvider{ @@ -398,6 +400,7 @@ func TestVerifyOrderByOutTradeNoRejectsPaidQueryWithZeroAmount(t *testing.T) { nil, client, nil, + nil, ) registry := payment.NewRegistry() provider := &paymentOrderLifecycleQueryProvider{ @@ -496,6 +499,7 @@ func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsFor nil, client, nil, + nil, ) registry := payment.NewRegistry() provider := &paymentOrderLifecycleQueryProvider{ diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go index 91a02901da5..8a03371053a 100644 --- a/backend/internal/service/pricing_service.go +++ b/backend/internal/service/pricing_service.go @@ -625,6 +625,9 @@ func normalizeModelNameForPricing(model string) string { } model = strings.TrimLeft(model, "/") + if canonical := canonicalizeOpenAIModelAliasSpelling(model); canonical != "" { + return canonical + } return model } diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go index e2bd7cf33a5..3c3e2c5ba6c 100644 --- a/backend/internal/service/pricing_service_test.go +++ b/backend/internal/service/pricing_service_test.go @@ -98,6 +98,19 @@ func TestGetModelPricing_Gpt54UsesStaticFallbackWhenRemoteMissing(t *testing.T) require.InDelta(t, 1.5, got.LongContextOutputCostMultiplier, 1e-12) } +func TestGetModelPricing_OpenAICompactAliasUsesStaticFallback(t *testing.T) { + svc := &PricingService{ + pricingData: map[string]*LiteLLMModelPricing{ + "gpt-5.1-codex": {InputCostPerToken: 1.25e-6}, + }, + } + + got := svc.GetModelPricing("openai/gpt5.5") + require.NotNil(t, got) + require.InDelta(t, 2.5e-6, got.InputCostPerToken, 1e-12) + require.InDelta(t, 1.5e-5, got.OutputCostPerToken, 1e-12) +} + func TestGetModelPricing_Gpt54MiniUsesDedicatedStaticFallbackWhenRemoteMissing(t *testing.T) { svc := &PricingService{ pricingData: map[string]*LiteLLMModelPricing{ diff --git a/backend/internal/service/rate_limit_429_cooldown_test.go b/backend/internal/service/rate_limit_429_cooldown_test.go new file mode 100644 index 00000000000..fb7e0dd7afd --- /dev/null +++ b/backend/internal/service/rate_limit_429_cooldown_test.go @@ -0,0 +1,113 @@ +//go:build unit + +package service + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +type rateLimit429AccountRepoStub struct { + mockAccountRepoForGemini + rateLimitCalls int + lastRateLimitID int64 + lastRateLimitReset time.Time +} + +func (r *rateLimit429AccountRepoStub) SetRateLimited(_ context.Context, id int64, resetAt time.Time) error { + r.rateLimitCalls++ + r.lastRateLimitID = id + r.lastRateLimitReset = resetAt + return nil +} + +func TestGetRateLimit429CooldownSettings_DefaultsWhenNotSet(t *testing.T) { + repo := newMockSettingRepo() + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetRateLimit429CooldownSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.Enabled) + require.Equal(t, 5, settings.CooldownSeconds) +} + +func TestGetRateLimit429CooldownSettings_ReadsFromDB(t *testing.T) { + repo := newMockSettingRepo() + data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: false, CooldownSeconds: 12}) + repo.data[SettingKeyRateLimit429CooldownSettings] = string(data) + svc := NewSettingService(repo, &config.Config{}) + + settings, err := svc.GetRateLimit429CooldownSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.Enabled) + require.Equal(t, 12, settings.CooldownSeconds) +} + +func TestSetRateLimit429CooldownSettings_EnabledRejectsOutOfRange(t *testing.T) { + svc := NewSettingService(newMockSettingRepo(), &config.Config{}) + + for _, seconds := range []int{0, -1, 7201, 99999} { + err := svc.SetRateLimit429CooldownSettings(context.Background(), &RateLimit429CooldownSettings{ + Enabled: true, CooldownSeconds: seconds, + }) + require.Error(t, err, "should reject enabled=true + cooldown_seconds=%d", seconds) + require.Contains(t, err.Error(), "cooldown_seconds must be between 1-7200") + } +} + +func TestHandle429_FallbackUsesDBSeconds(t *testing.T) { + accountRepo := &rateLimit429AccountRepoStub{} + settingRepo := newMockSettingRepo() + data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: true, CooldownSeconds: 12}) + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data) + + settingSvc := NewSettingService(settingRepo, &config.Config{}) + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 42, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + before := time.Now() + svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"slow down"}}`)) + after := time.Now() + + require.Equal(t, 1, accountRepo.rateLimitCalls) + require.Equal(t, int64(42), accountRepo.lastRateLimitID) + require.True(t, !accountRepo.lastRateLimitReset.Before(before.Add(12*time.Second)) && !accountRepo.lastRateLimitReset.After(after.Add(12*time.Second))) +} + +func TestHandle429_FallbackDisabledSkipsLocalMark(t *testing.T) { + accountRepo := &rateLimit429AccountRepoStub{} + settingRepo := newMockSettingRepo() + data, _ := json.Marshal(RateLimit429CooldownSettings{Enabled: false, CooldownSeconds: 12}) + settingRepo.data[SettingKeyRateLimit429CooldownSettings] = string(data) + + settingSvc := NewSettingService(settingRepo, &config.Config{}) + svc := NewRateLimitService(accountRepo, nil, &config.Config{}, nil, nil) + svc.SetSettingService(settingSvc) + + account := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeOAuth} + svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"type":"rate_limit_error","message":"slow down"}}`)) + + require.Zero(t, accountRepo.rateLimitCalls) +} + +func TestHandle429_FallbackUsesDefaultSecondsWhenSettingServiceMissing(t *testing.T) { + accountRepo := &rateLimit429AccountRepoStub{} + cfg := &config.Config{} + svc := NewRateLimitService(accountRepo, nil, cfg, nil, nil) + + account := &Account{ID: 44, Platform: PlatformGemini, Type: AccountTypeAPIKey} + before := time.Now() + svc.handle429(context.Background(), account, http.Header{}, []byte(`{"error":{"message":"slow down"}}`)) + after := time.Now() + + require.Equal(t, 1, accountRepo.rateLimitCalls) + require.Equal(t, int64(44), accountRepo.lastRateLimitID) + require.True(t, !accountRepo.lastRateLimitReset.Before(before.Add(5*time.Second)) && !accountRepo.lastRateLimitReset.After(after.Add(5*time.Second))) +} diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index 9344de47d86..a53cb0e9c10 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -55,6 +55,11 @@ type geminiUsageTotalsBatchProvider interface { const geminiPrecheckCacheTTL = time.Minute +const ( + defaultRateLimit429CooldownSeconds = 5 + maxRateLimit429CooldownSeconds = 7200 +) + const ( openAI403CooldownMinutesDefault = 10 openAI403DisableThreshold = 3 @@ -891,12 +896,8 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head return } - // 其他平台:没有重置时间,使用默认5分钟 - resetAt := time.Now().Add(5 * time.Minute) - slog.Warn("rate_limit_no_reset_time", "account_id", account.ID, "platform", account.Platform, "using_default", "5m") - if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil { - slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) - } + // 其他平台:没有重置时间,使用可配置的秒级默认回避,避免误伤长时间不可调度。 + s.apply429FallbackRateLimit(ctx, account, "no_reset_time") return } @@ -904,10 +905,7 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head ts, err := strconv.ParseInt(resetTimestamp, 10, 64) if err != nil { slog.Warn("rate_limit_reset_parse_failed", "reset_timestamp", resetTimestamp, "error", err) - resetAt := time.Now().Add(5 * time.Minute) - if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil { - slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) - } + s.apply429FallbackRateLimit(ctx, account, "reset_parse_failed") return } @@ -929,6 +927,48 @@ func (s *RateLimitService) handle429(ctx context.Context, account *Account, head slog.Info("account_rate_limited", "account_id", account.ID, "reset_at", resetAt) } +func (s *RateLimitService) apply429FallbackRateLimit(ctx context.Context, account *Account, reason string) { + cooldown, enabled := s.get429FallbackCooldown(ctx, account) + if !enabled { + slog.Info("rate_limit_429_fallback_ignored", "account_id", account.ID, "platform", account.Platform, "reason", reason) + return + } + + resetAt := time.Now().Add(cooldown) + slog.Warn("rate_limit_429_fallback_used", "account_id", account.ID, "platform", account.Platform, "reason", reason, "using_default", cooldown.String()) + if err := s.accountRepo.SetRateLimited(ctx, account.ID, resetAt); err != nil { + slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err) + } +} + +func (s *RateLimitService) get429FallbackCooldown(ctx context.Context, account *Account) (time.Duration, bool) { + if s.settingService != nil { + settings, err := s.settingService.GetRateLimit429CooldownSettings(ctx) + if err == nil && settings != nil { + if !settings.Enabled { + return 0, false + } + seconds := clampRateLimit429CooldownSeconds(settings.CooldownSeconds) + return time.Duration(seconds) * time.Second, true + } + slog.Warn("rate_limit_429_settings_read_failed", "account_id", account.ID, "error", err) + } + + seconds := defaultRateLimit429CooldownSeconds + seconds = clampRateLimit429CooldownSeconds(seconds) + return time.Duration(seconds) * time.Second, true +} + +func clampRateLimit429CooldownSeconds(seconds int) int { + if seconds < 1 { + return 1 + } + if seconds > maxRateLimit429CooldownSeconds { + return maxRateLimit429CooldownSeconds + } + return seconds +} + // calculateOpenAI429ResetTime 从 OpenAI 429 响应头计算正确的重置时间 // 返回 nil 表示无法从响应头中确定重置时间 func calculateOpenAI429ResetTime(headers http.Header) *time.Time { diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index 9ced62016f0..dcf293c565a 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -11,6 +11,7 @@ import ( dbent "github.com/Wei-Shaw/sub2api/ent" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" ) @@ -28,6 +29,15 @@ const ( redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁 ) +type ctxKeySkipRedeemAffiliate struct{} + +// ContextSkipRedeemAffiliate returns a context that suppresses the redeem-level +// affiliate rebate. Used by payment fulfillment which handles rebate separately +// via applyAffiliateRebateForOrder (with audit-log deduplication). +func ContextSkipRedeemAffiliate(ctx context.Context) context.Context { + return context.WithValue(ctx, ctxKeySkipRedeemAffiliate{}, true) +} + // RedeemCache defines cache operations for redeem service type RedeemCache interface { GetRedeemAttemptCount(ctx context.Context, userID int64) (int, error) @@ -80,6 +90,7 @@ type RedeemService struct { billingCacheService *BillingCacheService entClient *dbent.Client authCacheInvalidator APIKeyAuthCacheInvalidator + affiliateService *AffiliateService } // NewRedeemService 创建兑换码服务实例 @@ -91,6 +102,7 @@ func NewRedeemService( billingCacheService *BillingCacheService, entClient *dbent.Client, authCacheInvalidator APIKeyAuthCacheInvalidator, + affiliateService *AffiliateService, ) *RedeemService { return &RedeemService{ redeemRepo: redeemRepo, @@ -100,6 +112,7 @@ func NewRedeemService( billingCacheService: billingCacheService, entClient: entClient, authCacheInvalidator: authCacheInvalidator, + affiliateService: affiliateService, } } @@ -369,6 +382,11 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) ( // 事务提交成功后失效缓存 s.invalidateRedeemCaches(ctx, userID, redeemCode) + // 余额类正数兑换码触发邀请返利(best-effort,失败不影响兑换结果) + if redeemCode.Type == RedeemTypeBalance && redeemCode.Value > 0 { + s.tryAccrueAffiliateRebateForRedeem(ctx, userID, redeemCode.Value) + } + // 重新获取更新后的兑换码 redeemCode, err = s.redeemRepo.GetByID(ctx, redeemCode.ID) if err != nil { @@ -418,6 +436,26 @@ func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64 } } +func (s *RedeemService) tryAccrueAffiliateRebateForRedeem(ctx context.Context, userID int64, amount float64) { + if ctx.Value(ctxKeySkipRedeemAffiliate{}) != nil { + return + } + if s.affiliateService == nil { + return + } + if !s.affiliateService.IsEnabled(ctx) { + return + } + rebate, err := s.affiliateService.AccrueInviteRebate(ctx, userID, amount) + if err != nil { + logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate failed for user %d amount %.2f: %v", userID, amount, err) + return + } + if rebate > 0 { + logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate accrued %.8f for inviter of user %d", rebate, userID) + } +} + // GetByID 根据ID获取兑换码 func (s *RedeemService) GetByID(ctx context.Context, id int64) (*RedeemCode, error) { code, err := s.redeemRepo.GetByID(ctx, id) diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index 2bae686ae54..762a16de893 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -3,6 +3,7 @@ package service import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -129,6 +130,8 @@ type AuthSourceDefaultSettings struct { LinuxDo ProviderDefaultGrantSettings OIDC ProviderDefaultGrantSettings WeChat ProviderDefaultGrantSettings + GitHub ProviderDefaultGrantSettings + Google ProviderDefaultGrantSettings ForceEmailOnThirdPartySignup bool } @@ -169,6 +172,20 @@ var ( grantOnSignup: SettingKeyAuthSourceDefaultWeChatGrantOnSignup, grantOnFirstBind: SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind, } + gitHubAuthSourceDefaultKeys = authSourceDefaultKeySet{ + balance: SettingKeyAuthSourceDefaultGitHubBalance, + concurrency: SettingKeyAuthSourceDefaultGitHubConcurrency, + subscriptions: SettingKeyAuthSourceDefaultGitHubSubscriptions, + grantOnSignup: SettingKeyAuthSourceDefaultGitHubGrantOnSignup, + grantOnFirstBind: SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind, + } + googleAuthSourceDefaultKeys = authSourceDefaultKeySet{ + balance: SettingKeyAuthSourceDefaultGoogleBalance, + concurrency: SettingKeyAuthSourceDefaultGoogleConcurrency, + subscriptions: SettingKeyAuthSourceDefaultGoogleSubscriptions, + grantOnSignup: SettingKeyAuthSourceDefaultGoogleGrantOnSignup, + grantOnFirstBind: SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind, + } ) const ( @@ -177,8 +194,151 @@ const ( defaultWeChatConnectMode = "open" defaultWeChatConnectScopes = "snsapi_login" defaultWeChatConnectFrontend = "/auth/wechat/callback" + defaultGitHubOAuthAuthorize = "https://github.com/login/oauth/authorize" + defaultGitHubOAuthToken = "https://github.com/login/oauth/access_token" + defaultGitHubOAuthUserInfo = "https://api.github.com/user" + defaultGitHubOAuthEmails = "https://api.github.com/user/emails" + defaultGitHubOAuthScopes = "read:user user:email" + defaultGitHubOAuthFrontend = "/auth/oauth/callback" + defaultGoogleOAuthAuthorize = "https://accounts.google.com/o/oauth2/v2/auth" + defaultGoogleOAuthToken = "https://oauth2.googleapis.com/token" + defaultGoogleOAuthUserInfo = "https://openidconnect.googleapis.com/v1/userinfo" + defaultGoogleOAuthScopes = "openid email profile" + defaultGoogleOAuthFrontend = "/auth/oauth/callback" + defaultLoginAgreementMode = "modal" + defaultLoginAgreementDate = "2026-03-31" ) +func normalizeLoginAgreementMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "checkbox": + return "checkbox" + default: + return defaultLoginAgreementMode + } +} + +func defaultLoginAgreementDocuments() []LoginAgreementDocument { + return []LoginAgreementDocument{ + { + ID: "terms", + Title: "服务条款", + ContentMD: "", + }, + { + ID: "usage-policy", + Title: "使用政策", + ContentMD: "", + }, + { + ID: "supported-regions", + Title: "支持的国家和地区", + ContentMD: "", + }, + { + ID: "service-specific-terms", + Title: "服务特定条款", + ContentMD: "", + }, + } +} + +func normalizeLoginAgreementDocumentID(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + var b strings.Builder + lastSeparator := false + for _, r := range raw { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + _, _ = b.WriteRune(r) + lastSeparator = false + continue + } + if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' { + if !lastSeparator && b.Len() > 0 { + if r == '_' { + _, _ = b.WriteRune('_') + } else { + _, _ = b.WriteRune('-') + } + lastSeparator = true + } + } + } + return strings.Trim(b.String(), "-_") +} + +func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument { + normalized := make([]LoginAgreementDocument, 0, len(docs)) + seen := make(map[string]int, len(docs)) + for i, doc := range docs { + title := strings.TrimSpace(doc.Title) + content := strings.TrimSpace(doc.ContentMD) + if title == "" && content == "" { + continue + } + id := normalizeLoginAgreementDocumentID(doc.ID) + if id == "" { + sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content))) + id = hex.EncodeToString(sum[:])[:12] + } + baseID := id + for suffix := 2; seen[id] > 0; suffix++ { + id = fmt.Sprintf("%s-%d", baseID, suffix) + } + seen[id]++ + normalized = append(normalized, LoginAgreementDocument{ + ID: id, + Title: title, + ContentMD: content, + }) + } + return normalized +} + +func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument { + raw = strings.TrimSpace(raw) + if raw == "" { + return defaultLoginAgreementDocuments() + } + var docs []LoginAgreementDocument + if err := json.Unmarshal([]byte(raw), &docs); err != nil { + return defaultLoginAgreementDocuments() + } + docs = normalizeLoginAgreementDocuments(docs) + if len(docs) == 0 { + return defaultLoginAgreementDocuments() + } + return docs +} + +func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) { + normalized := normalizeLoginAgreementDocuments(docs) + if len(normalized) == 0 { + normalized = defaultLoginAgreementDocuments() + } + b, err := json.Marshal(normalized) + if err != nil { + return "", fmt.Errorf("marshal login agreement documents: %w", err) + } + return string(b), nil +} + +func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string { + normalized := normalizeLoginAgreementDocuments(docs) + payload, err := json.Marshal(struct { + UpdatedAt string `json:"updated_at"` + Documents []LoginAgreementDocument `json:"documents"` + }{ + UpdatedAt: strings.TrimSpace(updatedAt), + Documents: normalized, + }) + if err != nil { + payload = []byte(strings.TrimSpace(updatedAt)) + } + sum := sha256.Sum256(payload) + return hex.EncodeToString(sum[:])[:16] +} + func normalizeWeChatConnectModeSetting(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "mp": @@ -411,6 +571,10 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyPasswordResetEnabled, SettingKeyInvitationCodeEnabled, SettingKeyTotpEnabled, + SettingKeyLoginAgreementEnabled, + SettingKeyLoginAgreementMode, + SettingKeyLoginAgreementUpdatedAt, + SettingKeyLoginAgreementDocuments, SettingKeyTurnstileEnabled, SettingKeyTurnstileSiteKey, SettingKeySiteName, @@ -448,6 +612,12 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingPaymentEnabled, SettingKeyOIDCConnectEnabled, SettingKeyOIDCConnectProviderName, + SettingKeyGitHubOAuthEnabled, + SettingKeyGitHubOAuthClientID, + SettingKeyGitHubOAuthClientSecret, + SettingKeyGoogleOAuthEnabled, + SettingKeyGoogleOAuthClientID, + SettingKeyGoogleOAuthClientSecret, SettingKeyBalanceLowNotifyEnabled, SettingKeyBalanceLowNotifyThreshold, SettingKeyBalanceLowNotifyRechargeURL, @@ -455,7 +625,10 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyChannelMonitorEnabled, SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyAvailableChannelsEnabled, + SettingKeyImageGenerationEnabled, + SettingKeyChatCompletionEnabled, SettingKeyAffiliateEnabled, + SettingKeyRiskControlEnabled, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -482,6 +655,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings if oidcProviderName == "" { oidcProviderName = "OIDC" } + gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github") + googleEnabled := s.emailOAuthPublicEnabled(settings, "google") weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings) // Password reset requires email verification to be enabled @@ -494,6 +669,11 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings settings[SettingKeyTableDefaultPageSize], settings[SettingKeyTablePageSizeOptions], ) + loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) + loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) + if loginAgreementUpdatedAt == "" { + loginAgreementUpdatedAt = defaultLoginAgreementDate + } var balanceLowNotifyThreshold float64 if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 { @@ -509,6 +689,11 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings PasswordResetEnabled: passwordResetEnabled, InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0, + LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments), + LoginAgreementDocuments: loginAgreementDocuments, TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true", TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey], SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"), @@ -534,6 +719,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings PaymentEnabled: settings[SettingPaymentEnabled] == "true", OIDCOAuthEnabled: oidcEnabled, OIDCOAuthProviderName: oidcProviderName, + GitHubOAuthEnabled: gitHubEnabled, + GoogleOAuthEnabled: googleEnabled, BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true", AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", BalanceLowNotifyThreshold: balanceLowNotifyThreshold, @@ -543,8 +730,12 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true", + ImageGenerationEnabled: settings[SettingKeyImageGenerationEnabled] == "true", + ChatCompletionEnabled: settings[SettingKeyChatCompletionEnabled] == "true", AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true", + + RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true", }, nil } @@ -647,43 +838,50 @@ func (s *SettingService) SetVersion(version string) { // A unit test diffs this struct's JSON keys against dto.PublicSettings to catch // drift automatically (see setting_service_injection_test.go). type PublicSettingsInjectionPayload struct { - RegistrationEnabled bool `json:"registration_enabled"` - EmailVerifyEnabled bool `json:"email_verify_enabled"` - RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` - PromoCodeEnabled bool `json:"promo_code_enabled"` - PasswordResetEnabled bool `json:"password_reset_enabled"` - InvitationCodeEnabled bool `json:"invitation_code_enabled"` - TotpEnabled bool `json:"totp_enabled"` - TurnstileEnabled bool `json:"turnstile_enabled"` - TurnstileSiteKey string `json:"turnstile_site_key"` - SiteName string `json:"site_name"` - SiteLogo string `json:"site_logo"` - SiteSubtitle string `json:"site_subtitle"` - APIBaseURL string `json:"api_base_url"` - ContactInfo string `json:"contact_info"` - DocURL string `json:"doc_url"` - HomeContent string `json:"home_content"` - HideCcsImportButton bool `json:"hide_ccs_import_button"` - PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` - PurchaseSubscriptionURL string `json:"purchase_subscription_url"` - TableDefaultPageSize int `json:"table_default_page_size"` - TablePageSizeOptions []int `json:"table_page_size_options"` - CustomMenuItems json.RawMessage `json:"custom_menu_items"` - CustomEndpoints json.RawMessage `json:"custom_endpoints"` - LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` - WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` - WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` - WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` - WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` - OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` - OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` - BackendModeEnabled bool `json:"backend_mode_enabled"` - PaymentEnabled bool `json:"payment_enabled"` - Version string `json:"version"` - BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` - AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` - BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` - BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` + RegistrationEnabled bool `json:"registration_enabled"` + EmailVerifyEnabled bool `json:"email_verify_enabled"` + RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"` + PromoCodeEnabled bool `json:"promo_code_enabled"` + PasswordResetEnabled bool `json:"password_reset_enabled"` + InvitationCodeEnabled bool `json:"invitation_code_enabled"` + TotpEnabled bool `json:"totp_enabled"` + LoginAgreementEnabled bool `json:"login_agreement_enabled"` + LoginAgreementMode string `json:"login_agreement_mode"` + LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"` + LoginAgreementRevision string `json:"login_agreement_revision"` + LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"` + TurnstileEnabled bool `json:"turnstile_enabled"` + TurnstileSiteKey string `json:"turnstile_site_key"` + SiteName string `json:"site_name"` + SiteLogo string `json:"site_logo"` + SiteSubtitle string `json:"site_subtitle"` + APIBaseURL string `json:"api_base_url"` + ContactInfo string `json:"contact_info"` + DocURL string `json:"doc_url"` + HomeContent string `json:"home_content"` + HideCcsImportButton bool `json:"hide_ccs_import_button"` + PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"` + PurchaseSubscriptionURL string `json:"purchase_subscription_url"` + TableDefaultPageSize int `json:"table_default_page_size"` + TablePageSizeOptions []int `json:"table_page_size_options"` + CustomMenuItems json.RawMessage `json:"custom_menu_items"` + CustomEndpoints json.RawMessage `json:"custom_endpoints"` + LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"` + WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"` + WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"` + WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"` + WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"` + OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"` + OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"` + GitHubOAuthEnabled bool `json:"github_oauth_enabled"` + GoogleOAuthEnabled bool `json:"google_oauth_enabled"` + BackendModeEnabled bool `json:"backend_mode_enabled"` + PaymentEnabled bool `json:"payment_enabled"` + Version string `json:"version"` + BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"` + AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` + BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` + BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` // Feature flags — MUST match the opt-in/opt-out registry in // frontend/src/utils/featureFlags.ts. Missing a field here is the bug @@ -691,7 +889,10 @@ type PublicSettingsInjectionPayload struct { ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` AvailableChannelsEnabled bool `json:"available_channels_enabled"` + ImageGenerationEnabled bool `json:"image_generation_enabled"` + ChatCompletionEnabled bool `json:"chat_completion_enabled"` AffiliateEnabled bool `json:"affiliate_enabled"` + RiskControlEnabled bool `json:"risk_control_enabled"` } // GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection. @@ -710,6 +911,11 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any PasswordResetEnabled: settings.PasswordResetEnabled, InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, + LoginAgreementEnabled: settings.LoginAgreementEnabled, + LoginAgreementMode: settings.LoginAgreementMode, + LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt, + LoginAgreementRevision: settings.LoginAgreementRevision, + LoginAgreementDocuments: settings.LoginAgreementDocuments, TurnstileEnabled: settings.TurnstileEnabled, TurnstileSiteKey: settings.TurnstileSiteKey, SiteName: settings.SiteName, @@ -733,6 +939,8 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled, OIDCOAuthEnabled: settings.OIDCOAuthEnabled, OIDCOAuthProviderName: settings.OIDCOAuthProviderName, + GitHubOAuthEnabled: settings.GitHubOAuthEnabled, + GoogleOAuthEnabled: settings.GoogleOAuthEnabled, BackendModeEnabled: settings.BackendModeEnabled, PaymentEnabled: settings.PaymentEnabled, Version: s.version, @@ -744,7 +952,10 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, AvailableChannelsEnabled: settings.AvailableChannelsEnabled, + ImageGenerationEnabled: settings.ImageGenerationEnabled, + ChatCompletionEnabled: settings.ChatCompletionEnabled, AffiliateEnabled: settings.AffiliateEnabled, + RiskControlEnabled: settings.RiskControlEnabled, }, nil } @@ -806,6 +1017,98 @@ func (s *SettingService) weChatOAuthCapabilitiesFromSettings(settings map[string return openReady || mpReady, openReady, mpReady, mobileReady } +func (s *SettingService) emailOAuthBaseConfig(provider string) config.EmailOAuthProviderConfig { + switch strings.ToLower(strings.TrimSpace(provider)) { + case "github": + cfg := config.EmailOAuthProviderConfig{ + AuthorizeURL: defaultGitHubOAuthAuthorize, + TokenURL: defaultGitHubOAuthToken, + UserInfoURL: defaultGitHubOAuthUserInfo, + EmailsURL: defaultGitHubOAuthEmails, + Scopes: defaultGitHubOAuthScopes, + FrontendRedirectURL: defaultGitHubOAuthFrontend, + } + if s != nil && s.cfg != nil { + cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GitHubOAuth) + } + return cfg + case "google": + cfg := config.EmailOAuthProviderConfig{ + AuthorizeURL: defaultGoogleOAuthAuthorize, + TokenURL: defaultGoogleOAuthToken, + UserInfoURL: defaultGoogleOAuthUserInfo, + Scopes: defaultGoogleOAuthScopes, + FrontendRedirectURL: defaultGoogleOAuthFrontend, + } + if s != nil && s.cfg != nil { + cfg = mergeEmailOAuthBaseConfig(cfg, s.cfg.GoogleOAuth) + } + return cfg + default: + return config.EmailOAuthProviderConfig{} + } +} + +func mergeEmailOAuthBaseConfig(base, override config.EmailOAuthProviderConfig) config.EmailOAuthProviderConfig { + base.Enabled = override.Enabled + if strings.TrimSpace(override.ClientID) != "" { + base.ClientID = strings.TrimSpace(override.ClientID) + } + if strings.TrimSpace(override.ClientSecret) != "" { + base.ClientSecret = strings.TrimSpace(override.ClientSecret) + } + if strings.TrimSpace(override.AuthorizeURL) != "" { + base.AuthorizeURL = strings.TrimSpace(override.AuthorizeURL) + } + if strings.TrimSpace(override.TokenURL) != "" { + base.TokenURL = strings.TrimSpace(override.TokenURL) + } + if strings.TrimSpace(override.UserInfoURL) != "" { + base.UserInfoURL = strings.TrimSpace(override.UserInfoURL) + } + if strings.TrimSpace(override.EmailsURL) != "" { + base.EmailsURL = strings.TrimSpace(override.EmailsURL) + } + if strings.TrimSpace(override.Scopes) != "" { + base.Scopes = strings.TrimSpace(override.Scopes) + } + if strings.TrimSpace(override.RedirectURL) != "" { + base.RedirectURL = strings.TrimSpace(override.RedirectURL) + } + if strings.TrimSpace(override.FrontendRedirectURL) != "" { + base.FrontendRedirectURL = strings.TrimSpace(override.FrontendRedirectURL) + } + return base +} + +func (s *SettingService) emailOAuthPublicEnabled(settings map[string]string, provider string) bool { + cfg := s.effectiveEmailOAuthConfig(settings, provider) + return cfg.Enabled && strings.TrimSpace(cfg.ClientID) != "" && strings.TrimSpace(cfg.ClientSecret) != "" +} + +func (s *SettingService) effectiveEmailOAuthConfig(settings map[string]string, provider string) config.EmailOAuthProviderConfig { + cfg := s.emailOAuthBaseConfig(provider) + switch strings.ToLower(strings.TrimSpace(provider)) { + case "github": + if raw, ok := settings[SettingKeyGitHubOAuthEnabled]; ok { + cfg.Enabled = raw == "true" + } + cfg.ClientID = firstNonEmpty(settings[SettingKeyGitHubOAuthClientID], cfg.ClientID) + cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGitHubOAuthClientSecret], cfg.ClientSecret) + cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthRedirectURL], cfg.RedirectURL) + cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGitHubOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGitHubOAuthFrontend) + case "google": + if raw, ok := settings[SettingKeyGoogleOAuthEnabled]; ok { + cfg.Enabled = raw == "true" + } + cfg.ClientID = firstNonEmpty(settings[SettingKeyGoogleOAuthClientID], cfg.ClientID) + cfg.ClientSecret = firstNonEmpty(settings[SettingKeyGoogleOAuthClientSecret], cfg.ClientSecret) + cfg.RedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthRedirectURL], cfg.RedirectURL) + cfg.FrontendRedirectURL = firstNonEmpty(settings[SettingKeyGoogleOAuthFrontendRedirectURL], cfg.FrontendRedirectURL, defaultGoogleOAuthFrontend) + } + return cfg +} + // filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON // array string, returning only items with visibility != "admin". func filterUserVisibleMenuItems(raw string) json.RawMessage { @@ -1052,6 +1355,16 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting if settings.WeChatConnectFrontendRedirectURL == "" { settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend } + settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL) + settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL) + if settings.GitHubOAuthFrontendRedirectURL == "" { + settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend + } + settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL) + settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL) + if settings.GoogleOAuthFrontendRedirectURL == "" { + settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend + } updates := make(map[string]string) @@ -1068,6 +1381,19 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyFrontendURL] = settings.FrontendURL updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) + settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode) + settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt) + if settings.LoginAgreementUpdatedAt == "" { + settings.LoginAgreementUpdatedAt = defaultLoginAgreementDate + } + loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(settings.LoginAgreementDocuments) + if err != nil { + return nil, err + } + updates[SettingKeyLoginAgreementEnabled] = strconv.FormatBool(settings.LoginAgreementEnabled) + updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode + updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt + updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON // 邮件服务设置(只有非空才更新密码) updates[SettingKeySMTPHost] = settings.SMTPHost @@ -1121,6 +1447,22 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret } + // GitHub / Google 邮箱快捷登录 + updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled) + updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID) + updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL + updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL + if settings.GitHubOAuthClientSecret != "" { + updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret) + } + updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled) + updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID) + updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL + updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL + if settings.GoogleOAuthClientSecret != "" { + updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret) + } + // WeChat Connect OAuth 登录 updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled) updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID @@ -1229,9 +1571,18 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting // Available channels feature switch updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled) + // Image generation feature switch + updates[SettingKeyImageGenerationEnabled] = strconv.FormatBool(settings.ImageGenerationEnabled) + + // Chat completion feature switch + updates[SettingKeyChatCompletionEnabled] = strconv.FormatBool(settings.ChatCompletionEnabled) + // Affiliate (邀请返利) feature switch updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled) + // 风控中心功能开关 + updates[SettingKeyRiskControlEnabled] = strconv.FormatBool(settings.RiskControlEnabled) + // Claude Code version check updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion @@ -1273,17 +1624,21 @@ func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, sett settings.LinuxDo.Subscriptions, settings.OIDC.Subscriptions, settings.WeChat.Subscriptions, + settings.GitHub.Subscriptions, + settings.Google.Subscriptions, } { if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil { return nil, err } } - updates := make(map[string]string, 21) + updates := make(map[string]string, 31) writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email) writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo) writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC) writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat) + writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub) + writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google) updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup) return updates, nil } @@ -1362,6 +1717,61 @@ func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context, return nil } +func (s *SettingService) GetEmailOAuthProviderConfig(ctx context.Context, provider string) (config.EmailOAuthProviderConfig, error) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider != "github" && provider != "google" { + return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_PROVIDER_NOT_FOUND", "oauth provider not found") + } + keys := []string{ + SettingKeyGitHubOAuthEnabled, + SettingKeyGitHubOAuthClientID, + SettingKeyGitHubOAuthClientSecret, + SettingKeyGitHubOAuthRedirectURL, + SettingKeyGitHubOAuthFrontendRedirectURL, + SettingKeyGoogleOAuthEnabled, + SettingKeyGoogleOAuthClientID, + SettingKeyGoogleOAuthClientSecret, + SettingKeyGoogleOAuthRedirectURL, + SettingKeyGoogleOAuthFrontendRedirectURL, + } + settings, err := s.settingRepo.GetMultiple(ctx, keys) + if err != nil { + return config.EmailOAuthProviderConfig{}, fmt.Errorf("get email oauth settings: %w", err) + } + cfg := s.effectiveEmailOAuthConfig(settings, provider) + if !cfg.Enabled { + return config.EmailOAuthProviderConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled") + } + if strings.TrimSpace(cfg.ClientID) == "" { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured") + } + if strings.TrimSpace(cfg.ClientSecret) == "" { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured") + } + for label, rawURL := range map[string]string{ + "authorize": cfg.AuthorizeURL, + "token": cfg.TokenURL, + "userinfo": cfg.UserInfoURL, + "redirect": cfg.RedirectURL, + } { + if strings.TrimSpace(rawURL) == "" { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url not configured") + } + if err := config.ValidateAbsoluteHTTPURL(rawURL); err != nil { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth "+label+" url invalid") + } + } + if strings.TrimSpace(cfg.EmailsURL) != "" { + if err := config.ValidateAbsoluteHTTPURL(cfg.EmailsURL); err != nil { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth emails url invalid") + } + } + if err := config.ValidateFrontendRedirectURL(cfg.FrontendRedirectURL); err != nil { + return config.EmailOAuthProviderConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid") + } + return cfg, nil +} + // IsRegistrationEnabled 检查是否开放注册 func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled) @@ -1534,6 +1944,15 @@ func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool { return value == "true" } +// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting. +func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string { + value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems) + if err != nil { + return "[]" + } + return value +} + // IsAffiliateEnabled 检查是否启用邀请返利功能(总开关) func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool { value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled) @@ -1711,6 +2130,16 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut SettingKeyAuthSourceDefaultWeChatSubscriptions, SettingKeyAuthSourceDefaultWeChatGrantOnSignup, SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind, + SettingKeyAuthSourceDefaultGitHubBalance, + SettingKeyAuthSourceDefaultGitHubConcurrency, + SettingKeyAuthSourceDefaultGitHubSubscriptions, + SettingKeyAuthSourceDefaultGitHubGrantOnSignup, + SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind, + SettingKeyAuthSourceDefaultGoogleBalance, + SettingKeyAuthSourceDefaultGoogleConcurrency, + SettingKeyAuthSourceDefaultGoogleSubscriptions, + SettingKeyAuthSourceDefaultGoogleGrantOnSignup, + SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind, SettingKeyForceEmailOnThirdPartySignup, } @@ -1724,6 +2153,8 @@ func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*Aut LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys), OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys), WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys), + GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys), + Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys), ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true", }, nil } @@ -1793,6 +2224,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken } } + loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(defaultLoginAgreementDocuments()) + if err != nil { + return err + } // 初始化默认设置 defaults := map[string]string{ @@ -1800,6 +2235,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyEmailVerifyEnabled: "false", SettingKeyRegistrationEmailSuffixWhitelist: "[]", SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能 + SettingKeyLoginAgreementEnabled: "false", + SettingKeyLoginAgreementMode: defaultLoginAgreementMode, + SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate, + SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON, SettingKeySiteName: "Sub2API", SettingKeySiteLogo: "", SettingKeyPurchaseSubscriptionEnabled: "false", @@ -1824,6 +2263,16 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyWeChatConnectScopes: "snsapi_login", SettingKeyWeChatConnectRedirectURL: "", SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend, + SettingKeyGitHubOAuthEnabled: "false", + SettingKeyGitHubOAuthClientID: "", + SettingKeyGitHubOAuthClientSecret: "", + SettingKeyGitHubOAuthRedirectURL: "", + SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend, + SettingKeyGoogleOAuthEnabled: "false", + SettingKeyGoogleOAuthClientID: "", + SettingKeyGoogleOAuthClientSecret: "", + SettingKeyGoogleOAuthRedirectURL: "", + SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend, SettingKeyOIDCConnectEnabled: "false", SettingKeyOIDCConnectProviderName: "OIDC", SettingKeyOIDCConnectClientID: "", @@ -1874,6 +2323,16 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]", SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false", SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultGitHubBalance: "0", + SettingKeyAuthSourceDefaultGitHubConcurrency: "5", + SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]", + SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false", + SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false", + SettingKeyAuthSourceDefaultGoogleBalance: "0", + SettingKeyAuthSourceDefaultGoogleConcurrency: "5", + SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]", + SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false", + SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false", SettingKeyForceEmailOnThirdPartySignup: "false", SettingKeySMTPPort: "587", SettingKeySMTPUseTLS: "false", @@ -1900,9 +2359,18 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // Available channels feature (default disabled; opt-in) SettingKeyAvailableChannelsEnabled: "false", + // Image generation feature (default disabled; opt-in) + SettingKeyImageGenerationEnabled: "false", + + // Chat completion feature (default disabled; opt-in) + SettingKeyChatCompletionEnabled: "false", + // Affiliate (邀请返利) feature (default disabled; opt-in) SettingKeyAffiliateEnabled: "false", + // 风控中心功能(默认关闭,显式启用) + SettingKeyRiskControlEnabled: "false", + // Claude Code version check (default: empty = disabled) SettingKeyMinClaudeCodeVersion: "", SettingKeyMaxClaudeCodeVersion: "", @@ -1923,6 +2391,11 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { // parseSettings 解析设置到结构体 func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings { emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true" + loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments]) + loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt]) + if loginAgreementUpdatedAt == "" { + loginAgreementUpdatedAt = defaultLoginAgreementDate + } result := &SystemSettings{ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true", EmailVerifyEnabled: emailVerifyEnabled, @@ -1932,6 +2405,10 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin FrontendURL: settings[SettingKeyFrontendURL], InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", + LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true", + LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]), + LoginAgreementUpdatedAt: loginAgreementUpdatedAt, + LoginAgreementDocuments: loginAgreementDocuments, SMTPHost: settings[SettingKeySMTPHost], SMTPUsername: settings[SettingKeySMTPUsername], SMTPFrom: settings[SettingKeySMTPFrom], @@ -2173,6 +2650,22 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != "" + gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github") + result.GitHubOAuthEnabled = gitHubEffective.Enabled + result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID) + result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret) + result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != "" + result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL) + result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL) + + googleEffective := s.effectiveEmailOAuthConfig(settings, "google") + result.GoogleOAuthEnabled = googleEffective.Enabled + result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID) + result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret) + result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != "" + result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL) + result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL) + // WeChat Connect 设置: // - 优先读取 DB 系统设置 // - 缺失时回退到 config/env,保持升级兼容 @@ -2239,9 +2732,18 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin // Available channels feature (default: disabled; strict true) result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" + // Image generation feature (default: disabled; strict true) + result.ImageGenerationEnabled = settings[SettingKeyImageGenerationEnabled] == "true" + + // Chat completion feature (default: disabled; strict true) + result.ChatCompletionEnabled = settings[SettingKeyChatCompletionEnabled] == "true" + // Affiliate (邀请返利) feature (default: disabled; strict true) result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true" + // 风控中心功能(默认关闭,严格 true 才启用) + result.RiskControlEnabled = settings[SettingKeyRiskControlEnabled] == "true" + // Claude Code version check result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion] result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion] @@ -2778,6 +3280,55 @@ func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settin return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data)) } +// GetRateLimit429CooldownSettings 获取429默认回避配置 +func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) { + value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings) + if err != nil { + if errors.Is(err, ErrSettingNotFound) { + return DefaultRateLimit429CooldownSettings(), nil + } + return nil, fmt.Errorf("get 429 cooldown settings: %w", err) + } + if value == "" { + return DefaultRateLimit429CooldownSettings(), nil + } + + var settings RateLimit429CooldownSettings + if err := json.Unmarshal([]byte(value), &settings); err != nil { + return DefaultRateLimit429CooldownSettings(), nil + } + + if settings.CooldownSeconds < 1 { + settings.CooldownSeconds = 1 + } + if settings.CooldownSeconds > 7200 { + settings.CooldownSeconds = 7200 + } + + return &settings, nil +} + +// SetRateLimit429CooldownSettings 设置429默认回避配置 +func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error { + if settings == nil { + return fmt.Errorf("settings cannot be nil") + } + + if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 { + if settings.Enabled { + return fmt.Errorf("cooldown_seconds must be between 1-7200") + } + settings.CooldownSeconds = 5 + } + + data, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("marshal 429 cooldown settings: %w", err) + } + + return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data)) +} + // GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。 // // 优先级: diff --git a/backend/internal/service/setting_service_public_test.go b/backend/internal/service/setting_service_public_test.go index 1ecd4e6f416..f23dde786b0 100644 --- a/backend/internal/service/setting_service_public_test.go +++ b/backend/internal/service/setting_service_public_test.go @@ -78,6 +78,46 @@ func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T) require.Equal(t, []int{20, 50, 100}, settings.TablePageSizeOptions) } +func TestSettingService_GetPublicSettings_ExposesImageGenerationFeatureFlag(t *testing.T) { + svc := NewSettingService(&settingPublicRepoStub{ + values: map[string]string{ + SettingKeyImageGenerationEnabled: "true", + }, + }, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.ImageGenerationEnabled) +} + +func TestSettingService_GetPublicSettings_DefaultsImageGenerationFeatureFlagOff(t *testing.T) { + svc := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.ImageGenerationEnabled) +} + +func TestSettingService_GetPublicSettings_ExposesChatCompletionFeatureFlag(t *testing.T) { + svc := NewSettingService(&settingPublicRepoStub{ + values: map[string]string{ + SettingKeyChatCompletionEnabled: "true", + }, + }, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.True(t, settings.ChatCompletionEnabled) +} + +func TestSettingService_GetPublicSettings_DefaultsChatCompletionFeatureFlagOff(t *testing.T) { + svc := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}) + + settings, err := svc.GetPublicSettings(context.Background()) + require.NoError(t, err) + require.False(t, settings.ChatCompletionEnabled) +} + func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) { repo := &settingPublicRepoStub{ values: map[string]string{ diff --git a/backend/internal/service/setting_service_update_test.go b/backend/internal/service/setting_service_update_test.go index 9dc0ca59a3f..70b8399fbef 100644 --- a/backend/internal/service/setting_service_update_test.go +++ b/backend/internal/service/setting_service_update_test.go @@ -224,6 +224,17 @@ func TestSettingService_UpdateSettings_TablePreferences(t *testing.T) { require.Equal(t, "[20,100]", repo.updates[SettingKeyTablePageSizeOptions]) } +func TestSettingService_UpdateSettings_PersistsImageGenerationFeatureFlag(t *testing.T) { + repo := &settingUpdateRepoStub{} + svc := NewSettingService(repo, &config.Config{}) + + err := svc.UpdateSettings(context.Background(), &SystemSettings{ + ImageGenerationEnabled: true, + }) + require.NoError(t, err) + require.Equal(t, "true", repo.updates[SettingKeyImageGenerationEnabled]) +} + func TestSettingService_UpdateSettings_PaymentVisibleMethodsAndAdvancedScheduler(t *testing.T) { repo := &settingUpdateRepoStub{} svc := NewSettingService(repo, &config.Config{}) diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 41c01ccac0e..aab1c7b7ffe 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -20,6 +20,10 @@ type SystemSettings struct { FrontendURL string InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 + LoginAgreementEnabled bool + LoginAgreementMode string + LoginAgreementUpdatedAt string + LoginAgreementDocuments []LoginAgreementDocument SMTPHost string SMTPPort int @@ -89,6 +93,20 @@ type SystemSettings struct { OIDCConnectUserInfoIDPath string OIDCConnectUserInfoUsernamePath string + // GitHub / Google 邮箱快捷登录 + GitHubOAuthEnabled bool + GitHubOAuthClientID string + GitHubOAuthClientSecret string + GitHubOAuthClientSecretConfigured bool + GitHubOAuthRedirectURL string + GitHubOAuthFrontendRedirectURL string + GoogleOAuthEnabled bool + GoogleOAuthClientID string + GoogleOAuthClientSecret string + GoogleOAuthClientSecretConfigured bool + GoogleOAuthRedirectURL string + GoogleOAuthFrontendRedirectURL string + SiteName string SiteLogo string SiteSubtitle string @@ -106,6 +124,7 @@ type SystemSettings struct { DefaultConcurrency int DefaultBalance float64 + RiskControlEnabled bool AffiliateEnabled bool AffiliateRebateRate float64 AffiliateRebateFreezeHours int @@ -138,6 +157,12 @@ type SystemSettings struct { // Available Channels feature (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` + // Image Generation feature (user-facing tool) + ImageGenerationEnabled bool `json:"image_generation_enabled"` + + // Chat Completion feature (user-facing tool) + ChatCompletionEnabled bool `json:"chat_completion_enabled"` + // Claude Code version check MinClaudeCodeVersion string MaxClaudeCodeVersion string @@ -190,6 +215,11 @@ type PublicSettings struct { PasswordResetEnabled bool InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 + LoginAgreementEnabled bool + LoginAgreementMode string + LoginAgreementUpdatedAt string + LoginAgreementRevision string + LoginAgreementDocuments []LoginAgreementDocument TurnstileEnabled bool TurnstileSiteKey string SiteName string @@ -217,6 +247,8 @@ type PublicSettings struct { PaymentEnabled bool OIDCOAuthEnabled bool OIDCOAuthProviderName string + GitHubOAuthEnabled bool + GoogleOAuthEnabled bool Version string BalanceLowNotifyEnabled bool @@ -231,8 +263,23 @@ type PublicSettings struct { // Available Channels feature (user-facing aggregate view) AvailableChannelsEnabled bool `json:"available_channels_enabled"` + // Image Generation feature (user-facing tool) + ImageGenerationEnabled bool `json:"image_generation_enabled"` + + // Chat Completion feature (user-facing tool) + ChatCompletionEnabled bool `json:"chat_completion_enabled"` + // Affiliate (邀请返利) feature toggle AffiliateEnabled bool `json:"affiliate_enabled"` + + // 风控中心功能开关 + RiskControlEnabled bool `json:"risk_control_enabled"` +} + +type LoginAgreementDocument struct { + ID string `json:"id"` + Title string `json:"title"` + ContentMD string `json:"content_md"` } type WeChatConnectOAuthConfig struct { @@ -381,6 +428,14 @@ type OverloadCooldownSettings struct { CooldownMinutes int `json:"cooldown_minutes"` } +// RateLimit429CooldownSettings 429默认回避配置 +type RateLimit429CooldownSettings struct { + // Enabled 是否在无法解析上游重置时间时应用默认429回避 + Enabled bool `json:"enabled"` + // CooldownSeconds 默认回避时长(秒) + CooldownSeconds int `json:"cooldown_seconds"` +} + // DefaultOverloadCooldownSettings 返回默认的过载冷却配置(启用,10分钟) func DefaultOverloadCooldownSettings() *OverloadCooldownSettings { return &OverloadCooldownSettings{ @@ -389,6 +444,14 @@ func DefaultOverloadCooldownSettings() *OverloadCooldownSettings { } } +// DefaultRateLimit429CooldownSettings 返回默认的429回避配置(启用,5秒) +func DefaultRateLimit429CooldownSettings() *RateLimit429CooldownSettings { + return &RateLimit429CooldownSettings{ + Enabled: true, + CooldownSeconds: 5, + } +} + // DefaultBetaPolicySettings 返回默认的 Beta 策略配置 func DefaultBetaPolicySettings() *BetaPolicySettings { return &BetaPolicySettings{ diff --git a/backend/internal/service/token_refresher.go b/backend/internal/service/token_refresher.go index 916c226715c..823f98123a3 100644 --- a/backend/internal/service/token_refresher.go +++ b/backend/internal/service/token_refresher.go @@ -2,6 +2,7 @@ package service import ( "context" + "strings" "time" ) @@ -95,6 +96,9 @@ func (r *OpenAITokenRefresher) CanRefresh(account *Account) bool { // NeedsRefresh 检查token是否需要刷新 // expires_at 缺失且处于限流状态时需要刷新,防止限流期间 token 静默过期 func (r *OpenAITokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool { + if strings.TrimSpace(account.GetOpenAIRefreshToken()) == "" { + return false + } expiresAt := account.GetCredentialAsTime("expires_at") if expiresAt == nil { return account.IsRateLimited() diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index a7279e6a7d7..f84e6f0ab06 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -96,6 +96,8 @@ type UserRepository interface { UpdateBalance(ctx context.Context, id int64, amount float64) error DeductBalance(ctx context.Context, id int64, amount float64) error UpdateConcurrency(ctx context.Context, id int64, amount int) error + BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) + BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) ExistsByEmail(ctx context.Context, email string) (bool, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups(幂等,冲突忽略) diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index ff55c2a50ab..775dd6028df 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -199,6 +199,9 @@ func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { re func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } + +func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } +func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil } func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) { out := make([]UserAuthIdentityRecord, len(m.identities)) diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index 8b50e478382..dde594b57dd 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -142,6 +142,12 @@ func ProvideUsageCleanupService(repo UsageCleanupRepository, timingWheel *Timing return svc } +func ProvideImageTaskService(repo ImageTaskRepository, timingWheel *TimingWheelService) *ImageTaskService { + svc := NewImageTaskService(repo) + svc.Start(timingWheel) + return svc +} + // ProvideAccountExpiryService creates and starts AccountExpiryService. func ProvideAccountExpiryService(accountRepo AccountRepository) *AccountExpiryService { svc := NewAccountExpiryService(accountRepo, time.Minute) @@ -271,15 +277,22 @@ func ProvideOpsAlertEvaluatorService( // ProvideOpsCleanupService creates and starts OpsCleanupService (cron scheduled). // channelMonitorSvc 让维护任务(聚合 + 历史/聚合软删)跟随 ops 清理 cron 一起跑, // 共享 leader lock + heartbeat。 +// settingRepo 让 cleanup service 自己读 ops_advanced_settings.data_retention 覆盖 cfg; +// opsService 用来反向注入 cleanup hook,以便 UI 改清理设置时能 Reload cron。 func ProvideOpsCleanupService( opsRepo OpsRepository, db *sql.DB, redisClient *redis.Client, cfg *config.Config, channelMonitorSvc *ChannelMonitorService, + settingRepo SettingRepository, + opsService *OpsService, ) *OpsCleanupService { - svc := NewOpsCleanupService(opsRepo, db, redisClient, cfg, channelMonitorSvc) + svc := NewOpsCleanupService(opsRepo, db, redisClient, cfg, channelMonitorSvc, settingRepo) svc.Start() + if opsService != nil { + opsService.SetCleanupReloader(svc) + } return svc } @@ -476,6 +489,7 @@ var ProviderSet = wire.NewSet( ProvideConcurrencyService, ProvideUserMessageQueueService, NewUsageRecordWorkerPool, + ProvideImageTaskService, ProvideSchedulerSnapshotService, NewIdentityService, NewCRSSyncService, @@ -502,6 +516,7 @@ var ProviderSet = wire.NewSet( NewGroupCapacityService, NewChannelService, NewModelPricingResolver, + NewContentModerationService, NewAffiliateService, ProvidePaymentConfigService, NewPaymentService, diff --git a/backend/migrations/134_image_generation_group_controls.sql b/backend/migrations/134_image_generation_group_controls.sql new file mode 100644 index 00000000000..37941c001ed --- /dev/null +++ b/backend/migrations/134_image_generation_group_controls.sql @@ -0,0 +1,26 @@ +-- 生图能力与图片倍率模式控制 +-- 兼容性原则: +-- 1. 不改写现有 image_price_1k/2k/4k,避免改变已配置分组的最终图片价格。 +-- 2. 现有 openai/gemini/antigravity 分组默认保持可生图,避免升级后中断已有图片业务。 +-- 3. 现有分组默认共享当前有效分组倍率,保持历史扣费公式。 + +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS allow_image_generation BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS image_rate_independent BOOLEAN NOT NULL DEFAULT false; + +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS image_rate_multiplier DECIMAL(10,4) NOT NULL DEFAULT 1.0; + +UPDATE groups +SET allow_image_generation = true +WHERE platform IN ('openai', 'gemini', 'antigravity'); + +UPDATE groups +SET image_rate_independent = false, + image_rate_multiplier = 1.0; + +COMMENT ON COLUMN groups.allow_image_generation IS '是否允许该分组使用图片生成能力'; +COMMENT ON COLUMN groups.image_rate_independent IS '图片生成是否使用独立倍率;false 表示共享分组有效倍率'; +COMMENT ON COLUMN groups.image_rate_multiplier IS '图片生成独立倍率,仅 image_rate_independent=true 时生效'; diff --git a/backend/migrations/135_allow_email_oauth_provider_types.sql b/backend/migrations/135_allow_email_oauth_provider_types.sql new file mode 100644 index 00000000000..a04edd7ce19 --- /dev/null +++ b/backend/migrations/135_allow_email_oauth_provider_types.sql @@ -0,0 +1,27 @@ +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_signup_source_check; + +ALTER TABLE users + ADD CONSTRAINT users_signup_source_check + CHECK (signup_source IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google')); + +ALTER TABLE auth_identities + DROP CONSTRAINT IF EXISTS auth_identities_provider_type_check; + +ALTER TABLE auth_identities + ADD CONSTRAINT auth_identities_provider_type_check + CHECK (provider_type IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google')); + +ALTER TABLE auth_identity_channels + DROP CONSTRAINT IF EXISTS auth_identity_channels_provider_type_check; + +ALTER TABLE auth_identity_channels + ADD CONSTRAINT auth_identity_channels_provider_type_check + CHECK (provider_type IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google')); + +ALTER TABLE pending_auth_sessions + DROP CONSTRAINT IF EXISTS pending_auth_sessions_provider_type_check; + +ALTER TABLE pending_auth_sessions + ADD CONSTRAINT pending_auth_sessions_provider_type_check + CHECK (provider_type IN ('email', 'linuxdo', 'wechat', 'oidc', 'github', 'google')); diff --git a/backend/migrations/135_content_moderation.sql b/backend/migrations/135_content_moderation.sql new file mode 100644 index 00000000000..4873bbf2160 --- /dev/null +++ b/backend/migrations/135_content_moderation.sql @@ -0,0 +1,45 @@ +-- 风控中心内容审计配置与记录 + +INSERT INTO settings (key, value, updated_at) +VALUES ('risk_control_enabled', 'false', NOW()) +ON CONFLICT (key) DO NOTHING; + +CREATE TABLE IF NOT EXISTS content_moderation_logs ( + id BIGSERIAL PRIMARY KEY, + request_id VARCHAR(128) NOT NULL DEFAULT '', + user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + user_email VARCHAR(255) NOT NULL DEFAULT '', + api_key_id BIGINT REFERENCES api_keys(id) ON DELETE SET NULL, + api_key_name VARCHAR(100) NOT NULL DEFAULT '', + group_id BIGINT REFERENCES groups(id) ON DELETE SET NULL, + group_name VARCHAR(255) NOT NULL DEFAULT '', + endpoint VARCHAR(128) NOT NULL DEFAULT '', + provider VARCHAR(64) NOT NULL DEFAULT '', + model VARCHAR(255) NOT NULL DEFAULT '', + mode VARCHAR(32) NOT NULL DEFAULT '', + action VARCHAR(32) NOT NULL DEFAULT '', + flagged BOOLEAN NOT NULL DEFAULT FALSE, + highest_category VARCHAR(64) NOT NULL DEFAULT '', + highest_score DECIMAL(8, 6) NOT NULL DEFAULT 0, + category_scores JSONB NOT NULL DEFAULT '{}'::jsonb, + threshold_snapshot JSONB NOT NULL DEFAULT '{}'::jsonb, + input_excerpt TEXT NOT NULL DEFAULT '', + upstream_latency_ms INT, + error TEXT NOT NULL DEFAULT '', + violation_count INT NOT NULL DEFAULT 0, + auto_banned BOOLEAN NOT NULL DEFAULT FALSE, + email_sent BOOLEAN NOT NULL DEFAULT FALSE, + queue_delay_ms INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE content_moderation_logs ADD COLUMN IF NOT EXISTS violation_count INT NOT NULL DEFAULT 0; +ALTER TABLE content_moderation_logs ADD COLUMN IF NOT EXISTS auto_banned BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE content_moderation_logs ADD COLUMN IF NOT EXISTS email_sent BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE content_moderation_logs ADD COLUMN IF NOT EXISTS queue_delay_ms INT; +CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_created_at ON content_moderation_logs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_group_created_at ON content_moderation_logs(group_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_flagged_created_at ON content_moderation_logs(flagged, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_user_created_at ON content_moderation_logs(user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_api_key_created_at ON content_moderation_logs(api_key_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_content_moderation_logs_endpoint_created_at ON content_moderation_logs(endpoint, created_at DESC); diff --git a/backend/migrations/135_create_image_tasks.sql b/backend/migrations/135_create_image_tasks.sql new file mode 100644 index 00000000000..e93d0986504 --- /dev/null +++ b/backend/migrations/135_create_image_tasks.sql @@ -0,0 +1,27 @@ +-- Temporary asynchronous image generation task records. + +CREATE TABLE IF NOT EXISTS image_tasks ( + task_id VARCHAR(40) PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + api_key_id BIGINT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL, + endpoint VARCHAR(64) NOT NULL, + model VARCHAR(128) NOT NULL DEFAULT '', + prompt TEXT NOT NULL DEFAULT '', + file_path TEXT NOT NULL DEFAULT '', + mime_type VARCHAR(64) NOT NULL DEFAULT '', + byte_size BIGINT NOT NULL DEFAULT 0, + error_message TEXT NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_image_tasks_user_created_at + ON image_tasks(user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_image_tasks_expires_at + ON image_tasks(expires_at); + +CREATE INDEX IF NOT EXISTS idx_image_tasks_status + ON image_tasks(status); diff --git a/backend/migrations/136_chat_sessions.sql b/backend/migrations/136_chat_sessions.sql new file mode 100644 index 00000000000..0908762f410 --- /dev/null +++ b/backend/migrations/136_chat_sessions.sql @@ -0,0 +1,46 @@ +-- Add server-backed chat history tables for the user chat workbench. + +CREATE TABLE IF NOT EXISTS chat_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + api_key_id BIGINT NOT NULL, + title VARCHAR(160) NOT NULL, + model VARCHAR(100) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT chat_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT chat_sessions_api_key_id_fkey FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id BIGSERIAL PRIMARY KEY, + session_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(20) NOT NULL, + content TEXT NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'completed', + model VARCHAR(100) NULL, + duration_ms INTEGER NULL, + usage_log_id BIGINT NULL, + actual_cost DECIMAL(20,10) NULL, + error_message TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chat_messages_session_id_fkey FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE, + CONSTRAINT chat_messages_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT chat_messages_usage_log_id_fkey FOREIGN KEY (usage_log_id) REFERENCES usage_logs(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS chat_sessions_user_updated_at_idx ON chat_sessions (user_id, updated_at); +CREATE INDEX IF NOT EXISTS chat_sessions_user_expires_at_idx ON chat_sessions (user_id, expires_at); +CREATE INDEX IF NOT EXISTS chat_sessions_user_deleted_at_idx ON chat_sessions (user_id, deleted_at); +CREATE INDEX IF NOT EXISTS chat_sessions_api_key_id_idx ON chat_sessions (api_key_id); +CREATE INDEX IF NOT EXISTS chat_sessions_status_idx ON chat_sessions (status); + +CREATE INDEX IF NOT EXISTS chat_messages_session_created_at_idx ON chat_messages (session_id, created_at); +CREATE INDEX IF NOT EXISTS chat_messages_user_created_at_idx ON chat_messages (user_id, created_at); +CREATE INDEX IF NOT EXISTS chat_messages_usage_log_id_idx ON chat_messages (usage_log_id); +CREATE INDEX IF NOT EXISTS chat_messages_status_idx ON chat_messages (status); diff --git a/backend/migrations/auth_identity_payment_migrations_regression_test.go b/backend/migrations/auth_identity_payment_migrations_regression_test.go index 9921629664b..b4e366df872 100644 --- a/backend/migrations/auth_identity_payment_migrations_regression_test.go +++ b/backend/migrations/auth_identity_payment_migrations_regression_test.go @@ -142,3 +142,34 @@ func TestMigration134AddsAffiliateLedgerAuditFieldsWithoutJSONCast(t *testing.T) require.Contains(t, sql, "COUNT(*) OVER (PARTITION BY ual.id) AS ledger_match_count") require.NotContains(t, sql, "detail::jsonb") } + +func TestMigration135AllowsGitHubAndGoogleAuthProviders(t *testing.T) { + content, err := FS.ReadFile("135_allow_email_oauth_provider_types.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "users_signup_source_check") + require.Contains(t, sql, "auth_identities_provider_type_check") + require.Contains(t, sql, "auth_identity_channels_provider_type_check") + require.Contains(t, sql, "pending_auth_sessions_provider_type_check") + require.Contains(t, sql, "'github'") + require.Contains(t, sql, "'google'") +} + +func TestMigration136CreatesChatHistoryTablesForRelease(t *testing.T) { + content, err := FS.ReadFile("136_chat_sessions.sql") + require.NoError(t, err) + + sql := string(content) + require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS chat_sessions") + require.Contains(t, sql, "CREATE TABLE IF NOT EXISTS chat_messages") + require.Contains(t, sql, "expires_at TIMESTAMPTZ NOT NULL") + require.Contains(t, sql, "FOREIGN KEY (user_id) REFERENCES users(id)") + require.Contains(t, sql, "FOREIGN KEY (api_key_id) REFERENCES api_keys(id)") + require.Contains(t, sql, "FOREIGN KEY (session_id) REFERENCES chat_sessions(id)") + require.Contains(t, sql, "CREATE INDEX IF NOT EXISTS chat_sessions_user_updated_at_idx") + require.Contains(t, sql, "CREATE INDEX IF NOT EXISTS chat_sessions_user_expires_at_idx") + require.Contains(t, sql, "CREATE INDEX IF NOT EXISTS chat_messages_session_created_at_idx") + require.Contains(t, sql, "CREATE INDEX IF NOT EXISTS chat_messages_user_created_at_idx") + require.Contains(t, sql, "CREATE INDEX IF NOT EXISTS chat_messages_usage_log_id_idx") +} diff --git a/deploy/.env.example b/deploy/.env.example index e1eb8256f6a..28205f7c3d2 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -285,6 +285,25 @@ GATEWAY_SCHEDULING_OUTBOX_BACKLOG_REBUILD_ROWS=10000 # 全量重建周期(秒) GATEWAY_SCHEDULING_FULL_REBUILD_INTERVAL_SECONDS=300 +# ----------------------------------------------------------------------------- +# Image Generation Stream & Concurrency (Optional) +# 图片生成流式与并发隔离配置(可选) +# ----------------------------------------------------------------------------- +# 图片流式上游数据间隔超时(秒)。0 表示禁用;非 0 时必须为 60-1800。 +GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=900 +# 图片流式 keepalive 间隔(秒)。0 表示禁用;非 0 时必须为 5-60。 +GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=10 +# 是否启用进程级图片生成并发限制。默认 false,保持历史行为。 +GATEWAY_IMAGE_CONCURRENCY_ENABLED=false +# 当前进程允许同时处理的图片生成请求数。0 表示不限制。 +GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=0 +# 图片并发超限策略:reject 直接返回 429;wait 等待空闲槽位。 +GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=reject +# wait 模式下等待空闲图片槽位的最长时间(秒)。 +GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS=30 +# wait 模式下当前进程允许排队等待的最大图片请求数。0 表示不允许等待队列。 +GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS=100 + # ----------------------------------------------------------------------------- # Dashboard Aggregation (Optional) # ----------------------------------------------------------------------------- diff --git a/deploy/Dockerfile b/deploy/Dockerfile index b0b6036c67d..0180cea64ad 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -7,7 +7,7 @@ # ============================================================================= ARG NODE_IMAGE=node:24-alpine -ARG GOLANG_IMAGE=golang:1.26.2-alpine +ARG GOLANG_IMAGE=golang:1.26.3-alpine ARG ALPINE_IMAGE=alpine:3.20 ARG GOPROXY=https://goproxy.cn,direct ARG GOSUMDB=sum.golang.google.cn @@ -23,7 +23,7 @@ WORKDIR /app/frontend RUN corepack enable && corepack prepare pnpm@latest --activate # Install dependencies first (better caching) -COPY frontend/package.json frontend/pnpm-lock.yaml ./ +COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./ RUN pnpm install --frozen-lockfile # Copy frontend source and build diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index dfc363b5a5e..d6aab68eda5 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -152,9 +152,9 @@ gateway: # Max request body size in bytes (default: 256MB) # 请求体最大字节数(默认 256MB) max_body_size: 268435456 - # Max bytes to read for non-stream upstream responses (default: 8MB) - # 非流式上游响应体读取上限(默认 8MB) - upstream_response_read_max_bytes: 8388608 + # Max bytes to read for non-stream upstream responses (default: 128MB) + # 非流式上游响应体读取上限(默认 128MB,可容纳 base64 图片响应) + upstream_response_read_max_bytes: 134217728 # Max bytes to read for proxy probe responses (default: 1MB) # 代理探测响应体读取上限(默认 1MB) proxy_probe_response_read_max_bytes: 1048576 @@ -202,6 +202,14 @@ gateway: # # 注意:开启后会影响所有客户端的行为(不仅限于 VS Code / Codex CLI),请谨慎开启。 force_codex_cli: false + # Enable Codex image-generation bridge injection for /openai/v1/responses. + # 是否为 Codex /responses 请求自动注入 image_generation 工具与桥接指令。 + # + # Default false keeps text-only Codex requests text-only. Explicit client-provided + # image_generation tools are still forwarded when the group allows image generation. + # 默认 false:保持纯文本 Codex 请求不被改写;客户端显式提供 image_generation tool 时, + # 仍会在分组允许图片生成的情况下正常转发。 + codex_image_generation_bridge_enabled: false # Optional: template file used to build the final top-level Codex `instructions`. # 可选:用于构建最终 Codex 顶层 `instructions` 的模板文件路径。 # @@ -340,6 +348,30 @@ gateway: # Stream keepalive interval (seconds), 0=disable # 流式 keepalive 间隔(秒),0=禁用 stream_keepalive_interval: 10 + # Image stream data interval timeout (seconds), 0=disable; independent from ordinary text streams + # 图片流数据间隔超时(秒),0=禁用;独立于普通文本流式 + image_stream_data_interval_timeout: 900 + # Image stream keepalive interval (seconds), 0=disable; independent from ordinary text streams + # 图片流式 keepalive 间隔(秒),0=禁用;独立于普通文本流式 + image_stream_keepalive_interval: 10 + # Image generation independent concurrency limiter (process-local, default disabled) + # 图片生成独立并发限制(进程级,默认关闭;多实例总上限约为实例数×该值) + image_concurrency: + # Enable image-only concurrency protection; false keeps existing behavior unchanged + # 是否启用图片独立并发保护;false 保持现有行为不变 + enabled: false + # Max concurrent image generation requests in this process, 0=unlimited + # 当前进程允许同时处理的图片生成请求数,0=不限制 + max_concurrent_requests: 0 + # Overflow mode when the image concurrency limit is full: reject/wait + # 图片并发满时的处理方式:reject=立即拒绝,wait=等待槽位 + overflow_mode: "reject" + # Wait timeout for overflow_mode=wait (seconds), 0=do not wait + # wait 模式等待图片并发槽位的超时时间(秒),0=不等待 + wait_timeout_seconds: 30 + # Max image requests waiting in this process when overflow_mode=wait, 0=unlimited + # wait 模式当前进程允许排队等待的图片请求数,0=不限制 + max_waiting_requests: 100 # SSE max line size in bytes (default: 40MB) # SSE 单行最大字节数(默认 40MB) max_line_size: 41943040 diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index 7793e424a34..b7a805b5b4a 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -40,6 +40,13 @@ services: - JWT_SECRET=${JWT_SECRET:-} - TOTP_ENCRYPTION_KEY=${TOTP_ENCRYPTION_KEY:-} - TZ=${TZ:-Asia/Shanghai} + - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} + - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} + - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} + - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} + - GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS=${GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS:-30} + - GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS:-100} depends_on: postgres: condition: service_healthy diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 5aea78fb27f..51a80227dff 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -146,6 +146,17 @@ services: # Proxy for accessing GitHub (online updates + pricing data) # Examples: http://host:port, socks5://host:port - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} + + # ======================================================================= + # Image Generation Stream & Concurrency + # ======================================================================= + - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} + - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} + - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} + - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} + - GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS=${GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS:-30} + - GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS:-100} depends_on: postgres: condition: service_healthy diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index df0ccfccc37..438d0a8a92b 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -93,6 +93,17 @@ services: # SECURITY: This repo does not embed third-party client_secret. - GEMINI_CLI_OAUTH_CLIENT_SECRET=${GEMINI_CLI_OAUTH_CLIENT_SECRET:-} - ANTIGRAVITY_OAUTH_CLIENT_SECRET=${ANTIGRAVITY_OAUTH_CLIENT_SECRET:-} + + # ======================================================================= + # Image Generation Stream & Concurrency + # ======================================================================= + - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} + - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} + - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} + - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} + - GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS=${GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS:-30} + - GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS:-100} healthcheck: test: ["CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8080/health"] interval: 30s diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 3a714260f20..1d639ea4fd1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -142,6 +142,17 @@ services: # Proxy for accessing GitHub (online updates + pricing data) # Examples: http://host:port, socks5://host:port - UPDATE_PROXY_URL=${UPDATE_PROXY_URL:-} + + # ======================================================================= + # Image Generation Stream & Concurrency + # ======================================================================= + - GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT=${GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT:-900} + - GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL=${GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL:-10} + - GATEWAY_IMAGE_CONCURRENCY_ENABLED=${GATEWAY_IMAGE_CONCURRENCY_ENABLED:-false} + - GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS:-0} + - GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE=${GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE:-reject} + - GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS=${GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS:-30} + - GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS=${GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS:-100} depends_on: postgres: condition: service_healthy diff --git a/docs/CHAT_COMPLETION_FEATURE_DEVELOPMENT.md b/docs/CHAT_COMPLETION_FEATURE_DEVELOPMENT.md new file mode 100644 index 00000000000..174ffaf6c22 --- /dev/null +++ b/docs/CHAT_COMPLETION_FEATURE_DEVELOPMENT.md @@ -0,0 +1,53 @@ +# 对话聊天功能开发记录 + +日期:2026-05-12 + +## 目标 + +新增用户侧“对话聊天”功能。用户只能选择自己在平台创建的 Sub2API API Key 发起聊天,不支持填写上游厂商密钥。功能由后台开关控制,菜单位置放在“AI生图”下方。 + +## 本次修改 + +### 后端 + +- 新增系统设置键:`chat_completion_enabled`。 +- 默认值为 `false`,与 AI 生图一样需要管理员显式开启。 +- 接入公开设置、后台设置、SSR 注入配置、设置更新和审计变更列表。 +- 新增 `/api/v1/chat/completions` 网关别名,供前端应用在同一 API base path 下发起流式聊天请求;原有 `/v1/chat/completions` 和 `/chat/completions` 仍保留。 + +### 前端 + +- 新增 feature flag:`FeatureFlags.chatCompletion`,按 opt-in 处理。 +- 用户侧新增路由:`/chat`。 +- 侧边栏新增“对话聊天”菜单,位于 `/images` 后、`/usage` 前。 +- 调整 Vite 开发代理默认后端地址为 `http://127.0.0.1:8080`,避免 `localhost` 在本地开发环境中解析到不可用地址。 +- 二开项目不再需要官方更新检查:已移除侧边栏版本组件对 `/api/v1/admin/system/check-updates` 的自动调用,版本号仅展示公开设置注入的当前版本,避免 GitHub 访问不通时拖慢页面。 +- 新增 `frontend/src/api/chat.ts`: + - `createChatCompletion`:非流式兼容 helper。 + - `streamChatCompletion`:使用 `fetch` 读取 `${VITE_API_BASE_URL || '/api/v1'}/chat/completions` SSE 流。 +- 新增 `ChatCompletionView.vue`: + - 加载当前用户 active 且已绑定分组的 API Key。 + - 记住上次选择的 Key。 + - 选中 API Key 后按其 `group_id` 从用户可见渠道定价数据中汇总支持模型并提供下拉选择,不再要求用户手动输入模型名。 + - 将页面重构为左右工作台布局:左侧为 Key/模型上下文,右侧为对话区和输入区;页面标题和外层间距对齐模型广场。 + - 默认使用流式响应追加助手内容。 + - 支持停止生成、清空对话、错误提示;清空对话移动到页面顶部操作区。 + +### 测试 + +- 新增/更新设置、网关路由、feature flag、侧边栏顺序、路由声明和 chat API 测试。 + +## 验证结果 + +- `go test -tags unit ./internal/service -run 'TestSettingService_GetPublicSettings_.*ChatCompletion|TestSettingService_GetPublicSettings_.*ImageGeneration'`:通过。 +- `go test ./internal/handler/dto -run TestPublicSettingsInjectionPayload_SchemaDoesNotDrift`:通过。 +- `go test ./internal/handler/admin -run TestSetting`:通过。 +- `go test ./internal/server/routes -run TestGatewayRoutesOpenAI`:通过。 +- `pnpm vitest run src/utils/__tests__/featureFlags.spec.ts src/components/layout/__tests__/AppSidebar.spec.ts src/router/__tests__/title.spec.ts src/api/__tests__/chat.spec.ts`:通过,4 个文件、14 个测试。 +- `pnpm typecheck`:通过。 +- `git diff --check`:通过。 + +## 注意事项 + +- `.gitignore` 已为本文件增加例外规则,后续可随本次功能代码一起提交。 +- 本地曾存在被 `.gitignore` 忽略的 `frontend/vite.config.js` / `frontend/vite.config.d.ts` 生成文件,会覆盖 `vite.config.ts` 的代理配置;已删除这些本地生成文件。修改代理配置后需要重启前端 dev server。 diff --git a/findings.md b/findings.md new file mode 100644 index 00000000000..96306c4ab90 --- /dev/null +++ b/findings.md @@ -0,0 +1,63 @@ +# Findings & Decisions + +## Requirements +-梳理当前项目的前端和后端代码。 +-整理一个代码说明文档,面向二次开发。 +-重点解释核心逻辑:秘钥管理、账号池、每次会话费用计算。 + +## Research Findings +- Project root contains `backend/`, `frontend/`, `deploy/`, and `docs/`. +- Backend appears to be Go-based (`backend/go.mod`). +- Frontend appears to be Vue/Vite/Tailwind-based (`frontend/package.json`, `frontend/vite.config.ts`, `frontend/tailwind.config.js`). +- README_CN states the product is an AI API gateway for distributing and managing AI subscription quotas. Users call upstream AI services via platform-generated API keys; the platform handles auth, billing, load balancing, and proxying. +- README_CN lists key features directly relevant to this document: multi-account management, API key distribution, token-level usage tracking/cost calculation, intelligent account selection with sticky sessions, user/account concurrency limits, and rate limits. +- Backend dependencies confirm Gin (`github.com/gin-gonic/gin`), Ent (`entgo.io/ent`), PostgreSQL driver (`github.com/lib/pq`), Redis (`github.com/redis/go-redis/v9`), decimal arithmetic (`github.com/shopspring/decimal`), and go-cache/Ristretto caching. +- `backend/go.mod` currently declares `go 1.26.2`, while README badges/tech stack still mention Go 1.25.7. +- Ent schemas include `api_key`, `account`, `account_group`, `usage_log`, `user`, `user_subscription`, `subscription_plan`, and payment-related entities, so the core data model is Ent-first. +- Runtime assembly uses Wire provider sets: config, repository, service, payment, middleware, handler, and server are composed from `backend/cmd/server/wire.go`. +- Gin route registration starts in `backend/internal/server/router.go`: `/api/v1/*` hosts auth/user/admin/payment APIs, while gateway-compatible paths are registered directly on `/v1`, `/v1beta`, `/responses`, `/chat/completions`, `/images/*`, and `/backend-api/codex`. +- API key auth accepts `Authorization: Bearer`, `x-api-key`, and Gemini-compatible `x-goog-api-key`; query-string keys are rejected. +- API key auth always checks key existence/status, IP restrictions, user existence/status, and group context. Billing enforcement then checks key expiry/quota, subscription limits, or user balance depending on group mode. +- API key auth has L1 Ristretto + L2 Redis cache keyed by SHA-256 of the raw key, with negative caching, singleflight, jittered TTL, and Redis pub/sub invalidation. +- User API key CRUD is exposed by `frontend/src/api/keys.ts` and `/api/v1/keys`; the user UI is `frontend/src/views/user/KeysView.vue`. +- Account pool membership is the many-to-many `accounts` ↔ `groups` relationship through `account_groups`; accounts carry platform/type/credentials/proxy/concurrency/priority/status/schedulable/limit-window fields. +- Scheduler snapshots are maintained by `SchedulerSnapshotService`, backed by `SchedulerCache` and outbox events. Gateway selection prefers snapshot reads and falls back to DB with throttling. +- Gateway account selection considers group/platform, model support, model routing, sticky session, excluded failed accounts, account schedulability, quota/window/RPM checks, concurrency load, priority, and LRU. +- Sticky session bindings are stored in Redis as `sticky_session:{groupID}:{sessionHash}` with 1-hour TTL and group isolation. +- Usage cost calculation is centralized in `BillingService` and `GatewayService.recordUsageCore`. +- Cost formula for token mode is: raw component costs from tokens and model prices, optional service-tier/long-context/cache-breakdown adjustments, then `actual_cost = total_cost * rate_multiplier`. +- Rate multiplier precedence in gateway usage recording is system default, then group default, then user-specific group multiplier when configured. +- Production billing uses `UsageBillingRepository.Apply`, which deduplicates by `(request_id, api_key_id)` plus a request fingerprint before applying subscription usage, balance deduction, API key quota/rate-limit usage, and account quota usage in one DB transaction. +- Frontend core routes: `/keys`, `/usage`, `/admin/accounts`, `/admin/groups`, `/admin/usage`, `/admin/channels/pricing`. + +## Technical Decisions +| Decision | Rationale | +|----------|-----------| +| Use source-code tracing rather than README-only summary | The requested output is for second-stage development and must explain implementation logic. | + +## Issues Encountered +| Issue | Resolution | +|-------|------------| +| `python` binary unavailable | Used `python3` for the planning skill catch-up script. | + +## Resources +- `README.md` +- `README_CN.md` +- `backend/go.mod` +- `frontend/package.json` +- `backend/ent/schema/api_key.go` +- `backend/ent/schema/account.go` +- `backend/ent/schema/usage_log.go` +- `backend/internal/server/middleware/api_key_auth.go` +- `backend/internal/service/api_key_service.go` +- `backend/internal/service/gateway_service.go` +- `backend/internal/service/billing_service.go` +- `backend/internal/repository/usage_billing_repo.go` +- `frontend/src/api/keys.ts` +- `frontend/src/api/admin/accounts.ts` +- `frontend/src/api/admin/groups.ts` +- `frontend/src/api/usage.ts` +- `frontend/src/api/admin/usage.ts` + +## Visual/Browser Findings +- No browser or visual inspection used so far. diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000000..dbddd38c49a --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,7379 @@ +{ + "name": "sub2api-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sub2api-frontend", + "version": "1.0.0", + "dependencies": { + "@lobehub/icons": "^4.0.2", + "@stripe/stripe-js": "^9.0.1", + "@tanstack/vue-virtual": "^3.13.23", + "@vueuse/core": "^10.7.0", + "axios": "^1.15.0", + "chart.js": "^4.4.1", + "dompurify": "^3.3.1", + "driver.js": "^1.4.0", + "file-saver": "^2.0.5", + "marked": "^17.0.1", + "pinia": "^2.1.7", + "qrcode": "^1.5.4", + "vue": "^3.4.0", + "vue-chartjs": "^5.3.0", + "vue-draggable-plus": "^0.6.1", + "vue-i18n": "^9.14.5", + "vue-router": "^4.2.5", + "xlsx": "^0.18.5" + }, + "devDependencies": { + "@types/dompurify": "^3.0.5", + "@types/file-saver": "^2.0.7", + "@types/mdx": "^2.0.13", + "@types/node": "^20.10.5", + "@types/qrcode": "^1.5.6", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "@vitejs/plugin-vue": "^5.2.3", + "@vitest/coverage-v8": "^2.1.9", + "@vue/test-utils": "^2.4.6", + "autoprefixer": "^10.4.16", + "eslint": "^8.57.0", + "eslint-plugin-vue": "^9.25.0", + "jsdom": "^24.1.3", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "~5.6.0", + "vite": "^5.0.10", + "vite-plugin-checker": "^0.9.1", + "vitest": "^2.1.9", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/babel-plugin/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache/node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/@emotion/css": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/css/-/css-11.13.5.tgz", + "integrity": "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==", + "license": "MIT", + "dependencies": { + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/serialize/node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/serialize/node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@intlify/core-base": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz", + "integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "9.14.5", + "@intlify/shared": "9.14.5" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz", + "integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "9.14.5", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz", + "integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@lobehub/icons": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@lobehub/icons/-/icons-4.12.0.tgz", + "integrity": "sha512-DVH7pVzM6wEvua2LXH+Iv10/cLeBbueggMFBHa8IlfQel5u3I6JzuaNXXxj2qJu5QYjUCNL5LTpSWuh4TnuLGw==", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "antd-style": "^4.1.0", + "lucide-react": "^0.469.0", + "polished": "^4.3.1" + }, + "peerDependencies": { + "@lobehub/ui": "^4.3.3", + "antd": "^6.1.1", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rc-component/util": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.1.tgz", + "integrity": "sha512-q++9S6rUa5Idb/xIBNz6jtvumw5+O5YV5V0g4iK9mn9jWs4oGJheE3ZN1kAnE723AXyaD8v95yeOASmdk8Jnng==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@stripe/stripe-js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.3.1.tgz", + "integrity": "sha512-oWpAEENuVg8aw4W2OUAM9WxRDtIV2YTLr2nr6qHT+D8tHPW7bya61ufinPpUespyRNUVXqesnHo+jQdUNsGywA==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.24.tgz", + "integrity": "sha512-A0k2qF0zFSUStXSZkGXABouXr2Tw2Ztl/cVIYG9qy84uR8W7UNjAcX3DvzBS3YnDcwvLxab8v7dbmYBZ39itDA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/file-saver": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz", + "integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdx": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", + "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", + "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/sortablejs": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz", + "integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==", + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.33.tgz", + "integrity": "sha512-3PZLQwFw4Za3TC8t0FvTy3wI16Kt+pmwcgNZca4Pj9iWL2E72a/gZlpBtAJvEdDMdCxdG/qq0C7PN0bsJuv0Rw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.33.tgz", + "integrity": "sha512-PXq0yrfCLzzL07rbXO4awtXY1Z06LG2eu6Adg3RJFa/j3Cii217XxxLXG22N330gw7GmALCY0Z8RgXEviwgpjA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.33.tgz", + "integrity": "sha512-UTUvRO9cY+rROrx/pvN9P5Z7FgA6QGfokUCfhQE4EnmUj3rVnK+CHI0LsEO1pg+I7//iRYMUfcNcCPe7tg0CoA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.33.tgz", + "integrity": "sha512-IErjYdnj1qIupG5xxiVIYiiRvDhGWV4zuh/RCrwfYpuL+HWQzeU6lCk/nF9r7olWMnjKxCAkOctT2qFWFkzb1A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.33.tgz", + "integrity": "sha512-p8UfIqyIhb0rYGlSgSBV+lPhF2iUSBcRy7enhTmPqKWadHy9kcOFYF1AejYBP9P+avnd3OBbD49DU4pLWX/94A==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.33.tgz", + "integrity": "sha512-UpFF45RI9//a7rvq7RdOQblb4tup7hHG9QsmIrxkFQLzQ7R8/iNQ5LE15NhLZ1/WcHMU2b47u6P33CPUelHyIQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.33.tgz", + "integrity": "sha512-IOxMsAOwquhfITgmOgaPYl7/j8gKUxUFoflRc+u4LxyD3+783xne8vNta1PONVCvCV9A0w7hkyEepINDqfO0tw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.33.tgz", + "integrity": "sha512-0xylq/8/h44lVG0pZFknv1XIdEgymq2E9n59uTWJBG+dIgiT0TMCSsxrN7nO16Z0MU0MPjFcguBbZV8Itk52Hw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.33.tgz", + "integrity": "sha512-5vR2QIlmaLG77Ygd4pMP6+SGQ5yox9VhtnbDWTy9DzMzdmeLxZ1QqxrywEZ9sa1AVubfIJyaCG3ytyWU81ufcQ==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.9.tgz", + "integrity": "sha512-YwgowiO1mPleZqpgAGfxvWu/A5A8nkLrbyH2SqiQRkyzCIaDzzo27/2uS/F1g7fRLvl8BUY0+Sr1eC+6+IHfrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd-style": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/antd-style/-/antd-style-4.1.0.tgz", + "integrity": "sha512-vnPBGg0OVlSz90KRYZhxd89aZiOImTiesF+9MQqN8jsLGZUQTjbP04X9jTdEfsztKUuMbBWg/RmB/wHTakbtMQ==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.0.0", + "@babel/runtime": "^7.24.1", + "@emotion/cache": "^11.11.0", + "@emotion/css": "^11.11.2", + "@emotion/react": "^11.11.4", + "@emotion/serialize": "^1.1.3", + "@emotion/utils": "^1.2.1", + "use-merge-value": "^1.2.0" + }, + "peerDependencies": { + "antd": ">=6.0.0", + "react": ">=18" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", + "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dompurify": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", + "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/driver.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", + "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-vue": { + "version": "9.33.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz", + "integrity": "sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "globals": "^13.24.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^6.0.15", + "semver": "^7.6.3", + "vue-eslint-parser": "^9.4.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-saver": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz", + "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==", + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "24.1.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz", + "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.4", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/marked": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.6.tgz", + "integrity": "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/polished": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", + "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/postcss": { + "version": "8.5.12", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", + "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/use-merge-value": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-merge-value/-/use-merge-value-1.2.0.tgz", + "integrity": "sha512-DXgG0kkgJN45TcyoXL49vJnn55LehnrmoHc7MbKi+QDBvr8dsesqws8UlyIWGHMR+JXgxc1nvY+jDGMlycsUcw==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16.x" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-plugin-checker": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.9.3.tgz", + "integrity": "sha512-Tf7QBjeBtG7q11zG0lvoF38/2AVUzzhMNu+Wk+mcsJ00Rk/FpJ4rmUviVJpzWkagbU13cGXvKpt7CMiqtxVTbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "chokidar": "^4.0.3", + "npm-run-path": "^6.0.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.2", + "strip-ansi": "^7.1.0", + "tiny-invariant": "^1.3.3", + "tinyglobby": "^0.2.13", + "vscode-uri": "^3.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "peerDependencies": { + "@biomejs/biome": ">=1.7", + "eslint": ">=7", + "meow": "^13.2.0", + "optionator": "^0.9.4", + "stylelint": ">=16", + "typescript": "*", + "vite": ">=2.0.0", + "vls": "*", + "vti": "*", + "vue-tsc": "~2.2.10" + }, + "peerDependenciesMeta": { + "@biomejs/biome": { + "optional": true + }, + "eslint": { + "optional": true + }, + "meow": { + "optional": true + }, + "optionator": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vls": { + "optional": true + }, + "vti": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/vite-plugin-checker/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/vite-plugin-checker/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite-plugin-checker/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite-plugin-checker/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/vite-plugin-checker/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.33", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", + "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-chartjs": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz", + "integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.7.tgz", + "integrity": "sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-draggable-plus": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/vue-draggable-plus/-/vue-draggable-plus-0.6.1.tgz", + "integrity": "sha512-FbtQ/fuoixiOfTZzG3yoPl4JAo9HJXRHmBQZFB9x2NYCh6pq0TomHf7g5MUmpaDYv+LU2n6BPq2YN9sBO+FbIg==", + "license": "MIT", + "dependencies": { + "@types/sortablejs": "^1.15.8" + }, + "peerDependencies": { + "@types/sortablejs": "^1.15.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-eslint-parser": { + "version": "9.4.3", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz", + "integrity": "sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "eslint-scope": "^7.1.1", + "eslint-visitor-keys": "^3.3.0", + "espree": "^9.3.1", + "esquery": "^1.4.0", + "lodash": "^4.17.21", + "semver": "^7.3.6" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/vue-i18n": { + "version": "9.14.5", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz", + "integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==", + "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "9.14.5", + "@intlify/shared": "9.14.5", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json index a220d3a7fdd..d33026f9296 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,7 @@ "@stripe/stripe-js": "^9.0.1", "@tanstack/vue-virtual": "^3.13.23", "@vueuse/core": "^10.7.0", - "axios": "^1.15.0", + "axios": "^1.16.0", "chart.js": "^4.4.1", "dompurify": "^3.3.1", "driver.js": "^1.4.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0a7b3fa1cbe..316377605b5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: specifier: ^10.7.0 version: 10.11.1(vue@3.5.26(typescript@5.6.3)) axios: - specifier: ^1.15.0 - version: 1.15.0 + specifier: ^1.16.0 + version: 1.16.0 chart.js: specifier: ^4.4.1 version: 4.5.1 @@ -1858,8 +1858,8 @@ packages: peerDependencies: postcss: ^8.1.0 - axios@1.15.0: - resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + axios@1.16.0: + resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==} babel-plugin-macros@3.1.0: resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} @@ -2534,8 +2534,8 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -6484,9 +6484,9 @@ snapshots: postcss: 8.5.6 postcss-value-parser: 4.2.0 - axios@1.15.0: + axios@1.16.0: dependencies: - follow-redirects: 1.15.11 + follow-redirects: 1.16.0 form-data: 4.0.5 proxy-from-env: 2.1.0 transitivePeerDependencies: @@ -7228,7 +7228,7 @@ snapshots: flatted@3.3.3: {} - follow-redirects@1.15.11: {} + follow-redirects@1.16.0: {} for-in@1.0.2: {} diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 00000000000..a23eae08660 --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: true + vue-demi: true diff --git a/frontend/src/__tests__/viteProxy.spec.ts b/frontend/src/__tests__/viteProxy.spec.ts new file mode 100644 index 00000000000..4b981fb1614 --- /dev/null +++ b/frontend/src/__tests__/viteProxy.spec.ts @@ -0,0 +1,16 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +import { describe, expect, it } from 'vitest' + +describe('Vite dev proxy', () => { + it('does not proxy the image generation page route in development', () => { + const configSource = readFileSync(resolve(process.cwd(), 'vite.config.ts'), 'utf8') + + expect(configSource).not.toContain("'/images'") + expect(configSource).toContain("'/api'") + expect(configSource).toContain('target: backendUrl') + expect(configSource).toContain('proxyTimeout: 0') + expect(configSource).toContain('timeout: 0') + }) +}) diff --git a/frontend/src/api/__tests__/chat.spec.ts b/frontend/src/api/__tests__/chat.spec.ts new file mode 100644 index 00000000000..30f59edebcd --- /dev/null +++ b/frontend/src/api/__tests__/chat.spec.ts @@ -0,0 +1,224 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const post = vi.hoisted(() => vi.fn()) + +vi.mock('../client', () => ({ + apiClient: { + post, + }, +})) + +describe('chat API', () => { + beforeEach(() => { + post.mockReset() + }) + + it('sends non-streaming chat completions with the selected API key', async () => { + post.mockResolvedValueOnce({ data: { choices: [{ message: { content: 'ok' } }] } }) + const { createChatCompletion } = await import('../chat') + + await createChatCompletion({ + apiKey: 'sk-user', + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello' }], + }) + + expect(post).toHaveBeenCalledWith('/chat/completions', { + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello' }], + stream: false, + }, { + headers: { Authorization: 'Bearer sk-user' }, + }) + }) + + it('streams chat completion deltas from SSE chunks', async () => { + const chunks = [ + 'data: {"choices":[{"delta":{"content":"he"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":"llo"}}]}\n\n', + 'data: [DONE]\n\n', + ] + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + body: stream, + headers: new Headers({ 'content-type': 'text/event-stream' }), + }) + vi.stubGlobal('fetch', fetchMock) + const onDelta = vi.fn() + const { streamChatCompletion } = await import('../chat') + + await streamChatCompletion({ + apiKey: 'sk-user', + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello' }], + promptCacheKey: 'chat-session-100', + onDelta, + }) + + expect(fetchMock).toHaveBeenCalledWith('/api/v1/chat/completions', expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer sk-user', + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello' }], + stream: true, + prompt_cache_key: 'chat-session-100', + }), + })) + expect(onDelta).toHaveBeenNthCalledWith(1, 'he') + expect(onDelta).toHaveBeenNthCalledWith(2, 'llo') + }) + + it('streams OpenAI platform chats through the Responses endpoint', async () => { + const chunks = [ + 'data: {"type":"response.output_text.delta","delta":"he"}\n\n', + 'data: {"type":"response.output_text.delta","delta":"llo"}\n\n', + 'data: [DONE]\n\n', + ] + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + body: stream, + headers: new Headers({ 'content-type': 'text/event-stream' }), + }) + vi.stubGlobal('fetch', fetchMock) + const onDelta = vi.fn() + const { streamChatCompletion } = await import('../chat') + + await streamChatCompletion({ + apiKey: 'sk-user', + model: 'gpt-5.4', + platform: 'openai', + messages: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + ], + promptCacheKey: 'chat-session-100', + onDelta, + }) + + expect(fetchMock).toHaveBeenCalledWith('/v1/responses', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + model: 'gpt-5.4', + input: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + ], + stream: true, + prompt_cache_key: 'chat-session-100', + }), + })) + expect(onDelta).toHaveBeenNthCalledWith(1, 'he') + expect(onDelta).toHaveBeenNthCalledWith(2, 'llo') + }) + + it('streams Anthropic platform chats through the Messages endpoint', async () => { + const chunks = [ + 'event: content_block_delta\n', + 'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"ok"}}\n\n', + 'event: message_stop\n', + 'data: {"type":"message_stop"}\n\n', + ] + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + body: stream, + headers: new Headers({ 'content-type': 'text/event-stream' }), + }) + vi.stubGlobal('fetch', fetchMock) + const onDelta = vi.fn() + const { streamChatCompletion } = await import('../chat') + + await streamChatCompletion({ + apiKey: 'sk-user', + model: 'claude-sonnet-4-5', + platform: 'anthropic', + messages: [ + { role: 'system', content: 'be concise' }, + { role: 'user', content: 'hello' }, + ], + onDelta, + }) + + expect(fetchMock).toHaveBeenCalledWith('/v1/messages', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + model: 'claude-sonnet-4-5', + max_tokens: 4096, + system: 'be concise', + messages: [{ role: 'user', content: 'hello' }], + stream: true, + }), + })) + expect(onDelta).toHaveBeenCalledWith('ok') + }) + + it('streams Gemini platform chats through the native Gemini endpoint', async () => { + const chunks = [ + 'data: {"candidates":[{"content":{"parts":[{"text":"he"}]}}]}\n\n', + 'data: {"candidates":[{"content":{"parts":[{"text":"llo"}]}}]}\n\n', + ] + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + body: stream, + headers: new Headers({ 'content-type': 'text/event-stream' }), + }) + vi.stubGlobal('fetch', fetchMock) + const onDelta = vi.fn() + const { streamChatCompletion } = await import('../chat') + + await streamChatCompletion({ + apiKey: 'sk-user', + model: 'gemini-2.5-pro', + platform: 'gemini', + messages: [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi' }, + ], + onDelta, + }) + + expect(fetchMock).toHaveBeenCalledWith('/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + contents: [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'hi' }] }, + ], + }), + })) + expect(onDelta).toHaveBeenNthCalledWith(1, 'he') + expect(onDelta).toHaveBeenNthCalledWith(2, 'llo') + }) + +}) diff --git a/frontend/src/api/__tests__/chatSessions.spec.ts b/frontend/src/api/__tests__/chatSessions.spec.ts new file mode 100644 index 00000000000..cb24a9a8bd8 --- /dev/null +++ b/frontend/src/api/__tests__/chatSessions.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const get = vi.hoisted(() => vi.fn()) +const post = vi.hoisted(() => vi.fn()) +const patch = vi.hoisted(() => vi.fn()) +const del = vi.hoisted(() => vi.fn()) + +vi.mock('../client', () => ({ + apiClient: { + get, + post, + patch, + delete: del, + }, +})) + +describe('chat session API', () => { + beforeEach(() => { + get.mockReset() + post.mockReset() + patch.mockReset() + del.mockReset() + }) + + it('lists and creates chat sessions', async () => { + get.mockResolvedValueOnce({ data: [{ id: 1, title: 'History' }] }) + post.mockResolvedValueOnce({ data: { id: 2, title: 'New chat' } }) + const { listChatSessions, createChatSession } = await import('../chatSessions') + + await expect(listChatSessions()).resolves.toEqual([{ id: 1, title: 'History' }]) + await createChatSession({ api_key_id: 9, title: 'New chat', model: 'gpt-5.4' }) + + expect(get).toHaveBeenCalledWith('/chat/sessions') + expect(post).toHaveBeenCalledWith('/chat/sessions', { + api_key_id: 9, + title: 'New chat', + model: 'gpt-5.4', + }) + }) + + it('loads, creates, updates messages and deletes sessions', async () => { + get.mockResolvedValueOnce({ data: [{ id: 10, role: 'user', content: 'hello' }] }) + post.mockResolvedValueOnce({ data: { id: 11, role: 'assistant', status: 'streaming' } }) + patch.mockResolvedValueOnce({ data: { id: 11, status: 'completed' } }) + del.mockResolvedValueOnce({ data: { deleted: true } }) + const { + getChatSessionMessages, + createChatMessage, + updateChatMessage, + deleteChatSession, + } = await import('../chatSessions') + + await expect(getChatSessionMessages(7)).resolves.toEqual([{ id: 10, role: 'user', content: 'hello' }]) + await createChatMessage(7, { role: 'assistant', content: '', status: 'streaming' }) + await updateChatMessage(7, 11, { content: 'done', status: 'completed', duration_ms: 1200 }) + await deleteChatSession(7) + + expect(get).toHaveBeenCalledWith('/chat/sessions/7/messages') + expect(post).toHaveBeenCalledWith('/chat/sessions/7/messages', { + role: 'assistant', + content: '', + status: 'streaming', + }) + expect(patch).toHaveBeenCalledWith('/chat/sessions/7/messages/11', { + content: 'done', + status: 'completed', + duration_ms: 1200, + }) + expect(del).toHaveBeenCalledWith('/chat/sessions/7') + }) + + it('updates session metadata', async () => { + patch.mockResolvedValueOnce({ data: { id: 3, title: 'Renamed' } }) + const { updateChatSession } = await import('../chatSessions') + + await updateChatSession(3, { title: 'Renamed', model: 'gpt-5.4-mini' }) + + expect(patch).toHaveBeenCalledWith('/chat/sessions/3', { + title: 'Renamed', + model: 'gpt-5.4-mini', + }) + }) +}) diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index a46c39eb46f..244d36834f2 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -7,6 +7,19 @@ vi.mock('@/i18n', () => ({ getLocale: () => 'zh-CN', })) +vi.hoisted(() => { + const store = new Map() + Object.defineProperty(globalThis, 'localStorage', { + value: { + getItem: vi.fn((key: string) => store.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => store.set(key, value)), + removeItem: vi.fn((key: string) => store.delete(key)), + clear: vi.fn(() => store.clear()), + }, + configurable: true, + }) +}) + describe('API Client', () => { let apiClient: AxiosInstance @@ -44,6 +57,26 @@ describe('API Client', () => { expect(config.headers.get('Authorization')).toBe('Bearer my-jwt-token') }) + it('保留调用方显式传入的 Authorization 头', async () => { + localStorage.setItem('auth_token', 'my-jwt-token') + + const adapter = vi.fn().mockResolvedValue({ + status: 200, + data: { code: 0, data: {} }, + headers: {}, + config: {}, + statusText: 'OK', + }) + apiClient.defaults.adapter = adapter + + await apiClient.post('/images/generations', {}, { + headers: { Authorization: 'Bearer selected-api-key' }, + }) + + const config = adapter.mock.calls[0][0] + expect(config.headers.get('Authorization')).toBe('Bearer selected-api-key') + }) + it('无 token 时不附加 Authorization 头', async () => { const adapter = vi.fn().mockResolvedValue({ status: 200, @@ -107,6 +140,27 @@ describe('API Client', () => { const config = adapter.mock.calls[0][0] expect(config.withCredentials).toBe(true) }) + + it('FormData 请求不会被默认 JSON Content-Type 序列化', async () => { + const adapter = vi.fn().mockResolvedValue({ + status: 200, + data: { code: 0, data: {} }, + headers: {}, + config: {}, + statusText: 'OK', + }) + apiClient.defaults.adapter = adapter + + const form = new FormData() + form.append('n', '1') + form.append('image', new File(['image'], 'ref.png', { type: 'image/png' })) + + await apiClient.post('/images/edits', form) + + const config = adapter.mock.calls[0][0] + expect(config.data).toBe(form) + expect(config.headers.get('Content-Type')).not.toBe('application/json') + }) }) // --- 响应拦截器 --- @@ -187,6 +241,23 @@ describe('API Client', () => { // --- 网络错误 --- describe('网络错误', () => { + it('超时错误返回更明确的错误信息', async () => { + const adapter = vi.fn().mockRejectedValue({ + code: 'ECONNABORTED', + message: 'timeout of 30000ms exceeded', + config: { url: '/images/generations' }, + }) + apiClient.defaults.adapter = adapter + + await expect(apiClient.get('/images/generations')).rejects.toEqual( + expect.objectContaining({ + status: 0, + code: 'REQUEST_TIMEOUT', + message: 'Request timed out. Please try again.', + }) + ) + }) + it('网络错误返回 status 0 的错误', async () => { const adapter = vi.fn().mockRejectedValue({ code: 'ERR_NETWORK', diff --git a/frontend/src/api/__tests__/images.spec.ts b/frontend/src/api/__tests__/images.spec.ts new file mode 100644 index 00000000000..f9fb9e95c4d --- /dev/null +++ b/frontend/src/api/__tests__/images.spec.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() })) + +vi.mock('@/api/client', () => ({ + apiClient: { post, get }, +})) + +import { createImageEditTask, createImageGenerationTask, downloadImageTask, editImage, generateImage, getImageTask } from '../images' + +describe('images API', () => { + beforeEach(() => { + post.mockReset() + get.mockReset() + }) + + it('posts generations through the /api/v1 client so the dev proxy handles the request', async () => { + post.mockResolvedValueOnce({ data: { data: [] } }) + + await generateImage({ + model: 'gpt-image-2', + prompt: 'draw a cat', + size: '1024x1024', + quality: 'auto', + }, 'sk-test') + + expect(post).toHaveBeenCalledWith('/images/generations', expect.any(Object), { + headers: { Authorization: 'Bearer sk-test' }, + timeout: 0, + }) + }) + + it('posts edits through the /api/v1 client so the dev proxy handles the request', async () => { + post.mockResolvedValueOnce({ data: { data: [] } }) + + await editImage({ + model: 'gpt-image-2', + prompt: 'make it cinematic', + size: '1024x1024', + quality: 'auto', + images: [new File(['image'], 'ref.png', { type: 'image/png' })], + }, 'sk-test') + + expect(post).toHaveBeenCalledWith('/images/edits', expect.any(FormData), { + headers: { Authorization: 'Bearer sk-test' }, + timeout: 0, + }) + }) + + it('creates async generation tasks without the long image timeout', async () => { + post.mockResolvedValueOnce({ data: { task_id: 'img_123', status: 'pending', expires_at: '2026-05-08T12:00:00Z' } }) + + await createImageGenerationTask({ + model: 'gpt-image-2', + prompt: 'draw a cat', + size: '1024x1024', + quality: 'auto', + }, 'sk-test') + + expect(post).toHaveBeenCalledWith('/images/async/generations', expect.any(Object), { + headers: { Authorization: 'Bearer sk-test' }, + }) + }) + + it('creates async edit tasks and polls task status', async () => { + post.mockResolvedValueOnce({ data: { task_id: 'img_123', status: 'pending', expires_at: '2026-05-08T12:00:00Z' } }) + get.mockResolvedValueOnce({ data: { task_id: 'img_123', status: 'succeeded', download_url: '/download', expires_at: '2026-05-08T12:00:00Z' } }) + get.mockResolvedValueOnce({ data: new Blob(['image'], { type: 'image/png' }) }) + + await createImageEditTask({ + model: 'gpt-image-2', + prompt: 'make it cinematic', + size: '1024x1024', + quality: 'auto', + images: [new File(['image'], 'ref.png', { type: 'image/png' })], + }, 'sk-test') + await getImageTask('img_123', 'sk-test') + await downloadImageTask('img_123', 'sk-test') + + expect(post).toHaveBeenCalledWith('/images/async/edits', expect.any(FormData), { + headers: { Authorization: 'Bearer sk-test' }, + }) + expect(get).toHaveBeenCalledWith('/images/async/tasks/img_123', { + headers: { Authorization: 'Bearer sk-test' }, + }) + expect(get).toHaveBeenCalledWith('/images/async/tasks/img_123/download', { + headers: { Authorization: 'Bearer sk-test' }, + responseType: 'blob', + }) + }) + +}) diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 8a1277930c3..00ed40878c3 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -16,6 +16,8 @@ import type { TempUnschedulableStatus, AdminDataPayload, AdminDataImportResult, + CodexSessionImportRequest, + CodexSessionImportResult, CheckMixedChannelRequest, CheckMixedChannelResponse } from '@/types' @@ -547,6 +549,11 @@ export async function importData(payload: { return data } +export async function importCodexSession(payload: CodexSessionImportRequest): Promise { + const { data } = await apiClient.post('/admin/accounts/import/codex-session', payload) + return data +} + /** * Get Antigravity default model mapping from backend * @returns Default model mapping (from -> to) @@ -663,6 +670,7 @@ export const accountsAPI = { syncFromCrs, exportData, importData, + importCodexSession, getAntigravityDefaultModelMapping, batchClearError, batchRefresh, diff --git a/frontend/src/api/admin/index.ts b/frontend/src/api/admin/index.ts index 802417942d1..384e3796da4 100644 --- a/frontend/src/api/admin/index.ts +++ b/frontend/src/api/admin/index.ts @@ -30,6 +30,7 @@ import channelMonitorAPI from './channelMonitor' import channelMonitorTemplateAPI from './channelMonitorTemplate' import adminPaymentAPI from './payment' import affiliatesAPI from './affiliates' +import riskControlAPI from './riskControl' /** * Unified admin API object for convenient access @@ -61,7 +62,8 @@ export const adminAPI = { channelMonitor: channelMonitorAPI, channelMonitorTemplate: channelMonitorTemplateAPI, payment: adminPaymentAPI, - affiliates: affiliatesAPI + affiliates: affiliatesAPI, + riskControl: riskControlAPI } export { @@ -91,7 +93,8 @@ export { channelMonitorAPI, channelMonitorTemplateAPI, adminPaymentAPI, - affiliatesAPI + affiliatesAPI, + riskControlAPI } export default adminAPI @@ -101,3 +104,4 @@ export type { BalanceHistoryItem } from './users' export type { ErrorPassthroughRule, CreateRuleRequest, UpdateRuleRequest } from './errorPassthrough' export type { BackupAgentHealth, DataManagementConfig } from './dataManagement' export type { TLSFingerprintProfile, CreateProfileRequest, UpdateProfileRequest } from './tlsFingerprintProfile' +export type { ContentModerationConfig, ContentModerationLog, ModerationMode } from './riskControl' diff --git a/frontend/src/api/admin/riskControl.ts b/frontend/src/api/admin/riskControl.ts new file mode 100644 index 00000000000..e63a53a2061 --- /dev/null +++ b/frontend/src/api/admin/riskControl.ts @@ -0,0 +1,253 @@ +import { apiClient } from '../client' + +export type ModerationMode = 'off' | 'observe' | 'pre_block' + +export interface ContentModerationConfig { + enabled: boolean + mode: ModerationMode + base_url: string + model: string + api_key_configured: boolean + api_key_masked: string + api_key_count: number + api_key_masks: string[] + api_key_statuses: ContentModerationAPIKeyStatus[] + timeout_ms: number + sample_rate: number + all_groups: boolean + group_ids: number[] + record_non_hits: boolean + worker_count: number + queue_size: number + block_status: number + block_message: string + email_on_hit: boolean + auto_ban_enabled: boolean + ban_threshold: number + violation_window_hours: number + retry_count: number + hit_retention_days: number + non_hit_retention_days: number + pre_hash_check_enabled: boolean +} + +export type ContentModerationAPIKeyStatusValue = 'unknown' | 'ok' | 'error' | 'frozen' + +export interface ContentModerationAPIKeyStatus { + index: number + key_hash: string + masked: string + status: ContentModerationAPIKeyStatusValue + failure_count: number + success_count: number + last_error: string + last_checked_at?: string + frozen_until?: string + last_latency_ms: number + last_http_status: number + last_tested: boolean + configured: boolean +} + +export interface TestContentModerationAPIKeysPayload { + api_keys?: string[] + base_url?: string + model?: string + timeout_ms?: number + prompt?: string + images?: string[] +} + +export interface TestContentModerationAPIKeysResponse { + items: ContentModerationAPIKeyStatus[] + audit_result?: ContentModerationTestAuditResult + image_count: number +} + +export interface ContentModerationTestAuditResult { + flagged: boolean + highest_category: string + highest_score: number + composite_score: number + category_scores: Record + thresholds: Record +} + +export interface UpdateContentModerationConfig { + enabled?: boolean + mode?: ModerationMode + base_url?: string + model?: string + api_key?: string + api_keys?: string[] + api_keys_mode?: 'append' | 'replace' + delete_api_key_hashes?: string[] + clear_api_key?: boolean + timeout_ms?: number + sample_rate?: number + all_groups?: boolean + group_ids?: number[] + record_non_hits?: boolean + worker_count?: number + queue_size?: number + block_status?: number + block_message?: string + email_on_hit?: boolean + auto_ban_enabled?: boolean + ban_threshold?: number + violation_window_hours?: number + retry_count?: number + hit_retention_days?: number + non_hit_retention_days?: number + pre_hash_check_enabled?: boolean +} + +export interface ContentModerationRuntimeStatus { + enabled: boolean + risk_control_enabled: boolean + mode: ModerationMode + worker_count: number + max_workers: number + active_workers: number + idle_workers: number + queue_size: number + queue_length: number + queue_usage_percent: number + enqueued: number + dropped: number + processed: number + errors: number + api_key_statuses: ContentModerationAPIKeyStatus[] + flagged_hash_count: number + last_cleanup_at?: string + last_cleanup_deleted_hit: number + last_cleanup_deleted_non_hit: number +} + +export interface ContentModerationLog { + id: number + request_id: string + user_id: number | null + user_email: string + api_key_id: number | null + api_key_name: string + group_id: number | null + group_name: string + endpoint: string + provider: string + model: string + mode: string + action: string + flagged: boolean + highest_category: string + highest_score: number + category_scores: Record + threshold_snapshot: Record + input_excerpt: string + upstream_latency_ms: number | null + error: string + violation_count: number + auto_banned: boolean + email_sent: boolean + user_status: string + queue_delay_ms: number | null + created_at: string +} + +export interface ListContentModerationLogsParams { + page?: number + page_size?: number + result?: string + group_id?: number + endpoint?: string + search?: string + from?: string + to?: string +} + +export interface ContentModerationLogsResponse { + items: ContentModerationLog[] + total: number + page: number + page_size: number + pages: number +} + +export interface ContentModerationUnbanUserResponse { + user_id: number + status: string +} + +export interface DeleteFlaggedHashResponse { + input_hash: string + deleted: boolean +} + +export interface ClearFlaggedHashesResponse { + deleted: number +} + +export async function getConfig(): Promise { + const { data } = await apiClient.get('/admin/risk-control/config') + return data +} + +export async function updateConfig( + payload: UpdateContentModerationConfig +): Promise { + const { data } = await apiClient.put('/admin/risk-control/config', payload) + return data +} + +export async function getStatus(): Promise { + const { data } = await apiClient.get('/admin/risk-control/status') + return data +} + +export async function testAPIKeys( + payload: TestContentModerationAPIKeysPayload = {} +): Promise { + const { data } = await apiClient.post('/admin/risk-control/api-keys/test', payload) + return data +} + +export async function listLogs( + params: ListContentModerationLogsParams = {} +): Promise { + const { data } = await apiClient.get('/admin/risk-control/logs', { + params, + }) + return data +} + +export async function unbanUser(userID: number): Promise { + const { data } = await apiClient.post( + `/admin/risk-control/users/${userID}/unban` + ) + return data +} + +export async function deleteFlaggedHash(inputHash: string): Promise { + const { data } = await apiClient.delete('/admin/risk-control/hashes', { + data: { input_hash: inputHash }, + }) + return data +} + +export async function clearFlaggedHashes(): Promise { + const { data } = await apiClient.delete('/admin/risk-control/hashes/all') + return data +} + +export const riskControlAPI = { + getConfig, + updateConfig, + getStatus, + testAPIKeys, + listLogs, + unbanUser, + deleteFlaggedHash, + clearFlaggedHashes, +} + +export default riskControlAPI diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 35eef9dec61..7c0c656a9d3 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -4,14 +4,25 @@ */ import { apiClient } from "../client"; -import type { CustomMenuItem, CustomEndpoint, NotifyEmailEntry } from "@/types"; +import type { + CustomEndpoint, + CustomMenuItem, + LoginAgreementDocument, + NotifyEmailEntry, +} from "@/types"; export interface DefaultSubscriptionSetting { group_id: number; validity_days: number; } -export type AuthSourceType = "email" | "linuxdo" | "oidc" | "wechat"; +export type AuthSourceType = + | "email" + | "linuxdo" + | "oidc" + | "wechat" + | "github" + | "google"; export interface AuthSourceDefaultsValue { balance: number; @@ -51,6 +62,8 @@ const AUTH_SOURCE_TYPES: AuthSourceType[] = [ "linuxdo", "oidc", "wechat", + "github", + "google", ]; const AUTH_SOURCE_DEFAULT_BALANCE = 0; const AUTH_SOURCE_DEFAULT_CONCURRENCY = 5; @@ -306,6 +319,10 @@ export interface SystemSettings { invitation_code_enabled: boolean; totp_enabled: boolean; // TOTP 双因素认证 totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置 + login_agreement_enabled: boolean; + login_agreement_mode: "modal" | "checkbox" | string; + login_agreement_updated_at: string; + login_agreement_documents: LoginAgreementDocument[]; // Default settings default_balance: number; affiliate_rebate_rate: number; @@ -335,6 +352,16 @@ export interface SystemSettings { auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[]; auth_source_default_wechat_grant_on_signup?: boolean; auth_source_default_wechat_grant_on_first_bind?: boolean; + auth_source_default_github_balance?: number; + auth_source_default_github_concurrency?: number; + auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_github_grant_on_signup?: boolean; + auth_source_default_github_grant_on_first_bind?: boolean; + auth_source_default_google_balance?: number; + auth_source_default_google_concurrency?: number; + auth_source_default_google_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_google_grant_on_signup?: boolean; + auth_source_default_google_grant_on_first_bind?: boolean; force_email_on_third_party_signup?: boolean; // OEM settings site_name: string; @@ -410,6 +437,16 @@ export interface SystemSettings { oidc_connect_userinfo_email_path: string; oidc_connect_userinfo_id_path: string; oidc_connect_userinfo_username_path: string; + github_oauth_enabled: boolean; + github_oauth_client_id: string; + github_oauth_client_secret_configured: boolean; + github_oauth_redirect_url: string; + github_oauth_frontend_redirect_url: string; + google_oauth_enabled: boolean; + google_oauth_client_id: string; + google_oauth_client_secret_configured: boolean; + google_oauth_redirect_url: string; + google_oauth_frontend_redirect_url: string; // Model fallback configuration enable_model_fallback: boolean; @@ -444,6 +481,7 @@ export interface SystemSettings { // Payment configuration payment_enabled: boolean; + risk_control_enabled: boolean; payment_min_amount: number; payment_max_amount: number; payment_daily_limit: number; @@ -483,6 +521,12 @@ export interface SystemSettings { // Available Channels feature switch available_channels_enabled: boolean; + // Image Generation feature switch + image_generation_enabled: boolean; + + // Chat Completion feature switch + chat_completion_enabled: boolean; + // Affiliate (邀请返利) feature switch affiliate_enabled: boolean; @@ -499,6 +543,10 @@ export interface UpdateSettingsRequest { frontend_url?: string; invitation_code_enabled?: boolean; totp_enabled?: boolean; // TOTP 双因素认证 + login_agreement_enabled?: boolean; + login_agreement_mode?: "modal" | "checkbox" | string; + login_agreement_updated_at?: string; + login_agreement_documents?: LoginAgreementDocument[]; default_balance?: number; affiliate_rebate_rate?: number; affiliate_rebate_freeze_hours?: number; @@ -527,6 +575,16 @@ export interface UpdateSettingsRequest { auth_source_default_wechat_subscriptions?: DefaultSubscriptionSetting[]; auth_source_default_wechat_grant_on_signup?: boolean; auth_source_default_wechat_grant_on_first_bind?: boolean; + auth_source_default_github_balance?: number; + auth_source_default_github_concurrency?: number; + auth_source_default_github_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_github_grant_on_signup?: boolean; + auth_source_default_github_grant_on_first_bind?: boolean; + auth_source_default_google_balance?: number; + auth_source_default_google_concurrency?: number; + auth_source_default_google_subscriptions?: DefaultSubscriptionSetting[]; + auth_source_default_google_grant_on_signup?: boolean; + auth_source_default_google_grant_on_first_bind?: boolean; force_email_on_third_party_signup?: boolean; site_name?: string; site_logo?: string; @@ -593,6 +651,16 @@ export interface UpdateSettingsRequest { oidc_connect_userinfo_email_path?: string; oidc_connect_userinfo_id_path?: string; oidc_connect_userinfo_username_path?: string; + github_oauth_enabled?: boolean; + github_oauth_client_id?: string; + github_oauth_client_secret?: string; + github_oauth_redirect_url?: string; + github_oauth_frontend_redirect_url?: string; + google_oauth_enabled?: boolean; + google_oauth_client_id?: string; + google_oauth_client_secret?: string; + google_oauth_redirect_url?: string; + google_oauth_frontend_redirect_url?: string; enable_model_fallback?: boolean; fallback_model_anthropic?: string; fallback_model_openai?: string; @@ -613,6 +681,7 @@ export interface UpdateSettingsRequest { enable_anthropic_cache_ttl_1h_injection?: boolean; // Payment configuration payment_enabled?: boolean; + risk_control_enabled?: boolean; payment_min_amount?: number; payment_max_amount?: number; payment_daily_limit?: number; @@ -651,6 +720,12 @@ export interface UpdateSettingsRequest { // Available Channels feature switch available_channels_enabled?: boolean; + // Image Generation feature switch + image_generation_enabled?: boolean; + + // Chat Completion feature switch + chat_completion_enabled?: boolean; + // Affiliate (邀请返利) feature switch affiliate_enabled?: boolean; @@ -805,6 +880,30 @@ export async function updateOverloadCooldownSettings( return data; } +// ==================== 429 Rate Limit Cooldown Settings ==================== + +export interface RateLimit429CooldownSettings { + enabled: boolean; + cooldown_seconds: number; +} + +export async function getRateLimit429CooldownSettings(): Promise { + const { data } = await apiClient.get( + "/admin/settings/rate-limit-429-cooldown", + ); + return data; +} + +export async function updateRateLimit429CooldownSettings( + settings: RateLimit429CooldownSettings, +): Promise { + const { data } = await apiClient.put( + "/admin/settings/rate-limit-429-cooldown", + settings, + ); + return data; +} + // ==================== Stream Timeout Settings ==================== /** @@ -1024,6 +1123,8 @@ export const settingsAPI = { deleteAdminApiKey, getOverloadCooldownSettings, updateOverloadCooldownSettings, + getRateLimit429CooldownSettings, + updateRateLimit429CooldownSettings, getStreamTimeoutSettings, updateStreamTimeoutSettings, getRectifierSettings, diff --git a/frontend/src/api/admin/system.ts b/frontend/src/api/admin/system.ts index 9ea312d568f..efd1146d6d0 100644 --- a/frontend/src/api/admin/system.ts +++ b/frontend/src/api/admin/system.ts @@ -29,17 +29,6 @@ export async function getVersion(): Promise<{ version: string }> { return data } -/** - * Check for updates - * @param force - Force refresh from GitHub API - */ -export async function checkUpdates(force = false): Promise { - const { data } = await apiClient.get('/admin/system/check-updates', { - params: force ? { force: 'true' } : undefined - }) - return data -} - export interface UpdateResult { message: string need_restart: boolean @@ -72,7 +61,6 @@ export async function restartService(): Promise<{ message: string }> { export const systemAPI = { getVersion, - checkUpdates, performUpdate, rollback, restartService diff --git a/frontend/src/api/channels.ts b/frontend/src/api/channels.ts index 8962af2c4d8..182752fe658 100644 --- a/frontend/src/api/channels.ts +++ b/frontend/src/api/channels.ts @@ -46,6 +46,21 @@ export interface UserSupportedModel { pricing: UserSupportedModelPricing | null } +export interface UserDefaultModelPricing { + found: boolean + billing_mode?: BillingMode + input_price?: number + output_price?: number + cache_write_price?: number + cache_read_price?: number + image_output_price?: number + per_request_price?: number +} + +export interface UserModelPricingBatchResponse { + prices: Record +} + /** * 渠道下单个平台的子视图:用户可访问的分组 + 该平台支持的模型。 * 后端把一个渠道按平台聚合成 sections,前端可以把渠道名作为 row-group @@ -53,6 +68,7 @@ export interface UserSupportedModel { */ export interface UserChannelPlatformSection { platform: string + base_url?: string groups: UserAvailableGroup[] supported_models: UserSupportedModel[] } @@ -63,14 +79,26 @@ export interface UserAvailableChannel { platforms: UserChannelPlatformSection[] } -/** 列出当前用户可见的「可用渠道」(与 /groups/available 保持一致,返回平数组)。 */ -export async function getAvailable(options?: { signal?: AbortSignal }): Promise { - const { data } = await apiClient.get('/channels/available', { +/** 列出可见的「可用渠道」。未登录模型广场使用公开接口,只展示公开分组可见数据。 */ +export async function getAvailable(options?: { signal?: AbortSignal, public?: boolean }): Promise { + const { data } = await apiClient.get( + options?.public ? '/public/channels/available' : '/channels/available', + { + signal: options?.signal + }, + ) + return data +} + +export async function getModelPricingBatch(models: string[], options?: { signal?: AbortSignal }): Promise { + const { data } = await apiClient.post('/channels/model-pricing/batch', { + models + }, { signal: options?.signal }) return data } -export const userChannelsAPI = { getAvailable } +export const userChannelsAPI = { getAvailable, getModelPricingBatch } export default userChannelsAPI diff --git a/frontend/src/api/chat.ts b/frontend/src/api/chat.ts new file mode 100644 index 00000000000..edd21130dac --- /dev/null +++ b/frontend/src/api/chat.ts @@ -0,0 +1,246 @@ +import { apiClient } from './client' + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1' + +export type ChatRole = 'system' | 'developer' | 'user' | 'assistant' + +export interface ChatMessage { + role: ChatRole + content: string +} + +export interface ChatCompletionOptions { + apiKey: string + model: string + messages: ChatMessage[] + platform?: string + promptCacheKey?: string +} + +export interface StreamChatCompletionOptions extends ChatCompletionOptions { + signal?: AbortSignal + onDelta: (delta: string) => void +} + +export interface ChatModel { + id: string + display_name?: string + type?: string + base_url?: string +} + +type ChatProtocol = 'chat_completions' | 'responses' | 'messages' | 'gemini' + +interface StreamRequestConfig { + endpoint: string + protocol: ChatProtocol + body: Record +} + +function authHeaders(apiKey: string): Record { + const key = apiKey.trim() + return key ? { Authorization: `Bearer ${key}` } : {} +} + +export async function createChatCompletion(options: ChatCompletionOptions): Promise { + const body: Record = { + model: options.model, + messages: options.messages, + stream: false, + } + if (options.promptCacheKey?.trim()) { + body.prompt_cache_key = options.promptCacheKey.trim() + } + + const { data } = await apiClient.post('/chat/completions', { + ...body, + }, { + headers: authHeaders(options.apiKey), + }) + return data +} + +export async function streamChatCompletion(options: StreamChatCompletionOptions): Promise { + const request = buildStreamRequest(options) + + const response = await fetch(request.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authHeaders(options.apiKey), + }, + body: JSON.stringify(request.body), + signal: options.signal, + }) + + if (!response.ok) { + throw new Error(await readErrorMessage(response)) + } + if (!response.body) { + throw new Error('Streaming response body is empty') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + buffer = consumeSSEBuffer(buffer, options.onDelta, request.protocol) + } + buffer += decoder.decode() + consumeSSEBuffer(buffer, options.onDelta, request.protocol, true) +} + +function buildStreamRequest(options: StreamChatCompletionOptions): StreamRequestConfig { + const protocol = resolveProtocol(options.platform) + switch (protocol) { + case 'responses': + return { + endpoint: gatewayEndpoint('/v1/responses'), + protocol, + body: withPromptCacheKey({ + model: options.model, + input: options.messages.map((message) => ({ + role: message.role, + content: message.content, + })), + stream: true, + }, options.promptCacheKey), + } + case 'messages': + return { + endpoint: gatewayEndpoint('/v1/messages'), + protocol, + body: buildAnthropicMessagesBody(options), + } + case 'gemini': + return { + endpoint: gatewayEndpoint(`/v1beta/models/${encodeURIComponent(options.model)}:streamGenerateContent?alt=sse`), + protocol, + body: { + contents: options.messages + .filter((message) => message.role !== 'system' && message.role !== 'developer') + .map((message) => ({ + role: message.role === 'assistant' ? 'model' : 'user', + parts: [{ text: message.content }], + })), + }, + } + case 'chat_completions': + default: + return { + endpoint: `${API_BASE_URL}/chat/completions`, + protocol: 'chat_completions', + body: withPromptCacheKey({ + model: options.model, + messages: options.messages, + stream: true, + }, options.promptCacheKey), + } + } +} + +function buildAnthropicMessagesBody(options: StreamChatCompletionOptions): Record { + const system = options.messages + .filter((message) => message.role === 'system' || message.role === 'developer') + .map((message) => message.content.trim()) + .filter(Boolean) + .join('\n\n') + const body: Record = { + model: options.model, + max_tokens: 4096, + } + if (system) body.system = system + body.messages = options.messages + .filter((message) => message.role === 'user' || message.role === 'assistant') + .map((message) => ({ + role: message.role, + content: message.content, + })) + body.stream = true + return body +} + +function resolveProtocol(platform?: string): ChatProtocol { + switch ((platform || '').toLowerCase()) { + case 'openai': + return 'responses' + case 'anthropic': + return 'messages' + case 'gemini': + return 'gemini' + case 'antigravity': + return 'messages' + default: + return 'chat_completions' + } +} + +function gatewayEndpoint(path: string): string { + const configured = API_BASE_URL.replace(/\/+$/, '') + if (!configured || configured === '/api/v1') return path + return `${configured.replace(/\/api\/v1$/, '')}${path}` +} + +function withPromptCacheKey(body: Record, promptCacheKey?: string): Record { + const trimmed = promptCacheKey?.trim() + if (!trimmed) return body + return { ...body, prompt_cache_key: trimmed } +} + +function consumeSSEBuffer( + buffer: string, + onDelta: (delta: string) => void, + protocol: ChatProtocol = 'chat_completions', + flush = false, +): string { + const parts = buffer.split(/\n\n/) + const pending = flush ? [] : parts.splice(parts.length - 1, 1) + for (const part of parts) { + for (const line of part.split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed.startsWith('data:')) continue + const payload = trimmed.slice(5).trim() + if (!payload || payload === '[DONE]') continue + try { + const event = JSON.parse(payload) + const delta = extractDelta(event, protocol) + if (typeof delta === 'string' && delta) { + onDelta(delta) + } + } catch { + // Ignore malformed SSE payloads and continue reading later events. + } + } + } + return pending[0] ?? '' +} + +function extractDelta(event: Record, protocol: ChatProtocol): string | undefined { + if (protocol === 'responses') { + return event?.delta + } + if (protocol === 'messages') { + return event?.delta?.text + } + if (protocol === 'gemini') { + return event?.candidates?.[0]?.content?.parts + ?.map((part: { text?: string }) => part.text || '') + .join('') + } + return event?.choices?.[0]?.delta?.content +} + +async function readErrorMessage(response: Response): Promise { + try { + const data = await response.json() + const message = data?.error?.message || data?.message + if (typeof message === 'string' && message.trim()) return message + } catch { + // Fall through to status text. + } + return response.statusText || `Request failed with status ${response.status}` +} diff --git a/frontend/src/api/chatSessions.ts b/frontend/src/api/chatSessions.ts new file mode 100644 index 00000000000..30468fd50ff --- /dev/null +++ b/frontend/src/api/chatSessions.ts @@ -0,0 +1,96 @@ +import { apiClient } from './client' + +export type ChatSessionStatus = 'active' | 'archived' +export type ChatMessageStatus = 'pending' | 'streaming' | 'completed' | 'stopped' | 'failed' +export type ChatMessageRole = 'system' | 'developer' | 'user' | 'assistant' + +export interface ChatSessionRecord { + id: number + api_key_id: number + title: string + model: string + status: ChatSessionStatus | string + expires_at: string + created_at: string + updated_at: string + deleted_at?: string | null +} + +export interface ChatMessageRecord { + id: number + session_id: number + role: ChatMessageRole + content: string + status: ChatMessageStatus | string + model?: string | null + duration_ms?: number | null + usage_log_id?: number | null + actual_cost?: number | null + error_message?: string | null + created_at: string + updated_at: string +} + +export interface CreateChatSessionPayload { + api_key_id: number + title?: string + model: string +} + +export interface UpdateChatSessionPayload { + title?: string + status?: ChatSessionStatus | string + model?: string +} + +export interface CreateChatMessagePayload { + role: ChatMessageRole + content: string + status?: ChatMessageStatus | string + model?: string + duration_ms?: number + usage_log_id?: number + actual_cost?: number + error_message?: string +} + +export type UpdateChatMessagePayload = Partial> + +export async function listChatSessions(): Promise { + const { data } = await apiClient.get('/chat/sessions') + return data +} + +export async function createChatSession(payload: CreateChatSessionPayload): Promise { + const { data } = await apiClient.post('/chat/sessions', payload) + return data +} + +export async function updateChatSession(sessionId: number, payload: UpdateChatSessionPayload): Promise { + const { data } = await apiClient.patch(`/chat/sessions/${sessionId}`, payload) + return data +} + +export async function deleteChatSession(sessionId: number): Promise<{ deleted: boolean }> { + const { data } = await apiClient.delete(`/chat/sessions/${sessionId}`) + return data +} + +export async function getChatSessionMessages(sessionId: number): Promise { + const { data } = await apiClient.get(`/chat/sessions/${sessionId}/messages`) + return data +} + +export async function createChatMessage(sessionId: number, payload: CreateChatMessagePayload): Promise { + const { data } = await apiClient.post(`/chat/sessions/${sessionId}/messages`, payload) + return data +} + +export async function updateChatMessage( + sessionId: number, + messageId: number, + payload: UpdateChatMessagePayload, +): Promise { + const { data } = await apiClient.patch(`/chat/sessions/${sessionId}/messages/${messageId}`, payload) + return data +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 54ea4520097..c8cf952aa47 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -55,9 +55,15 @@ const getUserTimezone = (): string => { apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { + if (config.data instanceof FormData && config.headers) { + config.headers.delete?.('Content-Type') + delete (config.headers as Record)['Content-Type'] + delete (config.headers as Record)['content-type'] + } + // Attach token from localStorage const token = localStorage.getItem('auth_token') - if (token && config.headers) { + if (token && config.headers && !config.headers.Authorization) { config.headers.Authorization = `Bearer ${token}` } @@ -278,6 +284,14 @@ apiClient.interceptors.response.use( }) } + if (error.code === 'ECONNABORTED') { + return Promise.reject({ + status: 0, + code: 'REQUEST_TIMEOUT', + message: 'Request timed out. Please try again.' + }) + } + // Network error return Promise.reject({ status: 0, diff --git a/frontend/src/api/images.ts b/frontend/src/api/images.ts new file mode 100644 index 00000000000..8f0232a3105 --- /dev/null +++ b/frontend/src/api/images.ts @@ -0,0 +1,110 @@ +import { apiClient } from './client' + +export interface ImageGenerationRequest { + model: string + prompt: string + size: string + quality: string + n?: number + response_format?: 'url' | 'b64_json' +} + +export interface ImageEditRequest extends ImageGenerationRequest { + images: File[] +} + +export interface GeneratedImage { + url?: string + b64_json?: string + revised_prompt?: string +} + +export interface ImageGenerationResponse { + created?: number + data: GeneratedImage[] +} + +export type ImageTaskStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'expired' + +export interface ImageTaskResponse { + task_id: string + status: ImageTaskStatus + expires_at: string + download_url?: string + mime_type?: string + byte_size?: number + error_message?: string +} + +const IMAGE_REQUEST_TIMEOUT_MS = 0 + +function authHeaders(apiKey: string) { + return apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined +} + +export async function generateImage(payload: ImageGenerationRequest, apiKey = ''): Promise { + const { data } = await apiClient.post('/images/generations', payload, { + headers: authHeaders(apiKey), + timeout: IMAGE_REQUEST_TIMEOUT_MS, + }) + return data +} + +export async function createImageGenerationTask(payload: ImageGenerationRequest, apiKey = ''): Promise { + const { data } = await apiClient.post('/images/async/generations', payload, { + headers: authHeaders(apiKey), + }) + return data +} + +export async function editImage(payload: ImageEditRequest, apiKey = ''): Promise { + const form = new FormData() + form.append('model', payload.model) + form.append('prompt', payload.prompt) + form.append('size', payload.size) + form.append('quality', payload.quality) + form.append('response_format', payload.response_format || 'b64_json') + form.append('n', String(payload.n ?? 1)) + for (const image of payload.images) { + form.append('image', image) + } + + const { data } = await apiClient.post('/images/edits', form, { + headers: authHeaders(apiKey), + timeout: IMAGE_REQUEST_TIMEOUT_MS, + }) + return data +} + +export async function createImageEditTask(payload: ImageEditRequest, apiKey = ''): Promise { + const form = new FormData() + form.append('model', payload.model) + form.append('prompt', payload.prompt) + form.append('size', payload.size) + form.append('quality', payload.quality) + form.append('response_format', payload.response_format || 'b64_json') + form.append('n', String(payload.n ?? 1)) + for (const image of payload.images) { + form.append('image', image) + } + + const { data } = await apiClient.post('/images/async/edits', form, { + headers: authHeaders(apiKey), + }) + return data +} + +export async function getImageTask(taskId: string, apiKey = ''): Promise { + const { data } = await apiClient.get(`/images/async/tasks/${encodeURIComponent(taskId)}`, { + headers: authHeaders(apiKey), + }) + return data +} + +export async function downloadImageTask(taskId: string, apiKey = ''): Promise { + const { data } = await apiClient.get(`/images/async/tasks/${encodeURIComponent(taskId)}/download`, { + headers: authHeaders(apiKey), + responseType: 'blob', + }) + return data +} diff --git a/frontend/src/components/__tests__/ApiKeyCreate.spec.ts b/frontend/src/components/__tests__/ApiKeyCreate.spec.ts index 537f43e770c..e2f545106c9 100644 --- a/frontend/src/components/__tests__/ApiKeyCreate.spec.ts +++ b/frontend/src/components/__tests__/ApiKeyCreate.spec.ts @@ -24,10 +24,6 @@ vi.mock('@/api', () => ({ isTotp2FARequired: () => false, })) -vi.mock('@/api/admin/system', () => ({ - checkUpdates: vi.fn(), -})) - vi.mock('@/api/auth', () => ({ getPublicSettings: vi.fn().mockResolvedValue({}), })) diff --git a/frontend/src/components/__tests__/Dashboard.spec.ts b/frontend/src/components/__tests__/Dashboard.spec.ts index 72bc4d283af..e3af438ccd0 100644 --- a/frontend/src/components/__tests__/Dashboard.spec.ts +++ b/frontend/src/components/__tests__/Dashboard.spec.ts @@ -27,10 +27,6 @@ vi.mock('@/api/usage', () => ({ }, })) -vi.mock('@/api/admin/system', () => ({ - checkUpdates: vi.fn(), -})) - vi.mock('@/api/auth', () => ({ getPublicSettings: vi.fn().mockResolvedValue({}), })) diff --git a/frontend/src/components/__tests__/LoginForm.spec.ts b/frontend/src/components/__tests__/LoginForm.spec.ts index 14b86fc2267..881fbee80d9 100644 --- a/frontend/src/components/__tests__/LoginForm.spec.ts +++ b/frontend/src/components/__tests__/LoginForm.spec.ts @@ -25,10 +25,6 @@ vi.mock('@/api', () => ({ isTotp2FARequired: (response: any) => response?.requires_2fa === true, })) -vi.mock('@/api/admin/system', () => ({ - checkUpdates: vi.fn(), -})) - vi.mock('@/api/auth', () => ({ getPublicSettings: vi.fn().mockResolvedValue({}), })) diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index d38c31c5c91..9ef6c9d2451 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -2765,6 +2765,7 @@ :show-mobile-refresh-token-option="form.platform === 'openai'" :show-session-token-option="false" :show-access-token-option="false" + :show-codex-session-import-option="form.platform === 'openai'" :platform="form.platform" :show-project-id="geminiOAuthType === 'code_assist'" @generate-url="handleGenerateUrl" @@ -2772,6 +2773,7 @@ @validate-refresh-token="handleValidateRefreshToken" @validate-mobile-refresh-token="handleOpenAIValidateMobileRT" @validate-session-token="handleValidateSessionToken" + @import-codex-session="handleOpenAIImportCodexSession" /> @@ -3119,6 +3121,7 @@ import type { AccountType, CheckMixedChannelResponse, CreateAccountRequest, + CodexSessionImportMessage, OpenAICompactMode } from '@/types' import BaseDialog from '@/components/common/BaseDialog.vue' @@ -3152,6 +3155,7 @@ interface OAuthFlowExposed { sessionKey: string refreshToken: string sessionToken: string + codexSession: string inputMethod: AuthInputMethod reset: () => void } @@ -4631,6 +4635,113 @@ const handleOpenAIExchange = async (authCode: string) => { // OpenAI Mobile RT client_id const OPENAI_MOBILE_RT_CLIENT_ID = 'app_LlGpXReQgckcGGUo2JrYvtJK' +const buildOpenAICodexImportCredentialExtras = (): Record | null => { + const credentials: Record = {} + if (!isOpenAIModelRestrictionDisabled.value) { + const modelMapping = buildModelMappingObject(modelRestrictionMode.value, allowedModels.value, modelMappings.value) + if (modelMapping) { + credentials.model_mapping = modelMapping + } + } + + const compactModelMapping = buildOpenAICompactModelMapping() + if (compactModelMapping) { + credentials.compact_model_mapping = compactModelMapping + } + + if (!applyTempUnschedConfig(credentials)) { + return null + } + return credentials +} + +const formatCodexImportMessages = (messages?: CodexSessionImportMessage[]) => { + return (messages || []) + .map((item) => { + const name = item.name ? ` ${item.name}` : '' + return `#${item.index}${name}: ${item.message}` + }) + .join('\n') +} + +const handleOpenAIImportCodexSession = async (content: string) => { + const oauthClient = openaiOAuth + const trimmed = content.trim() + if (!trimmed) { + oauthClient.error.value = t('admin.accounts.oauth.openai.codexSessionEmpty') + return + } + + const credentialExtras = buildOpenAICodexImportCredentialExtras() + if (credentialExtras === null) { + return + } + + oauthClient.loading.value = true + oauthClient.error.value = '' + + try { + const extra = buildOpenAIExtra() + const result = await adminAPI.accounts.importCodexSession({ + content: trimmed, + name: form.name, + notes: form.notes || null, + proxy_id: form.proxy_id, + concurrency: form.concurrency, + load_factor: form.load_factor ?? undefined, + priority: form.priority, + rate_multiplier: form.rate_multiplier, + group_ids: form.group_ids, + expires_at: form.expires_at, + auto_pause_on_expired: autoPauseOnExpired.value, + credential_extras: Object.keys(credentialExtras).length > 0 ? credentialExtras : undefined, + extra, + update_existing: true + }) + + const successCount = result.created + result.updated + const params = { + created: result.created, + updated: result.updated, + skipped: result.skipped, + failed: result.failed + } + + if (successCount > 0 && result.failed === 0) { + appStore.showSuccess(t('admin.accounts.oauth.openai.codexSessionImportSuccess', params)) + emit('created') + handleClose() + return + } + + const errorText = formatCodexImportMessages(result.errors) + const warningText = formatCodexImportMessages(result.warnings) + oauthClient.error.value = [errorText, warningText].filter(Boolean).join('\n') + + if (result.failed === 0) { + appStore.showWarning(t('admin.accounts.oauth.openai.codexSessionImportSuccess', params)) + return + } + + if (successCount > 0) { + appStore.showWarning(t('admin.accounts.oauth.openai.codexSessionImportPartial', params)) + emit('created') + return + } + + appStore.showError(t('admin.accounts.oauth.openai.codexSessionImportFailed')) + } catch (error: any) { + oauthClient.error.value = + error.response?.data?.detail || + error.response?.data?.message || + error.message || + t('admin.accounts.oauth.openai.codexSessionImportFailed') + appStore.showError(oauthClient.error.value) + } finally { + oauthClient.loading.value = false + } +} + // OpenAI RT 批量验证和创建(共享逻辑) const handleOpenAIBatchRT = async (refreshTokenInput: string, clientId?: string) => { const oauthClient = openaiOAuth diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 56874474684..80f0b8908d0 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1317,6 +1317,66 @@ + +
+
+
+
+ +
+
+
+ + + {{ codexImageGenerationBridgeBadgeLabel }} + +
+

+ {{ t('admin.accounts.openai.codexImageGenerationBridgeDesc') }} +

+
+
+
+
+ +
+
+
+
+
('auto') const openaiOAuthResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const openaiAPIKeyResponsesWebSocketV2Mode = ref(OPENAI_WS_MODE_OFF) const codexCLIOnlyEnabled = ref(false) +type CodexImageGenerationBridgeMode = 'inherit' | 'enabled' | 'disabled' +const codexImageGenerationBridgeMode = ref('inherit') const anthropicPassthroughEnabled = ref(false) const webSearchEmulationMode = ref('default') const webSearchGlobalEnabled = ref(false) @@ -2325,6 +2387,47 @@ const openaiResponsesWebSocketV2Mode = computed({ const openAIWSModeConcurrencyHintKey = computed(() => resolveOpenAIWSModeConcurrencyHintKey(openaiResponsesWebSocketV2Mode.value) ) +const codexImageGenerationBridgeOptions = computed>(() => [ + { + value: 'inherit', + label: t('admin.accounts.openai.codexImageGenerationBridgeInherit'), + description: t('admin.accounts.openai.codexImageGenerationBridgeInheritDesc') + }, + { + value: 'enabled', + label: t('admin.accounts.openai.codexImageGenerationBridgeEnabled'), + description: t('admin.accounts.openai.codexImageGenerationBridgeEnabledDesc') + }, + { + value: 'disabled', + label: t('admin.accounts.openai.codexImageGenerationBridgeDisabled'), + description: t('admin.accounts.openai.codexImageGenerationBridgeDisabledDesc') + } +]) +const codexImageGenerationBridgeBadgeLabel = computed(() => { + switch (codexImageGenerationBridgeMode.value) { + case 'enabled': + return t('admin.accounts.openai.codexImageGenerationBridgeBadgeEnabled') + case 'disabled': + return t('admin.accounts.openai.codexImageGenerationBridgeBadgeDisabled') + default: + return t('admin.accounts.openai.codexImageGenerationBridgeBadgeInherit') + } +}) +const codexImageGenerationBridgeBadgeClass = computed(() => { + switch (codexImageGenerationBridgeMode.value) { + case 'enabled': + return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' + case 'disabled': + return 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300' + default: + return 'bg-slate-100 text-slate-600 dark:bg-dark-600 dark:text-slate-300' + } +}) const openAICompactModeOptions = computed(() => [ { value: 'auto', label: t('admin.accounts.openai.compactModeAuto') }, { value: 'force_on', label: t('admin.accounts.openai.compactModeForceOn') }, @@ -2344,7 +2447,7 @@ const openAICompactStatusKey = computed(() => { ? 'admin.accounts.openai.compactSupported' : 'admin.accounts.openai.compactUnsupported' } - return 'admin.accounts.openai.compactUnknown' + return 'admin.accounts.openai.compactAuto' }) // Computed: current preset mappings based on platform @@ -2483,11 +2586,20 @@ const syncFormFromAccount = (newAccount: Account | null) => { openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF codexCLIOnlyEnabled.value = false + codexImageGenerationBridgeMode.value = 'inherit' anthropicPassthroughEnabled.value = false webSearchEmulationMode.value = 'default' if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'apikey')) { openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true openAICompactMode.value = (extra?.openai_compact_mode as OpenAICompactMode) || 'auto' + const codexImageGenerationBridgeValue = typeof extra?.codex_image_generation_bridge === 'boolean' + ? extra.codex_image_generation_bridge + : extra?.codex_image_generation_bridge_enabled + if (codexImageGenerationBridgeValue === true) { + codexImageGenerationBridgeMode.value = 'enabled' + } else if (codexImageGenerationBridgeValue === false) { + codexImageGenerationBridgeMode.value = 'disabled' + } openaiOAuthResponsesWebSocketV2Mode.value = resolveOpenAIWSModeFromExtra(extra, { modeKey: 'openai_oauth_responses_websockets_v2_mode', enabledKey: 'openai_oauth_responses_websockets_v2_enabled', @@ -3610,6 +3722,13 @@ const handleSubmit = async () => { newExtra.openai_compact_mode = openAICompactMode.value } + delete newExtra.codex_image_generation_bridge_enabled + if (codexImageGenerationBridgeMode.value === 'inherit') { + delete newExtra.codex_image_generation_bridge + } else { + newExtra.codex_image_generation_bridge = codexImageGenerationBridgeMode.value === 'enabled' + } + if (props.account.type === 'oauth') { if (codexCLIOnlyEnabled.value) { newExtra.codex_cli_only = true diff --git a/frontend/src/components/account/OAuthAuthorizationFlow.vue b/frontend/src/components/account/OAuthAuthorizationFlow.vue index 08c674946a4..9526e8785aa 100644 --- a/frontend/src/components/account/OAuthAuthorizationFlow.vue +++ b/frontend/src/components/account/OAuthAuthorizationFlow.vue @@ -81,6 +81,17 @@ t('admin.accounts.oauth.openai.accessTokenAuth', '手动输入 AT') }} +
@@ -168,6 +179,85 @@ + +
+
+

+ {{ t('admin.accounts.oauth.openai.codexSessionDesc') }} +

+ +
+ + +

+ {{ t('admin.accounts.oauth.openai.codexSessionHint') }} +

+
+ +
+

+ {{ error }} +

+
+ + +
+
+
(), { showMobileRefreshTokenOption: false, showSessionTokenOption: false, showAccessTokenOption: false, + showCodexSessionImportOption: false, platform: 'anthropic', showProjectId: true }) @@ -591,6 +683,7 @@ const emit = defineEmits<{ 'validate-mobile-refresh-token': [refreshToken: string] 'validate-session-token': [sessionToken: string] 'import-access-token': [accessToken: string] + 'import-codex-session': [content: string] 'update:inputMethod': [method: AuthInputMethod] }>() @@ -630,12 +723,13 @@ const authCodeInput = ref('') const sessionKeyInput = ref('') const refreshTokenInput = ref('') const sessionTokenInput = ref('') +const codexSessionInput = ref('') const showHelpDialog = ref(false) const oauthState = ref('') const projectId = ref('') // Computed: show method selection when either cookie or refresh token option is enabled -const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption) +const showMethodSelection = computed(() => props.showCookieOption || props.showRefreshTokenOption || props.showMobileRefreshTokenOption || props.showSessionTokenOption || props.showAccessTokenOption || props.showCodexSessionImportOption) // Clipboard const { copied, copyToClipboard } = useClipboard() @@ -656,6 +750,16 @@ const parsedRefreshTokenCount = computed(() => { .filter((rt) => rt).length }) +const parsedCodexSessionCount = computed(() => { + const trimmed = codexSessionInput.value.trim() + if (!trimmed) return 0 + if (trimmed.startsWith('{') || trimmed.startsWith('[')) return 1 + return trimmed + .split('\n') + .map((item) => item.trim()) + .filter((item) => item).length +}) + // Watchers watch(inputMethod, (newVal) => { emit('update:inputMethod', newVal) @@ -727,6 +831,12 @@ const handleValidateRefreshToken = () => { } } +const handleImportCodexSession = () => { + if (codexSessionInput.value.trim()) { + emit('import-codex-session', codexSessionInput.value.trim()) + } +} + // Expose methods and state defineExpose({ authCode: authCodeInput, @@ -735,6 +845,7 @@ defineExpose({ sessionKey: sessionKeyInput, refreshToken: refreshTokenInput, sessionToken: sessionTokenInput, + codexSession: codexSessionInput, inputMethod, reset: () => { authCodeInput.value = '' @@ -743,6 +854,7 @@ defineExpose({ sessionKeyInput.value = '' refreshTokenInput.value = '' sessionTokenInput.value = '' + codexSessionInput.value = '' inputMethod.value = 'manual' showHelpDialog.value = false } diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index c4e2a9bcef2..04486154d76 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -216,4 +216,25 @@ describe('EditAccountModal', () => { 'gpt-5.4': 'gpt-5.4-openai-compact' }) }) + + it('submits account-level Codex image generation bridge override', async () => { + const account = buildAccount() + account.extra = { + codex_image_generation_bridge: false, + codex_image_generation_bridge_enabled: true + } + updateAccountMock.mockReset() + checkMixedChannelRiskMock.mockReset() + checkMixedChannelRiskMock.mockResolvedValue({ has_risk: false }) + updateAccountMock.mockResolvedValue(account) + + const wrapper = mountModal(account) + + await wrapper.get('button[data-testid="codex-image-bridge-enabled"]').trigger('click') + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra?.codex_image_generation_bridge).toBe(true) + expect(updateAccountMock.mock.calls[0]?.[1]?.extra).not.toHaveProperty('codex_image_generation_bridge_enabled') + }) }) diff --git a/frontend/src/components/admin/account/AccountTableActions.vue b/frontend/src/components/admin/account/AccountTableActions.vue index ee521f83767..6874625b369 100644 --- a/frontend/src/components/admin/account/AccountTableActions.vue +++ b/frontend/src/components/admin/account/AccountTableActions.vue @@ -5,7 +5,6 @@ - @@ -17,7 +16,7 @@ import { useI18n } from 'vue-i18n' import Icon from '@/components/icons/Icon.vue' defineProps(['loading']) -defineEmits(['refresh', 'sync', 'create']) +defineEmits(['refresh', 'create']) const { t } = useI18n() diff --git a/frontend/src/components/admin/channel/__tests__/types.spec.ts b/frontend/src/components/admin/channel/__tests__/types.spec.ts new file mode 100644 index 00000000000..d4ac91164d9 --- /dev/null +++ b/frontend/src/components/admin/channel/__tests__/types.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { validateIntervals } from '../types' +import type { IntervalFormEntry } from '../types' + +function imageTier(label: string, price: number): IntervalFormEntry { + return { + min_tokens: 0, + max_tokens: null, + tier_label: label, + input_price: null, + output_price: null, + cache_write_price: null, + cache_read_price: null, + per_request_price: price, + sort_order: 0, + } +} + +describe('channel pricing interval validation', () => { + it('allows image tiers to share open-ended token ranges', () => { + expect(validateIntervals([ + imageTier('1K', 0.04), + imageTier('2K', 0.08), + imageTier('4K', 0.16), + ], 'image')).toBeNull() + }) + + it('keeps open-ended overlap validation for token intervals', () => { + expect(validateIntervals([ + imageTier('A', 0.04), + imageTier('B', 0.08), + ], 'token')).toContain('无上限区间') + }) + + it('rejects duplicate image tier labels', () => { + expect(validateIntervals([ + imageTier('1K', 0.04), + imageTier('1k', 0.08), + ], 'image')).toContain('分辨率 1K 重复') + }) +}) diff --git a/frontend/src/components/admin/channel/types.ts b/frontend/src/components/admin/channel/types.ts index 955b6487847..f03da004aef 100644 --- a/frontend/src/components/admin/channel/types.ts +++ b/frontend/src/components/admin/channel/types.ts @@ -116,9 +116,13 @@ export function findModelConflict(models: string[]): [string, string] | null { // ── 区间校验 ────────────────────────────────────────────── /** 校验区间列表的合法性,返回错误消息;通过则返回 null */ -export function validateIntervals(intervals: IntervalFormEntry[]): string | null { +export function validateIntervals(intervals: IntervalFormEntry[], mode: BillingMode = 'token'): string | null { if (!intervals || intervals.length === 0) return null + if (mode === 'image') { + return validateImageTiers(intervals) + } + // 按 min_tokens 排序(不修改原数组) const sorted = [...intervals].sort((a, b) => a.min_tokens - b.min_tokens) @@ -129,6 +133,24 @@ export function validateIntervals(intervals: IntervalFormEntry[]): string | null return checkIntervalOverlap(sorted) } +function validateImageTiers(intervals: IntervalFormEntry[]): string | null { + const seen = new Set() + for (let i = 0; i < intervals.length; i++) { + const iv = intervals[i] + const label = iv.tier_label.trim().toUpperCase() + if (!label) { + return `层级 #${i + 1}: 分辨率不能为空` + } + if (seen.has(label)) { + return `层级 #${i + 1}: 分辨率 ${label} 重复` + } + seen.add(label) + const err = validateIntervalPrices(iv, i) + if (err) return err + } + return null +} + function validateSingleInterval(iv: IntervalFormEntry, idx: number): string | null { if (iv.min_tokens < 0) { return `区间 #${idx + 1}: 最小 token 数 (${iv.min_tokens}) 不能为负数` diff --git a/frontend/src/components/admin/usage/UsageTable.vue b/frontend/src/components/admin/usage/UsageTable.vue index adcb3cc627c..1f7da45a918 100644 --- a/frontend/src/components/admin/usage/UsageTable.vue +++ b/frontend/src/components/admin/usage/UsageTable.vue @@ -91,8 +91,7 @@ -