From d6029f17d37df69e514b8ca2fe8e8270e9b79113 Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:48:39 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .dockerignore | 79 + .env.example | 211 + .eslintrc.cjs | 85 + .gitattributes | 1 + .github/AUTO_RELEASE_GUIDE.md | 164 + .github/DOCKER_HUB_SETUP.md | 109 + .github/FUNDING.yml | 12 + .github/RELEASE_PROCESS.md | 94 + .github/TELEGRAM_SETUP.md | 110 + .github/WORKFLOW_USAGE.md | 129 + .github/cliff.toml | 68 + .github/secret_scanning.yml | 6 + .github/workflows/auto-release-pipeline.yml | 509 + .github/workflows/pr-lint-check.yml | 320 + .github/workflows/sync-model-pricing.yml | 62 + .gitignore | 251 + .prettierrc | 14 + CLAUDE.md | 188 + Dockerfile | 84 + LICENSE | 21 + Makefile | 263 + README.md | 1010 ++ README.wehub.md | 7 + README_EN.md | 637 ++ SECURITY.md | 21 + VERSION | 1 + cli/index.js | 1025 ++ config/config.example.js | 252 + config/models.js | 93 + config/pricingSource.js | 17 + docker-compose.yml | 172 + docker-entrypoint.sh | 65 + docs/claude-code-gemini3-guide/README.md | 240 + .../model-mapping.png | Bin 0 -> 92696 bytes nodemon.json | 5 + package-lock.json | 9260 +++++++++++++++++ package.json | 104 + pnpm-lock.yaml | 6428 ++++++++++++ resources/model-pricing/README.md | 37 + .../model_prices_and_context_window.json | 5318 ++++++++++ scripts/analyze-log-sessions.js | 606 ++ scripts/check-redis-keys.js | 53 + scripts/data-transfer-enhanced.js | 1304 +++ scripts/data-transfer.js | 738 ++ scripts/debug-redis-keys.js | 126 + scripts/fix-inquirer.js | 29 + scripts/fix-usage-stats.js | 227 + scripts/generate-test-data.js | 280 + scripts/manage-session-windows.js | 561 + scripts/manage.js | 333 + scripts/manage.sh | 1830 ++++ scripts/migrate-apikey-expiry.js | 193 + scripts/migrate-usage-index.js | 138 + scripts/monitor-enhanced.sh | 273 + .../reset-request-detail-retention-hours.js | 127 + scripts/setup.js | 128 + scripts/status-unified.sh | 262 + scripts/test-account-display.js | 143 + scripts/test-api-response.js | 141 + scripts/test-bedrock-models.js | 33 + scripts/test-billing-events.js | 340 + scripts/test-dedicated-accounts.js | 133 + scripts/test-gemini-refresh.js | 145 + scripts/test-group-scheduling.js | 549 + scripts/test-model-mapping.js | 47 + scripts/test-official-models.js | 108 + scripts/test-pricing-fallback.js | 91 + scripts/test-web-dist.sh | 227 + scripts/test-window-remaining.js | 78 + scripts/update-model-pricing.js | 278 + src/app.js | 954 ++ src/cli/initCosts.js | 35 + src/handlers/geminiHandlers.js | 3053 ++++++ src/middleware/auth.js | 2109 ++++ src/middleware/browserFallback.js | 78 + src/models/redis.js | 5310 ++++++++++ src/routes/admin/accountBalance.js | 214 + src/routes/admin/accountGroups.js | 153 + src/routes/admin/apiKeys.js | 2781 +++++ src/routes/admin/azureOpenaiAccounts.js | 513 + src/routes/admin/balanceScripts.js | 41 + src/routes/admin/bedrockAccounts.js | 379 + src/routes/admin/ccrAccounts.js | 502 + src/routes/admin/claudeAccounts.js | 1188 +++ src/routes/admin/claudeConsoleAccounts.js | 503 + src/routes/admin/claudeRelayConfig.js | 316 + src/routes/admin/concurrency.js | 313 + src/routes/admin/dashboard.js | 599 ++ src/routes/admin/droidAccounts.js | 706 ++ src/routes/admin/errorHistory.js | 44 + src/routes/admin/geminiAccounts.js | 595 ++ src/routes/admin/geminiApiAccounts.js | 615 ++ src/routes/admin/index.js | 62 + src/routes/admin/openaiAccounts.js | 829 ++ src/routes/admin/openaiResponsesAccounts.js | 551 + src/routes/admin/quotaCards.js | 242 + src/routes/admin/requestDetails.js | 94 + src/routes/admin/serviceRates.js | 72 + src/routes/admin/sync.js | 464 + src/routes/admin/system.js | 454 + src/routes/admin/usageStats.js | 3338 ++++++ src/routes/admin/utils.js | 78 + src/routes/api.js | 1938 ++++ src/routes/apiStats.js | 1677 +++ src/routes/azureOpenaiRoutes.js | 542 + src/routes/droidRoutes.js | 196 + src/routes/geminiRoutes.js | 115 + src/routes/openaiClaudeRoutes.js | 606 ++ src/routes/openaiGeminiRoutes.js | 868 ++ src/routes/openaiRoutes.js | 1040 ++ src/routes/standardGeminiRoutes.js | 264 + src/routes/unified.js | 702 ++ src/routes/userRoutes.js | 926 ++ src/routes/web.js | 381 + src/routes/webhook.js | 439 + src/services/account/accountBalanceService.js | 799 ++ .../account/azureOpenaiAccountService.js | 628 ++ src/services/account/bedrockAccountService.js | 849 ++ src/services/account/ccrAccountService.js | 938 ++ src/services/account/claudeAccountService.js | 3508 +++++++ .../account/claudeConsoleAccountService.js | 1632 +++ src/services/account/droidAccountService.js | 1563 +++ src/services/account/geminiAccountService.js | 1921 ++++ .../account/geminiApiAccountService.js | 637 ++ src/services/account/openaiAccountService.js | 1254 +++ .../account/openaiResponsesAccountService.js | 684 ++ src/services/accountGroupService.js | 644 ++ src/services/accountNameCacheService.js | 286 + src/services/accountTestSchedulerService.js | 420 + src/services/anthropicGeminiBridgeService.js | 3112 ++++++ src/services/antigravityClient.js | 595 ++ src/services/apiKeyIndexService.js | 654 ++ src/services/apiKeyService.js | 2864 +++++ .../balanceProviders/baseBalanceProvider.js | 133 + .../balanceProviders/claudeBalanceProvider.js | 30 + .../claudeConsoleBalanceProvider.js | 14 + .../balanceProviders/geminiBalanceProvider.js | 250 + .../genericBalanceProvider.js | 23 + src/services/balanceProviders/index.js | 25 + .../openaiResponsesBalanceProvider.js | 54 + src/services/balanceScriptService.js | 210 + src/services/billingEventPublisher.js | 224 + src/services/claudeCodeHeadersService.js | 241 + src/services/claudeRelayConfigService.js | 453 + src/services/codexToOpenAI.js | 717 ++ src/services/costInitService.js | 358 + src/services/costRankService.js | 606 ++ src/services/geminiToOpenAI.js | 392 + src/services/ldapService.js | 753 ++ src/services/modelService.js | 156 + src/services/openaiToClaude.js | 488 + src/services/pricingService.js | 900 ++ src/services/quotaCardService.js | 698 ++ src/services/rateLimitCleanupService.js | 560 + src/services/relay/antigravityRelayService.js | 180 + src/services/relay/azureOpenaiRelayService.js | 774 ++ src/services/relay/bedrockRelayService.js | 850 ++ src/services/relay/ccrRelayService.js | 969 ++ .../relay/claudeConsoleRelayService.js | 1534 +++ src/services/relay/claudeRelayService.js | 3635 +++++++ src/services/relay/droidRelayService.js | 1574 +++ src/services/relay/geminiRelayService.js | 579 ++ .../relay/openaiResponsesRelayService.js | 939 ++ src/services/requestBodyRuleService.js | 192 + src/services/requestDetailService.js | 1628 +++ src/services/requestIdentityService.js | 397 + src/services/scheduler/droidScheduler.js | 197 + .../scheduler/unifiedClaudeScheduler.js | 1924 ++++ .../scheduler/unifiedGeminiScheduler.js | 867 ++ .../scheduler/unifiedOpenAIScheduler.js | 1015 ++ src/services/serviceRatesService.js | 259 + src/services/tokenRefreshService.js | 143 + src/services/userMessageQueueService.js | 373 + src/services/userService.js | 603 ++ src/services/webhookConfigService.js | 467 + src/services/webhookService.js | 892 ++ src/services/weeklyClaudeCostInitService.js | 456 + src/utils/anthropicRequestDump.js | 126 + src/utils/anthropicResponseDump.js | 125 + src/utils/antigravityModel.js | 158 + src/utils/antigravityUpstreamDump.js | 121 + src/utils/antigravityUpstreamResponseDump.js | 175 + src/utils/cacheMonitor.js | 294 + src/utils/commonHelper.js | 408 + src/utils/contents.js | 534 + src/utils/costCalculator.js | 447 + src/utils/dateHelper.js | 100 + src/utils/errorSanitizer.js | 265 + src/utils/featureFlags.js | 46 + src/utils/geminiSchemaCleaner.js | 265 + src/utils/headerFilter.js | 122 + src/utils/inputValidator.js | 291 + src/utils/logger.js | 450 + src/utils/lruCache.js | 134 + src/utils/metadataUserIdHelper.js | 100 + src/utils/modelHelper.js | 270 + src/utils/oauthHelper.js | 897 ++ src/utils/performanceOptimizer.js | 168 + src/utils/projectPaths.js | 10 + src/utils/proxyHelper.js | 272 + src/utils/rateLimitHelper.js | 115 + src/utils/requestDetailHelper.js | 717 ++ src/utils/runtimeAddon.js | 121 + src/utils/safeRotatingAppend.js | 88 + src/utils/sessionHelper.js | 165 + src/utils/signatureCache.js | 183 + src/utils/sseParser.js | 118 + src/utils/statsHelper.js | 105 + src/utils/streamHelper.js | 36 + src/utils/tempUnavailablePolicy.js | 56 + src/utils/testPayloadHelper.js | 364 + src/utils/tokenMask.js | 108 + src/utils/tokenRefreshLogger.js | 175 + src/utils/unstableUpstreamHelper.js | 81 + src/utils/upstreamErrorHelper.js | 528 + src/utils/warmupInterceptor.js | 202 + src/utils/webhookNotifier.js | 115 + src/utils/workosOAuthHelper.js | 184 + src/validators/clientDefinitions.js | 123 + src/validators/clientValidator.js | 166 + src/validators/clients/claudeCodeValidator.js | 216 + src/validators/clients/codexCliValidator.js | 153 + src/validators/clients/droidCliValidator.js | 57 + src/validators/clients/geminiCliValidator.js | 111 + tests/accountBalanceService.test.js | 218 + tests/adminApiKeysPayloadRulesRoute.test.js | 171 + ...apiKeyServiceOpenAIResponsesConfig.test.js | 240 + tests/claudeAccountFableRateLimit.test.js | 123 + tests/claudeAccountModelRateLimit.test.js | 123 + tests/claudeConsoleAccounts.test.js | 73 + tests/claudeConsoleRelayService.test.js | 93 + tests/concurrencyQueue.integration.test.js | 860 ++ tests/concurrencyQueue.test.js | 278 + tests/costCalculator.test.js | 158 + .../metadataUserIdHelper.integration.test.js | 381 + tests/metadataUserIdHelper.test.js | 193 + tests/modelRateLimitFamily.test.js | 28 + tests/modelsConfig.test.js | 10 + tests/openaiResponsesPayloadToggles.test.js | 550 + tests/pricingService.test.js | 355 + tests/redisApiKeyParse.test.js | 45 + tests/requestBodyRuleService.test.js | 88 + tests/requestDetailHelper.test.js | 320 + tests/requestDetailService.test.js | 1854 ++++ tests/requestDetailsRoute.test.js | 141 + tests/unifiedClaudeSchedulerDedicated.test.js | 153 + tests/unifiedOpenAIScheduler.test.js | 75 + tests/upstreamErrorTtlCap.test.js | 59 + tests/userMessageQueue.test.js | 434 + web/admin-spa/.env.example | 37 + web/admin-spa/.env.production | 4 + web/admin-spa/.eslintrc.cjs | 44 + web/admin-spa/.gitignore | 35 + web/admin-spa/.prettierrc | 17 + web/admin-spa/README.md | 147 + web/admin-spa/components/.gitkeep | 1 + web/admin-spa/index.html | 15 + web/admin-spa/package-lock.json | 5485 ++++++++++ web/admin-spa/package.json | 44 + web/admin-spa/postcss.config.js | 6 + web/admin-spa/src/App.vue | 39 + .../src/assets/fonts/inter/Inter-Bold.woff2 | Bin 0 -> 114840 bytes .../src/assets/fonts/inter/Inter-Light.woff2 | Bin 0 -> 112592 bytes .../src/assets/fonts/inter/Inter-Medium.woff2 | Bin 0 -> 114348 bytes .../assets/fonts/inter/Inter-Regular.woff2 | Bin 0 -> 111268 bytes .../assets/fonts/inter/Inter-SemiBold.woff2 | Bin 0 -> 114812 bytes .../src/assets/fonts/inter/inter.css | 46 + .../src/assets/styles/components.css | 488 + web/admin-spa/src/assets/styles/global.css | 830 ++ web/admin-spa/src/assets/styles/main.css | 168 + web/admin-spa/src/assets/styles/variables.css | 13 + .../accounts/AccountBalanceScriptModal.vue | 293 + .../accounts/AccountErrorHistoryModal.vue | 234 + .../accounts/AccountExpiryEditModal.vue | 434 + .../src/components/accounts/AccountForm.vue | 6753 ++++++++++++ .../accounts/AccountScheduledTestModal.vue | 392 + .../accounts/AccountUsageDetailModal.vue | 650 ++ .../accounts/ApiKeyManagementModal.vue | 881 ++ .../components/accounts/BalanceDisplay.vue | 361 + .../components/accounts/CcrAccountForm.vue | 439 + .../accounts/GroupManagementModal.vue | 505 + .../src/components/accounts/OAuthFlow.vue | 1271 +++ .../src/components/accounts/ProxyConfig.vue | 415 + .../accounts/TempUnavailablePolicyFields.vue | 101 + .../src/components/admin/ChangeRoleModal.vue | 250 + .../components/admin/RequestDetailModal.vue | 669 ++ .../components/admin/UserUsageStatsModal.vue | 402 + .../components/apikeys/BatchApiKeyModal.vue | 363 + .../apikeys/BatchEditApiKeyModal.vue | 931 ++ .../components/apikeys/CreateApiKeyModal.vue | 1622 +++ .../components/apikeys/EditApiKeyModal.vue | 1772 ++++ .../components/apikeys/ExpiryEditModal.vue | 514 + .../src/components/apikeys/LimitBadge.vue | 94 + .../components/apikeys/LimitProgressBar.vue | 375 + .../src/components/apikeys/NewApiKeyModal.vue | 330 + .../components/apikeys/RecordDetailModal.vue | 211 + .../components/apikeys/RenewApiKeyModal.vue | 227 + .../components/apikeys/TagManagementModal.vue | 292 + .../components/apikeys/UsageDetailModal.vue | 439 + .../components/apikeys/WindowCountdown.vue | 294 + .../src/components/apikeys/WindowLimitBar.vue | 241 + .../apistats/AggregatedStatsCard.vue | 188 + .../src/components/apistats/ApiKeyInput.vue | 548 + .../src/components/apistats/LimitConfig.vue | 457 + .../components/apistats/ModelUsageStats.vue | 259 + .../components/apistats/ServiceCostCards.vue | 275 + .../src/components/apistats/StatsOverview.vue | 765 ++ .../components/apistats/TokenDistribution.vue | 113 + .../src/components/common/AccountSelector.vue | 712 ++ .../src/components/common/ActionDropdown.vue | 191 + .../src/components/common/ConfirmModal.vue | 107 + .../src/components/common/CustomDropdown.vue | 254 + .../src/components/common/LogoTitle.vue | 94 + .../src/components/common/ModelSelector.vue | 69 + .../src/components/common/StatCard.vue | 61 + .../src/components/common/ThemeToggle.vue | 681 ++ .../components/common/ToastNotification.vue | 429 + .../components/common/UnifiedTestModal.vue | 598 ++ .../dashboard/ModelDistribution.vue | 146 + .../src/components/dashboard/UsageTrend.vue | 191 + .../src/components/layout/AppHeader.vue | 582 ++ .../src/components/layout/MainLayout.vue | 139 + .../src/components/layout/TabBar.vue | 78 + .../settings/ModelPricingSection.vue | 353 + .../tutorial/ClaudeCodeTutorial.vue | 495 + .../src/components/tutorial/CodexTutorial.vue | 155 + .../components/tutorial/DroidCliTutorial.vue | 98 + .../components/tutorial/GeminiCliTutorial.vue | 183 + .../tutorial/NodeInstallTutorial.vue | 234 + .../src/components/tutorial/VerifyInstall.vue | 30 + .../src/components/user/CreateApiKeyModal.vue | 261 + .../components/user/UserApiKeysManager.vue | 330 + .../src/components/user/UserUsageStats.vue | 384 + .../src/components/user/ViewApiKeyModal.vue | 226 + web/admin-spa/src/main.js | 34 + web/admin-spa/src/router/index.js | 246 + web/admin-spa/src/stores/accounts.js | 338 + web/admin-spa/src/stores/apiKeys.js | 106 + web/admin-spa/src/stores/apistats.js | 625 ++ web/admin-spa/src/stores/auth.js | 128 + web/admin-spa/src/stores/clients.js | 23 + web/admin-spa/src/stores/dashboard.js | 767 ++ web/admin-spa/src/stores/settings.js | 134 + web/admin-spa/src/stores/theme.js | 399 + web/admin-spa/src/stores/user.js | 218 + web/admin-spa/src/utils/http_apis.js | 359 + web/admin-spa/src/utils/request.js | 57 + web/admin-spa/src/utils/tools.js | 164 + web/admin-spa/src/utils/useChartConfig.js | 120 + web/admin-spa/src/utils/useTestState.js | 208 + web/admin-spa/src/utils/useTutorialUrls.js | 52 + .../src/views/AccountUsageRecordsView.vue | 567 + web/admin-spa/src/views/AccountsView.vue | 5425 ++++++++++ .../src/views/ApiKeyUsageRecordsView.vue | 542 + web/admin-spa/src/views/ApiKeysView.vue | 5030 +++++++++ web/admin-spa/src/views/ApiStatsView.vue | 1240 +++ .../src/views/BalanceScriptsView.vue | 302 + web/admin-spa/src/views/DashboardView.vue | 1820 ++++ web/admin-spa/src/views/LoginView.vue | 123 + web/admin-spa/src/views/QuotaCardsView.vue | 1071 ++ .../src/views/RequestDetailsView.vue | 1294 +++ web/admin-spa/src/views/SettingsView.vue | 3266 ++++++ web/admin-spa/src/views/TutorialView.vue | 105 + web/admin-spa/src/views/UserDashboardView.vue | 412 + web/admin-spa/src/views/UserLoginView.vue | 197 + .../src/views/UserManagementView.vue | 652 ++ web/admin-spa/tailwind.config.js | 62 + web/admin-spa/vite.config.js | 127 + 368 files changed, 206753 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .eslintrc.cjs create mode 100644 .gitattributes create mode 100644 .github/AUTO_RELEASE_GUIDE.md create mode 100644 .github/DOCKER_HUB_SETUP.md create mode 100644 .github/FUNDING.yml create mode 100644 .github/RELEASE_PROCESS.md create mode 100644 .github/TELEGRAM_SETUP.md create mode 100644 .github/WORKFLOW_USAGE.md create mode 100644 .github/cliff.toml create mode 100644 .github/secret_scanning.yml create mode 100644 .github/workflows/auto-release-pipeline.yml create mode 100644 .github/workflows/pr-lint-check.yml create mode 100644 .github/workflows/sync-model-pricing.yml create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 README_EN.md create mode 100644 SECURITY.md create mode 100644 VERSION create mode 100644 cli/index.js create mode 100644 config/config.example.js create mode 100644 config/models.js create mode 100644 config/pricingSource.js create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 docs/claude-code-gemini3-guide/README.md create mode 100644 docs/claude-code-gemini3-guide/model-mapping.png create mode 100644 nodemon.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 resources/model-pricing/README.md create mode 100644 resources/model-pricing/model_prices_and_context_window.json create mode 100644 scripts/analyze-log-sessions.js create mode 100644 scripts/check-redis-keys.js create mode 100644 scripts/data-transfer-enhanced.js create mode 100644 scripts/data-transfer.js create mode 100644 scripts/debug-redis-keys.js create mode 100644 scripts/fix-inquirer.js create mode 100644 scripts/fix-usage-stats.js create mode 100755 scripts/generate-test-data.js create mode 100644 scripts/manage-session-windows.js create mode 100644 scripts/manage.js create mode 100755 scripts/manage.sh create mode 100644 scripts/migrate-apikey-expiry.js create mode 100644 scripts/migrate-usage-index.js create mode 100755 scripts/monitor-enhanced.sh create mode 100644 scripts/reset-request-detail-retention-hours.js create mode 100644 scripts/setup.js create mode 100755 scripts/status-unified.sh create mode 100644 scripts/test-account-display.js create mode 100644 scripts/test-api-response.js create mode 100644 scripts/test-bedrock-models.js create mode 100755 scripts/test-billing-events.js create mode 100644 scripts/test-dedicated-accounts.js create mode 100644 scripts/test-gemini-refresh.js create mode 100644 scripts/test-group-scheduling.js create mode 100644 scripts/test-model-mapping.js create mode 100644 scripts/test-official-models.js create mode 100644 scripts/test-pricing-fallback.js create mode 100644 scripts/test-web-dist.sh create mode 100644 scripts/test-window-remaining.js create mode 100644 scripts/update-model-pricing.js create mode 100644 src/app.js create mode 100644 src/cli/initCosts.js create mode 100644 src/handlers/geminiHandlers.js create mode 100644 src/middleware/auth.js create mode 100644 src/middleware/browserFallback.js create mode 100644 src/models/redis.js create mode 100644 src/routes/admin/accountBalance.js create mode 100644 src/routes/admin/accountGroups.js create mode 100644 src/routes/admin/apiKeys.js create mode 100644 src/routes/admin/azureOpenaiAccounts.js create mode 100644 src/routes/admin/balanceScripts.js create mode 100644 src/routes/admin/bedrockAccounts.js create mode 100644 src/routes/admin/ccrAccounts.js create mode 100644 src/routes/admin/claudeAccounts.js create mode 100644 src/routes/admin/claudeConsoleAccounts.js create mode 100644 src/routes/admin/claudeRelayConfig.js create mode 100644 src/routes/admin/concurrency.js create mode 100644 src/routes/admin/dashboard.js create mode 100644 src/routes/admin/droidAccounts.js create mode 100644 src/routes/admin/errorHistory.js create mode 100644 src/routes/admin/geminiAccounts.js create mode 100644 src/routes/admin/geminiApiAccounts.js create mode 100644 src/routes/admin/index.js create mode 100644 src/routes/admin/openaiAccounts.js create mode 100644 src/routes/admin/openaiResponsesAccounts.js create mode 100644 src/routes/admin/quotaCards.js create mode 100644 src/routes/admin/requestDetails.js create mode 100644 src/routes/admin/serviceRates.js create mode 100644 src/routes/admin/sync.js create mode 100644 src/routes/admin/system.js create mode 100644 src/routes/admin/usageStats.js create mode 100644 src/routes/admin/utils.js create mode 100644 src/routes/api.js create mode 100644 src/routes/apiStats.js create mode 100644 src/routes/azureOpenaiRoutes.js create mode 100644 src/routes/droidRoutes.js create mode 100644 src/routes/geminiRoutes.js create mode 100644 src/routes/openaiClaudeRoutes.js create mode 100644 src/routes/openaiGeminiRoutes.js create mode 100644 src/routes/openaiRoutes.js create mode 100644 src/routes/standardGeminiRoutes.js create mode 100644 src/routes/unified.js create mode 100644 src/routes/userRoutes.js create mode 100644 src/routes/web.js create mode 100644 src/routes/webhook.js create mode 100644 src/services/account/accountBalanceService.js create mode 100644 src/services/account/azureOpenaiAccountService.js create mode 100644 src/services/account/bedrockAccountService.js create mode 100644 src/services/account/ccrAccountService.js create mode 100644 src/services/account/claudeAccountService.js create mode 100644 src/services/account/claudeConsoleAccountService.js create mode 100644 src/services/account/droidAccountService.js create mode 100644 src/services/account/geminiAccountService.js create mode 100644 src/services/account/geminiApiAccountService.js create mode 100644 src/services/account/openaiAccountService.js create mode 100644 src/services/account/openaiResponsesAccountService.js create mode 100644 src/services/accountGroupService.js create mode 100644 src/services/accountNameCacheService.js create mode 100644 src/services/accountTestSchedulerService.js create mode 100644 src/services/anthropicGeminiBridgeService.js create mode 100644 src/services/antigravityClient.js create mode 100644 src/services/apiKeyIndexService.js create mode 100644 src/services/apiKeyService.js create mode 100644 src/services/balanceProviders/baseBalanceProvider.js create mode 100644 src/services/balanceProviders/claudeBalanceProvider.js create mode 100644 src/services/balanceProviders/claudeConsoleBalanceProvider.js create mode 100644 src/services/balanceProviders/geminiBalanceProvider.js create mode 100644 src/services/balanceProviders/genericBalanceProvider.js create mode 100644 src/services/balanceProviders/index.js create mode 100644 src/services/balanceProviders/openaiResponsesBalanceProvider.js create mode 100644 src/services/balanceScriptService.js create mode 100644 src/services/billingEventPublisher.js create mode 100644 src/services/claudeCodeHeadersService.js create mode 100644 src/services/claudeRelayConfigService.js create mode 100644 src/services/codexToOpenAI.js create mode 100644 src/services/costInitService.js create mode 100644 src/services/costRankService.js create mode 100644 src/services/geminiToOpenAI.js create mode 100644 src/services/ldapService.js create mode 100644 src/services/modelService.js create mode 100644 src/services/openaiToClaude.js create mode 100644 src/services/pricingService.js create mode 100644 src/services/quotaCardService.js create mode 100644 src/services/rateLimitCleanupService.js create mode 100644 src/services/relay/antigravityRelayService.js create mode 100644 src/services/relay/azureOpenaiRelayService.js create mode 100644 src/services/relay/bedrockRelayService.js create mode 100644 src/services/relay/ccrRelayService.js create mode 100644 src/services/relay/claudeConsoleRelayService.js create mode 100644 src/services/relay/claudeRelayService.js create mode 100644 src/services/relay/droidRelayService.js create mode 100644 src/services/relay/geminiRelayService.js create mode 100644 src/services/relay/openaiResponsesRelayService.js create mode 100644 src/services/requestBodyRuleService.js create mode 100644 src/services/requestDetailService.js create mode 100644 src/services/requestIdentityService.js create mode 100644 src/services/scheduler/droidScheduler.js create mode 100644 src/services/scheduler/unifiedClaudeScheduler.js create mode 100644 src/services/scheduler/unifiedGeminiScheduler.js create mode 100644 src/services/scheduler/unifiedOpenAIScheduler.js create mode 100644 src/services/serviceRatesService.js create mode 100644 src/services/tokenRefreshService.js create mode 100644 src/services/userMessageQueueService.js create mode 100644 src/services/userService.js create mode 100644 src/services/webhookConfigService.js create mode 100755 src/services/webhookService.js create mode 100644 src/services/weeklyClaudeCostInitService.js create mode 100644 src/utils/anthropicRequestDump.js create mode 100644 src/utils/anthropicResponseDump.js create mode 100644 src/utils/antigravityModel.js create mode 100644 src/utils/antigravityUpstreamDump.js create mode 100644 src/utils/antigravityUpstreamResponseDump.js create mode 100644 src/utils/cacheMonitor.js create mode 100644 src/utils/commonHelper.js create mode 100644 src/utils/contents.js create mode 100644 src/utils/costCalculator.js create mode 100644 src/utils/dateHelper.js create mode 100644 src/utils/errorSanitizer.js create mode 100644 src/utils/featureFlags.js create mode 100644 src/utils/geminiSchemaCleaner.js create mode 100644 src/utils/headerFilter.js create mode 100644 src/utils/inputValidator.js create mode 100644 src/utils/logger.js create mode 100644 src/utils/lruCache.js create mode 100644 src/utils/metadataUserIdHelper.js create mode 100644 src/utils/modelHelper.js create mode 100644 src/utils/oauthHelper.js create mode 100644 src/utils/performanceOptimizer.js create mode 100644 src/utils/projectPaths.js create mode 100644 src/utils/proxyHelper.js create mode 100644 src/utils/rateLimitHelper.js create mode 100644 src/utils/requestDetailHelper.js create mode 100644 src/utils/runtimeAddon.js create mode 100644 src/utils/safeRotatingAppend.js create mode 100644 src/utils/sessionHelper.js create mode 100644 src/utils/signatureCache.js create mode 100644 src/utils/sseParser.js create mode 100644 src/utils/statsHelper.js create mode 100644 src/utils/streamHelper.js create mode 100644 src/utils/tempUnavailablePolicy.js create mode 100644 src/utils/testPayloadHelper.js create mode 100644 src/utils/tokenMask.js create mode 100644 src/utils/tokenRefreshLogger.js create mode 100644 src/utils/unstableUpstreamHelper.js create mode 100644 src/utils/upstreamErrorHelper.js create mode 100644 src/utils/warmupInterceptor.js create mode 100644 src/utils/webhookNotifier.js create mode 100644 src/utils/workosOAuthHelper.js create mode 100644 src/validators/clientDefinitions.js create mode 100644 src/validators/clientValidator.js create mode 100644 src/validators/clients/claudeCodeValidator.js create mode 100644 src/validators/clients/codexCliValidator.js create mode 100644 src/validators/clients/droidCliValidator.js create mode 100644 src/validators/clients/geminiCliValidator.js create mode 100644 tests/accountBalanceService.test.js create mode 100644 tests/adminApiKeysPayloadRulesRoute.test.js create mode 100644 tests/apiKeyServiceOpenAIResponsesConfig.test.js create mode 100644 tests/claudeAccountFableRateLimit.test.js create mode 100644 tests/claudeAccountModelRateLimit.test.js create mode 100644 tests/claudeConsoleAccounts.test.js create mode 100644 tests/claudeConsoleRelayService.test.js create mode 100644 tests/concurrencyQueue.integration.test.js create mode 100644 tests/concurrencyQueue.test.js create mode 100644 tests/costCalculator.test.js create mode 100644 tests/metadataUserIdHelper.integration.test.js create mode 100644 tests/metadataUserIdHelper.test.js create mode 100644 tests/modelRateLimitFamily.test.js create mode 100644 tests/modelsConfig.test.js create mode 100644 tests/openaiResponsesPayloadToggles.test.js create mode 100644 tests/pricingService.test.js create mode 100644 tests/redisApiKeyParse.test.js create mode 100644 tests/requestBodyRuleService.test.js create mode 100644 tests/requestDetailHelper.test.js create mode 100644 tests/requestDetailService.test.js create mode 100644 tests/requestDetailsRoute.test.js create mode 100644 tests/unifiedClaudeSchedulerDedicated.test.js create mode 100644 tests/unifiedOpenAIScheduler.test.js create mode 100644 tests/upstreamErrorTtlCap.test.js create mode 100644 tests/userMessageQueue.test.js create mode 100644 web/admin-spa/.env.example create mode 100644 web/admin-spa/.env.production create mode 100644 web/admin-spa/.eslintrc.cjs create mode 100644 web/admin-spa/.gitignore create mode 100644 web/admin-spa/.prettierrc create mode 100644 web/admin-spa/README.md create mode 100644 web/admin-spa/components/.gitkeep create mode 100644 web/admin-spa/index.html create mode 100644 web/admin-spa/package-lock.json create mode 100644 web/admin-spa/package.json create mode 100644 web/admin-spa/postcss.config.js create mode 100644 web/admin-spa/src/App.vue create mode 100644 web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2 create mode 100644 web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2 create mode 100644 web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2 create mode 100644 web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2 create mode 100644 web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2 create mode 100644 web/admin-spa/src/assets/fonts/inter/inter.css create mode 100644 web/admin-spa/src/assets/styles/components.css create mode 100644 web/admin-spa/src/assets/styles/global.css create mode 100644 web/admin-spa/src/assets/styles/main.css create mode 100644 web/admin-spa/src/assets/styles/variables.css create mode 100644 web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue create mode 100644 web/admin-spa/src/components/accounts/AccountErrorHistoryModal.vue create mode 100644 web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue create mode 100644 web/admin-spa/src/components/accounts/AccountForm.vue create mode 100644 web/admin-spa/src/components/accounts/AccountScheduledTestModal.vue create mode 100644 web/admin-spa/src/components/accounts/AccountUsageDetailModal.vue create mode 100644 web/admin-spa/src/components/accounts/ApiKeyManagementModal.vue create mode 100644 web/admin-spa/src/components/accounts/BalanceDisplay.vue create mode 100644 web/admin-spa/src/components/accounts/CcrAccountForm.vue create mode 100644 web/admin-spa/src/components/accounts/GroupManagementModal.vue create mode 100644 web/admin-spa/src/components/accounts/OAuthFlow.vue create mode 100644 web/admin-spa/src/components/accounts/ProxyConfig.vue create mode 100644 web/admin-spa/src/components/accounts/TempUnavailablePolicyFields.vue create mode 100644 web/admin-spa/src/components/admin/ChangeRoleModal.vue create mode 100644 web/admin-spa/src/components/admin/RequestDetailModal.vue create mode 100644 web/admin-spa/src/components/admin/UserUsageStatsModal.vue create mode 100644 web/admin-spa/src/components/apikeys/BatchApiKeyModal.vue create mode 100644 web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue create mode 100644 web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue create mode 100644 web/admin-spa/src/components/apikeys/EditApiKeyModal.vue create mode 100644 web/admin-spa/src/components/apikeys/ExpiryEditModal.vue create mode 100644 web/admin-spa/src/components/apikeys/LimitBadge.vue create mode 100644 web/admin-spa/src/components/apikeys/LimitProgressBar.vue create mode 100644 web/admin-spa/src/components/apikeys/NewApiKeyModal.vue create mode 100644 web/admin-spa/src/components/apikeys/RecordDetailModal.vue create mode 100644 web/admin-spa/src/components/apikeys/RenewApiKeyModal.vue create mode 100644 web/admin-spa/src/components/apikeys/TagManagementModal.vue create mode 100644 web/admin-spa/src/components/apikeys/UsageDetailModal.vue create mode 100644 web/admin-spa/src/components/apikeys/WindowCountdown.vue create mode 100644 web/admin-spa/src/components/apikeys/WindowLimitBar.vue create mode 100644 web/admin-spa/src/components/apistats/AggregatedStatsCard.vue create mode 100644 web/admin-spa/src/components/apistats/ApiKeyInput.vue create mode 100644 web/admin-spa/src/components/apistats/LimitConfig.vue create mode 100644 web/admin-spa/src/components/apistats/ModelUsageStats.vue create mode 100644 web/admin-spa/src/components/apistats/ServiceCostCards.vue create mode 100644 web/admin-spa/src/components/apistats/StatsOverview.vue create mode 100644 web/admin-spa/src/components/apistats/TokenDistribution.vue create mode 100644 web/admin-spa/src/components/common/AccountSelector.vue create mode 100644 web/admin-spa/src/components/common/ActionDropdown.vue create mode 100644 web/admin-spa/src/components/common/ConfirmModal.vue create mode 100644 web/admin-spa/src/components/common/CustomDropdown.vue create mode 100644 web/admin-spa/src/components/common/LogoTitle.vue create mode 100644 web/admin-spa/src/components/common/ModelSelector.vue create mode 100644 web/admin-spa/src/components/common/StatCard.vue create mode 100644 web/admin-spa/src/components/common/ThemeToggle.vue create mode 100644 web/admin-spa/src/components/common/ToastNotification.vue create mode 100644 web/admin-spa/src/components/common/UnifiedTestModal.vue create mode 100644 web/admin-spa/src/components/dashboard/ModelDistribution.vue create mode 100644 web/admin-spa/src/components/dashboard/UsageTrend.vue create mode 100644 web/admin-spa/src/components/layout/AppHeader.vue create mode 100644 web/admin-spa/src/components/layout/MainLayout.vue create mode 100644 web/admin-spa/src/components/layout/TabBar.vue create mode 100644 web/admin-spa/src/components/settings/ModelPricingSection.vue create mode 100644 web/admin-spa/src/components/tutorial/ClaudeCodeTutorial.vue create mode 100644 web/admin-spa/src/components/tutorial/CodexTutorial.vue create mode 100644 web/admin-spa/src/components/tutorial/DroidCliTutorial.vue create mode 100644 web/admin-spa/src/components/tutorial/GeminiCliTutorial.vue create mode 100644 web/admin-spa/src/components/tutorial/NodeInstallTutorial.vue create mode 100644 web/admin-spa/src/components/tutorial/VerifyInstall.vue create mode 100644 web/admin-spa/src/components/user/CreateApiKeyModal.vue create mode 100644 web/admin-spa/src/components/user/UserApiKeysManager.vue create mode 100644 web/admin-spa/src/components/user/UserUsageStats.vue create mode 100644 web/admin-spa/src/components/user/ViewApiKeyModal.vue create mode 100644 web/admin-spa/src/main.js create mode 100644 web/admin-spa/src/router/index.js create mode 100644 web/admin-spa/src/stores/accounts.js create mode 100644 web/admin-spa/src/stores/apiKeys.js create mode 100644 web/admin-spa/src/stores/apistats.js create mode 100644 web/admin-spa/src/stores/auth.js create mode 100644 web/admin-spa/src/stores/clients.js create mode 100644 web/admin-spa/src/stores/dashboard.js create mode 100644 web/admin-spa/src/stores/settings.js create mode 100644 web/admin-spa/src/stores/theme.js create mode 100644 web/admin-spa/src/stores/user.js create mode 100644 web/admin-spa/src/utils/http_apis.js create mode 100644 web/admin-spa/src/utils/request.js create mode 100644 web/admin-spa/src/utils/tools.js create mode 100644 web/admin-spa/src/utils/useChartConfig.js create mode 100644 web/admin-spa/src/utils/useTestState.js create mode 100644 web/admin-spa/src/utils/useTutorialUrls.js create mode 100644 web/admin-spa/src/views/AccountUsageRecordsView.vue create mode 100644 web/admin-spa/src/views/AccountsView.vue create mode 100644 web/admin-spa/src/views/ApiKeyUsageRecordsView.vue create mode 100644 web/admin-spa/src/views/ApiKeysView.vue create mode 100644 web/admin-spa/src/views/ApiStatsView.vue create mode 100644 web/admin-spa/src/views/BalanceScriptsView.vue create mode 100644 web/admin-spa/src/views/DashboardView.vue create mode 100644 web/admin-spa/src/views/LoginView.vue create mode 100644 web/admin-spa/src/views/QuotaCardsView.vue create mode 100644 web/admin-spa/src/views/RequestDetailsView.vue create mode 100644 web/admin-spa/src/views/SettingsView.vue create mode 100644 web/admin-spa/src/views/TutorialView.vue create mode 100644 web/admin-spa/src/views/UserDashboardView.vue create mode 100644 web/admin-spa/src/views/UserLoginView.vue create mode 100644 web/admin-spa/src/views/UserManagementView.vue create mode 100644 web/admin-spa/tailwind.config.js create mode 100644 web/admin-spa/vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ed6c9f3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,79 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment files +.env.local +.env.*.local + +# Logs +logs/ +*.log + +# Data files +data/ +temp/ +redis_data/ + +# Git +.git/ +.gitignore +.gitattributes + +# GitHub +.github/ + +# Documentation +README.md +README_EN.md +CHANGELOG.md +docs/ +*.md + +# Development files +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Docker files +docker-compose.yml +docker-compose.*.yml +Dockerfile +.dockerignore + +# Test files +test/ +tests/ +__tests__/ +*.test.js +*.spec.js +coverage/ +.nyc_output/ + +# Build files +# dist/ # 前端构建阶段需要复制源文件,所以不能忽略 +build/ +*.pid +*.seed +*.pid.lock + +# 但可以忽略本地已构建的 dist 目录 +web/admin-spa/dist/ + +# CI/CD +.travis.yml +.gitlab-ci.yml +azure-pipelines.yml + +# Package manager files +# package-lock.json # 需要保留此文件以支持 npm ci +yarn.lock +pnpm-lock.yaml + +# CLI +cli/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ea0a99 --- /dev/null +++ b/.env.example @@ -0,0 +1,211 @@ +# 🚀 Claude Relay Service Configuration + +# 🌐 服务器配置 +PORT=3000 +HOST=0.0.0.0 +NODE_ENV=production + +# 🔐 安全配置 +JWT_SECRET=your-jwt-secret-here +ADMIN_SESSION_TIMEOUT=86400000 +API_KEY_PREFIX=cr_ +ENCRYPTION_KEY=your-encryption-key-here + +# 👤 管理员凭据(可选,不设置则自动生成) +# ADMIN_USERNAME=cr_admin_custom +# ADMIN_PASSWORD=your-secure-password + +# 📊 Redis 配置 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_ENABLE_TLS= + +# 🔗 会话管理配置 +# 粘性会话TTL配置(小时),默认1小时 +STICKY_SESSION_TTL_HOURS=1 +# 续期阈值(分钟),默认0分钟(不续期) +STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=15 + +# 🎯 Claude API 配置 +CLAUDE_API_URL=https://api.anthropic.com/v1/messages +CLAUDE_API_VERSION=2023-06-01 +CLAUDE_BETA_HEADER=claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14 + +# 🤖 Gemini OAuth / Antigravity 配置(可选) +# 不配置时使用内置默认值;如需自定义或避免在代码中出现 client secret,可在此覆盖 +# GEMINI_OAUTH_CLIENT_ID= +# GEMINI_OAUTH_CLIENT_SECRET= +# Gemini CLI OAuth redirect_uri(可选,默认 https://codeassist.google.com/authcode) +# GEMINI_OAUTH_REDIRECT_URI= +# ANTIGRAVITY_OAUTH_CLIENT_ID= +# ANTIGRAVITY_OAUTH_CLIENT_SECRET= +# Antigravity OAuth redirect_uri(可选,默认 http://localhost:45462;用于避免 redirect_uri_mismatch) +# ANTIGRAVITY_OAUTH_REDIRECT_URI=http://localhost:45462 +# Antigravity 上游地址(可选,默认 sandbox) +# ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.sandbox.googleapis.com +# Antigravity User-Agent(可选) +# ANTIGRAVITY_USER_AGENT=antigravity/1.15.8 windows/amd64 + +# Claude Code(Anthropic Messages API)路由分流(无需额外环境变量): +# - /api -> Claude 账号池(默认) +# - /antigravity/api -> Antigravity OAuth +# - /gemini-cli/api -> Gemini CLI OAuth + +# ============================================================================ +# 🐛 调试 Dump 配置(可选) +# ============================================================================ +# 以下开启后会在项目根目录写入 .jsonl 调试文件,便于排查问题。 +# ⚠️ 生产环境建议关闭,避免磁盘占用。 +# +# 📄 输出文件列表: +# - anthropic-requests-dump.jsonl (客户端请求) +# - anthropic-responses-dump.jsonl (返回给客户端的响应) +# - anthropic-tools-dump.jsonl (工具定义快照) +# - antigravity-upstream-requests-dump.jsonl (发往上游的请求) +# - antigravity-upstream-responses-dump.jsonl (上游 SSE 响应) +# +# 📌 开关配置: +# ANTHROPIC_DEBUG_REQUEST_DUMP=true +# ANTHROPIC_DEBUG_RESPONSE_DUMP=true +# ANTHROPIC_DEBUG_TOOLS_DUMP=true +# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP=true +# ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP=true +# +# 📏 单条记录大小上限(字节),默认 2MB: +# ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES=2097152 +# ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES=2097152 +# ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES=2097152 +# +# 📦 整个 Dump 文件大小上限(字节),超过后自动轮转为 .bak 文件,默认 10MB: +# DUMP_MAX_FILE_SIZE_BYTES=10485760 +# +# 🔧 工具失败继续:当 tool_result 标记 is_error=true 时,提示模型不要中断任务 +# (仅 /antigravity/api 分流生效) +# ANTHROPIC_TOOL_ERROR_CONTINUE=true + + +# 🚫 529错误处理配置 +# 启用529错误处理,0表示禁用,>0表示过载状态持续时间(分钟) +CLAUDE_OVERLOAD_HANDLING_MINUTES=0 + +# 400错误处理:0表示禁用,>0表示临时禁用时间(分钟) +# 只有匹配特定错误模式的 400 才会触发临时禁用 +# - organization has been disabled +# - account has been disabled +# - account is disabled +# - no account supporting +# - account not found +# - invalid account +# - Too many active sessions +CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES=10 + +# 🌐 代理配置 +DEFAULT_PROXY_TIMEOUT=600000 +MAX_PROXY_RETRIES=3 +# IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好) +PROXY_USE_IPV4=true +# 代理连接池 / Keep-Alive 配置(默认关闭,如需启用请取消注释) +# PROXY_KEEP_ALIVE=true +# PROXY_MAX_SOCKETS=50 +# PROXY_MAX_FREE_SOCKETS=10 + +# ⏱️ 请求超时配置 +REQUEST_TIMEOUT=600000 # 请求超时设置(毫秒),默认10分钟 + +# 🔗 HTTP 连接池配置(keep-alive) +# 流式请求最大连接数(默认65535) +# HTTPS_MAX_SOCKETS_STREAM=65535 +# 非流式请求最大连接数(默认16384) +# HTTPS_MAX_SOCKETS_NON_STREAM=16384 +# 空闲连接数(默认2048) +# HTTPS_MAX_FREE_SOCKETS=2048 +# 空闲连接超时(毫秒,默认30000) +# HTTPS_FREE_SOCKET_TIMEOUT=30000 + +# 🔧 请求体大小配置 +REQUEST_MAX_SIZE_MB=60 + +# 📈 使用限制 +DEFAULT_TOKEN_LIMIT=1000000 + +# 📝 日志配置 +LOG_LEVEL=info +LOG_MAX_SIZE=10m +LOG_MAX_FILES=5 + +# 🔧 系统配置 +CLEANUP_INTERVAL=3600000 +TOKEN_USAGE_RETENTION=2592000000 +HEALTH_CHECK_INTERVAL=60000 +TIMEZONE_OFFSET=8 # UTC偏移小时数,默认+8(中国时区) +METRICS_WINDOW=5 # 实时指标统计窗口(分钟),可选1-60,默认5分钟 +# 启动时清理残留的并发排队计数器(默认true,多实例部署时建议设为false) +CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=true + +# 🎨 Web 界面配置 +WEB_TITLE=Claude Relay Service +WEB_DESCRIPTION=Multi-account Claude API relay service with beautiful management interface +WEB_LOGO_URL=/assets/logo.png + +# 🛠️ 开发配置 +DEBUG=false +DEBUG_HTTP_TRAFFIC=false # 启用HTTP请求/响应调试日志(仅开发环境) +ENABLE_CORS=true +TRUST_PROXY=true + +# ⏱️ 上游错误自动暂停配置(秒) +# 说明: +# 1) 这是全局默认值 +# 2) Claude 官方 OAuth 账号可在后台单独覆盖 503/5xx 冷却(留空=跟随全局,0=关闭该类型) +# 3) 若账号勾选“禁用临时冷却”,则不会因 503/5xx 进入 temp_unavailable +# UPSTREAM_ERROR_503_TTL_SECONDS=60 # 503错误暂停时间(默认60秒) +# UPSTREAM_ERROR_5XX_TTL_SECONDS=300 # 500/502等5xx错误暂停时间(默认5分钟) +# UPSTREAM_ERROR_OVERLOAD_TTL_SECONDS=600 # 529过载暂停时间(默认10分钟) +# UPSTREAM_ERROR_AUTH_TTL_SECONDS=1800 # 401/403认证错误暂停时间(默认30分钟) +# UPSTREAM_ERROR_TIMEOUT_TTL_SECONDS=300 # 504超时暂停时间(默认5分钟) + +# 🔒 客户端限制(可选) +# ALLOW_CUSTOM_CLIENTS=false +# Claude Code 系统提示词最少匹配条目数 +# 0(默认)= 严格模式,system 数组中所有条目都必须通过相似度检测 +# N > 0 = 宽松模式,至少 N 条匹配即可(推荐设为 1,兼容带 billing-header 的新版本) +# CLAUDE_CODE_MIN_SYSTEM_PROMPT_MATCHES=0 + +# 🔐 LDAP 认证配置 +LDAP_ENABLED=false +LDAP_URL=ldaps://ldap-1.test1.bj.yxops.net:636 +LDAP_BIND_DN=cn=admin,dc=example,dc=com +LDAP_BIND_PASSWORD=admin_password +LDAP_SEARCH_BASE=dc=example,dc=com +LDAP_SEARCH_FILTER=(uid={{username}}) +LDAP_SEARCH_ATTRIBUTES=dn,uid,cn,mail,givenName,sn +LDAP_TIMEOUT=5000 +LDAP_CONNECT_TIMEOUT=10000 + +# 🔒 LDAP TLS/SSL 配置 (用于 ldaps:// URL) +# 是否忽略证书验证错误 (设置为false可忽略自签名证书错误) +LDAP_TLS_REJECT_UNAUTHORIZED=true +# CA 证书文件路径 (可选,用于自定义CA证书) +# LDAP_TLS_CA_FILE=/path/to/ca-cert.pem +# 客户端证书文件路径 (可选,用于双向认证) +# LDAP_TLS_CERT_FILE=/path/to/client-cert.pem +# 客户端私钥文件路径 (可选,用于双向认证) +# LDAP_TLS_KEY_FILE=/path/to/client-key.pem +# 服务器名称 (可选,用于 SNI) +# LDAP_TLS_SERVERNAME=ldap.example.com + +# 🗺️ LDAP 用户属性映射 +LDAP_USER_ATTR_USERNAME=uid +LDAP_USER_ATTR_DISPLAY_NAME=cn +LDAP_USER_ATTR_EMAIL=mail +LDAP_USER_ATTR_FIRST_NAME=givenName +LDAP_USER_ATTR_LAST_NAME=sn + +# 👥 用户管理配置 +USER_MANAGEMENT_ENABLED=false +DEFAULT_USER_ROLE=user +USER_SESSION_TIMEOUT=86400000 +MAX_API_KEYS_PER_USER=1 +ALLOW_USER_DELETE_API_KEYS=false diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..3028130 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,85 @@ +module.exports = { + root: true, + env: { + node: true, + es2021: true, + commonjs: true + }, + extends: ['eslint:recommended', 'plugin:prettier/recommended'], + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest' + }, + plugins: ['prettier'], + rules: { + // 基础规则 + 'no-console': 'off', // Node.js 项目允许 console + 'consistent-return': 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', + 'prettier/prettier': 'error', + + // 变量相关 + 'no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrors: 'none' + } + ], + 'prefer-const': 'error', + 'no-var': 'error', + 'no-shadow': 'error', + + // 代码质量 + eqeqeq: ['error', 'always'], + curly: ['error', 'all'], + 'no-throw-literal': 'error', + 'prefer-promise-reject-errors': 'error', + + // 代码风格 + 'object-shorthand': 'error', + 'prefer-template': 'error', + 'template-curly-spacing': ['error', 'never'], + + // Node.js 特定规则 + 'no-path-concat': 'error', + 'handle-callback-err': 'error', + + // ES6+ 规则 + 'arrow-body-style': ['error', 'as-needed'], + 'prefer-arrow-callback': 'error', + 'prefer-destructuring': [ + 'error', + { + array: false, + object: true + } + ], + + // 格式化规则(由 Prettier 处理) + semi: 'off', + quotes: 'off', + indent: 'off', + 'comma-dangle': 'off' + }, + overrides: [ + { + // CLI 和脚本文件 + files: ['cli/**/*.js', 'scripts/**/*.js'], + rules: { + 'no-process-exit': 'off' // CLI 脚本允许 process.exit + } + }, + { + // 测试文件 + files: ['**/*.test.js', '**/*.spec.js', 'tests/**/*.js'], + env: { + jest: true + }, + rules: { + 'no-unused-expressions': 'off' + } + } + ] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/AUTO_RELEASE_GUIDE.md b/.github/AUTO_RELEASE_GUIDE.md new file mode 100644 index 0000000..383be0d --- /dev/null +++ b/.github/AUTO_RELEASE_GUIDE.md @@ -0,0 +1,164 @@ +# 自动版本发布指南 + +## 📋 概述 + +本项目配置了自动版本发布功能,每次推送到 `main` 分支时会自动递增版本号并创建 GitHub Release。 + +## 🚀 工作原理 + +### 自动版本递增规则 + +- **版本格式**: `v..` (例如: v1.0.2) +- **递增规则**: 每次推送到 main 分支,自动递增 patch 版本号 + - v1.0.1 → v1.0.2 + - v1.0.9 → v1.0.10 + - v1.0.99 → v1.0.100 + +### 触发条件 + +当满足以下条件时,会自动创建新版本: + +1. 推送到 `main` 分支 +2. 有实际的代码变更(不包括纯文档更新) +3. 自上次发布以来有新的提交 + +## 📝 使用方法 + +### 1. 常规开发流程 + +```bash +# 在 dev 分支开发 +git checkout dev +# ... 进行开发 ... +git add . +git commit -m "feat: 添加新功能" +git push origin dev + +# 合并到 main 分支 +git checkout main +git merge dev +git push origin main # 这会触发自动发布 +``` + +### 2. 跳过自动发布 + +如果你的提交不想触发自动发布,在 commit 消息中添加 `[skip ci]`: + +```bash +git commit -m "docs: 更新文档 [skip ci]" +``` + +### 3. 手动控制版本号 + +如果需要发布大版本或中版本更新: + +```bash +# 大版本更新 (1.0.x → 2.0.0) +git tag -a v2.0.0 -m "Major release v2.0.0" +git push origin v2.0.0 + +# 中版本更新 (1.0.x → 1.1.0) +git tag -a v1.1.0 -m "Minor release v1.1.0" +git push origin v1.1.0 +``` + +## 🔧 配置说明 + +### 工作流文件 + +- **位置**: `.github/workflows/auto-release.yml` +- **功能**: + - 获取最新版本标签 + - 计算下一个版本号 + - 生成 changelog + - 创建 GitHub Release + - 更新 CHANGELOG.md 文件 + - 发送 Telegram 通知(可选) + +### Changelog 生成 + +使用 [git-cliff](https://github.com/orhun/git-cliff) 自动生成更新日志: + +- **配置文件**: `.github/cliff.toml` +- **提交规范**: 遵循 [Conventional Commits](https://www.conventionalcommits.org/) + - `feat:` 新功能 + - `fix:` Bug 修复 + - `docs:` 文档更新 + - `chore:` 其他变更 + - `refactor:` 代码重构 + - `perf:` 性能优化 + +## 📊 查看发布历史 + +1. **GitHub Releases 页面**: + - 访问 `https://github.com///releases` + - 查看所有发布版本和更新内容 + +2. **CHANGELOG.md**: + - 项目根目录的 `CHANGELOG.md` 文件 + - 包含完整的版本历史 + +## ❓ 常见问题 + +### Q: 如何查看当前版本? + +```bash +# 查看最新标签 +git describe --tags --abbrev=0 + +# 查看所有标签 +git tag -l +``` + +### Q: 自动发布失败怎么办? + +1. 检查 GitHub Actions 日志 +2. 确认是否有权限创建标签和发布 +3. 检查是否有语法错误 + +### Q: 如何回滚版本? + +自动发布只是创建标签和 Release,不会影响代码: + +```bash +# 回滚到特定版本 +git checkout v1.0.1 + +# 或者使用 Docker 镜像的特定版本 +docker pull weishaw/claude-relay-service:v1.0.1 +``` + +### Q: 如何修改版本递增规则? + +编辑 `.github/workflows/auto-release.yml` 中的版本计算逻辑: + +```yaml +# 当前是递增 patch 版本 +NEW_PATCH=$((PATCH + 1)) + +# 可以改为递增 minor 版本 +NEW_MINOR=$((MINOR + 1)) +NEW_PATCH=0 +``` + +## 📱 Telegram 通知(可选) + +自动发布系统支持发送通知到 Telegram 频道。配置后,每次发布新版本都会自动发送通知。 + +### 快速设置 + +1. 创建 Telegram Bot(通过 @BotFather) +2. 将 Bot 添加到频道作为管理员 +3. 获取频道的 Chat ID +4. 在 GitHub 仓库添加 Secrets: + - `TELEGRAM_BOT_TOKEN` + - `TELEGRAM_CHAT_ID` + +详细设置步骤请参考 [Telegram 通知设置指南](./TELEGRAM_SETUP.md) + +## 🔗 相关链接 + +- [GitHub Actions 工作流使用指南](./WORKFLOW_USAGE.md) +- [Telegram 通知设置指南](./TELEGRAM_SETUP.md) +- [Docker Hub 设置指南](./DOCKER_HUB_SETUP.md) +- [Git Cliff 配置文档](https://git-cliff.org/docs/configuration) \ No newline at end of file diff --git a/.github/DOCKER_HUB_SETUP.md b/.github/DOCKER_HUB_SETUP.md new file mode 100644 index 0000000..b771886 --- /dev/null +++ b/.github/DOCKER_HUB_SETUP.md @@ -0,0 +1,109 @@ +# Docker Hub 自动发布配置指南 + +本文档说明如何配置 GitHub Actions 自动构建并发布 Docker 镜像到 Docker Hub。 + +## 📋 前置要求 + +1. Docker Hub 账号 +2. GitHub 仓库的管理员权限 + +## 🔐 配置 GitHub Secrets + +在 GitHub 仓库中配置以下 secrets: + +1. 进入仓库设置:`Settings` → `Secrets and variables` → `Actions` +2. 点击 `New repository secret` +3. 添加以下 secrets: + +### 必需的 Secrets + +| Secret 名称 | 说明 | 如何获取 | +|------------|------|---------| +| `DOCKERHUB_USERNAME` | Docker Hub 用户名 | 你的 Docker Hub 登录用户名 | +| `DOCKERHUB_TOKEN` | Docker Hub Access Token | 见下方说明 | + +### 获取 Docker Hub Access Token + +1. 登录 [Docker Hub](https://hub.docker.com/) +2. 点击右上角头像 → `Account Settings` +3. 选择 `Security` → `Access Tokens` +4. 点击 `New Access Token` +5. 填写描述(如:`GitHub Actions`) +6. 选择权限:`Read, Write, Delete` +7. 点击 `Generate` +8. **立即复制 token**(只显示一次) + +## 🚀 工作流程说明 + +### 触发条件 + +- **自动触发**:推送到 `main` 分支 +- **版本发布**:创建 `v*` 格式的 tag(如 `v1.0.0`) +- **手动触发**:在 Actions 页面手动运行 + +### 镜像标签策略 + +工作流会自动创建以下标签: + +- `latest`:始终指向 main 分支的最新构建 +- `main`:main 分支的构建 +- `v1.0.0`:版本标签(当创建 tag 时) +- `1.0`:主次版本标签 +- `1`:主版本标签 +- `main-sha-xxxxxxx`:包含 commit SHA 的标签 + +### 支持的平台 + +- `linux/amd64`:Intel/AMD 架构 +- `linux/arm64`:ARM64 架构(如 Apple Silicon, 树莓派等) + +## 📦 使用发布的镜像 + +```bash +# 拉取最新版本 +docker pull weishaw/claude-relay-service:latest + +# 拉取特定版本 +docker pull weishaw/claude-relay-service:v1.0.0 + +# 运行容器 +docker run -d \ + --name claude-relay \ + -p 3000:3000 \ + -v ./data:/app/data \ + -v ./logs:/app/logs \ + -e ADMIN_USERNAME=my_admin \ + -e ADMIN_PASSWORD=my_password \ + weishaw/claude-relay-service:latest +``` + +## 🔍 验证配置 + +1. 推送代码到 main 分支 +2. 在 GitHub 仓库页面点击 `Actions` 标签 +3. 查看 `Docker Build & Push` 工作流运行状态 +4. 成功后在 Docker Hub 查看镜像 + +## 🛡️ 安全功能 + +- **漏洞扫描**:使用 Trivy 自动扫描镜像漏洞 +- **扫描报告**:上传到 GitHub Security 标签页 +- **自动更新 README**:同步更新 Docker Hub 的项目描述 + +## ❓ 常见问题 + +### 构建失败 + +- 检查 secrets 是否正确配置 +- 确认 Docker Hub token 有足够权限 +- 查看 Actions 日志详细错误信息 + +### 镜像推送失败 + +- 确认 Docker Hub 用户名正确 +- 检查是否达到 Docker Hub 免费账户限制 +- Token 可能过期,需要重新生成 + +### 多平台构建慢 + +这是正常的,因为需要模拟不同架构。可以在不需要时修改 `platforms` 配置。 \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..dfd6822 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Your GitHub username for GitHub Sponsors +patreon: # Replace with your Patreon username if you have one +open_collective: # Replace with your Open Collective username if you have one +ko_fi: # Replace with your Ko-fi username if you have one +tidelift: # Replace with your Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with your Community Bridge project-name +liberapay: # Replace with your Liberapay username +issuehunt: # Replace with your IssueHunt username +otechie: # Replace with your Otechie username +custom: ['https://afdian.com/a/claude-relay-service'] # Your custom donation link (Afdian) diff --git a/.github/RELEASE_PROCESS.md b/.github/RELEASE_PROCESS.md new file mode 100644 index 0000000..9ba1d3c --- /dev/null +++ b/.github/RELEASE_PROCESS.md @@ -0,0 +1,94 @@ +# 发布流程说明 + +## 概述 + +本项目采用全自动化的版本管理和发布流程。VERSION文件由GitHub Actions自动维护,无需手动修改。 + +## 自动发布流程 + +### 1. 工作原理 + +1. **代码推送**: 当你推送代码到main分支时 +2. **自动版本更新**: `auto-version-bump.yml`会: + - 检测是否有实质性代码变更(排除.md文件、docs/目录等) + - 如果有代码变更,自动将版本号+1并更新VERSION文件 + - 提交VERSION文件更新到main分支 +3. **自动发布**: `release-on-version.yml`会: + - 检测到只有VERSION文件变更的提交 + - 自动创建Git tag + - 创建GitHub Release + - 构建并推送Docker镜像 + - 发送Telegram通知(如果配置) + +### 2. 工作流文件说明 + +- **auto-version-bump.yml**: 自动检测代码变更并更新VERSION文件 +- **release-on-version.yml**: 检测VERSION文件单独提交并触发发布 +- **docker-publish.yml**: 在tag创建时构建Docker镜像(备用) +- **release.yml**: 在tag创建时生成Release(备用) + +### 3. 版本号规范 + +- 使用语义化版本号:`MAJOR.MINOR.PATCH` +- 默认自动递增PATCH版本(例如:1.1.10 → 1.1.11) +- VERSION文件只包含版本号,不包含`v`前缀 +- Git tag会自动添加`v`前缀 + +### 4. 触发条件 + +**会触发版本更新的文件变更**: +- 源代码文件(.js, .ts, .jsx, .tsx等) +- 配置文件(package.json, Dockerfile等) +- 其他功能性文件 + +**不会触发版本更新的文件变更**: +- Markdown文件(*.md) +- 文档目录(docs/) +- GitHub配置(.github/) +- VERSION文件本身 +- .gitignore、LICENSE等 + +## 使用指南 + +### 正常开发流程 + +1. 进行代码开发和修改 +2. 提交并推送到main分支 +3. 系统自动完成版本更新和发布 + +```bash +# 正常的开发流程 +git add . +git commit -m "feat: 添加新功能" +git push origin main + +# GitHub Actions会自动: +# 1. 检测到代码变更 +# 2. 更新VERSION文件(例如:1.1.10 → 1.1.11) +# 3. 创建新的release和Docker镜像 +``` + +### 跳过版本更新 + +如果只是更新文档或其他非代码文件,系统会自动识别并跳过版本更新。 + +## 故障排除 + +### 版本没有自动更新 + +1. 检查是否有实质性代码变更 +2. 查看GitHub Actions运行日志 +3. 确认推送的是main分支 + +### 需要手动触发发布 + +如果需要手动控制版本: +1. 直接修改VERSION文件 +2. 提交并推送 +3. 系统会检测到VERSION变更并触发发布 + +## 注意事项 + +- **不要**在同一个提交中既修改代码又修改VERSION文件 +- **不要**手动创建tag,让系统自动管理 +- 系统会自动避免死循环(GitHub Actions的提交不会触发新的版本更新) \ No newline at end of file diff --git a/.github/TELEGRAM_SETUP.md b/.github/TELEGRAM_SETUP.md new file mode 100644 index 0000000..82f4f8e --- /dev/null +++ b/.github/TELEGRAM_SETUP.md @@ -0,0 +1,110 @@ +# Telegram 自动通知设置指南 + +## 📋 概述 + +当 GitHub Actions 自动发布新版本时,系统会自动发送通知到你的 Telegram 频道。 + +## 🚀 设置步骤 + +### 1. 创建 Telegram Bot + +1. 在 Telegram 中找到 [@BotFather](https://t.me/botfather) +2. 发送 `/newbot` 命令 +3. 按提示设置 Bot 名称(例如:Claude Relay Updates) +4. 设置 Bot 用户名(例如:claude_relay_bot) +5. **保存 Bot Token**(格式类似:`1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`) + +### 2. 创建或选择 Telegram 频道 + +1. 创建一个新频道或使用现有频道 +2. 将你的 Bot 添加为频道管理员: + - 进入频道设置 + - 管理员 → 添加管理员 + - 搜索你的 Bot 用户名 + - 赋予发送消息权限 + +### 3. 获取频道 Chat ID + +有几种方法获取频道的 Chat ID: + +#### 方法 1:使用 Web Telegram +1. 打开 https://web.telegram.org +2. 进入你的频道 +3. 查看 URL,格式为:`https://web.telegram.org/k/#-1234567890` +4. Chat ID 就是 `#` 后面的数字(包括负号):`-1234567890` + +#### 方法 2:使用 Bot API +1. 先在频道发送一条消息 +2. 访问:`https://api.telegram.org/bot/getUpdates` +3. 找到你的频道消息,查看 `chat.id` 字段 + +#### 方法 3:使用频道用户名 +如果频道是公开的,可以直接使用 `@频道用户名` 作为 Chat ID + +### 4. 添加 GitHub Secrets + +1. 访问你的 GitHub 仓库 +2. 进入 Settings → Secrets and variables → Actions +3. 点击 "New repository secret" +4. 添加以下两个 Secrets: + + **TELEGRAM_BOT_TOKEN** + - Name: `TELEGRAM_BOT_TOKEN` + - Value: 你的 Bot Token(例如:`1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`) + + **TELEGRAM_CHAT_ID** + - Name: `TELEGRAM_CHAT_ID` + - Value: 你的频道 Chat ID(例如:`-1234567890` 或 `@your_channel`) + +## ✅ 测试配置 + +配置完成后,下次推送到 main 分支时,你的 Telegram 频道将收到类似这样的通知: + +``` +🚀 Claude Relay Service 新版本发布! + +📦 版本号: 1.1.3 + +📝 更新内容: +- feat: 添加 Telegram 自动通知功能 +- fix: 修复某个问题 + +🐳 Docker 部署: +docker pull weishaw/claude-relay-service:v1.1.3 +docker pull weishaw/claude-relay-service:latest + +🔗 相关链接: +• GitHub Release +• 完整更新日志 +• Docker Hub + +#ClaudeRelay #Update #v1_1_3 +``` + +## 🔧 自定义通知 + +如果你想修改通知格式,编辑 `.github/workflows/auto-release.yml` 中的 `Send Telegram Notification` 步骤。 + +## ❓ 常见问题 + +### Q: 通知发送失败怎么办? + +检查: +1. Bot Token 是否正确 +2. Bot 是否已添加为频道管理员 +3. Chat ID 是否正确(注意负号) +4. GitHub Secrets 是否正确配置 + +### Q: 可以发送到多个频道吗? + +可以修改工作流,添加多个通知步骤,或使用逗号分隔多个 Chat ID。 + +### Q: 通知失败会影响版本发布吗? + +不会。通知步骤配置了 `continue-on-error: true`,即使通知失败也不会影响版本发布。 + +## 🔐 安全提示 + +- **永远不要**在代码中直接写入 Bot Token +- 始终使用 GitHub Secrets 存储敏感信息 +- 定期更换 Bot Token 以保证安全 \ No newline at end of file diff --git a/.github/WORKFLOW_USAGE.md b/.github/WORKFLOW_USAGE.md new file mode 100644 index 0000000..b4a6578 --- /dev/null +++ b/.github/WORKFLOW_USAGE.md @@ -0,0 +1,129 @@ +# GitHub Actions 工作流使用指南 + +## 📋 概述 + +本项目配置了自动化 CI/CD 流程,每次推送到 main 分支都会自动构建并发布 Docker 镜像到 Docker Hub。 + +## 🚀 工作流程 + +### 1. Docker 构建和发布 (`docker-publish.yml`) + +**功能:** +- 自动构建多平台 Docker 镜像(amd64, arm64) +- 推送到 Docker Hub +- 执行安全漏洞扫描 +- 更新 Docker Hub 描述 + +**触发条件:** +- 推送到 `main` 分支 +- 创建版本标签(如 `v1.0.0`) +- Pull Request(仅构建,不推送) +- 手动触发 + +### 2. 发布管理 (`release.yml`) + +**功能:** +- 自动创建 GitHub Release +- 生成更新日志 +- 关联 Docker 镜像版本 + +**触发条件:** +- 创建版本标签(如 `v1.0.0`) + +### 3. 自动版本发布 (`auto-release.yml`) + +**功能:** +- 自动递增版本号(patch 版本) +- 自动创建版本标签 +- 生成 GitHub Release +- 更新 CHANGELOG.md + +**触发条件:** +- 推送到 `main` 分支(自动触发) +- 忽略纯文档更新 + +## 📝 版本发布流程 + +### 1. 常规更新(推送到 main) + +```bash +git add . +git commit -m "fix: 修复登录问题" +git push origin main +``` + +**结果:** +- 自动构建并推送 `latest` 标签到 Docker Hub +- 更新 `main` 标签 +- **自动递增版本号并创建 Release**(例如:v1.0.1 → v1.0.2) +- 生成更新日志 + +### 2. 版本发布 + +```bash +# 创建版本标签 +git tag -a v1.0.0 -m "Release version 1.0.0" +git push origin v1.0.0 +``` + +**结果:** +- 构建并推送以下标签到 Docker Hub: + - `v1.0.0`(完整版本) + - `1.0`(主次版本) + - `1`(主版本) + - `latest`(最新版本) +- 创建 GitHub Release +- 生成更新日志 + +## 🔧 手动触发构建 + +1. 访问仓库的 Actions 页面 +2. 选择 "Docker Build & Push" 工作流 +3. 点击 "Run workflow" +4. 选择分支并运行 + +## 📊 查看构建状态 + +- **Actions 页面**:查看所有工作流运行历史 +- **README 徽章**:实时显示构建状态 +- **Docker Hub**:查看镜像标签和拉取次数 + +## 🛡️ 安全扫描 + +每次构建都会运行 Trivy 安全扫描: +- 扫描结果上传到 GitHub Security 标签页 +- 发现高危漏洞会在 Actions 日志中警告 + +## ❓ 常见问题 + +### Q: 如何回滚到之前的版本? + +```bash +# 使用特定版本标签 +docker pull weishaw/claude-relay-service:v1.0.0 + +# 或在 docker-compose.yml 中指定版本 +image: weishaw/claude-relay-service:v1.0.0 +``` + +### Q: 如何跳过自动构建? + +在 commit 消息中添加 `[skip ci]`: +```bash +git commit -m "docs: 更新文档 [skip ci]" +``` + +### Q: 构建失败如何调试? + +1. 查看 Actions 日志详细错误信息 +2. 在本地测试 Docker 构建: + ```bash + docker build -t test . + ``` + +## 📚 相关文档 + +- [自动版本发布指南](.github/AUTO_RELEASE_GUIDE.md) +- [Docker Hub 配置指南](.github/DOCKER_HUB_SETUP.md) +- [GitHub Actions 文档](https://docs.github.com/en/actions) +- [Docker 官方文档](https://docs.docker.com/) \ No newline at end of file diff --git a/.github/cliff.toml b/.github/cliff.toml new file mode 100644 index 0000000..f78621d --- /dev/null +++ b/.github/cliff.toml @@ -0,0 +1,68 @@ +# git-cliff configuration file +# https://git-cliff.org/docs/configuration + +[changelog] +# changelog header +header = """ +# Changelog + +All notable changes to this project will be documented in this file. +""" +# template for the changelog body +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | upper_first }} + {% for commit in commits %} + - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }} ([{{ commit.id | truncate(length=7, end="") }}](https://github.com/Wei-Shaw/claude-relay-service/commit/{{ commit.id }})) + {%- endfor %} +{% endfor %}\n +""" +# remove the leading and trailing whitespace from the template +trim = true +# changelog footer +footer = """ +""" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/Wei-Shaw/claude-relay-service/issues/${2}))" }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "Features" }, + { message = "^fix", group = "Bug Fixes" }, + { message = "^docs", group = "Documentation" }, + { message = "^perf", group = "Performance" }, + { message = "^refactor", group = "Refactor" }, + { message = "^style", group = "Styling" }, + { message = "^test", group = "Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore", group = "Miscellaneous Tasks" }, + { body = ".*security", group = "Security" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# glob pattern for matching git tags +tag_pattern = "v[0-9]*" +# regex for skipping tags +skip_tags = "v0.1.0-beta.1" +# regex for ignoring tags +ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" \ No newline at end of file diff --git a/.github/secret_scanning.yml b/.github/secret_scanning.yml new file mode 100644 index 0000000..9350743 --- /dev/null +++ b/.github/secret_scanning.yml @@ -0,0 +1,6 @@ +# GitHub Secret Scanning Configuration +# This file excludes specific paths from secret scanning + +paths-ignore: + - 'src/services/geminiAccountService.js' + - 'data/demo/Gemini-CLI-2-API/gemini-core.js' \ No newline at end of file diff --git a/.github/workflows/auto-release-pipeline.yml b/.github/workflows/auto-release-pipeline.yml new file mode 100644 index 0000000..4e29adc --- /dev/null +++ b/.github/workflows/auto-release-pipeline.yml @@ -0,0 +1,509 @@ +name: Auto Release Pipeline + +on: + push: + branches: + - main + workflow_dispatch: # 支持手动触发 + +permissions: + contents: write + packages: write + +jobs: + release-pipeline: + runs-on: ubuntu-latest + # 跳过由GitHub Actions创建的提交,避免死循环 + if: github.event.pusher.name != 'github-actions[bot]' && !contains(github.event.head_commit.message, '[skip ci]') + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if version bump is needed + id: check + run: | + # 检查提交消息是否包含强制发布标记([force release]) + COMMIT_MSG=$(git log -1 --pretty=%B | tr -d '\r') + echo "Latest commit message:" + echo "$COMMIT_MSG" + + FORCE_RELEASE=false + if echo "$COMMIT_MSG" | grep -qi "\[force release\]"; then + echo "Detected [force release] marker, forcing version bump" + FORCE_RELEASE=true + fi + + # 检测是否是合并提交 + PARENT_COUNT=$(git rev-list --parents -n 1 HEAD | wc -w) + PARENT_COUNT=$((PARENT_COUNT - 1)) + echo "Parent count: $PARENT_COUNT" + + if [ "$PARENT_COUNT" -gt 1 ]; then + # 合并提交:获取合并进来的所有文件变更 + echo "Detected merge commit, getting all merged changes" + # 获取合并基准点 + MERGE_BASE=$(git merge-base HEAD^1 HEAD^2 2>/dev/null || echo "") + if [ -n "$MERGE_BASE" ]; then + # 获取从合并基准到 HEAD 的所有变更 + CHANGED_FILES=$(git diff --name-only $MERGE_BASE..HEAD) + else + # 如果无法获取合并基准,使用第二个父提交 + CHANGED_FILES=$(git diff --name-only HEAD^2..HEAD) + fi + else + # 普通提交:获取相对于上一个提交的变更 + CHANGED_FILES=$(git diff --name-only HEAD~1..HEAD 2>/dev/null || git diff --name-only $(git rev-list --max-parents=0 HEAD)..HEAD) + fi + + echo "Changed files:" + echo "$CHANGED_FILES" + + # 检查是否只有无关文件(.md, docs/, .github/等) + SIGNIFICANT_CHANGES=false + while IFS= read -r file; do + # 跳过空行 + [ -z "$file" ] && continue + + # 检查是否是需要忽略的文件 + if [[ ! "$file" =~ \.(md|txt)$ ]] && + [[ ! "$file" =~ ^docs/ ]] && + [[ ! "$file" =~ ^\.github/ ]] && + [[ "$file" != "VERSION" ]] && + [[ "$file" != ".gitignore" ]] && + [[ "$file" != "LICENSE" ]]; then + echo "Found significant change in: $file" + SIGNIFICANT_CHANGES=true + break + fi + done <<< "$CHANGED_FILES" + + # 检查是否是手动触发 + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "Manual workflow trigger detected, forcing version bump" + echo "needs_bump=true" >> $GITHUB_OUTPUT + elif [ "$FORCE_RELEASE" = true ]; then + echo "Force release marker detected, forcing version bump" + echo "needs_bump=true" >> $GITHUB_OUTPUT + elif [ "$SIGNIFICANT_CHANGES" = true ]; then + echo "Significant changes detected, version bump needed" + echo "needs_bump=true" >> $GITHUB_OUTPUT + else + echo "No significant changes, skipping version bump" + echo "needs_bump=false" >> $GITHUB_OUTPUT + fi + + - name: Get current version + if: steps.check.outputs.needs_bump == 'true' + id: get_version + run: | + # 获取最新的tag版本 + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest tag: $LATEST_TAG" + TAG_VERSION=${LATEST_TAG#v} + + # 获取VERSION文件中的版本 + FILE_VERSION=$(cat VERSION | tr -d '[:space:]') + echo "VERSION file: $FILE_VERSION" + + # 比较tag版本和文件版本,取较大值 + function version_gt() { test "$(printf '%s\n' "$@" | sort -V | head -n 1)" != "$1"; } + + if version_gt "$FILE_VERSION" "$TAG_VERSION"; then + VERSION="$FILE_VERSION" + echo "Using VERSION file: $VERSION (newer than tag)" + else + VERSION="$TAG_VERSION" + echo "Using tag version: $VERSION (newer or equal to file)" + fi + + echo "Current version: $VERSION" + echo "current_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Calculate next version + if: steps.check.outputs.needs_bump == 'true' + id: next_version + run: | + VERSION="${{ steps.get_version.outputs.current_version }}" + + # 分割版本号 + IFS='.' read -r -a version_parts <<< "$VERSION" + MAJOR="${version_parts[0]:-0}" + MINOR="${version_parts[1]:-0}" + PATCH="${version_parts[2]:-0}" + + # 默认递增patch版本 + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" + + echo "New version: $NEW_VERSION" + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "new_tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Update VERSION file + if: steps.check.outputs.needs_bump == 'true' + run: | + echo "${{ steps.next_version.outputs.new_version }}" > VERSION + + # 配置git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # 提交VERSION文件 - 添加 [skip ci] 以避免再次触发 + git add VERSION + git commit -m "chore: sync VERSION file with release ${{ steps.next_version.outputs.new_tag }} [skip ci]" + + # 构建前端并推送到 web-dist 分支 + - name: Setup Node.js + if: steps.check.outputs.needs_bump == 'true' + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + cache-dependency-path: web/admin-spa/package-lock.json + + - name: Build Frontend + if: steps.check.outputs.needs_bump == 'true' + run: | + echo "Building frontend for version ${{ steps.next_version.outputs.new_version }}..." + cd web/admin-spa + npm ci + npm run build + echo "Frontend build completed" + + - name: Push Frontend Build to web-dist Branch + if: steps.check.outputs.needs_bump == 'true' + run: | + # 创建临时目录 + TEMP_DIR=$(mktemp -d) + echo "Using temp directory: $TEMP_DIR" + + # 复制构建产物到临时目录 + cp -r web/admin-spa/dist/* "$TEMP_DIR/" + + # 检查 web-dist 分支是否存在 + if git ls-remote --heads origin web-dist | grep -q web-dist; then + echo "Checking out existing web-dist branch" + git fetch origin web-dist:web-dist + git checkout web-dist + else + echo "Creating new web-dist branch" + git checkout --orphan web-dist + fi + + # 清空当前目录(保留 .git) + git rm -rf . 2>/dev/null || true + + # 复制构建产物 + cp -r "$TEMP_DIR"/* . + + # 添加 README + cat > README.md << EOF + # Claude Relay Service - Web Frontend Build + + This branch contains the pre-built frontend assets for Claude Relay Service. + + **DO NOT EDIT FILES IN THIS BRANCH DIRECTLY** + + These files are automatically generated by the CI/CD pipeline. + + Version: ${{ steps.next_version.outputs.new_version }} + Build Date: $(date -u +"%Y-%m-%d %H:%M:%S UTC") + EOF + + # 创建 .gitignore 文件以排除 node_modules + cat > .gitignore << EOF + node_modules/ + *.log + .DS_Store + .env + EOF + + # 只添加必要的文件,排除 node_modules + git add --all -- ':!node_modules' + git commit -m "chore: update frontend build for v${{ steps.next_version.outputs.new_version }} [skip ci]" + git push origin web-dist --force + + # 切换回主分支 + git checkout main + + # 清理临时目录 + rm -rf "$TEMP_DIR" + + echo "Frontend build pushed to web-dist branch successfully" + + - name: Install git-cliff + if: steps.check.outputs.needs_bump == 'true' + run: | + wget -q https://github.com/orhun/git-cliff/releases/download/v1.4.0/git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz + tar -xzf git-cliff-1.4.0-x86_64-unknown-linux-gnu.tar.gz + chmod +x git-cliff-1.4.0/git-cliff + sudo mv git-cliff-1.4.0/git-cliff /usr/local/bin/ + + - name: Generate changelog + if: steps.check.outputs.needs_bump == 'true' + id: changelog + run: | + # 获取上一个tag以来的更新日志 + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -n "$LATEST_TAG" ]; then + # 排除VERSION文件的提交 + CHANGELOG=$(git-cliff --config .github/cliff.toml $LATEST_TAG..HEAD --strip header | grep -v "bump version" | sed '/^$/d' || echo "- 代码优化和改进") + else + CHANGELOG=$(git-cliff --config .github/cliff.toml --strip header || echo "- 初始版本发布") + fi + echo "content<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create and push tag + if: steps.check.outputs.needs_bump == 'true' + run: | + NEW_TAG="${{ steps.next_version.outputs.new_tag }}" + git tag -a "$NEW_TAG" -m "Release $NEW_TAG" + git push origin HEAD:main "$NEW_TAG" + + - name: Prepare image names + id: image_names + if: steps.check.outputs.needs_bump == 'true' + run: | + DOCKER_USERNAME="${{ secrets.DOCKERHUB_USERNAME }}" + if [ -z "$DOCKER_USERNAME" ]; then + DOCKER_USERNAME="weishaw" + fi + + DOCKER_IMAGE=$(echo "${DOCKER_USERNAME}/claude-relay-service" | tr '[:upper:]' '[:lower:]') + GHCR_IMAGE=$(echo "ghcr.io/${{ github.repository_owner }}/claude-relay-service" | tr '[:upper:]' '[:lower:]') + + { + echo "docker_image=${DOCKER_IMAGE}" + echo "ghcr_image=${GHCR_IMAGE}" + } >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + if: steps.check.outputs.needs_bump == 'true' + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.next_version.outputs.new_tag }} + name: Release ${{ steps.next_version.outputs.new_version }} + body: | + ## 🐳 Docker 镜像 + + ```bash + docker pull ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_tag }} + docker pull ${{ steps.image_names.outputs.docker_image }}:latest + docker pull ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_tag }} + docker pull ${{ steps.image_names.outputs.ghcr_image }}:latest + ``` + + ## 📦 主要更新 + + ${{ steps.changelog.outputs.content }} + + ## 📋 完整更新日志 + + 查看 [所有版本](https://github.com/${{ github.repository }}/releases) + draft: false + prerelease: false + generate_release_notes: true + + # 自动清理旧的tags和releases(保持最近50个) + - name: Cleanup old tags and releases + if: steps.check.outputs.needs_bump == 'true' + continue-on-error: true + env: + TAGS_TO_KEEP: 50 + run: | + echo "🧹 自动清理旧版本,保持最近 $TAGS_TO_KEEP 个tag..." + + # 获取所有版本tag并按版本号排序(从旧到新) + echo "正在获取所有tags..." + ALL_TAGS=$(git ls-remote --tags origin | grep -E 'refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' | awk '{print $2}' | sed 's|refs/tags/||' | sort -V) + + # 检查是否获取到tags + if [ -z "$ALL_TAGS" ]; then + echo "⚠️ 未找到任何版本tag" + exit 0 + fi + + TOTAL_COUNT=$(echo "$ALL_TAGS" | wc -l) + + echo "📊 当前tag统计:" + echo "- 总数: $TOTAL_COUNT" + echo "- 配置保留: $TAGS_TO_KEEP" + + if [ "$TOTAL_COUNT" -gt "$TAGS_TO_KEEP" ]; then + DELETE_COUNT=$((TOTAL_COUNT - TAGS_TO_KEEP)) + echo "- 将要删除: $DELETE_COUNT 个最旧的tag" + + # 获取要删除的tags(最老的) + TAGS_TO_DELETE=$(echo "$ALL_TAGS" | head -n "$DELETE_COUNT") + + # 显示将要删除的版本范围 + OLDEST_TO_DELETE=$(echo "$TAGS_TO_DELETE" | head -1) + NEWEST_TO_DELETE=$(echo "$TAGS_TO_DELETE" | tail -1) + echo "" + echo "🗑️ 将要删除的版本范围:" + echo "- 从: $OLDEST_TO_DELETE" + echo "- 到: $NEWEST_TO_DELETE" + + echo "" + echo "开始执行删除..." + SUCCESS_COUNT=0 + FAIL_COUNT=0 + + for tag in $TAGS_TO_DELETE; do + echo -n " 删除 $tag ... " + + # 先检查release是否存在 + if gh release view "$tag" >/dev/null 2>&1; then + # Release存在,删除release会同时删除tag + if gh release delete "$tag" --yes --cleanup-tag 2>/dev/null; then + echo "✅ (release+tag)" + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + else + echo "❌ (release删除失败)" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi + else + # Release不存在,只删除tag + if git push origin --delete "$tag" 2>/dev/null; then + echo "✅ (仅tag)" + SUCCESS_COUNT=$((SUCCESS_COUNT + 1)) + else + echo "⏭️ (已不存在)" + FAIL_COUNT=$((FAIL_COUNT + 1)) + fi + fi + done + + echo "" + echo "📊 清理结果:" + echo "- 成功删除: $SUCCESS_COUNT" + echo "- 失败/跳过: $FAIL_COUNT" + + # 重新获取并显示保留的版本范围 + echo "" + echo "正在验证清理结果..." + REMAINING_TAGS=$(git ls-remote --tags origin | grep -E 'refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' | awk '{print $2}' | sed 's|refs/tags/||' | sort -V) + REMAINING_COUNT=$(echo "$REMAINING_TAGS" | wc -l) + OLDEST=$(echo "$REMAINING_TAGS" | head -1) + NEWEST=$(echo "$REMAINING_TAGS" | tail -1) + + echo "✅ 清理完成!" + echo "" + echo "📌 当前保留的版本:" + echo "- 最旧版本: $OLDEST" + echo "- 最新版本: $NEWEST" + echo "- 版本总数: $REMAINING_COUNT" + + # 验证是否达到预期 + if [ "$REMAINING_COUNT" -le "$TAGS_TO_KEEP" ]; then + echo "- 状态: ✅ 符合预期(≤$TAGS_TO_KEEP)" + else + echo "- 状态: ⚠️ 超出预期(某些tag可能删除失败)" + fi + else + echo "✅ 当前tag数量($TOTAL_COUNT)未超过限制($TAGS_TO_KEEP),无需清理" + fi + + # Docker构建步骤 + - name: Set up QEMU + if: steps.check.outputs.needs_bump == 'true' + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + if: steps.check.outputs.needs_bump == 'true' + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: steps.check.outputs.needs_bump == 'true' + uses: docker/login-action@v3 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to GitHub Container Registry + if: steps.check.outputs.needs_bump == 'true' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + if: steps.check.outputs.needs_bump == 'true' + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_tag }} + ${{ steps.image_names.outputs.docker_image }}:latest + ${{ steps.image_names.outputs.docker_image }}:${{ steps.next_version.outputs.new_version }} + ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_tag }} + ${{ steps.image_names.outputs.ghcr_image }}:latest + ${{ steps.image_names.outputs.ghcr_image }}:${{ steps.next_version.outputs.new_version }} + labels: | + org.opencontainers.image.version=${{ steps.next_version.outputs.new_version }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=https://github.com/${{ github.repository }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Send Telegram Notification + if: steps.check.outputs.needs_bump == 'true' && env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + DOCKER_IMAGE: ${{ steps.image_names.outputs.docker_image }} + GHCR_IMAGE: ${{ steps.image_names.outputs.ghcr_image }} + continue-on-error: true + run: | + VERSION="${{ steps.next_version.outputs.new_version }}" + TAG="${{ steps.next_version.outputs.new_tag }}" + REPO="${{ github.repository }}" + + # 获取更新内容并限制长度 + CHANGELOG="${{ steps.changelog.outputs.content }}" + CHANGELOG_TRUNCATED=$(echo "$CHANGELOG" | head -c 1000) + if [ ${#CHANGELOG} -gt 1000 ]; then + CHANGELOG_TRUNCATED="${CHANGELOG_TRUNCATED}..." + fi + + # 构建消息内容 + MESSAGE="🚀 *Claude Relay Service 新版本发布!*"$'\n'$'\n' + MESSAGE+="📦 版本号: \`${VERSION}\`"$'\n'$'\n' + MESSAGE+="📝 *更新内容:*"$'\n' + MESSAGE+="${CHANGELOG_TRUNCATED}"$'\n'$'\n' + MESSAGE+="🐳 *Docker 部署:*"$'\n' + MESSAGE+="\`\`\`bash"$'\n' + MESSAGE+="docker pull ${DOCKER_IMAGE}:${TAG}"$'\n' + MESSAGE+="docker pull ${DOCKER_IMAGE}:latest"$'\n' + MESSAGE+="docker pull ${GHCR_IMAGE}:${TAG}"$'\n' + MESSAGE+="docker pull ${GHCR_IMAGE}:latest"$'\n' + MESSAGE+="\`\`\`"$'\n'$'\n' + MESSAGE+="🔗 *相关链接:*"$'\n' + MESSAGE+="• [GitHub Release](https://github.com/${REPO}/releases/tag/${TAG})"$'\n' + MESSAGE+="• [完整更新日志](https://github.com/${REPO}/releases)"$'\n' + MESSAGE+="• [Docker Hub](https://hub.docker.com/r/${DOCKER_IMAGE%/*}/claude-relay-service)"$'\n' + MESSAGE+="• [GHCR](https://ghcr.io/${GHCR_IMAGE#ghcr.io/})"$'\n'$'\n' + MESSAGE+="#ClaudeRelay #Update #v${VERSION//./_}" + + # 使用 jq 构建 JSON 并发送 + jq -n \ + --arg chat_id "${TELEGRAM_CHAT_ID}" \ + --arg text "${MESSAGE}" \ + '{ + chat_id: $chat_id, + text: $text, + parse_mode: "Markdown", + disable_web_page_preview: false + }' | \ + curl -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d @- diff --git a/.github/workflows/pr-lint-check.yml b/.github/workflows/pr-lint-check.yml new file mode 100644 index 0000000..17de780 --- /dev/null +++ b/.github/workflows/pr-lint-check.yml @@ -0,0 +1,320 @@ +name: PR Lint and Format Check + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - '**.js' + - '**.jsx' + - '**.ts' + - '**.tsx' + - '**.vue' + - '**.json' + - '**.cjs' + - '**.mjs' + - '.prettierrc' + - '.eslintrc.cjs' + - 'package.json' + - 'web/admin-spa/**' + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + lint-and-format: + runs-on: ubuntu-latest + name: Check Code Quality + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ~/.npm + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-node- + + - name: Install dependencies + run: | + npm ci --prefer-offline --no-audit + # 安装 web 目录的依赖(如果存在) + if [ -d "web/admin-spa" ] && [ -f "web/admin-spa/package.json" ]; then + cd web/admin-spa + npm ci --prefer-offline --no-audit + cd ../.. + fi + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v41 + with: + files: | + **/*.js + **/*.jsx + **/*.ts + **/*.tsx + **/*.vue + **/*.cjs + **/*.mjs + **/*.json + files_ignore: | + node_modules/** + dist/** + build/** + coverage/** + .git/** + logs/** + temp/** + tmp/** + + - name: Check Prettier formatting + if: steps.changed-files.outputs.any_changed == 'true' + id: prettier-check + run: | + echo "🔍 Checking Prettier formatting for changed files..." + echo "Changed files: ${{ steps.changed-files.outputs.all_changed_files }}" + + # 初始化标志 + PRETTIER_FAILED=false + PRETTIER_OUTPUT="" + + # 检查每个改变的文件 + for file in ${{ steps.changed-files.outputs.all_changed_files }}; do + if [ -f "$file" ]; then + echo "Checking: $file" + + # 根据文件位置选择正确的 prettier 配置 + if [[ "$file" == web/admin-spa/* ]]; then + # 前端文件:进入前端目录运行 prettier + cd web/admin-spa + RELATIVE_FILE="${file#web/admin-spa/}" + if ! npx prettier --check "$RELATIVE_FILE" 2>&1; then + PRETTIER_FAILED=true + DIFF=$(npx prettier "$RELATIVE_FILE" | diff -u "$RELATIVE_FILE" - || true) + if [ -n "$DIFF" ]; then + PRETTIER_OUTPUT="${PRETTIER_OUTPUT}❌ File needs formatting: $file\n" + PRETTIER_OUTPUT="${PRETTIER_OUTPUT}\`\`\`diff\n${DIFF}\n\`\`\`\n\n" + fi + else + echo "✅ $file is properly formatted" + fi + cd ../.. + else + # 后端文件:使用根目录的 prettier + if ! npx prettier --check "$file" 2>&1; then + PRETTIER_FAILED=true + DIFF=$(npx prettier "$file" | diff -u "$file" - || true) + if [ -n "$DIFF" ]; then + PRETTIER_OUTPUT="${PRETTIER_OUTPUT}❌ File needs formatting: $file\n" + PRETTIER_OUTPUT="${PRETTIER_OUTPUT}\`\`\`diff\n${DIFF}\n\`\`\`\n\n" + fi + else + echo "✅ $file is properly formatted" + fi + fi + fi + done + + # 输出结果 + if [ "$PRETTIER_FAILED" = true ]; then + echo "prettier_failed=true" >> $GITHUB_OUTPUT + echo -e "$PRETTIER_OUTPUT" > prettier-report.md + echo "❌ Some files are not properly formatted." + echo "Please run: npm run format (backend) or cd web/admin-spa && npm run format (frontend)" + exit 1 + else + echo "prettier_failed=false" >> $GITHUB_OUTPUT + echo "✅ All files are properly formatted" + fi + + - name: Run ESLint + if: steps.changed-files.outputs.any_changed == 'true' + id: eslint-check + run: | + echo "🔍 Running ESLint on changed files..." + + # 分离前端和后端文件 + BACKEND_FILES="" + FRONTEND_FILES="" + + for file in ${{ steps.changed-files.outputs.all_changed_files }}; do + if [[ "$file" =~ \.(js|jsx|vue|cjs|mjs)$ ]] && [ -f "$file" ]; then + if [[ "$file" == web/admin-spa/* ]]; then + FRONTEND_FILES="$FRONTEND_FILES ${file#web/admin-spa/}" + else + BACKEND_FILES="$BACKEND_FILES $file" + fi + fi + done + + ESLINT_FAILED=false + ESLINT_OUTPUT="" + + # 检查后端文件 + if [ -n "$BACKEND_FILES" ]; then + echo "Linting backend files: $BACKEND_FILES" + set +e + BACKEND_OUTPUT=$(npx eslint $BACKEND_FILES --format stylish 2>&1) + BACKEND_EXIT_CODE=$? + set -e + + if [ $BACKEND_EXIT_CODE -ne 0 ]; then + ESLINT_FAILED=true + ESLINT_OUTPUT="${ESLINT_OUTPUT}### Backend ESLint Issues\n\`\`\`\n${BACKEND_OUTPUT}\n\`\`\`\n\n" + fi + fi + + # 检查前端文件 + if [ -n "$FRONTEND_FILES" ]; then + echo "Linting frontend files: $FRONTEND_FILES" + cd web/admin-spa + set +e + FRONTEND_OUTPUT=$(npx eslint $FRONTEND_FILES --format stylish 2>&1) + FRONTEND_EXIT_CODE=$? + set -e + cd ../.. + + if [ $FRONTEND_EXIT_CODE -ne 0 ]; then + ESLINT_FAILED=true + ESLINT_OUTPUT="${ESLINT_OUTPUT}### Frontend ESLint Issues\n\`\`\`\n${FRONTEND_OUTPUT}\n\`\`\`\n\n" + fi + fi + + # 输出结果 + if [ "$ESLINT_FAILED" = true ]; then + echo "eslint_failed=true" >> $GITHUB_OUTPUT + echo "❌ ESLint found issues" + + # 创建错误报告 + echo "## ESLint Report" > eslint-report.md + echo "$ESLINT_OUTPUT" >> eslint-report.md + echo "" >> eslint-report.md + echo "Please fix these issues by running:" >> eslint-report.md + echo '```bash' >> eslint-report.md + echo "# Backend: npm run lint" >> eslint-report.md + echo "# Frontend: cd web/admin-spa && npm run lint" >> eslint-report.md + echo '```' >> eslint-report.md + + exit 1 + else + echo "eslint_failed=false" >> $GITHUB_OUTPUT + echo "✅ ESLint check passed" + fi + + - name: Debug PR Context + if: failure() + run: | + echo "PR Number: ${{ github.event.pull_request.number }}" + echo "Repo: ${{ github.repository }}" + echo "Event Name: ${{ github.event_name }}" + echo "Actor: ${{ github.actor }}" + + - name: Comment PR with results + if: failure() + continue-on-error: true # 即使评论失败也继续 + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + let comment = '## 🚨 Code Quality Check Failed\n\n'; + + // 读取 Prettier 报告 + if (fs.existsSync('prettier-report.md')) { + const prettierReport = fs.readFileSync('prettier-report.md', 'utf8'); + comment += '### Prettier Formatting Issues\n\n'; + comment += prettierReport; + comment += '\n**Fix command:**\n```bash\nnpm run format\n```\n\n'; + } + + // 读取 ESLint 报告 + if (fs.existsSync('eslint-report.md')) { + const eslintReport = fs.readFileSync('eslint-report.md', 'utf8'); + comment += '### ESLint Issues\n\n'; + comment += eslintReport; + } + + comment += '\n---\n'; + comment += '💡 **提示**: 在本地运行以下命令来自动修复大部分问题:\n'; + comment += '```bash\n'; + comment += '# 后端代码\n'; + comment += 'npm run format # 修复后端 Prettier 格式问题\n'; + comment += 'npm run lint # 修复后端 ESLint 问题\n'; + comment += '\n'; + comment += '# 前端代码\n'; + comment += 'cd web/admin-spa\n'; + comment += 'npm run format # 修复前端 Prettier 格式问题\n'; + comment += 'npm run lint # 修复前端 ESLint 问题\n'; + comment += '```\n'; + + // 查找是否已有机器人评论 + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Code Quality Check Failed') + ); + + if (botComment) { + // 更新现有评论 + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: comment + }); + } else { + // 创建新评论 + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } + + - name: Success comment + if: success() && steps.changed-files.outputs.any_changed == 'true' + continue-on-error: true # 即使评论失败也继续 + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }} + script: | + // 查找是否已有失败的评论 + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Code Quality Check Failed') + ); + + if (botComment) { + // 如果之前有失败评论,更新为成功 + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: '## ✅ Code Quality Check Passed\n\nAll files are properly formatted and pass linting checks!' + }); + } diff --git a/.github/workflows/sync-model-pricing.yml b/.github/workflows/sync-model-pricing.yml new file mode 100644 index 0000000..da6fb56 --- /dev/null +++ b/.github/workflows/sync-model-pricing.yml @@ -0,0 +1,62 @@ +name: 同步模型价格数据 + +on: + schedule: + - cron: '*/10 * * * *' + workflow_dispatch: {} + +jobs: + sync-pricing: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: 检出 price-mirror 分支 + uses: actions/checkout@v4 + with: + ref: price-mirror + fetch-depth: 0 + + - name: 下载上游价格文件 + id: fetch + run: | + set -euo pipefail + curl -fsSL https://github.com/Wei-Shaw/model-price-repo/raw/refs/heads/main/model_prices_and_context_window.json \ + -o model_prices_and_context_window.json.new + + NEW_HASH=$(sha256sum model_prices_and_context_window.json.new | awk '{print $1}') + + if [ -f model_prices_and_context_window.sha256 ]; then + OLD_HASH=$(cat model_prices_and_context_window.sha256 | tr -d ' \n\r') + else + OLD_HASH="" + fi + + if [ "$NEW_HASH" = "$OLD_HASH" ]; then + echo "价格文件无变化,跳过提交" + echo "changed=false" >> "$GITHUB_OUTPUT" + rm -f model_prices_and_context_window.json.new + exit 0 + fi + + mv model_prices_and_context_window.json.new model_prices_and_context_window.json + echo "$NEW_HASH" > model_prices_and_context_window.sha256 + + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "hash=$NEW_HASH" >> "$GITHUB_OUTPUT" + + - name: 提交并推送变更 + if: steps.fetch.outputs.changed == 'true' + env: + NEW_HASH: ${{ steps.fetch.outputs.hash }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add model_prices_and_context_window.json model_prices_and_context_window.sha256 + COMMIT_MSG="chore: 同步模型价格数据" + if [ -n "${NEW_HASH}" ]; then + COMMIT_MSG="$COMMIT_MSG (${NEW_HASH})" + fi + git commit -m "$COMMIT_MSG" + git push origin price-mirror diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71121e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,251 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Environment variables +.env +.env.* +!.env.example + +# Claude specific directories +.claude/ + +# MCP configuration (local only) +.mcp.json +.spec-workflow/ + +# Data directory (contains sensitive information) +data/ +!data/.gitkeep + +# Redis data directory +redis_data/ + +# Logs directory +logs/ +logs1/ +*.log +startup.log +app.log + +# Configuration files (may contain sensitive data) +config/config.js +!config/config.example.js + +# Runtime data +pids/ +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage +.grunt + +# Bower dependency directory +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons +build/Release + +# Dependency directories +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +# Gatsby files +.cache/ +public + +# Vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Temporary folders +tmp/ +temp/ +.tmp/ +.temp/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Backup files +*.bak +*.backup +*.backup.* +.env.backup.* +config.js.backup.* +*~ + +# Archive files (unless specifically needed) +*.7z +*.dmg +*.gz +*.iso +*.jar +*.rar +*.tar +*.zip + +# Application specific files +# JWT secrets and encryption keys +secrets/ +keys/ +certs/ + +# Database dumps +*.sql +*.db +*.sqlite +*.sqlite3 + +# Redis dumps +dump.rdb +appendonly.aof + +# PM2 files +ecosystem.config.js +.pm2/ + +# Docker files (keep main ones, ignore volumes) +.docker/ +docker-volumes/ + +# Monitoring data +prometheus/ +grafana/ + +# Test files and coverage +test-results/ +coverage/ +.nyc_output/ + +# Documentation build +docs/build/ +docs/dist/ + +# Deployment files +deploy/ +.deploy/ + +# Package lock files (choose one) +# Uncomment the one you DON'T want to track +# package-lock.json +# yarn.lock +# pnpm-lock.yaml + +# Local development files +.local/ +local/ + +# Debug files +debug.log +error.log +access.log +http-debug*.log +logs/http-debug-*.log + +src/middleware/debugInterceptor.js + +# Session files +sessions/ + +# Upload directories +uploads/ +files/ + +# Cache directories +.cache/ +cache/ + +# Build artifacts +build/ +dist/ +out/ + +# Runtime files +*.sock + +# Old admin interface (deprecated) +web/admin/ +web/apiStats/ + +# Admin SPA build files +web/admin-spa/dist/ + +.serena/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..7c10402 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,14 @@ +{ + "semi": false, + "trailingComma": "none", + "singleQuote": true, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf", + "quoteProps": "as-needed", + "bracketSameLine": false, + "proseWrap": "preserve" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e567752 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,188 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 项目概述 + +Claude Relay Service — 多平台 AI API 中转服务,作为客户端与上游 AI API 之间的中间件。 +支持 Claude (官方/Console)、Gemini、OpenAI Responses、AWS Bedrock、Azure OpenAI、Droid、CCR 等账户类型。 +核心能力:多账户管理、API Key 认证、统一调度、代理配置、限流、成本统计。 + +## 架构原则 + +### Clean Architecture 分层映射 + +| 层级 | 目录 | 职责 | +|------|------|------| +| **框架层** | `src/routes/`, `src/middleware/` | HTTP 路由、请求验证、响应格式化 | +| **接口适配层** | `src/handlers/`, `src/services/openaiToClaude.js` | 请求/响应格式转换 | +| **用例层** | `src/services/*Scheduler.js`, `*RelayService.js` | 调度逻辑、转发编排 | +| **实体层** | `src/services/*AccountService.js`, `src/models/` | 账户管理、数据模型 | +| **基础设施层** | `src/utils/`, `config/` | 日志、缓存、加密、代理 | + +### 开发原则 + +- **依赖方向**: 外层 → 内层,内层不知道外层存在 +- **新增路由**: 只做参数提取和响应格式化,业务逻辑放 service +- **新增服务**: 先确定属于哪一层,遵循该层职责边界 +- **格式转换**: 不同 API 格式的转换放 handlers 或专用转换服务 +- **数据访问**: 通过 `src/models/redis.js` 统一访问 + +### 安全约束 + +- 敏感数据(OAuth token、refreshToken、credentials)必须 AES 加密存储(参考 `claudeAccountService.js`) +- API Key 使用 SHA-256 哈希存储,禁止明文 +- 每个请求必须经过完整认证链(API Key → 权限 → 客户端限制 → 模型黑名单) +- 客户端断开时必须通过 AbortController 清理资源和并发计数 +- 日志中禁止输出完整 token,使用 `tokenMask.js` 脱敏 + +## 项目结构 + +``` +src/ +├── routes/ # HTTP 路由 +│ ├── api.js # Claude API 主路由 +│ ├── admin/ # 管理后台路由(24个子文件) +│ ├── geminiRoutes.js, standardGeminiRoutes.js +│ ├── openaiRoutes.js, openaiClaudeRoutes.js, openaiGeminiRoutes.js +│ ├── azureOpenaiRoutes.js, droidRoutes.js +│ ├── userRoutes.js, webhook.js, unified.js, apiStats.js, web.js +├── middleware/ # auth.js(认证/权限/限流), browserFallback.js +├── handlers/ # geminiHandlers.js +├── services/ # 业务服务 +│ ├── relay/ # 各平台转发服务(9个) +│ ├── account/ # 各平台账户管理(11个) +│ ├── scheduler/ # 统一调度器(4个) +│ ├── apiKeyService.js # API Key 管理 +│ ├── pricingService.js # 定价和成本 +│ └── ... # 其余 ~30 个业务服务 +├── models/redis.js # Redis 数据模型 +├── utils/ # 35+ 工具文件(logger, proxy, oauth, cache, stream...) +config/config.js # 主配置 +scripts/ # 运维脚本 +cli/ # CLI 工具 +web/admin-spa/ # Vue SPA 管理界面 +data/init.json # 管理员凭据 +``` + +## 核心请求流程 + +``` +客户端(cr_前缀Key) → 路由 → auth中间件(验证/权限/限流/模型黑名单) + → 统一调度器(选账户/粘性会话) → Token检查/刷新 + → 转发服务(通过代理发送) → 上游API + → 流式/非流式响应 → Usage捕获 → 成本计算 → 返回客户端 +``` + +关键机制: +- **粘性会话**: 基于请求内容 hash 绑定账户,同一会话用同一账户 +- **并发控制**: Redis Sorted Set 实现,支持排队等待(非直接 429) +- **529 处理**: 自动标记过载账户,配置时长内排除 +- **加密存储**: 敏感数据(OAuth token、credentials)AES 加密存于 Redis +- **流式响应**: SSE 传输,实时捕获 usage,客户端断开时 AbortController 清理资源 + +## 开发规范 + +### 代码风格 + +- **无分号**、**单引号**、**100字符行宽**、**尾逗号 none**、**箭头函数始终加括号** +- 强制 `const`(`no-var`、`prefer-const`),严格相等(`eqeqeq`) +- 下划线前缀变量 `_var` 可豁免 unused 检查 +- **必须使用 Prettier**: `npx prettier --write ` +- 前端额外安装了 `prettier-plugin-tailwindcss` + +### 开发工作流 + +1. **理解现有代码** → 读相关文件,了解现有模式 +2. **编写代码** → 重用已有服务和工具函数 +3. **格式化** → `npx prettier --write <修改的文件>` +4. **检查** → `npm run lint` +5. **测试** → `npm test` +6. **验证** → `npm run cli status` 确认服务正常 + +### 测试规范 + +- 测试文件在 `tests/` 目录,命名 `*.test.js` 或 `*.spec.js` +- 使用 `jest.mock()` 模拟依赖(logger、redis、services) +- `beforeEach` 中 `jest.resetModules()`,`afterEach` 中 `jest.clearAllMocks()` + +### 前端要求 + +- 技术栈:Vue 3 Composition API + Pinia + Element Plus + Tailwind CSS +- 响应式设计:Tailwind CSS 响应式前缀(sm:、md:、lg:、xl:) +- 暗黑模式:所有组件必须兼容,使用 `dark:` 前缀 +- 主题切换:`web/admin-spa/src/stores/theme.js` 的 `useThemeStore()` +- 保持现有玻璃态设计风格 + +暗黑模式配色对照: + +| 元素 | 明亮模式 | 暗黑模式 | +|------|----------|----------| +| 文本 | `text-gray-700` | `dark:text-gray-200` | +| 背景 | `bg-white` | `dark:bg-gray-800` | +| 边框 | `border-gray-200` | `dark:border-gray-700` | +| 状态色 | `text-blue-500` / `text-green-600` / `text-red-500` | 保持一致 | + +### 代码修改原则 + +- 先检查现有模式和风格,重用已有服务和工具函数 +- 敏感数据必须加密存储(参考 claudeAccountService.js) +- 遵循现有的错误处理和日志记录模式 + +## 常用命令 + +```bash +npm install && npm run setup # 初始化 +npm run dev # 开发模式(nodemon 热重载,自动 lint) +npm start # 生产模式(先 lint 再启动) +npm run lint # ESLint 检查并自动修复 +npm run lint:check # ESLint 仅检查不修复 +npm run format # Prettier 格式化所有后端文件 +npm run format:check # Prettier 仅检查格式 +npm test # Jest 运行所有测试(tests/ 目录) +npm test -- <文件名> # 运行单个测试,如: npm test -- pricingService +npm test -- --coverage # 运行测试并生成覆盖率报告 +npm run cli status # 系统状态 +npm run data:export # 导出 Redis 数据 +npm run data:debug # 调试 Redis 键 +``` + +### 前端命令 + +```bash +npm run install:web # 安装前端依赖 +npm run build:web # 构建前端(生成 dist) +cd web/admin-spa && npm run dev # 前端开发模式(Vite HMR) +``` + +## 环境变量(必须) + +- `JWT_SECRET` — JWT 密钥(32字符+) +- `ENCRYPTION_KEY` — AES 加密密钥(32字符固定) +- `REDIS_HOST` / `REDIS_PORT` / `REDIS_PASSWORD` — Redis 连接 + +其他可选环境变量见 `.env.example`。 + +## 故障排除 + +| 问题 | 排查方向 | +|------|----------| +| Redis 连接失败 | 检查 REDIS_HOST/PORT/PASSWORD | +| 管理员登录失败 | 检查 data/init.json,运行 `npm run setup` | +| API Key 格式错误 | 确保使用 `cr_` 前缀格式(可通过 API_KEY_PREFIX 配置) | +| Token 刷新失败 | 检查 refreshToken 有效性和代理配置,查看 `logs/token-refresh-error.log` | +| 调度器选账户失败 | 检查账户 status:'active',确认类型与路由匹配,查看粘性会话绑定 | +| 并发计数泄漏 | 系统每分钟自动清理,重启也会清理 | +| 粘性会话失效 | 检查 Redis 中 session 数据,Nginx 代理需添加 `underscores_in_headers on` | +| LDAP 认证失败 | 检查 LDAP_URL/BIND_DN/BIND_PASSWORD,自签名证书设 `LDAP_TLS_REJECT_UNAUTHORIZED=false` | +| Webhook 通知失败 | 确认 WEBHOOK_ENABLED=true,检查 WEBHOOK_URLS 格式,查看 `logs/webhook-*.log` | +| 成本统计不准确 | 运行 `npm run init:costs`,检查 pricingService 模型价格 | + +日志:`logs/` 目录。Web 界面 `/admin-next/` 可实时查看。 + +# important-instruction-reminders + +Do what has been asked; nothing more, nothing less. +NEVER create files unless they're absolutely necessary for achieving your goal. +ALWAYS prefer editing an existing file to creating a new one. +NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..932506a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,84 @@ +# 🎯 后端依赖阶段 (与前端构建并行) +FROM node:18-alpine AS backend-deps + +# 📁 设置工作目录 +WORKDIR /app + +# 📦 复制 package 文件 +COPY package*.json ./ + +# 🔽 安装依赖 (生产环境) - 使用 BuildKit 缓存加速 +RUN --mount=type=cache,target=/root/.npm \ + npm ci --only=production + +# 🎯 前端构建阶段 (与后端依赖并行) +FROM node:18-alpine AS frontend-builder + +# 📁 设置工作目录 +WORKDIR /app/web/admin-spa + +# 📦 复制前端依赖文件 +COPY web/admin-spa/package*.json ./ + +# 🔽 安装前端依赖 - 使用 BuildKit 缓存加速 +RUN --mount=type=cache,target=/root/.npm \ + npm ci + +# 📋 复制前端源代码 +COPY web/admin-spa/ ./ + +# 🏗️ 构建前端 +RUN npm run build + +# 🐳 主应用阶段 +FROM node:18-alpine + +# 📋 设置标签 +LABEL maintainer="claude-relay-service@example.com" +LABEL description="Claude Code API Relay Service" +LABEL version="1.0.0" + +# 🔧 安装系统依赖 +RUN apk add --no-cache \ + curl \ + dumb-init \ + sed \ + && rm -rf /var/cache/apk/* + +# 📁 设置工作目录 +WORKDIR /app + +# 📦 复制 package 文件 (用于版本信息等) +COPY package*.json ./ + +# 📦 从后端依赖阶段复制 node_modules (已预装好) +COPY --from=backend-deps /app/node_modules ./node_modules + +# 📋 复制应用代码 +COPY . . + +# 📦 从前端构建阶段复制前端产物 +COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist + +# 🔧 复制并设置启动脚本权限 +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# 📁 创建必要目录 +RUN mkdir -p logs data temp + +# 🔧 预先创建配置文件 +RUN if [ ! -f "/app/config/config.js" ] && [ -f "/app/config/config.example.js" ]; then \ + cp /app/config/config.example.js /app/config/config.js; \ + fi + +# 🌐 暴露端口 +EXPOSE 3000 + +# 🏥 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/health || exit 1 + +# 🚀 启动应用 +ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker-entrypoint.sh"] +CMD ["node", "src/app.js"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..05c83f7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Wesley Liddick + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..688df46 --- /dev/null +++ b/Makefile @@ -0,0 +1,263 @@ +# Claude Relay Service Makefile +# 功能完整的 AI API 中转服务,支持 Claude 和 Gemini 双平台 + +.PHONY: help install setup dev start test lint clean docker-up docker-down service-start service-stop service-status logs cli-admin cli-keys cli-accounts cli-status ci-release-trigger + +# 默认目标:显示帮助信息 +help: + @echo "Claude Relay Service - AI API 中转服务" + @echo "" + @echo "可用命令:" + @echo "" + @echo " 📦 安装和初始化:" + @echo " install - 安装项目依赖" + @echo " install-web - 安装Web界面依赖" + @echo " setup - 生成配置文件和管理员凭据" + @echo " clean - 清理依赖和构建文件" + @echo "" + @echo " 🎨 前端构建:" + @echo " build-web - 构建 Web 管理界面" + @echo " build-all - 构建完整项目(后端+前端)" + @echo "" + @echo " 🚀 开发和运行:" + @echo " dev - 开发模式运行(热重载)" + @echo " start - 生产模式运行" + @echo " test - 运行测试套件" + @echo " lint - 代码风格检查" + @echo "" + @echo " 🐳 Docker 部署:" + @echo " docker-up - 启动 Docker 服务" + @echo " docker-up-full - 启动 Docker 服务(包含监控)" + @echo " docker-down - 停止 Docker 服务" + @echo " docker-logs - 查看 Docker 日志" + @echo "" + @echo " 🔧 服务管理:" + @echo " service-start - 前台启动服务" + @echo " service-daemon - 后台启动服务(守护进程)" + @echo " service-stop - 停止服务" + @echo " service-restart - 重启服务" + @echo " service-restart-daemon - 重启服务(守护进程)" + @echo " service-status - 查看服务状态" + @echo " logs - 查看应用日志" + @echo " logs-follow - 实时查看日志" + @echo "" + @echo " ⚙️ CLI 管理工具:" + @echo " cli-admin - 管理员操作" + @echo " cli-keys - API Key 管理" + @echo " cli-accounts - Claude 账户管理" + @echo " cli-status - 系统状态查看" + @echo "" + @echo " 💡 快速开始:" + @echo " make setup && make dev" + @echo "" + +# 安装和初始化 +install: + @echo "📦 安装项目依赖..." + npm install + +install-web: + @echo "📦 安装 Web 界面依赖..." + npm run install:web + +# 前端构建 +build-web: + @echo "🎨 构建 Web 管理界面..." + npm run build:web + +build-all: install install-web build-web + @echo "🎉 完整项目构建完成!" + +setup: + @echo "⚙️ 初始化项目配置和管理员凭据..." + @if [ ! -f config/config.js ]; then cp config/config.example.js config/config.js; fi + @if [ ! -f .env ]; then cp .env.example .env; fi + npm run setup + +clean: + @echo "🧹 清理依赖和构建文件..." + rm -rf node_modules + rm -rf web/node_modules + rm -rf web/admin-spa/dist + rm -rf web/admin-spa/node_modules + rm -rf logs/*.log + +# 开发和运行 +dev: + @echo "🚀 启动开发模式(热重载)..." + npm run dev + +start: + @echo "🚀 启动生产模式..." + npm start + +test: + @echo "🧪 运行测试套件..." + npm test + +lint: + @echo "🔍 执行代码风格检查..." + npm run lint + +# Docker 部署 +docker-up: + @echo "🐳 启动 Docker 服务..." + docker-compose up -d + +docker-up-full: + @echo "🐳 启动 Docker 服务(包含监控)..." + docker-compose --profile monitoring up -d + +docker-down: + @echo "🛑 停止 Docker 服务..." + docker-compose down + +docker-logs: + @echo "📋 查看 Docker 服务日志..." + docker-compose logs -f + +# 服务管理 +service-start: + @echo "🚀 前台启动服务..." + npm run service:start + +service-daemon: + @echo "🔧 后台启动服务(守护进程)..." + npm run service:start:daemon + +service-stop: + @echo "🛑 停止服务..." + npm run service:stop + +service-restart: + @echo "🔄 重启服务..." + npm run service:restart + +service-restart-daemon: + @echo "🔄 重启服务(守护进程)..." + npm run service:restart:daemon + +service-status: + @echo "📊 查看服务状态..." + npm run service:status + +logs: + @echo "📋 查看应用日志..." + npm run service:logs + +logs-follow: + @echo "📋 实时查看日志..." + npm run service:logs:follow + +# CLI 管理工具 +cli-admin: + @echo "👤 启动管理员操作 CLI..." + npm run cli admin + +cli-keys: + @echo "🔑 启动 API Key 管理 CLI..." + npm run cli keys + +cli-accounts: + @echo "👥 启动 Claude 账户管理 CLI..." + npm run cli accounts + +cli-status: + @echo "📊 查看系统状态..." + npm run cli status + +# 开发辅助命令 +check-config: + @echo "🔍 检查配置文件..." + @if [ ! -f config/config.js ]; then echo "❌ config/config.js 不存在,请运行 'make setup'"; exit 1; fi + @if [ ! -f .env ]; then echo "❌ .env 不存在,请运行 'make setup'"; exit 1; fi + @echo "✅ 配置文件检查通过" + +health-check: + @echo "🏥 执行健康检查..." + @curl -s http://localhost:3000/health || echo "❌ 服务未运行或不可访问" + +# 快速启动组合命令 +quick-start: setup dev + +quick-daemon: setup service-daemon + @echo "🎉 服务已在后台启动!" + @echo "运行 'make service-status' 查看状态" + @echo "运行 'make logs-follow' 查看实时日志" + +# CI 触发占位目标:用于在不影响功能的情况下触发自动发布 +ci-release-trigger: + @echo "⚙️ 触发自动发布流水线的占位目标,避免引入功能变更" + +# 全栈开发环境 +dev-full: install install-web build-web setup dev + @echo "🚀 全栈开发环境启动!" + +# 完整部署流程 +deploy: clean install install-web build-web setup test lint docker-up + @echo "🎉 部署完成!" + @echo "访问 Web 管理界面: http://localhost:3000/web" + @echo "API 端点: http://localhost:3000/api/v1/messages" + +# 生产部署准备 +production-build: clean install install-web build-web + @echo "🚀 生产环境构建完成!" + +# 维护命令 +backup-redis: + @echo "💾 备份 Redis 数据..." + @docker exec claude-relay-service-redis-1 redis-cli BGSAVE || echo "❌ Redis 备份失败" + +restore-redis: + @echo "♻️ 恢复 Redis 数据..." + @echo "请手动恢复 Redis 数据文件" + +# 监控和日志 +monitor: + @echo "📊 启动监控面板..." + @echo "Grafana: http://localhost:3001" + @echo "Redis Commander: http://localhost:8081" + +tail-logs: + @echo "📋 实时查看日志..." + tail -f logs/claude-relay-*.log + +# 开发工具 +format: + @echo "🎨 格式化代码..." + npm run lint -- --fix + +check-deps: + @echo "🔍 检查依赖更新..." + npm outdated + +update-deps: + @echo "⬆️ 更新依赖..." + npm update + +# 测试相关 +test-coverage: + @echo "📊 运行测试覆盖率..." + npm test -- --coverage + +test-watch: + @echo "👀 监视模式运行测试..." + npm test -- --watch + +# Git 相关 +git-status: + @echo "📋 Git 状态..." + git status --short + +git-pull: + @echo "⬇️ 拉取最新代码..." + git pull origin main + +# 安全检查 +security-audit: + @echo "🔒 执行安全审计..." + npm audit + +security-fix: + @echo "🔧 修复安全漏洞..." + npm audit fix diff --git a/README.md b/README.md new file mode 100644 index 0000000..54066b3 --- /dev/null +++ b/README.md @@ -0,0 +1,1010 @@ +# Claude Relay Service + +> [!CAUTION] +> **安全更新通知**:v1.1.248 及以下版本存在严重的管理员认证绕过漏洞,攻击者可未授权访问管理面板。 +> +> **请立即更新到 v1.1.249+ 版本**,或迁移到新一代项目 **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)** + +
+ +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/) +[![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/) +[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) +[![Docker Build](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml/badge.svg)](https://github.com/Wei-Shaw/claude-relay-service/actions/workflows/auto-release-pipeline.yml) +[![Docker Pulls](https://img.shields.io/docker/pulls/weishaw/claude-relay-service)](https://hub.docker.com/r/weishaw/claude-relay-service) + +**🔐 自行搭建Claude API中转服务,支持多账户管理** + +[English](README_EN.md) • [快速开始](https://pincc.ai/) • [演示站点](https://demo.pincc.ai/admin-next/login) • [公告频道](https://t.me/claude_relay_service) + +
+ +--- + +## ⚠️ 重要提醒 + +**使用本项目前请仔细阅读:** + +🚨 **服务条款风险**: 使用本项目可能违反Anthropic的服务条款。请在使用前仔细阅读Anthropic的用户协议,使用本项目的一切风险由用户自行承担。 + +📖 **免责声明**: 本项目仅供技术学习和研究使用,作者不对因使用本项目导致的账户封禁、服务中断或其他损失承担任何责任。 + + +## 🤔 这个项目适合你吗? + +- 🌍 **地区限制**: 所在地区无法直接访问Claude Code服务? +- 🔒 **隐私担忧**: 担心第三方镜像服务会记录或泄露你的对话内容? +- 👥 **成本分摊**: 想和朋友一起分摊Claude Code Max订阅费用? +- ⚡ **稳定性**: 第三方镜像站经常故障不稳定,影响效率 ? + +如果有以上困惑,那这个项目可能适合你。 + +### 适合的场景 + +✅ **找朋友拼车**: 三五好友一起分摊Claude Code Max订阅 +✅ **隐私敏感**: 不想让第三方镜像看到你的对话内容 +✅ **技术折腾**: 有基本的技术基础,愿意自己搭建和维护 +✅ **稳定需求**: 需要长期稳定的Claude访问,不想受制于镜像站 +✅ **地区受限**: 无法直接访问Claude官方服务 + +--- + +## 💭 为什么要自己搭? + +### 现有镜像站可能的问题 + +- 🕵️ **隐私风险**: 你的对话内容都被人家看得一清二楚,商业机密什么的就别想了 +- 🐌 **性能不稳**: 用的人多了就慢,高峰期经常卡死 +- 💰 **价格不透明**: 不知道实际成本 + +### 自建的好处 + +- 🔐 **数据安全**: 所有接口请求都只经过你自己的服务器,直连Anthropic API +- ⚡ **性能可控**: 就你们几个人用,Max 200刀套餐基本上可以爽用Opus +- 💰 **成本透明**: 用了多少token一目了然,按官方价格换算了具体费用 +- 📊 **监控完整**: 使用情况、成本分析、性能监控全都有 + +--- + +## 🚀 核心功能 + +### 基础功能 + +- ✅ **多账户管理**: 可以添加多个Claude账户自动轮换 +- ✅ **自定义API Key**: 给每个人分配独立的Key +- ✅ **使用统计**: 详细记录每个人用了多少token + +### 高级功能 + +- 🔄 **智能切换**: 账户出问题自动换下一个 +- 🚀 **性能优化**: 连接池、缓存,减少延迟 +- 📊 **监控面板**: Web界面查看所有数据 +- 🛡️ **安全控制**: 访问限制、速率控制、客户端限制 +- 🌐 **代理支持**: 支持HTTP/SOCKS5代理 + +--- + +## 📋 部署要求 + +### 硬件要求(最低配置) + +- **CPU**: 1核心就够了 +- **内存**: 512MB(建议1GB) +- **硬盘**: 30GB可用空间 +- **网络**: 能访问到Anthropic API(建议使用US地区的机器) +- **建议**: 2核4G的基本够了,网络尽量选回国线路快一点的(为了提高速度,建议不要开代理或者设置服务器的IP直连) +- **经验**: 阿里云、腾讯云的海外主机经测试会被Cloudflare拦截,无法直接访问claude api + +### 软件要求 + +- **Node.js** 18或更高版本 +- **Redis** 6或更高版本 +- **操作系统**: 建议Linux + +### 费用估算 + +- **服务器**: 轻量云服务器,一个月30-60块 +- **Claude订阅**: 看你怎么分摊了 +- **其他**: 域名(可选) + +--- + +## 🚀 脚本部署(推荐) + +推荐使用管理脚本进行一键部署,简单快捷,自动处理所有依赖和配置。 + +### 快速安装 + +```bash +curl -fsSL https://pincc.ai/manage.sh -o manage.sh && chmod +x manage.sh && ./manage.sh install +``` + +### 脚本功能 + +- ✅ **一键安装**: 自动检测系统环境,安装 Node.js 18+、Redis 等依赖 +- ✅ **交互式配置**: 友好的配置向导,设置端口、Redis 连接等 +- ✅ **自动启动**: 安装完成后自动启动服务并显示访问地址 +- ✅ **便捷管理**: 通过 `crs` 命令随时管理服务状态 + +### 管理命令 + +```bash +crs install # 安装服务 +crs start # 启动服务 +crs stop # 停止服务 +crs restart # 重启服务 +crs status # 查看状态 +crs update # 更新服务 +crs uninstall # 卸载服务 +``` + +### 安装示例 + +```bash +$ crs install + +# 会依次询问: +安装目录 (默认: ~/claude-relay-service): +服务端口 (默认: 3000): 8080 +Redis 地址 (默认: localhost): +Redis 端口 (默认: 6379): +Redis 密码 (默认: 无密码): + +# 安装完成后自动启动并显示: +服务已成功安装并启动! + +访问地址: + 本地 Web: http://localhost:8080/web + 公网 Web: http://YOUR_IP:8080/web + +管理员账号信息已保存到: data/init.json +``` + +### 系统要求 + +- 支持系统: Ubuntu/Debian、CentOS/RedHat、Arch Linux、macOS +- 自动安装 Node.js 18+ 和 Redis +- Redis 使用系统默认位置,数据独立于应用 + +--- + +## 📦 手动部署 + +### 第一步:环境准备 + +**Ubuntu/Debian用户:** + +```bash +# 安装Node.js +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - +sudo apt-get install -y nodejs + +# 安装Redis +sudo apt update +sudo apt install redis-server +sudo systemctl start redis-server +``` + +**CentOS/RHEL用户:** + +```bash +# 安装Node.js +curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash - +sudo yum install -y nodejs + +# 安装Redis +sudo yum install redis +sudo systemctl start redis +``` + +### 第二步:下载和配置 + +```bash +# 下载项目 +git clone https://github.com/Wei-Shaw//claude-relay-service.git +cd claude-relay-service + +# 安装依赖 +npm install + +# 复制配置文件(重要!) +cp config/config.example.js config/config.js +cp .env.example .env +``` + +### 第三步:配置文件设置 + +**编辑 `.env` 文件:** + +```bash +# 这两个密钥随便生成,但要记住 +JWT_SECRET=你的超级秘密密钥 +ENCRYPTION_KEY=32位的加密密钥随便写 + +# Redis配置 +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= + +``` + +**编辑 `config/config.js` 文件:** + +```javascript +module.exports = { + server: { + port: 3000, // 服务端口,可以改 + host: '0.0.0.0' // 不用改 + }, + redis: { + host: '127.0.0.1', // Redis地址 + port: 6379 // Redis端口 + } + // 其他配置保持默认就行 +} +``` + +### 第四步:安装前端依赖并构建 + +```bash +# 安装前端依赖 +npm run install:web + +# 构建前端(生成 dist 目录) +npm run build:web +``` + +### 第五步:启动服务 + +```bash +# 初始化 +npm run setup # 会随机生成后台账号密码信息,存储在 data/init.json +# 或者通过环境变量预设管理员凭据: +# export ADMIN_USERNAME=cr_admin_custom +# export ADMIN_PASSWORD=your-secure-password + +# 启动服务 +npm run service:start:daemon # 后台运行 + +# 查看状态 +npm run service:status +``` + +--- + +## 🐳 Docker 部署 + +### Docker compose + +#### 第一步:下载构建docker-compose.yml文件的脚本并执行 +```bash +curl -fsSL https://pincc.ai/crs-compose.sh -o crs-compose.sh && chmod +x crs-compose.sh && ./crs-compose.sh +``` + +#### 第二步:启动 +```bash +docker-compose up -d +``` + +### Docker Compose 配置 + +docker-compose.yml 已包含: + +- ✅ 自动初始化管理员账号 +- ✅ 数据持久化(logs和data目录自动挂载) +- ✅ Redis数据库 +- ✅ 健康检查 +- ✅ 自动重启 + +### 环境变量说明 + +#### 必填项 + +- `JWT_SECRET`: JWT密钥,至少32个字符 +- `ENCRYPTION_KEY`: 加密密钥,必须是32个字符 + +#### 可选项 + +- `ADMIN_USERNAME`: 管理员用户名(不设置则自动生成) +- `ADMIN_PASSWORD`: 管理员密码(不设置则自动生成) +- `LOG_LEVEL`: 日志级别(默认:info) +- 更多配置项请参考 `.env.example` 文件 + +### 管理员凭据获取方式 + +1. **查看容器日志** + + ```bash + docker logs claude-relay-service + ``` + +2. **查看挂载的文件** + + ```bash + cat ./data/init.json + ``` + +3. **使用环境变量预设** + ```bash + # 在 .env 文件中设置 + ADMIN_USERNAME=cr_admin_custom + ADMIN_PASSWORD=your-secure-password + ``` + +--- + +## 🎮 开始使用 + +### 1. 打开管理界面 + +浏览器访问:`http://你的服务器IP:3000/web` + +管理员账号: + +- 自动生成:查看 data/init.json +- 环境变量预设:通过 ADMIN_USERNAME 和 ADMIN_PASSWORD 设置 +- Docker 部署:查看容器日志 `docker logs claude-relay-service` + +### 2. 添加Claude账户 + +这一步比较关键,需要OAuth授权: + +1. 点击「Claude账户」标签 +2. 如果你担心多个账号共用1个IP怕被封禁,可以选择设置静态代理IP(可选) +3. 点击「添加账户」 +4. 点击「生成授权链接」,会打开一个新页面 +5. 在新页面完成Claude登录和授权 +6. 复制返回的Authorization Code +7. 粘贴到页面完成添加 + +**注意**: 如果你在国内,这一步可能需要科学上网。 + +### 2.1 临时暂停(503/5xx)与账号级 TTL 覆盖 + +系统会在上游异常时临时暂停账号路由,默认由全局配置控制(见 `.env.example`): + +- `UPSTREAM_ERROR_503_TTL_SECONDS` +- `UPSTREAM_ERROR_5XX_TTL_SECONDS` +- `UPSTREAM_ERROR_OVERLOAD_TTL_SECONDS` +- `UPSTREAM_ERROR_AUTH_TTL_SECONDS` +- `UPSTREAM_ERROR_TIMEOUT_TTL_SECONDS` + +在管理后台编辑 **Claude 官方 OAuth 账号** 时,可做账号级覆盖: + +- `禁用该账号临时冷却`:该账号不再因 503/5xx 进入临时暂停 +- `503 冷却秒数`:留空=跟随全局,`0`=关闭该账号 503 冷却 +- `5xx 冷却秒数`:留空=跟随全局,`0`=关闭该账号 5xx 冷却 + +优先级从高到低: + +1. 账号级“禁用临时冷却” +2. 账号级 503/5xx 冷却秒数 +3. 代码调用时传入的自定义 TTL(若有) +4. 全局环境变量默认值 + +账户列表会显示“不可路由原因”,包含错误类型、HTTP 状态码、内部冷却总时长、剩余时间和预计恢复时间;点击 `重置状态` 可清除异常状态并恢复参与路由。 + +### 3. 创建API Key + +给每个使用者分配一个Key: + +1. 点击「API Keys」标签 +2. 点击「创建新Key」 +3. 给Key起个名字,比如「张三的Key」 +4. 设置使用限制(可选): + - **速率限制**: 限制每个时间窗口的请求次数和Token使用量 + - **并发限制**: 限制同时处理的请求数 + - **模型限制**: 限制可访问的模型列表 + - **客户端限制**: 限制只允许特定客户端使用(如ClaudeCode、Gemini-CLI等) +5. 保存,记下生成的Key + +### 4. 开始使用 Claude Code 和 Gemini CLI + +现在你可以用自己的服务替换官方API了: + +**Claude Code 设置环境变量:** + + +**使用标准 Claude 账号池** + +默认使用标准 Claude 账号池: + +```bash +export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # 根据实际填写你服务器的ip地址或者域名 +export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" +``` + +**使用 Antigravity 账户池** + +适用于通过 Antigravity 渠道使用 Claude 模型(如 `claude-opus-4-5` 等)。 + +```bash +# 1. 设置 Base URL 为 Antigravity 专用路径 +export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/antigravity/api/" + +# 2. 设置 API Key(在后台创建,权限需包含 'all' 或 'gemini') +export ANTHROPIC_AUTH_TOKEN="后台创建的API密钥" + +# 3. 指定模型名称(直接使用短名,无需前缀!) +export ANTHROPIC_MODEL="claude-opus-4-5" + +# 4. 启动 +claude +``` + +**VSCode Claude 插件配置:** + +如果使用 VSCode 的 Claude 插件,需要在 `~/.claude/config.json` 文件中配置: + +```json +{ + "primaryApiKey": "crs" +} +``` + +如果该文件不存在,请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。 + +> 💡 **IntelliJ IDEA 用户推荐**:[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE,支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。 + +**Gemini CLI 设置环境变量:** + +**方式一(推荐):通过 Gemini Assist API 方式访问** + +```bash +CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 +GOOGLE_CLOUD_ACCESS_TOKEN="后台创建的API密钥" +GOOGLE_GENAI_USE_GCA="true" +GEMINI_MODEL="gemini-2.5-pro" # 如果你有gemini3权限可以填: gemini-3-pro-preview +``` + +> **认证**:只能选 ```Login with Google``` 进行认证,如果跳 Google请删除 ```~/.gemini/settings.json``` 后再尝试启动```gemini```。 +> **注意**:gemini-cli 控制台会提示 `Failed to fetch user info: 401 Unauthorized`,但使用不受任何影响。 + +**方式二:通过 Gemini API 方式访问** + + +```bash +GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # 根据实际填写你服务器的ip地址或者域名 +GEMINI_API_KEY="后台创建的API密钥" +GEMINI_MODEL="gemini-2.5-pro" # 如果你有gemini3权限可以填: gemini-3-pro-preview +``` + +> **认证**:只能选 ```Use Gemini API Key``` 进行认证,如果提示 ```Enter Gemini API Key``` 请直接留空按回车。如果一打开就跳 Google请删除 ```~/.gemini/settings.json``` 后再尝试启动```gemini```。 + +> 💡 **进阶用法**:想在 Claude Code 中直接使用 Gemini 3 模型?请参考 [Claude Code 调用 Gemini 3 模型指南](docs/claude-code-gemini3-guide/README.md) + +**使用 Claude Code:** + +```bash +claude +``` + +**使用 Gemini CLI:** + +```bash +gemini # 或其他 Gemini CLI 命令 +``` + +**Codex 配置:** + +在 `~/.codex/config.toml` 文件**开头**添加以下配置: + +```toml +model_provider = "crs" +model = "gpt-5.5" +model_reasoning_effort = "high" +disable_response_storage = true +preferred_auth_method = "apikey" + +[model_providers.crs] +name = "crs" +base_url = "http://127.0.0.1:3000/openai" # 根据实际填写你服务器的ip地址或者域名 +wire_api = "responses" +requires_openai_auth = true +``` + +在 `~/.codex/auth.json` 文件中配置API密钥为 null: + +```json +{ + "OPENAI_API_KEY": "后台创建的API密钥" +} +``` + +> ⚠️ 在通过 Nginx 反向代理 CRS 服务并使用 Codex CLI 时,需要在 http 块中添加 underscores_in_headers on;。因为 Nginx 默认会移除带下划线的请求头(如 session_id),一旦该头被丢弃,多账号环境下的粘性会话功能将失效。 + +**Droid CLI 配置:** + +Droid CLI 读取 `~/.factory/config.json`。可以在该文件中添加自定义模型以指向本服务的新端点: + +```json +{ + "custom_models": [ + { + "model_display_name": "Opus 4.5 [crs]", + "model": "claude-opus-4-5-20251101", + "base_url": "http://127.0.0.1:3000/droid/claude", + "api_key": "后台创建的API密钥", + "provider": "anthropic", + "max_tokens": 64000 + }, + { + "model_display_name": "GPT5.5 [crs]", + "model": "gpt-5.5", + "base_url": "http://127.0.0.1:3000/droid/openai", + "api_key": "后台创建的API密钥", + "provider": "openai", + "max_tokens": 16384 + }, + { + "model_display_name": "Gemini-3-Pro [crs]", + "model": "gemini-3-pro-preview", + "base_url": "http://127.0.0.1:3000/droid/comm/v1/", + "api_key": "后台创建的API密钥", + "provider": "generic-chat-completion-api", + "max_tokens": 65535 + }, + { + "model_display_name": "GLM-4.6 [crs]", + "model": "glm-4.6", + "base_url": "http://127.0.0.1:3000/droid/comm/v1/", + "api_key": "后台创建的API密钥", + "provider": "generic-chat-completion-api", + "max_tokens": 202800 + } + ] +} +``` + +> 💡 将示例中的 `http://127.0.0.1:3000` 替换为你的服务域名或公网地址,并写入后台生成的 API 密钥(cr_ 开头)。 + +### 5. 第三方工具API接入 + +本服务支持多种API端点格式,方便接入不同的第三方工具(如Cherry Studio等)。 + +#### Cherry Studio 接入示例 + +Cherry Studio支持多种AI服务的接入,下面是不同账号类型的详细配置: + +**1. Claude账号接入:** + +``` +# API地址 +http://你的服务器:3000/claude + +# 模型ID示例 +claude-sonnet-4-5-20250929 # Claude Sonnet 4.5 +claude-opus-4-20250514 # Claude Opus 4 +``` + +配置步骤: +- 供应商类型选择"Anthropic" +- API地址填入:`http://你的服务器:3000/claude` +- API Key填入:后台创建的API密钥(cr_开头) + +**2. Gemini账号接入:** + +``` +# API地址 +http://你的服务器:3000/gemini + +# 模型ID示例 +gemini-2.5-pro # Gemini 2.5 Pro +``` + +配置步骤: +- 供应商类型选择"Gemini" +- API地址填入:`http://你的服务器:3000/gemini` +- API Key填入:后台创建的API密钥(cr_开头) + +**3. Codex接入:** + +``` +# API地址 +http://你的服务器:3000/openai + +# 模型ID(固定) +gpt-5 # Codex使用固定模型ID +``` + +配置步骤: +- 供应商类型选择"Openai-Response" +- API地址填入:`http://你的服务器:3000/openai` +- API Key填入:后台创建的API密钥(cr_开头) +- **重要**:Codex只支持Openai-Response标准 + + +**Cherry Studio 地址格式重要说明:** + +- ✅ **推荐格式**:`http://你的服务器:3000/claude`(不加结尾 `/`,让 Cherry Studio 自动加上 v1) +- ✅ **等效格式**:`http://你的服务器:3000/claude/v1/`(手动指定 v1 并加结尾 `/`) +- 💡 **说明**:这两种格式在 Cherry Studio 中是完全等效的 +- ❌ **错误格式**:`http://你的服务器:3000/claude/`(单独的 `/` 结尾会被 Cherry Studio 忽略 v1 版本) + +#### 其他第三方工具接入 + +**接入要点:** + +- 所有账号类型都使用相同的API密钥(在后台统一创建) +- 根据不同的路由前缀自动识别账号类型 +- `/claude/` - 使用Claude账号池 +- `/antigravity/api/` - 使用Antigravity账号池(推荐用于Claude Code) +- `/droid/claude/` - 使用Droid类型Claude账号池(只建议api调用或Droid Cli中使用) +- `/gemini/` - 使用Gemini账号池 +- `/openai/` - 使用Codex账号(只支持Openai-Response格式) +- `/droid/openai/` - 使用Droid类型OpenAI兼容账号池(只建议api调用或Droid Cli中使用) +- 支持所有标准API端点(messages、models等) + +**重要说明:** + +- 确保在后台已添加对应类型的账号(Claude/Gemini/Codex) +- API密钥可以通用,系统会根据路由自动选择账号类型 +- 建议为不同用户创建不同的API密钥便于使用统计 + +--- + +## 🔧 日常维护 + +### 服务管理 + +```bash +# 查看服务状态 +npm run service:status + +# 查看日志 +npm run service:logs + +# 重启服务 +npm run service:restart:daemon + +# 停止服务 +npm run service:stop +``` + +### 监控使用情况 + +- **Web界面**: `http://你的域名:3000/web` - 查看使用统计 +- **健康检查**: `http://你的域名:3000/health` - 确认服务正常 +- **日志文件**: `logs/` 目录下的各种日志文件 + +### 升级指南 + +当有新版本发布时,按照以下步骤升级服务: + +```bash +# 1. 进入项目目录 +cd claude-relay-service + +# 2. 拉取最新代码 +git pull origin main + +# 如果遇到 package-lock.json 冲突,使用远程版本 +git checkout --theirs package-lock.json +git add package-lock.json + +# 3. 安装新的依赖(如果有) +npm install + +# 4. 安装并构建前端 +npm run install:web +npm run build:web + +# 5. 重启服务 +npm run service:restart:daemon + +# 6. 检查服务状态 +npm run service:status +``` + +**注意事项:** + +- 升级前建议备份重要配置文件(.env, config/config.js) +- 查看更新日志了解是否有破坏性变更 +- 如果有数据库结构变更,会自动迁移 + +--- + +## 🔒 客户端限制功能 + +### 功能说明 + +客户端限制功能允许你控制每个API Key可以被哪些客户端使用,通过User-Agent识别客户端,提高API的安全性。 + +### 使用方法 + +1. **在创建或编辑API Key时启用客户端限制**: + - 勾选"启用客户端限制" + - 选择允许的客户端(支持多选) + +2. **预定义客户端**: + - **ClaudeCode**: 官方Claude CLI(匹配 `claude-cli/x.x.x (external, cli)` 格式) + - **Gemini-CLI**: Gemini命令行工具(匹配 `GeminiCLI/vx.x.x (platform; arch)` 格式) + +3. **调试和诊断**: + - 系统会在日志中记录所有请求的User-Agent + - 客户端验证失败时会返回403错误并记录详细信息 + - 通过日志可以查看实际的User-Agent格式,方便配置自定义客户端 + + +### 日志示例 + +认证成功时的日志: + +``` +🔓 Authenticated request from key: 测试Key (key-id) in 5ms + User-Agent: "claude-cli/1.0.58 (external, cli)" +``` + +客户端限制检查日志: + +``` +🔍 Checking client restriction for key: key-id (测试Key) + User-Agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + Allowed clients: claude_code, gemini_cli +🚫 Client restriction failed for key: key-id (测试Key) from 127.0.0.1, User-Agent: Mozilla/5.0... +``` + +### 常见问题处理 + +**Redis连不上?** + +```bash +# 检查Redis是否启动 +redis-cli ping + +# 应该返回 PONG +``` + +**OAuth授权失败?** + +- 检查代理设置是否正确 +- 确保能正常访问 claude.ai +- 清除浏览器缓存重试 + +**API请求失败?** + +- 检查API Key是否正确 +- 查看日志文件找错误信息 +- 确认Claude账户状态正常 + +--- + +## 🛠️ 进阶 + +### 反向代理部署指南 + +在生产环境中,建议通过反向代理进行连接,以便使用自动 HTTPS、安全头部和性能优化。下面提供两种常用方案: **Caddy** 和 **Nginx Proxy Manager (NPM)**。 + +--- + +## Caddy 方案 + +Caddy 是一款自动管理 HTTPS 证书的 Web 服务器,配置简单、性能优秀,很适合不需要 Docker 环境的部署方案。 + +**1. 安装 Caddy** + +```bash +# Ubuntu/Debian +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update +sudo apt install caddy + +# CentOS/RHEL/Fedora +sudo yum install yum-plugin-copr +sudo yum copr enable @caddy/caddy +sudo yum install caddy +``` + +**2. Caddy 配置** + +编辑 `/etc/caddy/Caddyfile` : + +```caddy +your-domain.com { + # 反向代理到本地服务 + reverse_proxy 127.0.0.1:3000 { + # 支持流式响应或 SSE + flush_interval -1 + + # 传递真实 IP + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + + # 长读/写超时配置 + transport http { + read_timeout 300s + write_timeout 300s + dial_timeout 30s + } + } + + # 安全头部 + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Frame-Options "DENY" + X-Content-Type-Options "nosniff" + -Server + } +} +``` + +**3. 启动 Caddy** + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl start caddy +sudo systemctl enable caddy +sudo systemctl status caddy +``` + +**4. 服务配置** + +Caddy 会自动管理 HTTPS,因此可以将服务限制在本地进行监听: + +```javascript +// config/config.js +module.exports = { + server: { + port: 3000, + host: '127.0.0.1' // 只监听本地 + } +} +``` + +**Caddy 特点** + +* 🔒 自动 HTTPS,零配置证书管理 +* 🛡️ 安全默认配置,启用现代 TLS 套件 +* ⚡ HTTP/2 和流式传输支持 +* 🔧 配置文件简洁,易于维护 + +--- + +## Nginx Proxy Manager (NPM) 方案 + +Nginx Proxy Manager 通过图形化界面管理反向代理和 HTTPS 证书,並以 Docker 容器部署。 + +**1. 在 NPM 创建新的 Proxy Host** + +Details 配置如下: + +| 项目 | 设置 | +| --------------------- | ----------------------- | +| Domain Names | relay.example.com | +| Scheme | http | +| Forward Hostname / IP | 192.168.0.1 (docker 机器 IP) | +| Forward Port | 3000 | +| Block Common Exploits | ☑️ | +| Websockets Support | ❌ **关闭** | +| Cache Assets | ❌ **关闭** | +| Access List | Publicly Accessible | + +> 注意: +> - 请确保 Claude Relay Service **监听 host 为 `0.0.0.0` 、容器 IP 或本机 IP**,以便 NPM 实现内网连接。 +> - **Websockets Support 和 Cache Assets 必须关闭**,否则会导致 SSE / 流式响应失败。 + +**2. Custom locations** + +無需添加任何内容,保持为空。 + +**3. SSL 设置** + +* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) 或已有证书 +* ☑️ **Force SSL** +* ☑️ **HTTP/2 Support** +* ☑️ **HSTS Enabled** +* ☑️ **HSTS Subdomains** + +**4. Advanced 配置** + +Custom Nginx Configuration 中添加以下内容: + +```nginx +# 传递真实用户 IP +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; + +# 支持 WebSocket / SSE 等流式通信 +proxy_http_version 1.1; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection "upgrade"; +proxy_buffering off; + +# 长连接 / 超时设置(适合 AI 聊天流式传输) +proxy_read_timeout 300s; +proxy_send_timeout 300s; +proxy_connect_timeout 30s; + +# ---- 安全性设置 ---- +# 严格 HTTPS 策略 (HSTS) +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + +# 阻挡点击劫持与内容嗅探 +add_header X-Frame-Options "DENY" always; +add_header X-Content-Type-Options "nosniff" always; + +# Referrer / Permissions 限制策略 +add_header Referrer-Policy "no-referrer-when-downgrade" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + +# 隐藏服务器信息(等效于 Caddy 的 `-Server`) +proxy_hide_header Server; + +# ---- 性能微调 ---- +# 关闭代理端缓存,确保即时响应(SSE / Streaming) +proxy_cache_bypass $http_upgrade; +proxy_no_cache $http_upgrade; +proxy_request_buffering off; +``` + +**4. 启动和验证** + +* 保存后等待 NPM 自动申请 Let's Encrypt 证书(如果有)。 +* Dashboard 中查看 Proxy Host 状态,确保显示为 "Online"。 +* 访问 `https://relay.example.com`,如果显示绿色锁图标即表示 HTTPS 正常。 + +**NPM 特点** + +* 🔒 自动申请和续期证书 +* 🔧 图形化界面,方便管理多服务 +* ⚡ 原生支持 HTTP/2 / HTTPS +* 🚀 适合 Docker 容器部署 + +--- + +上述两种方案均可用于生产部署。 + +--- + +## 💡 使用建议 + +### 账户管理 + +- **定期检查**: 每周看看账户状态,及时处理异常 +- **合理分配**: 可以给不同的人分配不同的apikey,可以根据不同的apikey来分析用量 + +### 安全建议 + +- **使用HTTPS**: 强烈建议使用Caddy反向代理(自动HTTPS),确保数据传输安全 +- **定期备份**: 重要配置和数据要备份 +- **监控日志**: 定期查看异常日志 +- **更新密钥**: 定期更换JWT和加密密钥 +- **防火墙设置**: 只开放必要的端口(80, 443),隐藏直接服务端口 + +--- + +## 🆘 遇到问题怎么办? + +### 自助排查 + +1. **查看日志**: `logs/` 目录下的日志文件 +2. **检查配置**: 确认配置文件设置正确 +3. **测试连通性**: 用 curl 测试API是否正常 +4. **重启服务**: 有时候重启一下就好了 + +### 寻求帮助 + +- **GitHub Issues**: 提交详细的错误信息 +- **查看文档**: 仔细阅读错误信息和文档 +- **社区讨论**: 看看其他人是否遇到类似问题 + +--- + +## 📄 许可证 + +本项目采用 [MIT许可证](LICENSE)。 + +--- + +
+ +**⭐ 觉得有用的话给个Star呗,这是对作者最大的鼓励!** + +**🤝 有问题欢迎提Issue,有改进建议欢迎PR** + +
diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..70e3ff9 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`Wei-Shaw/claude-relay-service` +- 原始仓库:https://github.com/Wei-Shaw/claude-relay-service +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..6c3fa99 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,637 @@ +# Claude Relay Service + +> [!CAUTION] +> **Security Update**: v1.1.248 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel. +> +> **Please update to v1.1.249+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)** + +
+ +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Node.js](https://img.shields.io/badge/Node.js-18+-green.svg)](https://nodejs.org/) +[![Redis](https://img.shields.io/badge/Redis-6+-red.svg)](https://redis.io/) +[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) + +**🔐 Self-hosted Claude API relay service with multi-account management** + +[中文文档](README.md) • [Preview](https://demo.pincc.ai/admin-next/login) • [Telegram Channel](https://t.me/claude_relay_service) + +
+ +--- + +## ⭐ If You Find It Useful, Please Give It a Star! + +> Open source is not easy, your Star is my motivation to continue updating 🚀 +> Join [Telegram Channel](https://t.me/claude_relay_service) for the latest updates + +--- + +## ⚠️ Important Notice + +**Please read carefully before using this project:** + +🚨 **Terms of Service Risk**: Using this project may violate Anthropic's terms of service. Please carefully read Anthropic's user agreement before use. All risks from using this project are borne by the user. + +📖 **Disclaimer**: This project is for technical learning and research purposes only. The author is not responsible for any account bans, service interruptions, or other losses caused by using this project. + +## 🤔 Is This Project Right for You? + +- 🌍 **Regional Restrictions**: Can't directly access Claude Code service in your region? +- 🔒 **Privacy Concerns**: Worried about third-party mirror services logging or leaking your conversation content? +- 👥 **Cost Sharing**: Want to share Claude Code Max subscription costs with friends? +- ⚡ **Stability Issues**: Third-party mirror sites often fail and are unstable, affecting efficiency? + +If you have any of these concerns, this project might be suitable for you. + +### Suitable Scenarios + +✅ **Cost Sharing with Friends**: 3-5 friends sharing Claude Code Max subscription, enjoying Opus freely +✅ **Privacy Sensitive**: Don't want third-party mirrors to see your conversation content +✅ **Technical Tinkering**: Have basic technical skills, willing to build and maintain yourself +✅ **Stability Needs**: Need long-term stable Claude access, don't want to be restricted by mirror sites +✅ **Regional Restrictions**: Cannot directly access Claude official service + +### Unsuitable Scenarios + +❌ **Complete Beginner**: Don't understand technology at all, don't even know how to buy a server +❌ **Occasional Use**: Use it only a few times a month, not worth the hassle +❌ **Registration Issues**: Cannot register Claude account yourself +❌ **Payment Issues**: No payment method to subscribe to Claude Code + +**If you're just an ordinary user with low privacy requirements, just want to casually play around and quickly experience Claude, then choosing a mirror site you're familiar with would be more suitable.** + +--- + +## 💭 Why Build Your Own? + +### Potential Issues with Existing Mirror Sites + +- 🕵️ **Privacy Risk**: Your conversation content is completely visible to others, forget about business secrets +- 🐌 **Performance Instability**: Slow when many people use it, often crashes during peak hours +- 💰 **Price Opacity**: Don't know the actual costs + +### Benefits of Self-hosting + +- 🔐 **Data Security**: All API requests only go through your own server, direct connection to Anthropic API +- ⚡ **Controllable Performance**: Only a few of you using it, Max $200 package basically allows you to enjoy Opus freely +- 💰 **Cost Transparency**: Clear view of how many tokens used, specific costs calculated at official prices +- 📊 **Complete Monitoring**: Usage statistics, cost analysis, performance monitoring all available + +--- + +## 🚀 Core Features + +> 📸 **[Click to view interface preview](docs/preview.md)** - See detailed screenshots of the Web management interface + +### Basic Features +- ✅ **Multi-account Management**: Add multiple Claude accounts for automatic rotation +- ✅ **Custom API Keys**: Assign independent keys to each person +- ✅ **Usage Statistics**: Detailed records of how many tokens each person used + +### Advanced Features +- 🔄 **Smart Switching**: Automatically switch to next account when one has issues +- 🚀 **Performance Optimization**: Connection pooling, caching to reduce latency +- 📊 **Monitoring Dashboard**: Web interface to view all data +- 🛡️ **Security Control**: Access restrictions, rate limiting +- 🌐 **Proxy Support**: Support for HTTP/SOCKS5 proxies + +--- + +## 📋 Deployment Requirements + +### Hardware Requirements (Minimum Configuration) +- **CPU**: 1 core is sufficient +- **Memory**: 512MB (1GB recommended) +- **Storage**: 30GB available space +- **Network**: Access to Anthropic API (recommend US region servers) +- **Recommendation**: 2 cores 4GB is basically enough, choose network with good return routes to your country (to improve speed, recommend not using proxy or setting server IP for direct connection) + +### Software Requirements +- **Node.js** 18 or higher +- **Redis** 6 or higher +- **Operating System**: Linux recommended + +### Cost Estimation +- **Server**: Light cloud server, $5-10 per month +- **Claude Subscription**: Depends on how you share costs +- **Others**: Domain name (optional) + +--- + +## 📦 Manual Deployment + +### Step 1: Environment Setup + +**Ubuntu/Debian users:** +```bash +# Install Node.js +curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - +sudo apt-get install -y nodejs + +# Install Redis +sudo apt update +sudo apt install redis-server +sudo systemctl start redis-server +``` + +**CentOS/RHEL users:** +```bash +# Install Node.js +curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash - +sudo yum install -y nodejs + +# Install Redis +sudo yum install redis +sudo systemctl start redis +``` + +### Step 2: Download and Configure + +```bash +# Download project +git clone https://github.com/Wei-Shaw/claude-relay-service.git +cd claude-relay-service + +# Install dependencies +npm install + +# Copy configuration files (Important!) +cp config/config.example.js config/config.js +cp .env.example .env +``` + +### Step 3: Configuration File Setup + +**Edit `.env` file:** +```bash +# Generate these two keys randomly, but remember them +JWT_SECRET=your-super-secret-key +ENCRYPTION_KEY=32-character-encryption-key-write-randomly + +# Redis configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD= +``` + +**Edit `config/config.js` file:** +```javascript +module.exports = { + server: { + port: 3000, // Service port, can be changed + host: '0.0.0.0' // Don't change + }, + redis: { + host: '127.0.0.1', // Redis address + port: 6379 // Redis port + }, + // Keep other configurations as default +} +``` + +### Step 4: Start Service + +```bash +# Initialize +npm run setup # Will randomly generate admin account password info, stored in data/init.json + +# Start service +npm run service:start:daemon # Run in background (recommended) + +# Check status +npm run service:status +``` + +--- + +## 🎮 Getting Started + +### 1. Open Management Interface + +Browser visit: `http://your-server-IP:3000/web` + +Default admin account: Look in data/init.json + +### 2. Add Claude Account + +This step is quite important, requires OAuth authorization: + +1. Click "Claude Accounts" tab +2. If you're worried about multiple accounts sharing 1 IP getting banned, you can optionally set a static proxy IP +3. Click "Add Account" +4. Click "Generate Authorization Link", will open a new page +5. Complete Claude login and authorization in the new page +6. Copy the returned Authorization Code +7. Paste to page to complete addition + +**Note**: If you're in China, this step may require VPN. + +### 2.1 Temporary Pause (503/5xx) and Account-Level TTL Overrides + +When upstream errors happen, the router can temporarily pause an account. Global defaults are controlled by `.env.example`: + +- `UPSTREAM_ERROR_503_TTL_SECONDS` +- `UPSTREAM_ERROR_5XX_TTL_SECONDS` +- `UPSTREAM_ERROR_OVERLOAD_TTL_SECONDS` +- `UPSTREAM_ERROR_AUTH_TTL_SECONDS` +- `UPSTREAM_ERROR_TIMEOUT_TTL_SECONDS` + +For **Claude official OAuth accounts**, you can override policy per account in Admin UI: + +- `Disable temporary cooldown for this account`: skip 503/5xx temp pause for this account +- `503 cooldown seconds`: empty = follow global default, `0` = disable 503 cooldown for this account +- `5xx cooldown seconds`: empty = follow global default, `0` = disable 5xx cooldown for this account + +Priority order (high to low): + +1. Account-level "disable temporary cooldown" +2. Account-level 503/5xx cooldown override +3. Call-site custom TTL (if provided) +4. Global env default TTL + +In Accounts view, "Routing blocked reason" shows error type, HTTP status, total cooldown, remaining time, and recovery time. Use `Reset Status` to clear abnormal state and restore routing eligibility. + +### 3. Create API Key + +Assign a key to each user: + +1. Click "API Keys" tab +2. Click "Create New Key" +3. Give the key a name, like "Zhang San's Key" +4. Set usage limits (optional) +5. Save, note down the generated key + +### 4. Start Using Claude Code and Gemini CLI + +Now you can replace the official API with your own service: + +**Claude Code Set Environment Variables:** + +Default uses standard Claude account pool: + +```bash +export ANTHROPIC_BASE_URL="http://127.0.0.1:3000/api/" # Fill in your server's IP address or domain +export ANTHROPIC_AUTH_TOKEN="API key created in the backend" +``` + +**VSCode Claude Plugin Configuration:** + +If using VSCode Claude plugin, configure in `~/.claude/config.json`: + +```json +{ + "primaryApiKey": "crs" +} +``` + +If the file doesn't exist, create it manually. Windows users path is `C:\Users\YourUsername\.claude\config.json`. + +**Gemini CLI Set Environment Variables:** + +**Method 1 (Recommended): Via Gemini Assist API** + +Each account enjoys 1000 requests per day, 60 requests per minute free quota. + +```bash +CODE_ASSIST_ENDPOINT="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain +GOOGLE_CLOUD_ACCESS_TOKEN="API key created in the backend" +GOOGLE_GENAI_USE_GCA="true" +GEMINI_MODEL="gemini-2.5-pro" +``` + +> **Note**: gemini-cli console will show `Failed to fetch user info: 401 Unauthorized`, but this doesn't affect usage. + +**Method 2: Via Gemini API** + +Very limited free quota, easily triggers 429 errors. + +```bash +GOOGLE_GEMINI_BASE_URL="http://127.0.0.1:3000/gemini" # Fill in your server's IP address or domain +GEMINI_API_KEY="API key created in the backend" +GEMINI_MODEL="gemini-2.5-pro" +``` + +**Use Claude Code:** + +```bash +claude +``` + +**Use Gemini CLI:** + +```bash +gemini +``` + +--- + +## 🔧 Daily Maintenance + +### Service Management + +```bash +# Check service status +npm run service:status + +# View logs +npm run service:logs + +# Restart service +npm run service:restart:daemon + +# Stop service +npm run service:stop +``` + +### Monitor Usage + +- **Web Interface**: `http://your-domain:3000/web` - View usage statistics +- **Health Check**: `http://your-domain:3000/health` - Confirm service is normal +- **Log Files**: Various log files in `logs/` directory + +### Upgrade Guide + +When a new version is released, follow these steps to upgrade the service: + +```bash +# 1. Navigate to project directory +cd claude-relay-service + +# 2. Pull latest code +git pull origin main + +# If you encounter package-lock.json conflicts, use the remote version +git checkout --theirs package-lock.json +git add package-lock.json + +# 3. Install new dependencies (if any) +npm install + +# 4. Restart service +npm run service:restart:daemon + +# 5. Check service status +npm run service:status +``` + +**Important Notes:** +- Before upgrading, it's recommended to backup important configuration files (.env, config/config.js) +- Check the changelog to understand if there are any breaking changes +- Database structure changes will be migrated automatically if needed + +### Common Issue Resolution + +**Can't connect to Redis?** +```bash +# Check if Redis is running +redis-cli ping + +# Should return PONG +``` + +**OAuth authorization failed?** +- Check if proxy settings are correct +- Ensure normal access to claude.ai +- Clear browser cache and retry + +**API request failed?** +- Check if API Key is correct +- View log files for error information +- Confirm Claude account status is normal + +--- + +## 🛠️ Advanced Usage + +### Reverse Proxy Deployment Guide + +For production environments, it is recommended to use a reverse proxy for automatic HTTPS, security headers, and performance optimization. Two common solutions are provided below: **Caddy** and **Nginx Proxy Manager (NPM)**. + +--- + +## Caddy Solution + +Caddy is a web server that automatically manages HTTPS certificates, with simple configuration and excellent performance, ideal for deployments without Docker environments. + +**1. Install Caddy** + +```bash +# Ubuntu/Debian +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update +sudo apt install caddy + +# CentOS/RHEL/Fedora +sudo yum install yum-plugin-copr +sudo yum copr enable @caddy/caddy +sudo yum install caddy +``` + +**2. Caddy Configuration** + +Edit `/etc/caddy/Caddyfile`: + +```caddy +your-domain.com { + # Reverse proxy to local service + reverse_proxy 127.0.0.1:3000 { + # Support streaming responses or SSE + flush_interval -1 + + # Pass real IP + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + + # Long read/write timeout configuration + transport http { + read_timeout 300s + write_timeout 300s + dial_timeout 30s + } + } + + # Security headers + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Frame-Options "DENY" + X-Content-Type-Options "nosniff" + -Server + } +} +``` + +**3. Start Caddy** + +```bash +sudo caddy validate --config /etc/caddy/Caddyfile +sudo systemctl start caddy +sudo systemctl enable caddy +sudo systemctl status caddy +``` + +**4. Service Configuration** + +Since Caddy automatically manages HTTPS, you can restrict the service to listen locally only: + +```javascript +// config/config.js +module.exports = { + server: { + port: 3000, + host: '127.0.0.1' // Listen locally only + } +} +``` + +**Caddy Features** + +* 🔒 Automatic HTTPS with zero-configuration certificate management +* 🛡️ Secure default configuration with modern TLS suites +* ⚡ HTTP/2 and streaming support +* 🔧 Concise configuration files, easy to maintain + +--- + +## Nginx Proxy Manager (NPM) Solution + +Nginx Proxy Manager manages reverse proxies and HTTPS certificates through a graphical interface, deployed as a Docker container. + +**1. Create a New Proxy Host in NPM** + +Configure the Details as follows: + +| Item | Setting | +| --------------------- | ------------------------ | +| Domain Names | relay.example.com | +| Scheme | http | +| Forward Hostname / IP | 192.168.0.1 (docker host IP) | +| Forward Port | 3000 | +| Block Common Exploits | ☑️ | +| Websockets Support | ❌ **Disable** | +| Cache Assets | ❌ **Disable** | +| Access List | Publicly Accessible | + +> Note: +> - Ensure Claude Relay Service **listens on `0.0.0.0`, container IP, or host IP** to allow NPM internal network connections. +> - **Websockets Support and Cache Assets must be disabled**, otherwise SSE / streaming responses will fail. + +**2. Custom locations** + +No content needed, keep it empty. + +**3. SSL Settings** + +* **SSL Certificate**: Request a new SSL Certificate (Let's Encrypt) or existing certificate +* ☑️ **Force SSL** +* ☑️ **HTTP/2 Support** +* ☑️ **HSTS Enabled** +* ☑️ **HSTS Subdomains** + +**4. Advanced Configuration** + +Add the following to Custom Nginx Configuration: + +```nginx +# Pass real user IP +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; + +# Support WebSocket / SSE streaming +proxy_http_version 1.1; +proxy_set_header Upgrade $http_upgrade; +proxy_set_header Connection "upgrade"; +proxy_buffering off; + +# Long connection / timeout settings (for AI chat streaming) +proxy_read_timeout 300s; +proxy_send_timeout 300s; +proxy_connect_timeout 30s; + +# ---- Security Settings ---- +# Strict HTTPS policy (HSTS) +add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + +# Block clickjacking and content sniffing +add_header X-Frame-Options "DENY" always; +add_header X-Content-Type-Options "nosniff" always; + +# Referrer / Permissions restriction policies +add_header Referrer-Policy "no-referrer-when-downgrade" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + +# Hide server information (equivalent to Caddy's `-Server`) +proxy_hide_header Server; + +# ---- Performance Tuning ---- +# Disable proxy caching for real-time responses (SSE / Streaming) +proxy_cache_bypass $http_upgrade; +proxy_no_cache $http_upgrade; +proxy_request_buffering off; +``` + +**5. Launch and Verify** + +* After saving, wait for NPM to automatically request Let's Encrypt certificate (if applicable). +* Check Proxy Host status in Dashboard to ensure it shows "Online". +* Visit `https://relay.example.com`, if the green lock icon appears, HTTPS is working properly. + +**NPM Features** + +* 🔒 Automatic certificate application and renewal +* 🔧 Graphical interface for easy multi-service management +* ⚡ Native HTTP/2 / HTTPS support +* 🚀 Ideal for Docker container deployments + +--- + +Both solutions are suitable for production deployment. If you use a Docker environment, **Nginx Proxy Manager is more convenient**; if you want to keep software lightweight and automated, **Caddy is a better choice**. + +--- + +## 💡 Usage Recommendations + +### Account Management +- **Regular Checks**: Check account status weekly, handle exceptions promptly +- **Reasonable Allocation**: Can assign different API keys to different people, analyze usage based on different API keys + +### Security Recommendations +- **Use HTTPS**: Strongly recommend using Caddy reverse proxy (automatic HTTPS) to ensure secure data transmission +- **Regular Backups**: Back up important configurations and data +- **Monitor Logs**: Regularly check exception logs +- **Update Keys**: Regularly change JWT and encryption keys +- **Firewall Settings**: Only open necessary ports (80, 443), hide direct service ports + +--- + +## 🆘 What to Do When You Encounter Problems? + +### Self-troubleshooting +1. **Check Logs**: Log files in `logs/` directory +2. **Check Configuration**: Confirm configuration files are set correctly +3. **Test Connectivity**: Use curl to test if API is normal +4. **Restart Service**: Sometimes restarting fixes it + +### Seeking Help +- **GitHub Issues**: Submit detailed error information +- **Read Documentation**: Carefully read error messages and documentation +- **Community Discussion**: See if others have encountered similar problems + +--- + +## 📄 License +This project uses the [MIT License](LICENSE). + +--- + +
+ +**⭐ If you find it useful, please give it a Star, this is the greatest encouragement to the author!** + +**🤝 Feel free to submit Issues for problems, welcome PRs for improvement suggestions** + +
diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..034e848 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..9fc542f --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.1.313 diff --git a/cli/index.js b/cli/index.js new file mode 100644 index 0000000..5514c30 --- /dev/null +++ b/cli/index.js @@ -0,0 +1,1025 @@ +#!/usr/bin/env node + +const { Command } = require('commander') +const inquirer = require('inquirer') +const chalk = require('chalk') +const ora = require('ora') +const { table } = require('table') +const bcrypt = require('bcryptjs') +const fs = require('fs') +const path = require('path') + +const redis = require('../src/models/redis') +const apiKeyService = require('../src/services/apiKeyService') +const claudeAccountService = require('../src/services/account/claudeAccountService') +const bedrockAccountService = require('../src/services/account/bedrockAccountService') + +const program = new Command() + +// 🎨 样式 +const styles = { + title: chalk.bold.blue, + success: chalk.green, + error: chalk.red, + warning: chalk.yellow, + info: chalk.cyan, + dim: chalk.dim +} + +// 🔧 初始化 +async function initialize() { + const spinner = ora('正在连接 Redis...').start() + try { + await redis.connect() + spinner.succeed('Redis 连接成功') + } catch (error) { + spinner.fail('Redis 连接失败') + console.error(styles.error(error.message)) + process.exit(1) + } +} + +// 🔐 管理员账户管理 +program + .command('admin') + .description('管理员账户操作') + .action(async () => { + await initialize() + + // 直接执行创建初始管理员 + await createInitialAdmin() + + await redis.disconnect() + }) + +// 🔑 API Key 管理 +program + .command('keys') + .description('API Key 管理操作') + .action(async () => { + await initialize() + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: '请选择操作:', + choices: [ + { name: '📋 查看所有 API Keys', value: 'list' }, + { name: '🔧 修改 API Key 过期时间', value: 'update-expiry' }, + { name: '🔄 续期即将过期的 API Key', value: 'renew' }, + { name: '🗑️ 删除 API Key', value: 'delete' } + ] + } + ]) + + switch (action) { + case 'list': + await listApiKeys() + break + case 'update-expiry': + await updateApiKeyExpiry() + break + case 'renew': + await renewApiKeys() + break + case 'delete': + await deleteApiKey() + break + } + + await redis.disconnect() + }) + +// 📊 系统状态 +program + .command('status') + .description('查看系统状态') + .action(async () => { + await initialize() + + const spinner = ora('正在获取系统状态...').start() + + try { + const [, apiKeys, accounts] = await Promise.all([ + redis.getSystemStats(), + apiKeyService.getAllApiKeysFast(), + claudeAccountService.getAllAccounts() + ]) + + spinner.succeed('系统状态获取成功') + + console.log(styles.title('\n📊 系统状态概览\n')) + + const statusData = [ + ['项目', '数量', '状态'], + ['API Keys', apiKeys.length, `${apiKeys.filter((k) => k.isActive).length} 活跃`], + ['Claude 账户', accounts.length, `${accounts.filter((a) => a.isActive).length} 活跃`], + ['Redis 连接', redis.isConnected ? '已连接' : '未连接', redis.isConnected ? '🟢' : '🔴'], + ['运行时间', `${Math.floor(process.uptime() / 60)} 分钟`, '🕐'] + ] + + console.log(table(statusData)) + + // 使用统计 + const totalTokens = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.tokens || 0), 0) + const totalRequests = apiKeys.reduce((sum, key) => sum + (key.usage?.total?.requests || 0), 0) + + console.log(styles.title('\n📈 使用统计\n')) + console.log(`总 Token 使用量: ${styles.success(totalTokens.toLocaleString())}`) + console.log(`总请求数: ${styles.success(totalRequests.toLocaleString())}`) + } catch (error) { + spinner.fail('获取系统状态失败') + console.error(styles.error(error.message)) + } + + await redis.disconnect() + }) + +// ☁️ Bedrock 账户管理 +program + .command('bedrock') + .description('Bedrock 账户管理操作') + .action(async () => { + await initialize() + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: '请选择操作:', + choices: [ + { name: '📋 查看所有 Bedrock 账户', value: 'list' }, + { name: '➕ 创建 Bedrock 账户', value: 'create' }, + { name: '✏️ 编辑 Bedrock 账户', value: 'edit' }, + { name: '🔄 切换账户状态', value: 'toggle' }, + { name: '🧪 测试账户连接', value: 'test' }, + { name: '🗑️ 删除账户', value: 'delete' } + ] + } + ]) + + switch (action) { + case 'list': + await listBedrockAccounts() + break + case 'create': + await createBedrockAccount() + break + case 'edit': + await editBedrockAccount() + break + case 'toggle': + await toggleBedrockAccount() + break + case 'test': + await testBedrockAccount() + break + case 'delete': + await deleteBedrockAccount() + break + } + + await redis.disconnect() + }) + +// 实现具体功能函数 + +async function createInitialAdmin() { + console.log(styles.title('\n🔐 创建初始管理员账户\n')) + + // 检查是否已存在 init.json + const initFilePath = path.join(__dirname, '..', 'data', 'init.json') + if (fs.existsSync(initFilePath)) { + const existingData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + console.log(styles.warning('⚠️ 检测到已存在管理员账户!')) + console.log(` 用户名: ${existingData.adminUsername}`) + console.log(` 创建时间: ${new Date(existingData.initializedAt).toLocaleString()}`) + + const { overwrite } = await inquirer.prompt([ + { + type: 'confirm', + name: 'overwrite', + message: '是否覆盖现有管理员账户?', + default: false + } + ]) + + if (!overwrite) { + console.log(styles.info('ℹ️ 已取消创建')) + return + } + } + + const adminData = await inquirer.prompt([ + { + type: 'input', + name: 'username', + message: '用户名:', + default: 'admin', + validate: (input) => input.length >= 3 || '用户名至少3个字符' + }, + { + type: 'password', + name: 'password', + message: '密码:', + validate: (input) => input.length >= 8 || '密码至少8个字符' + }, + { + type: 'password', + name: 'confirmPassword', + message: '确认密码:', + validate: (input, answers) => input === answers.password || '密码不匹配' + } + ]) + + const spinner = ora('正在创建管理员账户...').start() + + try { + // 1. 先更新 init.json(唯一真实数据源) + const initData = { + initializedAt: new Date().toISOString(), + adminUsername: adminData.username, + adminPassword: adminData.password, // 保存明文密码 + version: '1.0.0', + updatedAt: new Date().toISOString() + } + + // 确保 data 目录存在 + const dataDir = path.join(__dirname, '..', 'data') + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }) + } + + // 写入文件 + fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2)) + + // 2. 再更新 Redis 缓存 + const passwordHash = await bcrypt.hash(adminData.password, 12) + + const credentials = { + username: adminData.username, + passwordHash, + createdAt: new Date().toISOString(), + lastLogin: null, + updatedAt: new Date().toISOString() + } + + await redis.setSession('admin_credentials', credentials, 0) // 永不过期 + + spinner.succeed('管理员账户创建成功') + console.log(`${styles.success('✅')} 用户名: ${adminData.username}`) + console.log(`${styles.success('✅')} 密码: ${adminData.password}`) + console.log(`${styles.info('ℹ️')} 请妥善保管登录凭据`) + console.log(`${styles.info('ℹ️')} 凭据已保存到: ${initFilePath}`) + console.log(`${styles.warning('⚠️')} 如果服务正在运行,请重启服务以加载新凭据`) + } catch (error) { + spinner.fail('创建管理员账户失败') + console.error(styles.error(error.message)) + } +} + +// API Key 管理功能 +async function listApiKeys() { + const spinner = ora('正在获取 API Keys...').start() + + try { + const apiKeys = await apiKeyService.getAllApiKeysFast() + spinner.succeed(`找到 ${apiKeys.length} 个 API Keys`) + + if (apiKeys.length === 0) { + console.log(styles.warning('没有找到任何 API Keys')) + return + } + + const tableData = [['名称', 'API Key', '状态', '过期时间', '使用量', 'Token限制']] + + apiKeys.forEach((key) => { + const now = new Date() + const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null + let expiryStatus = '永不过期' + + if (expiresAt) { + if (expiresAt < now) { + expiryStatus = styles.error(`已过期 (${expiresAt.toLocaleDateString()})`) + } else { + const daysLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24)) + if (daysLeft <= 7) { + expiryStatus = styles.warning(`${daysLeft}天后过期 (${expiresAt.toLocaleDateString()})`) + } else { + expiryStatus = styles.success(`${expiresAt.toLocaleDateString()}`) + } + } + } + + tableData.push([ + key.name, + key.maskedKey || '-', + key.isActive ? '🟢 活跃' : '🔴 停用', + expiryStatus, + `${(key.usage?.total?.tokens || 0).toLocaleString()}`, + key.tokenLimit ? key.tokenLimit.toLocaleString() : '无限制' + ]) + }) + + console.log(styles.title('\n🔑 API Keys 列表:\n')) + console.log(table(tableData)) + } catch (error) { + spinner.fail('获取 API Keys 失败') + console.error(styles.error(error.message)) + } +} + +async function updateApiKeyExpiry() { + try { + // 获取所有 API Keys + const apiKeys = await apiKeyService.getAllApiKeysFast() + + if (apiKeys.length === 0) { + console.log(styles.warning('没有找到任何 API Keys')) + return + } + + // 选择要修改的 API Key + const { selectedKey } = await inquirer.prompt([ + { + type: 'list', + name: 'selectedKey', + message: '选择要修改的 API Key:', + choices: apiKeys.map((key) => ({ + name: `${key.name} (${key.maskedKey || key.id.substring(0, 8)}) - ${key.expiresAt ? new Date(key.expiresAt).toLocaleDateString() : '永不过期'}`, + value: key + })) + } + ]) + + console.log(`\n当前 API Key: ${selectedKey.name}`) + console.log( + `当前过期时间: ${selectedKey.expiresAt ? new Date(selectedKey.expiresAt).toLocaleString() : '永不过期'}` + ) + + // 选择新的过期时间 + const { expiryOption } = await inquirer.prompt([ + { + type: 'list', + name: 'expiryOption', + message: '选择新的过期时间:', + choices: [ + { name: '⏰ 1分后(测试用)', value: '1m' }, + { name: '⏰ 1小时后(测试用)', value: '1h' }, + { name: '📅 1天后', value: '1d' }, + { name: '📅 7天后', value: '7d' }, + { name: '📅 30天后', value: '30d' }, + { name: '📅 90天后', value: '90d' }, + { name: '📅 365天后', value: '365d' }, + { name: '♾️ 永不过期', value: 'never' }, + { name: '🎯 自定义日期时间', value: 'custom' } + ] + } + ]) + + let newExpiresAt = null + + if (expiryOption === 'never') { + newExpiresAt = null + } else if (expiryOption === 'custom') { + const { customDate, customTime } = await inquirer.prompt([ + { + type: 'input', + name: 'customDate', + message: '输入日期 (YYYY-MM-DD):', + default: new Date().toISOString().split('T')[0], + validate: (input) => { + const date = new Date(input) + return !isNaN(date.getTime()) || '请输入有效的日期格式' + } + }, + { + type: 'input', + name: 'customTime', + message: '输入时间 (HH:MM):', + default: '00:00', + validate: (input) => /^\d{2}:\d{2}$/.test(input) || '请输入有效的时间格式 (HH:MM)' + } + ]) + + newExpiresAt = new Date(`${customDate}T${customTime}:00`).toISOString() + } else { + // 计算新的过期时间 + const now = new Date() + const durations = { + '1m': 60 * 1000, + '1h': 60 * 60 * 1000, + '1d': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, + '90d': 90 * 24 * 60 * 60 * 1000, + '365d': 365 * 24 * 60 * 60 * 1000 + } + + newExpiresAt = new Date(now.getTime() + durations[expiryOption]).toISOString() + } + + // 确认修改 + const confirmMsg = newExpiresAt + ? `确认将过期时间修改为: ${new Date(newExpiresAt).toLocaleString()}?` + : '确认设置为永不过期?' + + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: confirmMsg, + default: true + } + ]) + + if (!confirmed) { + console.log(styles.info('已取消修改')) + return + } + + // 执行修改 + const spinner = ora('正在修改过期时间...').start() + + try { + await apiKeyService.updateApiKey(selectedKey.id, { expiresAt: newExpiresAt }) + spinner.succeed('过期时间修改成功') + + console.log(styles.success(`\n✅ API Key "${selectedKey.name}" 的过期时间已更新`)) + console.log( + `新的过期时间: ${newExpiresAt ? new Date(newExpiresAt).toLocaleString() : '永不过期'}` + ) + } catch (error) { + spinner.fail('修改失败') + console.error(styles.error(error.message)) + } + } catch (error) { + console.error(styles.error('操作失败:', error.message)) + } +} + +async function renewApiKeys() { + const spinner = ora('正在查找即将过期的 API Keys...').start() + + try { + const apiKeys = await apiKeyService.getAllApiKeysFast() + const now = new Date() + const sevenDaysLater = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) + + // 筛选即将过期的 Keys(7天内) + const expiringKeys = apiKeys.filter((key) => { + if (!key.expiresAt) { + return false + } + const expiresAt = new Date(key.expiresAt) + return expiresAt > now && expiresAt <= sevenDaysLater + }) + + spinner.stop() + + if (expiringKeys.length === 0) { + console.log(styles.info('没有即将过期的 API Keys(7天内)')) + return + } + + console.log(styles.warning(`\n找到 ${expiringKeys.length} 个即将过期的 API Keys:\n`)) + + expiringKeys.forEach((key, index) => { + const daysLeft = Math.ceil((new Date(key.expiresAt) - now) / (1000 * 60 * 60 * 24)) + console.log( + `${index + 1}. ${key.name} - ${daysLeft}天后过期 (${new Date(key.expiresAt).toLocaleDateString()})` + ) + }) + + const { renewOption } = await inquirer.prompt([ + { + type: 'list', + name: 'renewOption', + message: '选择续期方式:', + choices: [ + { name: '📅 全部续期30天', value: 'all30' }, + { name: '📅 全部续期90天', value: 'all90' }, + { name: '🎯 逐个选择续期', value: 'individual' } + ] + } + ]) + + if (renewOption.startsWith('all')) { + const days = renewOption === 'all30' ? 30 : 90 + const renewSpinner = ora(`正在为所有 API Keys 续期 ${days} 天...`).start() + + for (const key of expiringKeys) { + try { + const newExpiresAt = new Date( + new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000 + ).toISOString() + await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt }) + } catch (error) { + renewSpinner.fail(`续期 ${key.name} 失败: ${error.message}`) + } + } + + renewSpinner.succeed(`成功续期 ${expiringKeys.length} 个 API Keys`) + } else { + // 逐个选择续期 + for (const key of expiringKeys) { + console.log(`\n处理: ${key.name}`) + + const { action } = await inquirer.prompt([ + { + type: 'list', + name: 'action', + message: '选择操作:', + choices: [ + { name: '续期30天', value: '30' }, + { name: '续期90天', value: '90' }, + { name: '跳过', value: 'skip' } + ] + } + ]) + + if (action !== 'skip') { + const days = parseInt(action) + const newExpiresAt = new Date( + new Date(key.expiresAt).getTime() + days * 24 * 60 * 60 * 1000 + ).toISOString() + + try { + await apiKeyService.updateApiKey(key.id, { expiresAt: newExpiresAt }) + console.log(styles.success(`✅ 已续期 ${days} 天`)) + } catch (error) { + console.log(styles.error(`❌ 续期失败: ${error.message}`)) + } + } + } + } + } catch (error) { + spinner.fail('操作失败') + console.error(styles.error(error.message)) + } +} + +async function deleteApiKey() { + try { + const apiKeys = await apiKeyService.getAllApiKeysFast() + + if (apiKeys.length === 0) { + console.log(styles.warning('没有找到任何 API Keys')) + return + } + + const { selectedKeys } = await inquirer.prompt([ + { + type: 'checkbox', + name: 'selectedKeys', + message: '选择要删除的 API Keys (空格选择,回车确认):', + choices: apiKeys.map((key) => ({ + name: `${key.name} (${key.maskedKey || key.id.substring(0, 8)})`, + value: key.id + })) + } + ]) + + if (selectedKeys.length === 0) { + console.log(styles.info('未选择任何 API Key')) + return + } + + const { confirmed } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirmed', + message: styles.warning(`确认删除 ${selectedKeys.length} 个 API Keys?`), + default: false + } + ]) + + if (!confirmed) { + console.log(styles.info('已取消删除')) + return + } + + const spinner = ora('正在删除 API Keys...').start() + let successCount = 0 + + for (const keyId of selectedKeys) { + try { + await apiKeyService.deleteApiKey(keyId) + successCount++ + } catch (error) { + spinner.fail(`删除失败: ${error.message}`) + } + } + + spinner.succeed(`成功删除 ${successCount}/${selectedKeys.length} 个 API Keys`) + } catch (error) { + console.error(styles.error('删除失败:', error.message)) + } +} + +// async function listClaudeAccounts() { +// const spinner = ora('正在获取 Claude 账户...').start(); + +// try { +// const accounts = await claudeAccountService.getAllAccounts(); +// spinner.succeed(`找到 ${accounts.length} 个 Claude 账户`); + +// if (accounts.length === 0) { +// console.log(styles.warning('没有找到任何 Claude 账户')); +// return; +// } + +// const tableData = [ +// ['ID', '名称', '邮箱', '状态', '代理', '最后使用'] +// ]; + +// accounts.forEach(account => { +// tableData.push([ +// account.id.substring(0, 8) + '...', +// account.name, +// account.email || '-', +// account.isActive ? (account.status === 'active' ? '🟢 活跃' : '🟡 待激活') : '🔴 停用', +// account.proxy ? '🌐 是' : '-', +// account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '-' +// ]); +// }); + +// console.log('\n🏢 Claude 账户列表:\n'); +// console.log(table(tableData)); + +// } catch (error) { +// spinner.fail('获取 Claude 账户失败'); +// console.error(styles.error(error.message)); +// } +// } + +// ☁️ Bedrock 账户管理函数 + +async function listBedrockAccounts() { + const spinner = ora('正在获取 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.getAllAccounts() + if (!result.success) { + throw new Error(result.error) + } + + const accounts = result.data + spinner.succeed(`找到 ${accounts.length} 个 Bedrock 账户`) + + if (accounts.length === 0) { + console.log(styles.warning('没有找到任何 Bedrock 账户')) + return + } + + const tableData = [['ID', '名称', '区域', '模型', '状态', '凭证类型', '创建时间']] + + accounts.forEach((account) => { + tableData.push([ + `${account.id.substring(0, 8)}...`, + account.name, + account.region, + account.defaultModel?.split('.').pop() || 'default', + account.isActive ? (account.schedulable ? '🟢 活跃' : '🟡 不可调度') : '🔴 停用', + account.credentialType, + account.createdAt ? new Date(account.createdAt).toLocaleDateString() : '-' + ]) + }) + + console.log('\n☁️ Bedrock 账户列表:\n') + console.log(table(tableData)) + } catch (error) { + spinner.fail('获取 Bedrock 账户失败') + console.error(styles.error(error.message)) + } +} + +async function createBedrockAccount() { + console.log(styles.title('\n➕ 创建 Bedrock 账户\n')) + + const questions = [ + { + type: 'input', + name: 'name', + message: '账户名称:', + validate: (input) => input.trim() !== '' + }, + { + type: 'input', + name: 'description', + message: '描述 (可选):' + }, + { + type: 'list', + name: 'region', + message: '选择 AWS 区域:', + choices: [ + { name: 'us-east-1 (北弗吉尼亚)', value: 'us-east-1' }, + { name: 'us-west-2 (俄勒冈)', value: 'us-west-2' }, + { name: 'eu-west-1 (爱尔兰)', value: 'eu-west-1' }, + { name: 'ap-southeast-1 (新加坡)', value: 'ap-southeast-1' } + ] + }, + { + type: 'list', + name: 'credentialType', + message: '凭证类型:', + choices: [ + { name: '默认凭证链 (环境变量/AWS配置)', value: 'default' }, + { name: '访问密钥 (Access Key)', value: 'access_key' }, + { name: 'Bearer Token (API Key)', value: 'bearer_token' } + ] + } + ] + + // 根据凭证类型添加额外问题 + const answers = await inquirer.prompt(questions) + + if (answers.credentialType === 'access_key') { + const credQuestions = await inquirer.prompt([ + { + type: 'input', + name: 'accessKeyId', + message: 'AWS Access Key ID:', + validate: (input) => input.trim() !== '' + }, + { + type: 'password', + name: 'secretAccessKey', + message: 'AWS Secret Access Key:', + validate: (input) => input.trim() !== '' + }, + { + type: 'input', + name: 'sessionToken', + message: 'Session Token (可选,用于临时凭证):' + } + ]) + + answers.awsCredentials = { + accessKeyId: credQuestions.accessKeyId, + secretAccessKey: credQuestions.secretAccessKey + } + + if (credQuestions.sessionToken) { + answers.awsCredentials.sessionToken = credQuestions.sessionToken + } + } + + const spinner = ora('正在创建 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.createAccount(answers) + + if (!result.success) { + throw new Error(result.error) + } + + spinner.succeed('Bedrock 账户创建成功') + console.log(styles.success(`账户 ID: ${result.data.id}`)) + console.log(styles.info(`名称: ${result.data.name}`)) + console.log(styles.info(`区域: ${result.data.region}`)) + } catch (error) { + spinner.fail('创建 Bedrock 账户失败') + console.error(styles.error(error.message)) + } +} + +async function testBedrockAccount() { + const spinner = ora('正在获取 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.getAllAccounts() + if (!result.success || result.data.length === 0) { + spinner.fail('没有可测试的 Bedrock 账户') + return + } + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ + name: `${account.name} (${account.region})`, + value: account.id + })) + + const { accountId } = await inquirer.prompt([ + { + type: 'list', + name: 'accountId', + message: '选择要测试的账户:', + choices + } + ]) + + const testSpinner = ora('正在测试账户连接...').start() + + const testResult = await bedrockAccountService.testAccount(accountId) + + if (testResult.success) { + testSpinner.succeed('账户连接测试成功') + console.log(styles.success(`状态: ${testResult.data.status}`)) + console.log(styles.info(`区域: ${testResult.data.region}`)) + console.log(styles.info(`可用模型数量: ${testResult.data.modelsCount || 'N/A'}`)) + } else { + testSpinner.fail('账户连接测试失败') + console.error(styles.error(testResult.error)) + } + } catch (error) { + spinner.fail('测试过程中发生错误') + console.error(styles.error(error.message)) + } +} + +async function toggleBedrockAccount() { + const spinner = ora('正在获取 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.getAllAccounts() + if (!result.success || result.data.length === 0) { + spinner.fail('没有可操作的 Bedrock 账户') + return + } + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ + name: `${account.name} (${account.isActive ? '🟢 活跃' : '🔴 停用'})`, + value: account.id + })) + + const { accountId } = await inquirer.prompt([ + { + type: 'list', + name: 'accountId', + message: '选择要切换状态的账户:', + choices + } + ]) + + const toggleSpinner = ora('正在切换账户状态...').start() + + // 获取当前状态 + const accountResult = await bedrockAccountService.getAccount(accountId) + if (!accountResult.success) { + throw new Error('无法获取账户信息') + } + + const newStatus = !accountResult.data.isActive + const updateResult = await bedrockAccountService.updateAccount(accountId, { + isActive: newStatus + }) + + if (updateResult.success) { + toggleSpinner.succeed('账户状态切换成功') + console.log(styles.success(`新状态: ${newStatus ? '🟢 活跃' : '🔴 停用'}`)) + } else { + throw new Error(updateResult.error) + } + } catch (error) { + spinner.fail('切换账户状态失败') + console.error(styles.error(error.message)) + } +} + +async function editBedrockAccount() { + const spinner = ora('正在获取 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.getAllAccounts() + if (!result.success || result.data.length === 0) { + spinner.fail('没有可编辑的 Bedrock 账户') + return + } + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ + name: `${account.name} (${account.region})`, + value: account.id + })) + + const { accountId } = await inquirer.prompt([ + { + type: 'list', + name: 'accountId', + message: '选择要编辑的账户:', + choices + } + ]) + + const accountResult = await bedrockAccountService.getAccount(accountId) + if (!accountResult.success) { + throw new Error('无法获取账户信息') + } + + const account = accountResult.data + + const updates = await inquirer.prompt([ + { + type: 'input', + name: 'name', + message: '账户名称:', + default: account.name + }, + { + type: 'input', + name: 'description', + message: '描述:', + default: account.description + }, + { + type: 'number', + name: 'priority', + message: '优先级 (1-100):', + default: account.priority, + validate: (input) => input >= 1 && input <= 100 + } + ]) + + const updateSpinner = ora('正在更新账户...').start() + + const updateResult = await bedrockAccountService.updateAccount(accountId, updates) + + if (updateResult.success) { + updateSpinner.succeed('账户更新成功') + } else { + throw new Error(updateResult.error) + } + } catch (error) { + spinner.fail('编辑账户失败') + console.error(styles.error(error.message)) + } +} + +async function deleteBedrockAccount() { + const spinner = ora('正在获取 Bedrock 账户...').start() + + try { + const result = await bedrockAccountService.getAllAccounts() + if (!result.success || result.data.length === 0) { + spinner.fail('没有可删除的 Bedrock 账户') + return + } + + spinner.succeed('账户列表获取成功') + + const choices = result.data.map((account) => ({ + name: `${account.name} (${account.region})`, + value: { id: account.id, name: account.name } + })) + + const { account } = await inquirer.prompt([ + { + type: 'list', + name: 'account', + message: '选择要删除的账户:', + choices + } + ]) + + const { confirm } = await inquirer.prompt([ + { + type: 'confirm', + name: 'confirm', + message: `确定要删除账户 "${account.name}" 吗?此操作无法撤销!`, + default: false + } + ]) + + if (!confirm) { + console.log(styles.info('已取消删除')) + return + } + + const deleteSpinner = ora('正在删除账户...').start() + + const deleteResult = await bedrockAccountService.deleteAccount(account.id) + + if (deleteResult.success) { + deleteSpinner.succeed('账户删除成功') + } else { + throw new Error(deleteResult.error) + } + } catch (error) { + spinner.fail('删除账户失败') + console.error(styles.error(error.message)) + } +} + +// 程序信息 +program.name('claude-relay-cli').description('Claude Relay Service 命令行管理工具').version('1.0.0') + +// 解析命令行参数 +program.parse() + +// 如果没有提供命令,显示帮助 +if (!process.argv.slice(2).length) { + console.log(styles.title('🚀 Claude Relay Service CLI\n')) + console.log('使用以下命令管理服务:\n') + console.log(' claude-relay-cli admin - 创建初始管理员账户') + console.log(' claude-relay-cli keys - API Key 管理(查看/修改过期时间/续期/删除)') + console.log(' claude-relay-cli bedrock - Bedrock 账户管理(创建/查看/编辑/测试/删除)') + console.log(' claude-relay-cli status - 查看系统状态') + console.log('\n使用 --help 查看详细帮助信息') +} diff --git a/config/config.example.js b/config/config.example.js new file mode 100644 index 0000000..9458e9e --- /dev/null +++ b/config/config.example.js @@ -0,0 +1,252 @@ +const path = require('path') +require('dotenv').config() + +const config = { + // 🌐 服务器配置 + server: { + port: parseInt(process.env.PORT) || 3000, + host: process.env.HOST || '0.0.0.0', + nodeEnv: process.env.NODE_ENV || 'development', + trustProxy: process.env.TRUST_PROXY === 'true' + }, + + // 🔐 安全配置 + security: { + jwtSecret: process.env.JWT_SECRET || 'CHANGE-THIS-JWT-SECRET-IN-PRODUCTION', + adminSessionTimeout: parseInt(process.env.ADMIN_SESSION_TIMEOUT) || 86400000, // 24小时 + apiKeyPrefix: process.env.API_KEY_PREFIX || 'cr_', + encryptionKey: process.env.ENCRYPTION_KEY || 'CHANGE-THIS-32-CHARACTER-KEY-NOW' + }, + + // 📊 Redis配置 + redis: { + host: process.env.REDIS_HOST || '127.0.0.1', + port: parseInt(process.env.REDIS_PORT) || 6379, + password: process.env.REDIS_PASSWORD || '', + db: parseInt(process.env.REDIS_DB) || 0, + connectTimeout: 10000, + commandTimeout: 5000, + retryDelayOnFailover: 100, + maxRetriesPerRequest: 3, + lazyConnect: true, + enableTLS: process.env.REDIS_ENABLE_TLS === 'true' + }, + + // 🔗 会话管理配置 + session: { + // 粘性会话TTL配置(小时),默认1小时 + stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1, + // 续期阈值(分钟),默认0分钟(不续期) + renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0 + }, + + // 🎯 Claude API配置 + claude: { + apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages', + apiVersion: process.env.CLAUDE_API_VERSION || '2023-06-01', + // 专属账号不可用时是否回退到共享池。 + // 默认 false:API Key 绑定了专属账号就必须使用该账号,不可用时直接报错, + // 否则「限定账号」的语义会被破坏(请求会静默用到别的账号)。 + dedicatedAccountFallback: process.env.CLAUDE_DEDICATED_ACCOUNT_FALLBACK === 'true', + betaHeader: + process.env.CLAUDE_BETA_HEADER || + 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14', + overloadHandling: { + enabled: (() => { + const minutes = parseInt(process.env.CLAUDE_OVERLOAD_HANDLING_MINUTES) || 0 + // 验证配置值:限制在0-1440分钟(24小时)内 + return Math.max(0, Math.min(minutes, 1440)) + })() + } + }, + + // ☁️ Bedrock API配置 + bedrock: { + enabled: process.env.CLAUDE_CODE_USE_BEDROCK === '1', + defaultRegion: process.env.AWS_REGION || 'us-east-1', + smallFastModelRegion: process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION, + defaultModel: process.env.ANTHROPIC_MODEL || 'us.anthropic.claude-sonnet-4-20250514-v1:0', + smallFastModel: + process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0', + maxOutputTokens: parseInt(process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS) || 4096, + maxThinkingTokens: parseInt(process.env.MAX_THINKING_TOKENS) || 1024, + enablePromptCaching: process.env.DISABLE_PROMPT_CACHING !== '1' + }, + + // 🌐 代理配置 + proxy: { + timeout: parseInt(process.env.DEFAULT_PROXY_TIMEOUT) || 600000, // 10分钟 + maxRetries: parseInt(process.env.MAX_PROXY_RETRIES) || 3, + // 连接池与 Keep-Alive 配置(默认关闭,需要显式开启) + keepAlive: (() => { + if (process.env.PROXY_KEEP_ALIVE === undefined || process.env.PROXY_KEEP_ALIVE === '') { + return false + } + return process.env.PROXY_KEEP_ALIVE === 'true' + })(), + maxSockets: (() => { + if (process.env.PROXY_MAX_SOCKETS === undefined || process.env.PROXY_MAX_SOCKETS === '') { + return undefined + } + const parsed = parseInt(process.env.PROXY_MAX_SOCKETS) + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined + })(), + maxFreeSockets: (() => { + if ( + process.env.PROXY_MAX_FREE_SOCKETS === undefined || + process.env.PROXY_MAX_FREE_SOCKETS === '' + ) { + return undefined + } + const parsed = parseInt(process.env.PROXY_MAX_FREE_SOCKETS) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined + })(), + // IP协议族配置:true=IPv4, false=IPv6, 默认IPv4(兼容性更好) + useIPv4: process.env.PROXY_USE_IPV4 !== 'false' // 默认 true,只有明确设置为 'false' 才使用 IPv6 + }, + + // ⏱️ 请求超时配置 + requestTimeout: parseInt(process.env.REQUEST_TIMEOUT) || 600000, // 默认 10 分钟 + + // 📈 使用限制 + limits: { + defaultTokenLimit: parseInt(process.env.DEFAULT_TOKEN_LIMIT) || 1000000 + }, + + // 📝 日志配置 + logging: { + level: process.env.LOG_LEVEL || 'info', + dirname: path.join(__dirname, '..', 'logs'), + maxSize: process.env.LOG_MAX_SIZE || '10m', + maxFiles: parseInt(process.env.LOG_MAX_FILES) || 5 + }, + + // 🔧 系统配置 + system: { + cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL) || 3600000, // 1小时 + tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天 + healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟 + timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8(中国时区) + timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8, // UTC偏移小时数,默认+8 + metricsWindow: parseInt(process.env.METRICS_WINDOW) || 5 // 实时指标统计窗口(分钟) + }, + + // 🎨 Web界面配置 + web: { + title: process.env.WEB_TITLE || 'Claude Relay Service', + description: + process.env.WEB_DESCRIPTION || + 'Multi-account Claude API relay service with beautiful management interface', + logoUrl: process.env.WEB_LOGO_URL || '/assets/logo.png', + enableCors: process.env.ENABLE_CORS === 'true', + sessionSecret: process.env.WEB_SESSION_SECRET || 'CHANGE-THIS-SESSION-SECRET' + }, + + // 🔐 LDAP 认证配置 + ldap: { + enabled: process.env.LDAP_ENABLED === 'true', + server: { + url: process.env.LDAP_URL || 'ldap://localhost:389', + bindDN: process.env.LDAP_BIND_DN || 'cn=admin,dc=example,dc=com', + bindCredentials: process.env.LDAP_BIND_PASSWORD || 'admin', + searchBase: process.env.LDAP_SEARCH_BASE || 'dc=example,dc=com', + searchFilter: process.env.LDAP_SEARCH_FILTER || '(uid={{username}})', + searchAttributes: process.env.LDAP_SEARCH_ATTRIBUTES + ? process.env.LDAP_SEARCH_ATTRIBUTES.split(',') + : ['dn', 'uid', 'cn', 'mail', 'givenName', 'sn'], + timeout: parseInt(process.env.LDAP_TIMEOUT) || 5000, + connectTimeout: parseInt(process.env.LDAP_CONNECT_TIMEOUT) || 10000, + // TLS/SSL 配置 + tls: { + // 是否忽略证书错误 (用于自签名证书) + rejectUnauthorized: process.env.LDAP_TLS_REJECT_UNAUTHORIZED !== 'false', // 默认验证证书,设置为false则忽略 + // CA证书文件路径 (可选,用于自定义CA证书) + ca: process.env.LDAP_TLS_CA_FILE + ? require('fs').readFileSync(process.env.LDAP_TLS_CA_FILE) + : undefined, + // 客户端证书文件路径 (可选,用于双向认证) + cert: process.env.LDAP_TLS_CERT_FILE + ? require('fs').readFileSync(process.env.LDAP_TLS_CERT_FILE) + : undefined, + // 客户端私钥文件路径 (可选,用于双向认证) + key: process.env.LDAP_TLS_KEY_FILE + ? require('fs').readFileSync(process.env.LDAP_TLS_KEY_FILE) + : undefined, + // 服务器名称 (用于SNI,可选) + servername: process.env.LDAP_TLS_SERVERNAME || undefined + } + }, + userMapping: { + username: process.env.LDAP_USER_ATTR_USERNAME || 'uid', + displayName: process.env.LDAP_USER_ATTR_DISPLAY_NAME || 'cn', + email: process.env.LDAP_USER_ATTR_EMAIL || 'mail', + firstName: process.env.LDAP_USER_ATTR_FIRST_NAME || 'givenName', + lastName: process.env.LDAP_USER_ATTR_LAST_NAME || 'sn' + } + }, + + // 👥 用户管理配置 + userManagement: { + enabled: process.env.USER_MANAGEMENT_ENABLED === 'true', + defaultUserRole: process.env.DEFAULT_USER_ROLE || 'user', + userSessionTimeout: parseInt(process.env.USER_SESSION_TIMEOUT) || 86400000, // 24小时 + maxApiKeysPerUser: parseInt(process.env.MAX_API_KEYS_PER_USER) || 1, + allowUserDeleteApiKeys: process.env.ALLOW_USER_DELETE_API_KEYS === 'true' // 默认不允许用户删除自己的API Keys + }, + + // 📢 Webhook通知配置 + webhook: { + enabled: process.env.WEBHOOK_ENABLED !== 'false', // 默认启用 + urls: process.env.WEBHOOK_URLS + ? process.env.WEBHOOK_URLS.split(',').map((url) => url.trim()) + : [], + timeout: parseInt(process.env.WEBHOOK_TIMEOUT) || 10000, // 10秒超时 + retries: parseInt(process.env.WEBHOOK_RETRIES) || 3 // 重试3次 + }, + + // 🛠️ 开发配置 + development: { + debug: process.env.DEBUG === 'true', + hotReload: process.env.HOT_RELOAD === 'true' + }, + + // 💰 账户余额相关配置 + accountBalance: { + // 是否允许执行自定义余额脚本(安全开关) + // 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启 + // 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false + enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false' + }, + + // 📬 用户消息队列配置 + // 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算 + userMessageQueue: { + enabled: process.env.USER_MESSAGE_QUEUE_ENABLED === 'true', // 默认关闭 + delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒) + timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待 + lockTtlMs: parseInt(process.env.USER_MESSAGE_QUEUE_LOCK_TTL_MS) || 5000 // 锁TTL(毫秒),5秒足以覆盖请求发送 + }, + + // 🎫 额度卡兑换上限配置(防盗刷) + quotaCardLimits: { + enabled: process.env.QUOTA_CARD_LIMITS_ENABLED !== 'false', // 默认启用 + maxExpiryDays: parseInt(process.env.QUOTA_CARD_MAX_EXPIRY_DAYS) || 90, // 最大有效期距今天数 + maxTotalCostLimit: parseFloat(process.env.QUOTA_CARD_MAX_TOTAL_COST_LIMIT) || 1000 // 最大总额度(美元) + }, + + // ⏱️ 上游错误自动暂停配置 + // 说明:此处是全局默认值。Claude 官方 OAuth 账号可在后台做账号级 503/5xx 覆盖, + // 且可通过账号设置禁用 temp_unavailable(账号级策略优先于全局默认值)。 + upstreamError: { + serviceUnavailableTtlSeconds: parseInt(process.env.UPSTREAM_ERROR_503_TTL_SECONDS) || 60, // 503错误暂停秒数 + serverErrorTtlSeconds: parseInt(process.env.UPSTREAM_ERROR_5XX_TTL_SECONDS) || 300, // 5xx错误暂停秒数 + overloadTtlSeconds: parseInt(process.env.UPSTREAM_ERROR_OVERLOAD_TTL_SECONDS) || 600, // 529过载暂停秒数 + authErrorTtlSeconds: parseInt(process.env.UPSTREAM_ERROR_AUTH_TTL_SECONDS) || 1800, // 401/403认证错误暂停秒数 + timeoutTtlSeconds: parseInt(process.env.UPSTREAM_ERROR_TIMEOUT_TTL_SECONDS) || 300, // 504超时暂停秒数 + // 上游 retry-after 派生的暂停时长上限(秒)。周级限额的 retry-after 可达数天, + // 若不钳制会把账号整整下线数天。长时限额应由账号级/模型级限流桶承担。 + maxCustomTtlSeconds: parseInt(process.env.UPSTREAM_ERROR_MAX_CUSTOM_TTL_SECONDS) || 1800 + } +} + +module.exports = config diff --git a/config/models.js b/config/models.js new file mode 100644 index 0000000..409027b --- /dev/null +++ b/config/models.js @@ -0,0 +1,93 @@ +/** + * 模型列表配置 + * 用于前端展示和测试功能 + */ + +const CLAUDE_MODELS = [ + { value: 'claude-opus-4-6', label: 'Claude Opus 4.6' }, + { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6' }, + { value: 'claude-opus-4-5-20251101', label: 'Claude Opus 4.5' }, + { value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5' }, + { value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' }, + { value: 'claude-opus-4-1-20250805', label: 'Claude Opus 4.1' }, + { value: 'claude-opus-4-20250514', label: 'Claude Opus 4' }, + { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5' }, + { value: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' } +] + +const GEMINI_MODELS = [ + { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' }, + { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' }, + { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' }, + { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' } +] + +const OPENAI_MODELS = [ + { value: 'gpt-5', label: 'GPT-5' }, + { value: 'gpt-5-mini', label: 'GPT-5 Mini' }, + { value: 'gpt-5-nano', label: 'GPT-5 Nano' }, + { value: 'gpt-5.1', label: 'GPT-5.1' }, + { value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' }, + { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' }, + { value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' }, + { value: 'gpt-5.2', label: 'GPT-5.2' }, + { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' }, + { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' }, + { value: 'gpt-5.3-codex-spark', label: 'GPT-5.3 Codex Spark' }, + { value: 'gpt-5.4', label: 'GPT-5.4' }, + { value: 'gpt-5.4-pro', label: 'GPT-5.4 Pro' }, + { value: 'codex-mini', label: 'Codex Mini' } +] + +const BEDROCK_MODELS = [ + { value: 'us.anthropic.claude-opus-4-6-20250610-v1:0', label: 'Claude Opus 4.6' }, + { value: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', label: 'Claude Sonnet 4.5' }, + { value: 'us.anthropic.claude-sonnet-4-20250514-v1:0', label: 'Claude Sonnet 4' }, + { value: 'us.anthropic.claude-3-5-haiku-20241022-v1:0', label: 'Claude 3.5 Haiku' } +] + +// 其他模型(用于账户编辑的模型映射) +const OTHER_MODELS = [ + { value: 'deepseek-chat', label: 'DeepSeek Chat' }, + { value: 'Qwen', label: 'Qwen' }, + { value: 'Kimi', label: 'Kimi' }, + { value: 'GLM', label: 'GLM' } +] + +// 各平台测试可用模型 +const PLATFORM_TEST_MODELS = { + claude: CLAUDE_MODELS, + 'claude-console': CLAUDE_MODELS, + bedrock: BEDROCK_MODELS, + gemini: GEMINI_MODELS, + 'gemini-api': GEMINI_MODELS, + 'openai-responses': OPENAI_MODELS, + 'azure-openai': [], + droid: CLAUDE_MODELS, + ccr: CLAUDE_MODELS +} + +module.exports = { + CLAUDE_MODELS, + GEMINI_MODELS, + OPENAI_MODELS, + BEDROCK_MODELS, + OTHER_MODELS, + PLATFORM_TEST_MODELS, + // 按服务分组 + getModelsByService: (service) => { + switch (service) { + case 'claude': + return CLAUDE_MODELS + case 'gemini': + return GEMINI_MODELS + case 'openai': + return OPENAI_MODELS + default: + return [] + } + }, + // 获取所有模型(用于账户编辑) + getAllModels: () => [...CLAUDE_MODELS, ...GEMINI_MODELS, ...OPENAI_MODELS, ...OTHER_MODELS] +} diff --git a/config/pricingSource.js b/config/pricingSource.js new file mode 100644 index 0000000..235503f --- /dev/null +++ b/config/pricingSource.js @@ -0,0 +1,17 @@ +const repository = + process.env.PRICE_MIRROR_REPO || process.env.GITHUB_REPOSITORY || 'Wei-Shaw/claude-relay-service' +const branch = process.env.PRICE_MIRROR_BRANCH || 'price-mirror' +const pricingFileName = process.env.PRICE_MIRROR_FILENAME || 'model_prices_and_context_window.json' +const hashFileName = + process.env.PRICE_MIRROR_HASH_FILENAME || 'model_prices_and_context_window.sha256' + +const baseUrl = process.env.PRICE_MIRROR_BASE_URL + ? process.env.PRICE_MIRROR_BASE_URL.replace(/\/$/, '') + : `https://raw.githubusercontent.com/${repository}/${branch}` + +module.exports = { + pricingFileName, + hashFileName, + pricingUrl: process.env.PRICE_MIRROR_JSON_URL || `${baseUrl}/${pricingFileName}`, + hashUrl: process.env.PRICE_MIRROR_HASH_URL || `${baseUrl}/${hashFileName}` +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d8f78a2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,172 @@ +version: '3.8' + +# Claude Relay Service Docker Compose 配置 +# 所有配置通过环境变量设置,无需映射 .env 文件 + +services: + # 🚀 Claude Relay Service + claude-relay: + build: . + image: weishaw/claude-relay-service:latest + restart: unless-stopped + ports: + # 绑定地址:生产环境建议使用反向代理,设置 BIND_HOST=127.0.0.1 + - "${BIND_HOST:-0.0.0.0}:${PORT:-3000}:3000" + volumes: + - ./logs:/app/logs + - ./data:/app/data + environment: + # 🌐 服务器配置 + - NODE_ENV=production + - PORT=3000 + - HOST=0.0.0.0 + + # 🔧 请求体大小配置 + - REQUEST_MAX_SIZE_MB=60 + + # 🔐 安全配置(必填) + - JWT_SECRET=${JWT_SECRET} # 必填:至少32字符的随机字符串 + - ENCRYPTION_KEY=${ENCRYPTION_KEY} # 必填:32字符的加密密钥 + - ADMIN_SESSION_TIMEOUT=${ADMIN_SESSION_TIMEOUT:-86400000} + - API_KEY_PREFIX=${API_KEY_PREFIX:-cr_} + + # 👤 管理员凭据(可选) + - ADMIN_USERNAME=${ADMIN_USERNAME:-} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-} + + # 📊 Redis 配置 + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_PASSWORD=${REDIS_PASSWORD:-} + - REDIS_DB=${REDIS_DB:-0} + - REDIS_ENABLE_TLS=${REDIS_ENABLE_TLS:-} + + # 🎯 Claude API 配置 + - CLAUDE_API_URL=${CLAUDE_API_URL:-https://api.anthropic.com/v1/messages} + - CLAUDE_API_VERSION=${CLAUDE_API_VERSION:-2023-06-01} + - CLAUDE_BETA_HEADER=${CLAUDE_BETA_HEADER:-claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14} + + # 🌐 代理配置 + - DEFAULT_PROXY_TIMEOUT=${DEFAULT_PROXY_TIMEOUT:-60000} + - MAX_PROXY_RETRIES=${MAX_PROXY_RETRIES:-3} + - PROXY_USE_IPV4=${PROXY_USE_IPV4:-true} + - PROXY_KEEP_ALIVE=${PROXY_KEEP_ALIVE:-} + - PROXY_MAX_SOCKETS=${PROXY_MAX_SOCKETS:-} + - PROXY_MAX_FREE_SOCKETS=${PROXY_MAX_FREE_SOCKETS:-} + + # 📈 使用限制 + - DEFAULT_TOKEN_LIMIT=${DEFAULT_TOKEN_LIMIT:-1000000} + + # 📝 日志配置 + - LOG_LEVEL=${LOG_LEVEL:-info} + - LOG_MAX_SIZE=${LOG_MAX_SIZE:-10m} + - LOG_MAX_FILES=${LOG_MAX_FILES:-5} + + # 🔧 系统配置 + - CLEANUP_INTERVAL=${CLEANUP_INTERVAL:-3600000} + - TOKEN_USAGE_RETENTION=${TOKEN_USAGE_RETENTION:-2592000000} + - HEALTH_CHECK_INTERVAL=${HEALTH_CHECK_INTERVAL:-60000} + - TIMEZONE_OFFSET=${TIMEZONE_OFFSET:-8} + + # 🎨 Web 界面配置 + - WEB_TITLE=${WEB_TITLE:-Claude Relay Service} + - WEB_DESCRIPTION=${WEB_DESCRIPTION:-Multi-account Claude API relay service} + - WEB_LOGO_URL=${WEB_LOGO_URL:-/assets/logo.png} + + # 🛠️ 开发配置 + - DEBUG=${DEBUG:-false} + - ENABLE_CORS=${ENABLE_CORS:-true} + - TRUST_PROXY=${TRUST_PROXY:-true} + depends_on: + - redis + networks: + - claude-relay-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + + # 📊 Redis Database + redis: + image: redis:7-alpine + restart: unless-stopped + # 仅在容器网络内部暴露端口,不映射到主机 + expose: + - "6379" + # 注意:如需本地调试访问,可取消下行注释(生产环境禁用) + # ports: + # - "127.0.0.1:${REDIS_PORT:-6379}:6379" + volumes: + - ./redis_data:/data + command: redis-server --save 60 1 --appendonly yes --appendfsync everysec + networks: + - claude-relay-network + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 3 + + # 📈 Redis Monitoring (Optional) + redis-commander: + image: rediscommander/redis-commander:latest + restart: unless-stopped + ports: + - "127.0.0.1:${REDIS_WEB_PORT:-8081}:8081" + environment: + - REDIS_HOSTS=local:redis:6379 + depends_on: + - redis + networks: + - claude-relay-network + profiles: + - monitoring + + # 📊 Application Monitoring (Optional) + prometheus: + image: prom/prometheus:latest + restart: unless-stopped + ports: + - "127.0.0.1:${PROMETHEUS_PORT:-9090}:9090" + volumes: + - ./config/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + networks: + - claude-relay-network + profiles: + - monitoring + + # 📈 Grafana Dashboard (Optional) + grafana: + image: grafana/grafana:latest + restart: unless-stopped + ports: + - "127.0.0.1:${GRAFANA_PORT:-3001}:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin123} + volumes: + - grafana_data:/var/lib/grafana + - ./config/grafana:/etc/grafana/provisioning + depends_on: + - prometheus + networks: + - claude-relay-network + profiles: + - monitoring + +volumes: + prometheus_data: + driver: local + grafana_data: + driver: local + +networks: + claude-relay-network: + driver: bridge diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..b4e37c5 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,65 @@ +#!/bin/sh +set -e + +echo "🚀 Claude Relay Service 启动中..." + +# 检查关键环境变量 +if [ -z "$JWT_SECRET" ]; then + echo "❌ 错误: JWT_SECRET 环境变量未设置" + echo " 请在 docker-compose.yml 中设置 JWT_SECRET" + echo " 例如: JWT_SECRET=your-random-secret-key-at-least-32-chars" + exit 1 +fi + +if [ -z "$ENCRYPTION_KEY" ]; then + echo "❌ 错误: ENCRYPTION_KEY 环境变量未设置" + echo " 请在 docker-compose.yml 中设置 ENCRYPTION_KEY" + echo " 例如: ENCRYPTION_KEY=your-32-character-encryption-key" + exit 1 +fi + +# 检查并复制配置文件 +if [ ! -f "/app/config/config.js" ]; then + echo "📋 检测到 config.js 不存在,从模板创建..." + if [ -f "/app/config/config.example.js" ]; then + cp /app/config/config.example.js /app/config/config.js + echo "✅ config.js 已创建" + else + echo "❌ 错误: config.example.js 不存在" + exit 1 + fi +fi + +# 显示配置信息 +echo "✅ 环境配置已就绪" +echo " JWT_SECRET: [已设置]" +echo " ENCRYPTION_KEY: [已设置]" +echo " REDIS_HOST: ${REDIS_HOST:-localhost}" +echo " PORT: ${PORT:-3000}" + +# 检查是否需要初始化 +if [ ! -f "/app/data/init.json" ]; then + echo "📋 首次启动,执行初始化设置..." + + # 如果设置了环境变量,显示提示 + if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then + echo "📌 检测到预设的管理员凭据" + fi + + # 执行初始化脚本 + node /app/scripts/setup.js + + echo "✅ 初始化完成" +else + echo "✅ 检测到已有配置,跳过初始化" + + # 如果 init.json 存在但环境变量也设置了,显示警告 + if [ -n "$ADMIN_USERNAME" ] || [ -n "$ADMIN_PASSWORD" ]; then + echo "⚠️ 警告: 检测到环境变量 ADMIN_USERNAME/ADMIN_PASSWORD,但系统已初始化" + echo " 如需使用新凭据,请删除 data/init.json 文件后重启容器" + fi +fi + +# 启动应用 +echo "🌐 启动 Claude Relay Service..." +exec "$@" \ No newline at end of file diff --git a/docs/claude-code-gemini3-guide/README.md b/docs/claude-code-gemini3-guide/README.md new file mode 100644 index 0000000..e8d7189 --- /dev/null +++ b/docs/claude-code-gemini3-guide/README.md @@ -0,0 +1,240 @@ +# Claude Code 调用 Gemini 3 模型指南 + +本文档介绍如何通过 **claude-code-router (CCR)** 在 Claude Code 中调用 Gemini 3 模型,其他模型也可以参照此教程尝试。 + +--- + +## 概述 + +通过 CCR 转换格式,你可以让 Claude Code 客户端无缝使用 Gemini 3 模型。 + +### 工作原理 + +``` +Claude Code → CCR (模型路由) → CRS (账户调度) → Gemini API +``` + +--- + +## 第一步:安装 claude-code-router + +安装 CCR: + +> **安装位置建议**: +> - 如果只是本地使用,可以只安装到使用 Claude Code 的电脑上 +> - 如果需要 CRS 项目接入 CCR,建议安装在与 CRS 同一台服务器上 + +```bash +npm install -g @musistudio/claude-code-router +``` + +验证安装: + +```bash +ccr -v +``` + +--- + +## 第二步:配置 CCR + +创建或编辑 CCR 配置文件(通常位于 `~/.claude-code-router/config.json`): + +```json +{ + "APIKEY": "sk-c0e7fed7b-这里随便你自定义", + "LOG": true, + "HOST": "127.0.0.1", + "API_TIMEOUT_MS": 600000, + "NON_INTERACTIVE_MODE": false, + "Providers": [ + { + "name": "gemini", + "api_base_url": "http://127.0.0.1:3000/gemini/v1beta/models/", + "api_key": "cr_xxxxxxxxxxxxxxxxxxxxx", + "models": ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-3-pro-preview"], + "transformer": { + "use": ["gemini"] + } + } + ], + "Router": { + "default": "gemini", + "background": "gemini,gemini-3-pro-preview", + "think": "gemini,gemini-3-pro-preview", + "longContext": "gemini,gemini-3-pro-preview", + "longContextThreshold": 60000, + "webSearch": "gemini,gemini-2.5-flash" + } +} +``` + +### 配置说明 + +| 字段 | 说明 | +|------|------| +| `APIKEY` | CCR 自定义的 API Key,Claude Code 将使用这个 Key 访问 CCR | +| `api_base_url` | CRS 服务的 Gemini API 地址 | +| `api_key` | CRS 后台创建的 API Key(cr_ 开头),用于调度 OAuth、Gemini-API 账号 | + +--- + +## 第三步:在 CRS 中配置 Gemini 账号 + +确保你的 CRS 服务已添加 Gemini 账号: + +1. 登录 CRS 管理界面 +2. 进入「Gemini 账户」页面 +3. 添加 Gemini OAuth 账号或 API Key 账号 +4. 确保账号状态为「活跃」 + +--- + +## 第四步:启动 CCR 服务 + +保存配置后,启动 CCR 服务: + +```bash +ccr start +``` + +查看服务状态: + +```bash +ccr status +``` + +输出示例: + +``` +API Endpoint: http://127.0.0.1:3456 +``` + +**重要**:每次修改配置后,需要重启 CCR 服务才能生效: + +```bash +ccr restart +``` + +--- + +## 第五步:配置 Claude Code + +现在需要让 Claude Code 连接到 CCR 服务。有两种方式: + +### 方式一:本地直接使用 + +设置环境变量让 Claude Code 直接连接 CCR: + +```bash +export ANTHROPIC_BASE_URL="http://127.0.0.1:3456/" +export ANTHROPIC_AUTH_TOKEN="sk-c0e7fed7b-你的自定义Key" +``` + +然后启动 Claude Code: + +```bash +claude +``` + +### 方式二:通过 CRS 统一管理(推荐) + +如果你希望通过 CRS 统一管理所有用户的访问,可以在 CRS 中添加 Claude Console 类型账号来代理 CCR。 + +#### 1. 在 CRS 添加 Claude Console 账号 + +登录 CRS 管理界面,添加一个 **Claude Console** 类型的账号: + +| 字段 | 值 | +|------|-----| +| 账户名称 | CCR-Gemini3(或自定义名称)| +| 账户类型 | Claude Console | +| API 地址 | `http://127.0.0.1:3456`(CCR 服务地址)| +| API Key | `sk-c0e7fed7b-你的自定义Key`(CCR 配置中的 APIKEY)| + +> **注意**:如果 CCR 运行在其他服务器上,请将 `127.0.0.1` 替换为实际的服务器地址,配置文件中需要修改HOST参数为```0.0.0.0```。 + +#### 2. 配置模型映射 + +在 CRS 中配置模型映射,将 Claude 模型名映射到 Gemini 模型: + +| Claude 模型 | 映射到 Gemini 模型 | +|-------------|-------------------| +| `claude-opus-4-1-20250805` | `gemini-3-pro-preview` | +| `claude-sonnet-4-5-20250929` | `gemini-3-pro-preview` | +| `claude-haiku-4-5-20251001` | `gemini-2.5-flash` | + +**配置界面示例:** + +![模型映射配置](./model-mapping.png) + +> **说明**: +> - Opus 和 Sonnet 映射到性能更强的 `gemini-3-pro-preview` +> - Haiku 映射到响应更快的 `gemini-2.5-flash` + +#### 3. 用户使用方式 + +用户现在可以通过 CRS 统一入口使用 Claude Code: + +```bash +export ANTHROPIC_BASE_URL="http://你的CRS服务器:3000/api/" +export ANTHROPIC_AUTH_TOKEN="cr_用户的APIKey" +``` + +Claude Code 会自动将请求路由到 CCR,再由 CCR 转发到 Gemini API。 + +--- + +## 常见问题 + +### Q: CCR 配置修改后没有生效? + +A: 配置修改后必须重启 CCR 服务: + +```bash +ccr restart +``` + +### Q: 连接超时怎么办? + +A: 检查以下几点: +1. CRS 服务是否正常运行 +2. CCR 配置中的 `api_base_url` 是否正确 +3. 防火墙是否允许相应端口 +4. 尝试增加 `API_TIMEOUT_MS` 的值 + +### Q: 模型映射不生效? + +A: 确保: +1. CRS 中已正确配置 Claude Console 账号 +2. 模型映射配置已保存 +3. 重启 CRS 服务使配置生效 + +### Q: 如何测试连接? + +A: 使用 curl 测试 CCR 服务: + +```bash +curl -X POST http://127.0.0.1:3456/api/v1/messages \ + -H "Content-Type: application/json" \ + -H "x-api-key: sk-c0e7fed7b-你的自定义Key" \ + -d '{ + "model": "claude-sonnet-4-5-20250929", + "max_tokens": 100, + "messages": [{"role": "user", "content": "Hello"}] + }' +``` + +--- + +## 最佳实践 + +1. **生产环境**:将 CCR 部署在与 CRS 相同的服务器上,减少网络延迟 +2. **API Key 管理**:为每个用户创建独立的 CRS API Key,便于使用统计 +3. **超时配置**:对于长时间运行的任务,适当增加 `API_TIMEOUT_MS` + +--- + +## 相关资源 + +- [CCR 官方文档](https://github.com/musistudio/claude-code-router) \ No newline at end of file diff --git a/docs/claude-code-gemini3-guide/model-mapping.png b/docs/claude-code-gemini3-guide/model-mapping.png new file mode 100644 index 0000000000000000000000000000000000000000..460dc9015a9b6da0dc03fc2398ad0cd07b239f02 GIT binary patch literal 92696 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 literal 0 HcmV?d00001 diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..c4b3c5f --- /dev/null +++ b/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ext": "js,json", + "exec": "npm run lint && node src/app.js" +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d9ebcff --- /dev/null +++ b/package-lock.json @@ -0,0 +1,9260 @@ +{ + "name": "claude-relay-service", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claude-relay-service", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.861.0", + "@aws-sdk/credential-providers": "^3.859.0", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", + "chalk": "^4.1.2", + "commander": "^11.1.0", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "google-auth-library": "^10.1.0", + "helmet": "^7.1.0", + "https-proxy-agent": "^7.0.2", + "inquirer": "^8.2.6", + "ioredis": "^5.3.2", + "ldapjs": "^3.0.7", + "morgan": "^1.10.0", + "node-cron": "^4.2.1", + "nodemailer": "^7.0.6", + "ora": "^5.4.1", + "rate-limiter-flexible": "^5.0.5", + "socks-proxy-agent": "^8.0.2", + "string-similarity": "^4.0.4", + "table": "^6.8.1", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "winston-daily-rotate-file": "^4.7.1" + }, + "devDependencies": { + "@types/node": "^20.8.9", + "eslint": "^8.53.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "nodemon": "^3.0.1", + "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.7.2", + "supertest": "^6.3.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.883.0.tgz", + "integrity": "sha512-jWFwY+jc1NcyO8hlAAznL3p+8vbCpgon0GlxaagIwyI0x7Dx0IklyEhlF51UloWCdAyZxw1SNxsIQeQpETFpRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/credential-provider-node": "3.883.0", + "@aws-sdk/eventstream-handler-node": "3.873.0", + "@aws-sdk/middleware-eventstream": "3.873.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.883.0", + "@aws-sdk/middleware-websocket": "3.873.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/token-providers": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.883.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.2", + "@smithy/eventstream-serde-browser": "^4.0.5", + "@smithy/eventstream-serde-config-resolver": "^4.1.3", + "@smithy/eventstream-serde-node": "^4.0.5", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.21", + "@smithy/middleware-retry": "^4.1.22", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.29", + "@smithy/util-defaults-mode-node": "^4.0.29", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-stream": "^4.2.4", + "@smithy/util-utf8": "^4.0.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.883.0.tgz", + "integrity": "sha512-/uezRmLtkx7kZkC0o6B+hahCVBTij2ghCW+kXgbK0tz6Gl7WDYRIyszR9Vf0wDUqsj5S3hgBXKr6zR4V4ULTmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/credential-provider-node": "3.883.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.883.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.883.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.2", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.21", + "@smithy/middleware-retry": "^4.1.22", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.29", + "@smithy/util-defaults-mode-node": "^4.0.29", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/client-sso/-/client-sso-3.883.0.tgz", + "integrity": "sha512-Ybjw76yPceEBO7+VLjy5+/Gr0A1UNymSDHda5w8tfsS2iHZt/vuD6wrYpHdLoUx4H5la8ZhwcSfK/+kmE+QLPw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.883.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.883.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.2", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.21", + "@smithy/middleware-retry": "^4.1.22", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.29", + "@smithy/util-defaults-mode-node": "^4.0.29", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/core/-/core-3.883.0.tgz", + "integrity": "sha512-FmkqnqBLkXi4YsBPbF6vzPa0m4XKUuvgKDbamfw4DZX2CzfBZH6UU4IwmjNV3ZM38m0xraHarK8KIbGSadN3wg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@aws-sdk/xml-builder": "3.873.0", + "@smithy/core": "^3.9.2", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/property-provider": "^4.0.5", + "@smithy/protocol-http": "^5.1.3", + "@smithy/signature-v4": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.883.0.tgz", + "integrity": "sha512-r5KQ1UP1LxtZ5PfBQr08zgn1fIgpDlyDSk59h3kpj91+xcuaQtn3241D61iTv0ICFTaurO5SqM25f87aQuAsDw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.883.0.tgz", + "integrity": "sha512-Z6tPBXPCodfhIF1rvQKoeRGMkwL6TK0xdl1UoMIA1x4AfBpPICAF77JkFBExk/pdiFYq1d04Qzddd/IiujSlLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.883.0.tgz", + "integrity": "sha512-P589ug1lMOOEYLTaQJjSP+Gee34za8Kk2LfteNQfO9SpByHFgGj++Sg8VyIe30eZL8Q+i4qTt24WDCz1c+dgYg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/property-provider": "^4.0.5", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/util-stream": "^4.2.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.883.0.tgz", + "integrity": "sha512-n6z9HTzuDEdugXvPiE/95VJXbF4/gBffdV/SRHDJKtDHaRuvp/gggbfmfVSTFouGVnlKPb2pQWQsW3Nr/Y3Lrw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/credential-provider-env": "3.883.0", + "@aws-sdk/credential-provider-http": "3.883.0", + "@aws-sdk/credential-provider-process": "3.883.0", + "@aws-sdk/credential-provider-sso": "3.883.0", + "@aws-sdk/credential-provider-web-identity": "3.883.0", + "@aws-sdk/nested-clients": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.883.0.tgz", + "integrity": "sha512-QIUhsatsrwfB9ZsKpmi0EySSfexVP61wgN7hr493DOileh2QsKW4XATEfsWNmx0dj9323Vg1Mix7bXtRfl9cGg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.883.0", + "@aws-sdk/credential-provider-http": "3.883.0", + "@aws-sdk/credential-provider-ini": "3.883.0", + "@aws-sdk/credential-provider-process": "3.883.0", + "@aws-sdk/credential-provider-sso": "3.883.0", + "@aws-sdk/credential-provider-web-identity": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.883.0.tgz", + "integrity": "sha512-m1shbHY/Vppy4EdddG9r8x64TO/9FsCjokp5HbKcZvVoTOTgUJrdT8q2TAQJ89+zYIJDqsKbqfrmfwJ1zOdnGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.883.0.tgz", + "integrity": "sha512-37ve9Tult08HLXrJFHJM/sGB/vO7wzI6v1RUUfeTiShqx8ZQ5fTzCTNY/duO96jCtCexmFNSycpQzh7lDIf0aA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.883.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/token-providers": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.883.0.tgz", + "integrity": "sha512-SL82K9Jb0vpuTadqTO4Fpdu7SKtebZ3Yo4LZvk/U0UauVMlJj5ZTos0mFx1QSMB9/4TpqifYrSZcdnxgYg8Eqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/nested-clients": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/credential-providers/-/credential-providers-3.883.0.tgz", + "integrity": "sha512-gIoGVbOTAaWm9muDo5QI42EAYW03RyNbtGb+Yhiy72EX15aZhRsW9v9Gs1YxC2d7dTW5Zs3qXMcenoMzas5aQg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.883.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/credential-provider-cognito-identity": "3.883.0", + "@aws-sdk/credential-provider-env": "3.883.0", + "@aws-sdk/credential-provider-http": "3.883.0", + "@aws-sdk/credential-provider-ini": "3.883.0", + "@aws-sdk/credential-provider-node": "3.883.0", + "@aws-sdk/credential-provider-process": "3.883.0", + "@aws-sdk/credential-provider-sso": "3.883.0", + "@aws-sdk/credential-provider-web-identity": "3.883.0", + "@aws-sdk/nested-clients": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.2", + "@smithy/credential-provider-imds": "^4.0.7", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/property-provider": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.873.0.tgz", + "integrity": "sha512-c3j9Q3RSR4+/01oHgx8b4WuD2HinVAalbsL7rJKlw86sP6ef1Gq7rVYFn74Ooh+2fIVecvX3cla/tdkR8PwBtA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/eventstream-codec": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.873.0.tgz", + "integrity": "sha512-x/BFHxZcfL6siwAPILmF8bGuWAmxDhrXvTlxJZOwwozWnhgRSxgxX2sitpWGvS8pL64DoABwCWSgsgyoXJlMFw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-host-header/-/middleware-host-header-3.873.0.tgz", + "integrity": "sha512-KZ/W1uruWtMOs7D5j3KquOxzCnV79KQW9MjJFZM/M0l6KI8J6V3718MXxFHsTjUE4fpdV6SeCNLV1lwGygsjJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.876.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-logger/-/middleware-logger-3.876.0.tgz", + "integrity": "sha512-cpWJhOuMSyz9oV25Z/CMHCBTgafDCbv7fHR80nlRrPdPZ8ETNsahwRgltXP1QJJ8r3X/c1kwpOR7tc+RabVzNA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.873.0.tgz", + "integrity": "sha512-OtgY8EXOzRdEWR//WfPkA/fXl0+WwE8hq0y9iw2caNyKPtca85dzrrZWnPqyBK/cpImosrpR1iKMYr41XshsCg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.883.0.tgz", + "integrity": "sha512-q58uLYnGLg7hsnWpdj7Cd1Ulsq1/PUJOHvAfgcBuiDE/+Fwh0DZxZZyjrU+Cr+dbeowIdUaOO8BEDDJ0CUenJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@smithy/core": "^3.9.2", + "@smithy/protocol-http": "^5.1.3", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/middleware-websocket/-/middleware-websocket-3.873.0.tgz", + "integrity": "sha512-NLh9JmE460/WIVlsoP4vR5zbgPu50uVHXiEyr5lf34MatayiMTiC7Dd9KecKys8tppVVqahOMkOLb4/nl0hk6Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-format-url": "3.873.0", + "@smithy/eventstream-codec": "^4.0.5", + "@smithy/eventstream-serde-browser": "^4.0.5", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/signature-v4": "^5.1.3", + "@smithy/types": "^4.3.2", + "@smithy/util-hex-encoding": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/nested-clients/-/nested-clients-3.883.0.tgz", + "integrity": "sha512-IhzDM+v0ga53GOOrZ9jmGNr7JU5OR6h6ZK9NgB7GXaa+gsDbqfUuXRwyKDYXldrTXf1sUR3vy1okWDXA7S2ejQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.883.0", + "@aws-sdk/middleware-host-header": "3.873.0", + "@aws-sdk/middleware-logger": "3.876.0", + "@aws-sdk/middleware-recursion-detection": "3.873.0", + "@aws-sdk/middleware-user-agent": "3.883.0", + "@aws-sdk/region-config-resolver": "3.873.0", + "@aws-sdk/types": "3.862.0", + "@aws-sdk/util-endpoints": "3.879.0", + "@aws-sdk/util-user-agent-browser": "3.873.0", + "@aws-sdk/util-user-agent-node": "3.883.0", + "@smithy/config-resolver": "^4.1.5", + "@smithy/core": "^3.9.2", + "@smithy/fetch-http-handler": "^5.1.1", + "@smithy/hash-node": "^4.0.5", + "@smithy/invalid-dependency": "^4.0.5", + "@smithy/middleware-content-length": "^4.0.5", + "@smithy/middleware-endpoint": "^4.1.21", + "@smithy/middleware-retry": "^4.1.22", + "@smithy/middleware-serde": "^4.0.9", + "@smithy/middleware-stack": "^4.0.5", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/node-http-handler": "^4.1.1", + "@smithy/protocol-http": "^5.1.3", + "@smithy/smithy-client": "^4.5.2", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.29", + "@smithy/util-defaults-mode-node": "^4.0.29", + "@smithy/util-endpoints": "^3.0.7", + "@smithy/util-middleware": "^4.0.5", + "@smithy/util-retry": "^4.0.7", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/region-config-resolver/-/region-config-resolver-3.873.0.tgz", + "integrity": "sha512-q9sPoef+BBG6PJnc4x60vK/bfVwvRWsPgcoQyIra057S/QGjq5VkjvNk6H8xedf6vnKlXNBwq9BaANBXnldUJg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/types": "^4.3.2", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/token-providers/-/token-providers-3.883.0.tgz", + "integrity": "sha512-tcj/Z5paGn9esxhmmkEW7gt39uNoIRbXG1UwJrfKu4zcTr89h86PDiIE2nxUO3CMQf1KgncPpr5WouPGzkh/QQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.883.0", + "@aws-sdk/nested-clients": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/property-provider": "^4.0.5", + "@smithy/shared-ini-file-loader": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.862.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/types/-/types-3.862.0.tgz", + "integrity": "sha512-Bei+RL0cDxxV+lW2UezLbCYYNeJm6Nzee0TpW0FfyTRBhH9C1XQh4+x+IClriXvgBnRquTMMYsmJfvx8iyLKrg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.879.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/util-endpoints/-/util-endpoints-3.879.0.tgz", + "integrity": "sha512-aVAJwGecYoEmbEFju3127TyJDF9qJsKDUUTRMDuS8tGn+QiWQFnfInmbt+el9GU1gEJupNTXV+E3e74y51fb7A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/types": "^4.3.2", + "@smithy/url-parser": "^4.0.5", + "@smithy/util-endpoints": "^3.0.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/util-format-url/-/util-format-url-3.873.0.tgz", + "integrity": "sha512-v//b9jFnhzTKKV3HFTw2MakdM22uBAs2lBov51BWmFXuFtSTdBLrR7zgfetQPE3PVkFai0cmtJQPdc3MX+T/cQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/querystring-builder": "^4.0.5", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/util-locate-window/-/util-locate-window-3.873.0.tgz", + "integrity": "sha512-xcVhZF6svjM5Rj89T1WzkjQmrTF6dpR2UvIHPMTnSZoNe6CixejPZ6f0JJ2kAhO8H+dUHwNBlsUgOTIKiK/Syg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.873.0.tgz", + "integrity": "sha512-AcRdbK6o19yehEcywI43blIBhOCSo6UgyWcuOJX5CFF8k39xm1ILCjQlRRjchLAxWrm0lU0Q7XV90RiMMFMZtA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.862.0", + "@smithy/types": "^4.3.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.883.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.883.0.tgz", + "integrity": "sha512-28cQZqC+wsKUHGpTBr+afoIdjS6IoEJkMqcZsmo2Ag8LzmTa6BUWQenFYB0/9BmDy4PZFPUn+uX+rJgWKB+jzA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.883.0", + "@aws-sdk/types": "3.862.0", + "@smithy/node-config-provider": "^4.1.4", + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.873.0", + "resolved": "https://registry.npmmirror.com/@aws-sdk/xml-builder/-/xml-builder-3.873.0.tgz", + "integrity": "sha512-kLO7k7cGJ6KaHiExSJWojZurF7SnGMDHXRuQunFnEoD0n1yB6Lqy/S/zHiQ7oJnBhPr9q0TW9qFkrsZb1Uc54w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@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-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "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.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "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.npmmirror.com/@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/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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/@inquirer/external-editor": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@inquirer/external-editor/-/external-editor-1.0.1.tgz", + "integrity": "sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.6.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@ioredis/commands/-/commands-1.3.1.tgz", + "integrity": "sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==", + "license": "MIT" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@ldapjs/asn1": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/asn1/-/asn1-2.0.0.tgz", + "integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/attribute": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/attribute/-/attribute-1.0.0.tgz", + "integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/change": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/change/-/change-1.0.0.tgz", + "integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/attribute": "1.0.0" + } + }, + "node_modules/@ldapjs/controls": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/controls/-/controls-2.1.0.tgz", + "integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^1.2.0", + "@ldapjs/protocol": "^1.2.1" + } + }, + "node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/asn1/-/asn1-1.2.0.tgz", + "integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@ldapjs/dn": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/dn/-/dn-1.1.0.tgz", + "integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/filter": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@ldapjs/filter/-/filter-2.1.1.tgz", + "integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "2.0.0", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.1.0" + } + }, + "node_modules/@ldapjs/messages": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ldapjs/messages/-/messages-1.3.0.tgz", + "integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/protocol": "^1.2.1", + "process-warning": "^2.2.0" + } + }, + "node_modules/@ldapjs/protocol": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@ldapjs/protocol/-/protocol-1.2.1.tgz", + "integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT" + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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/@paralleldrive/cuid2": { + "version": "2.2.2", + "resolved": "https://registry.npmmirror.com/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", + "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmmirror.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/abort-controller/-/abort-controller-4.1.0.tgz", + "integrity": "sha512-wEhSYznxOmx7EdwK1tYEWJF5+/wmSFsff9BfTOn8oO/+KPl3gsmThrb6MJlWbOC391+Ya31s5JuHiC2RlT80Zg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/config-resolver/-/config-resolver-4.2.0.tgz", + "integrity": "sha512-FA10YhPFLy23uxeWu7pOM2ctlw+gzbPMTZQwrZ8FRIfyJ/p8YIVz7AVTB5jjLD+QIerydyKcVMZur8qzzDILAQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-config-provider": "^4.1.0", + "@smithy/util-middleware": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/@smithy/core/-/core-3.10.0.tgz", + "integrity": "sha512-bXyD3Ij6b1qDymEYlEcF+QIjwb9gObwZNaRjETJsUEvSIzxFdynSQ3E4ysY7lUFSBzeWBNaFvX+5A0smbC2q6A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.1.0", + "@smithy/protocol-http": "^5.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-body-length-browser": "^4.1.0", + "@smithy/util-middleware": "^4.1.0", + "@smithy/util-stream": "^4.3.0", + "@smithy/util-utf8": "^4.1.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.1.0.tgz", + "integrity": "sha512-iVwNhxTsCQTPdp++4C/d9xvaDmuEWhXi55qJobMp9QMaEHRGH3kErU4F8gohtdsawRqnUy/ANylCjKuhcR2mPw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.0", + "@smithy/property-provider": "^4.1.0", + "@smithy/types": "^4.4.0", + "@smithy/url-parser": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/eventstream-codec/-/eventstream-codec-4.1.0.tgz", + "integrity": "sha512-MSOb6pwG3Tss1UwlZMHC+rYergWCo4fwep3Y1fJxwdLLxReSaKFfXxPQhEHi/8LSNQFEcBYBxybgjXjw4jJWqQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-hex-encoding": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.1.0.tgz", + "integrity": "sha512-VvHXoBoLos2OCdMtUvKWK7ckcvun6ZP4KBYhf38+kszk6BEuK9k8c3xbIMIpC6K4vTK72qHlHAdBoR9qU+F7xw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.2.0.tgz", + "integrity": "sha512-T7YlcU0cP2bjAC4eXo9E6puqrrmqv5VHBL8bPMOMgEE1p4m+bwkDWRQpeiXqn/idoKM1qwXq8PvRLYmpbYB6uw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.1.0.tgz", + "integrity": "sha512-WlIKVRkcPjwuN3x+e8+5KOI9nL6s93bxgWH+39VwwQMl+4FagKPtTM3VCumSoZJ9qn/CNl4W5mVdFFRkDF84lQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.1.0.tgz", + "integrity": "sha512-GjMezHHd0xrjJcWLAcnXlVePe7PY8KsdxzKeXcMn7V3vfIScGUpKQJrlSmEXwzFH9Mjl0G0EdOS5GzewZEwtxg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.2.0.tgz", + "integrity": "sha512-VZenjDdVaUGiy3hwQtxm75nhXZrhFG+3xyL93qCQAlYDyhT/jeDWM8/3r5uCFMlTmmyrIjiDyiOynVFchb0BSg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.2.0", + "@smithy/querystring-builder": "^4.1.0", + "@smithy/types": "^4.4.0", + "@smithy/util-base64": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/hash-node/-/hash-node-4.1.0.tgz", + "integrity": "sha512-mXkJQ/6lAXTuoSsEH+d/fHa4ms4qV5LqYoPLYhmhCRTNcMMdg+4Ya8cMgU1W8+OR40eX0kzsExT7fAILqtTl2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/invalid-dependency/-/invalid-dependency-4.1.0.tgz", + "integrity": "sha512-4/FcV6aCMzgpM4YyA/GRzTtG28G0RQJcWK722MmpIgzOyfSceWcI9T9c8matpHU9qYYLaWtk8pSGNCLn5kzDRw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/is-array-buffer/-/is-array-buffer-4.1.0.tgz", + "integrity": "sha512-ePTYUOV54wMogio+he4pBybe8fwg4sDvEVDBU8ZlHOZXbXK3/C0XfJgUCu6qAZcawv05ZhZzODGUerFBPsPUDQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/middleware-content-length/-/middleware-content-length-4.1.0.tgz", + "integrity": "sha512-x3dgLFubk/ClKVniJu+ELeZGk4mq7Iv0HgCRUlxNUIcerHTLVmq7Q5eGJL0tOnUltY6KFw5YOKaYxwdcMwox/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.2.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/middleware-endpoint/-/middleware-endpoint-4.2.0.tgz", + "integrity": "sha512-J1eCF7pPDwgv7fGwRd2+Y+H9hlIolF3OZ2PjptonzzyOXXGh/1KGJAHpEcY1EX+WLlclKu2yC5k+9jWXdUG4YQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.10.0", + "@smithy/middleware-serde": "^4.1.0", + "@smithy/node-config-provider": "^4.2.0", + "@smithy/shared-ini-file-loader": "^4.1.0", + "@smithy/types": "^4.4.0", + "@smithy/url-parser": "^4.1.0", + "@smithy/util-middleware": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/middleware-retry/-/middleware-retry-4.2.0.tgz", + "integrity": "sha512-raL5oWYf5ALl3jCJrajE8enKJEnV/2wZkKS6mb3ZRY2tg3nj66ssdWy5Ps8E6Yu8Wqh3Tt+Sb9LozjvwZupq+A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.0", + "@smithy/protocol-http": "^5.2.0", + "@smithy/service-error-classification": "^4.1.0", + "@smithy/smithy-client": "^4.6.0", + "@smithy/types": "^4.4.0", + "@smithy/util-middleware": "^4.1.0", + "@smithy/util-retry": "^4.1.0", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/middleware-serde/-/middleware-serde-4.1.0.tgz", + "integrity": "sha512-CtLFYlHt7c2VcztyVRc+25JLV4aGpmaSv9F1sPB0AGFL6S+RPythkqpGDa2XBQLJQooKkjLA1g7Xe4450knShg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.2.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/middleware-stack/-/middleware-stack-4.1.0.tgz", + "integrity": "sha512-91Fuw4IKp0eK8PNhMXrHRcYA1jvbZ9BJGT91wwPy3bTQT8mHTcQNius/EhSQTlT9QUI3Ki1wjHeNXbWK0tO8YQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/node-config-provider/-/node-config-provider-4.2.0.tgz", + "integrity": "sha512-8/fpilqKurQ+f8nFvoFkJ0lrymoMJ+5/CQV5IcTv/MyKhk2Q/EFYCAgTSWHD4nMi9ux9NyBBynkyE9SLg2uSLA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.1.0", + "@smithy/shared-ini-file-loader": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/node-http-handler/-/node-http-handler-4.2.0.tgz", + "integrity": "sha512-G4NV70B4hF9vBrUkkvNfWO6+QR4jYjeO4tc+4XrKCb4nPYj49V9Hu8Ftio7Mb0/0IlFyEOORudHrm+isY29nCA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.1.0", + "@smithy/protocol-http": "^5.2.0", + "@smithy/querystring-builder": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/property-provider/-/property-provider-4.1.0.tgz", + "integrity": "sha512-eksMjMHUlG5PwOUWO3k+rfLNOPVPJ70mUzyYNKb5lvyIuAwS4zpWGsxGiuT74DFWonW0xRNy+jgzGauUzX7SyA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/protocol-http/-/protocol-http-5.2.0.tgz", + "integrity": "sha512-bwjlh5JwdOQnA01be+5UvHK4HQz4iaRKlVG46hHSJuqi0Ribt3K06Z1oQ29i35Np4G9MCDgkOGcHVyLMreMcbg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/querystring-builder/-/querystring-builder-4.1.0.tgz", + "integrity": "sha512-JqTWmVIq4AF8R8OK/2cCCiQo5ZJ0SRPsDkDgLO5/3z8xxuUp1oMIBBjfuueEe+11hGTZ6rRebzYikpKc6yQV9Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "@smithy/util-uri-escape": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/querystring-parser/-/querystring-parser-4.1.0.tgz", + "integrity": "sha512-VgdHhr8YTRsjOl4hnKFm7xEMOCRTnKw3FJ1nU+dlWNhdt/7eEtxtkdrJdx7PlRTabdANTmvyjE4umUl9cK4awg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/service-error-classification/-/service-error-classification-4.1.0.tgz", + "integrity": "sha512-UBpNFzBNmS20jJomuYn++Y+soF8rOK9AvIGjS9yGP6uRXF5rP18h4FDUsoNpWTlSsmiJ87e2DpZo9ywzSMH7PQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.1.0.tgz", + "integrity": "sha512-W0VMlz9yGdQ/0ZAgWICFjFHTVU0YSfGoCVpKaExRM/FDkTeP/yz8OKvjtGjs6oFokCRm0srgj/g4Cg0xuHu8Rw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@smithy/signature-v4/-/signature-v4-5.2.0.tgz", + "integrity": "sha512-ObX1ZqG2DdZQlXx9mLD7yAR8AGb7yXurGm+iWx9x4l1fBZ8CZN2BRT09aSbcXVPZXWGdn5VtMuupjxhOTI2EjA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.1.0", + "@smithy/protocol-http": "^5.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-hex-encoding": "^4.1.0", + "@smithy/util-middleware": "^4.1.0", + "@smithy/util-uri-escape": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@smithy/smithy-client/-/smithy-client-4.6.0.tgz", + "integrity": "sha512-TvlIshqx5PIi0I0AiR+PluCpJ8olVG++xbYkAIGCUkByaMUlfOXLgjQTmYbr46k4wuDe8eHiTIlUflnjK2drPQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.10.0", + "@smithy/middleware-endpoint": "^4.2.0", + "@smithy/middleware-stack": "^4.1.0", + "@smithy/protocol-http": "^5.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-stream": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/@smithy/types/-/types-4.4.0.tgz", + "integrity": "sha512-4jY91NgZz+ZnSFcVzWwngOW6VuK3gR/ihTwSU1R/0NENe9Jd8SfWgbhDCAGUWL3bI7DiDSW7XF6Ui6bBBjrqXw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/url-parser/-/url-parser-4.1.0.tgz", + "integrity": "sha512-/LYEIOuO5B2u++tKr1NxNxhZTrr3A63jW8N73YTwVeUyAlbB/YM+hkftsvtKAcMt3ADYo0FsF1GY3anehffSVQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-base64/-/util-base64-4.1.0.tgz", + "integrity": "sha512-RUGd4wNb8GeW7xk+AY5ghGnIwM96V0l2uzvs/uVHf+tIuVX2WSvynk5CxNoBCsM2rQRSZElAo9rt3G5mJ/gktQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-body-length-browser/-/util-body-length-browser-4.1.0.tgz", + "integrity": "sha512-V2E2Iez+bo6bUMOTENPr6eEmepdY8Hbs+Uc1vkDKgKNA/brTJqOW/ai3JO1BGj9GbCeLqw90pbbH7HFQyFotGQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-body-length-node/-/util-body-length-node-4.1.0.tgz", + "integrity": "sha512-BOI5dYjheZdgR9XiEM3HJcEMCXSoqbzu7CzIgYrx0UtmvtC3tC2iDGpJLsSRFffUpy8ymsg2ARMP5fR8mtuUQQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-buffer-from/-/util-buffer-from-4.1.0.tgz", + "integrity": "sha512-N6yXcjfe/E+xKEccWEKzK6M+crMrlwaCepKja0pNnlSkm6SjAeLKKA++er5Ba0I17gvKfN/ThV+ZOx/CntKTVw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-config-provider/-/util-config-provider-4.1.0.tgz", + "integrity": "sha512-swXz2vMjrP1ZusZWVTB/ai5gK+J8U0BWvP10v9fpcFvg+Xi/87LHvHfst2IgCs1i0v4qFZfGwCmeD/KNCdJZbQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.1.0.tgz", + "integrity": "sha512-D27cLtJtC4EEeERJXS+JPoogz2tE5zeE3zhWSSu6ER5/wJ5gihUxIzoarDX6K1U27IFTHit5YfHqU4Y9RSGE0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.1.0", + "@smithy/smithy-client": "^4.6.0", + "@smithy/types": "^4.4.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.1.0.tgz", + "integrity": "sha512-gnZo3u5dP1o87plKupg39alsbeIY1oFFnCyV2nI/++pL19vTtBLgOyftLEjPjuXmoKn2B2rskX8b7wtC/+3Okg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.2.0", + "@smithy/credential-provider-imds": "^4.1.0", + "@smithy/node-config-provider": "^4.2.0", + "@smithy/property-provider": "^4.1.0", + "@smithy/smithy-client": "^4.6.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-endpoints/-/util-endpoints-3.1.0.tgz", + "integrity": "sha512-5LFg48KkunBVGrNs3dnQgLlMXJLVo7k9sdZV5su3rjO3c3DmQ2LwUZI0Zr49p89JWK6sB7KmzyI2fVcDsZkwuw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.2.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-hex-encoding/-/util-hex-encoding-4.1.0.tgz", + "integrity": "sha512-1LcueNN5GYC4tr8mo14yVYbh/Ur8jHhWOxniZXii+1+ePiIbsLZ5fEI0QQGtbRRP5mOhmooos+rLmVASGGoq5w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-middleware/-/util-middleware-4.1.0.tgz", + "integrity": "sha512-612onNcKyxhP7/YOTKFTb2F6sPYtMRddlT5mZvYf1zduzaGzkYhpYIPxIeeEwBZFjnvEqe53Ijl2cYEfJ9d6/Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-retry/-/util-retry-4.1.0.tgz", + "integrity": "sha512-5AGoBHb207xAKSVwaUnaER+L55WFY8o2RhlafELZR3mB0J91fpL+Qn+zgRkPzns3kccGaF2vy0HmNVBMWmN6dA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.1.0", + "@smithy/types": "^4.4.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-stream/-/util-stream-4.3.0.tgz", + "integrity": "sha512-ZOYS94jksDwvsCJtppHprUhsIscRnCKGr6FXCo3SxgQ31ECbza3wqDBqSy6IsAak+h/oAXb1+UYEBmDdseAjUQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.2.0", + "@smithy/node-http-handler": "^4.2.0", + "@smithy/types": "^4.4.0", + "@smithy/util-base64": "^4.1.0", + "@smithy/util-buffer-from": "^4.1.0", + "@smithy/util-hex-encoding": "^4.1.0", + "@smithy/util-utf8": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-uri-escape/-/util-uri-escape-4.1.0.tgz", + "integrity": "sha512-b0EFQkq35K5NHUYxU72JuoheM6+pytEVUGlTwiFxWFpmddA+Bpz3LgsPRIpBk8lnPE47yT7AF2Egc3jVnKLuPg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@smithy/util-utf8/-/util-utf8-4.1.0.tgz", + "integrity": "sha512-mEu1/UIXAdNYuBcyEPbjScKi/+MQVXNIuY/7Cm5XLIWe319kDrT5SizBE95jqtmEXoDbGoZxKLCMttdZdqTZKQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmmirror.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.13", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.13.tgz", + "integrity": "sha512-yCAeZl7a0DxgNVteXFHt9+uyFbqXGy/ShC4BlcHkoE0AfGXYv/BUiplV72DjMYXHDBXFjhvr6DD1NiRVfB4j8g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmmirror.com/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmmirror.com/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmmirror.com/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmmirror.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/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/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "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/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/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/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmmirror.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==", + "license": "MIT", + "dependencies": { + "precond": "0.2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmmirror.com/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/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/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bowser": { + "version": "2.12.1", + "resolved": "https://registry.npmmirror.com/bowser/-/bowser-2.12.1.tgz", + "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/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.25.4", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "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": { + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/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/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001741", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", + "integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "license": "ISC", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/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/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/color/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/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": "11.1.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmmirror.com/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/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/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/dedent": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/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/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.214", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.214.tgz", + "integrity": "sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/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-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmmirror.com/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/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "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.npmmirror.com/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.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/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/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmmirror.com/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmmirror.com/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/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-stream-rotator": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/file-stream-rotator/-/file-stream-rotator-0.6.1.tgz", + "integrity": "sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/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/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.3.3", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/formidable": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/formidable/-/formidable-2.1.5.tgz", + "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0", + "qs": "^6.11.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/gaxios/-/gaxios-7.1.1.tgz", + "integrity": "sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/gcp-metadata/-/gcp-metadata-7.0.1.tgz", + "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/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-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/google-auth-library": { + "version": "10.3.0", + "resolved": "https://registry.npmmirror.com/google-auth-library/-/google-auth-library-10.3.0.tgz", + "integrity": "sha512-ylSE3RlCRZfZB56PFJSfUCuiuPq83Fx8hqu1KPWGK8FVdSaxlp/qkeMMX/DT/18xkwXIHvXEXkZsljRwfrdEfQ==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^7.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/google-logging-utils/-/google-logging-utils-1.1.1.tgz", + "integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/helmet/-/helmet-7.2.0.tgz", + "integrity": "sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "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/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inquirer": { + "version": "8.2.7", + "resolved": "https://registry.npmmirror.com/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "license": "MIT", + "dependencies": { + "@inquirer/external-editor": "^1.0.0", + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ioredis": { + "version": "5.7.0", + "resolved": "https://registry.npmmirror.com/ioredis/-/ioredis-5.7.0.tgz", + "integrity": "sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.3.0", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "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.npmmirror.com/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.npmmirror.com/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-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/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-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/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": "4.0.1", + "resolved": "https://registry.npmmirror.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/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/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmmirror.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/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/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/ldapjs": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/ldapjs/-/ldapjs-3.0.7.tgz", + "integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==", + "deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md", + "license": "MIT", + "dependencies": { + "@ldapjs/asn1": "^2.0.0", + "@ldapjs/attribute": "^1.0.0", + "@ldapjs/change": "^1.0.0", + "@ldapjs/controls": "^2.1.0", + "@ldapjs/dn": "^1.1.0", + "@ldapjs/filter": "^2.1.1", + "@ldapjs/messages": "^1.3.0", + "@ldapjs/protocol": "^1.2.1", + "abstract-logging": "^2.0.1", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "once": "^1.4.0", + "vasync": "^2.2.1", + "verror": "^1.10.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/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/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/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.17.21", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/logform/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/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/make-dir/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmmirror.com/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/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": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/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/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmmirror.com/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/morgan": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.1.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmmirror.com/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", + "license": "ISC" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.20", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.20.tgz", + "integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz", + "integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmmirror.com/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/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": "4.0.1", + "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/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/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "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/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/precond": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/precond/-/precond-0.2.3.tgz", + "integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-2.3.2.tgz", + "integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==", + "license": "MIT" + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmmirror.com/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/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/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/rate-limiter-flexible": { + "version": "5.0.5", + "resolved": "https://registry.npmmirror.com/rate-limiter-flexible/-/rate-limiter-flexible-5.0.5.tgz", + "integrity": "sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==", + "license": "ISC" + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/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/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "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-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/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/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/socks-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socks-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmmirror.com/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-similarity": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/string-similarity/-/string-similarity-4.0.4.tgz", + "integrity": "sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "ISC" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/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/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/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-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/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/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/superagent": { + "version": "8.1.2", + "resolved": "https://registry.npmmirror.com/superagent/-/superagent-8.1.2.tgz", + "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", + "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.0", + "cookiejar": "^2.1.4", + "debug": "^4.3.4", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.0", + "formidable": "^2.1.2", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.11.0", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=6.4.0 <13 || >=14" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/superagent/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/supertest": { + "version": "6.3.4", + "resolved": "https://registry.npmmirror.com/supertest/-/supertest-6.3.4.tgz", + "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", + "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", + "dev": true, + "license": "MIT", + "dependencies": { + "methods": "^1.1.2", + "superagent": "^8.1.2" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmmirror.com/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmmirror.com/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "license": "BSD-3-Clause", + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/table/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/table/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/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/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/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-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/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/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmmirror.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vasync": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/vasync/-/vasync-2.2.1.tgz", + "integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "verror": "1.10.0" + } + }, + "node_modules/vasync/node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/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/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-daily-rotate-file": { + "version": "4.7.1", + "resolved": "https://registry.npmmirror.com/winston-daily-rotate-file/-/winston-daily-rotate-file-4.7.1.tgz", + "integrity": "sha512-7LGPiYGBPNyGHLn9z33i96zx/bd71pjBn9tqQzO3I4Tayv94WPmBNwKC7CO1wPHdP9uvu+Md/1nr6VSH9h0iaA==", + "license": "MIT", + "dependencies": { + "file-stream-rotator": "^0.6.1", + "object-hash": "^2.0.1", + "triple-beam": "^1.3.0", + "winston-transport": "^4.4.0" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "winston": "^3" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/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": "6.2.0", + "resolved": "https://registry.npmmirror.com/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/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/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/package.json b/package.json new file mode 100644 index 0000000..b9efc01 --- /dev/null +++ b/package.json @@ -0,0 +1,104 @@ +{ + "name": "claude-relay-service", + "version": "1.0.0", + "description": "Claude Code API relay service with multi-account management, OpenAI compatibility, and API key authentication", + "main": "src/app.js", + "scripts": { + "start": "npm run lint && node src/app.js", + "dev": "nodemon", + "build:web": "cd web/admin-spa && npm run build", + "install:web": "cd web/admin-spa && npm install", + "update:pricing": "node scripts/update-model-pricing.js", + "setup": "node scripts/setup.js", + "cli": "node cli/index.js", + "init:costs": "node src/cli/initCosts.js", + "service": "node scripts/manage.js", + "service:start": "node scripts/manage.js start", + "service:start:daemon": "node scripts/manage.js start -d", + "service:start:d": "node scripts/manage.js start -d", + "service:daemon": "node scripts/manage.js start -d", + "service:stop": "node scripts/manage.js stop", + "service:restart": "node scripts/manage.js restart", + "service:restart:daemon": "node scripts/manage.js restart -d", + "service:logs:follow": "node scripts/manage.js logs -f", + "service:restart:d": "node scripts/manage.js restart -d", + "service:status": "node scripts/manage.js status", + "service:logs": "node scripts/manage.js logs", + "monitor": "bash scripts/monitor-enhanced.sh", + "status": "bash scripts/status-unified.sh", + "status:detail": "bash scripts/status-unified.sh --detail", + "test": "jest", + "lint": "eslint src/**/*.js cli/**/*.js scripts/**/*.js --fix", + "lint:check": "eslint src/**/*.js cli/**/*.js scripts/**/*.js", + "format": "prettier --write \"src/**/*.js\" \"cli/**/*.js\" \"scripts/**/*.js\"", + "format:check": "prettier --check \"src/**/*.js\" \"cli/**/*.js\" \"scripts/**/*.js\"", + "docker:build": "docker build -t claude-relay-service .", + "docker:up": "docker-compose up -d", + "docker:down": "docker-compose down", + "migrate:apikey-expiry": "node scripts/migrate-apikey-expiry.js", + "migrate:apikey-expiry:dry": "node scripts/migrate-apikey-expiry.js --dry-run", + "migrate:request-detail-retention-hours": "node scripts/reset-request-detail-retention-hours.js", + "migrate:fix-usage-stats": "node scripts/fix-usage-stats.js", + "data:export": "node scripts/data-transfer.js export", + "data:import": "node scripts/data-transfer.js import", + "data:export:sanitized": "node scripts/data-transfer.js export --sanitize", + "data:export:enhanced": "node scripts/data-transfer-enhanced.js export", + "data:export:encrypted": "node scripts/data-transfer-enhanced.js export --decrypt=false", + "data:import:enhanced": "node scripts/data-transfer-enhanced.js import", + "data:debug": "node scripts/debug-redis-keys.js", + "test:pricing-fallback": "node scripts/test-pricing-fallback.js" + }, + "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.861.0", + "@aws-sdk/credential-providers": "^3.859.0", + "axios": "^1.6.0", + "bcryptjs": "^2.4.3", + "chalk": "^4.1.2", + "commander": "^11.1.0", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "google-auth-library": "^10.1.0", + "helmet": "^7.1.0", + "https-proxy-agent": "^7.0.2", + "inquirer": "^8.2.6", + "ioredis": "^5.3.2", + "ldapjs": "^3.0.7", + "morgan": "^1.10.0", + "node-cron": "^4.2.1", + "nodemailer": "^7.0.6", + "ora": "^5.4.1", + "rate-limiter-flexible": "^5.0.5", + "socks-proxy-agent": "^8.0.2", + "string-similarity": "^4.0.4", + "table": "^6.8.1", + "uuid": "^9.0.1", + "winston": "^3.11.0", + "winston-daily-rotate-file": "^4.7.1" + }, + "devDependencies": { + "@types/node": "^20.8.9", + "eslint": "^8.53.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "nodemon": "^3.0.1", + "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.7.2", + "supertest": "^6.3.3" + }, + "engines": { + "node": ">=18.0.0" + }, + "keywords": [ + "claude", + "api", + "proxy", + "relay", + "claude-code", + "anthropic" + ], + "author": "Claude Relay Service", + "license": "MIT" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..9e8dc0f --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,6428 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@aws-sdk/client-bedrock-runtime': + specifier: ^3.861.0 + version: 3.943.0 + '@aws-sdk/credential-providers': + specifier: ^3.859.0 + version: 3.943.0 + axios: + specifier: ^1.6.0 + version: 1.13.2 + bcryptjs: + specifier: ^2.4.3 + version: 2.4.3 + chalk: + specifier: ^4.1.2 + version: 4.1.2 + commander: + specifier: ^11.1.0 + version: 11.1.0 + compression: + specifier: ^1.7.4 + version: 1.8.1 + cors: + specifier: ^2.8.5 + version: 2.8.5 + dotenv: + specifier: ^16.3.1 + version: 16.6.1 + express: + specifier: ^4.18.2 + version: 4.22.1 + google-auth-library: + specifier: ^10.1.0 + version: 10.5.0 + helmet: + specifier: ^7.1.0 + version: 7.2.0 + https-proxy-agent: + specifier: ^7.0.2 + version: 7.0.6 + inquirer: + specifier: ^8.2.6 + version: 8.2.7(@types/node@20.19.25) + ioredis: + specifier: ^5.3.2 + version: 5.8.2 + ldapjs: + specifier: ^3.0.7 + version: 3.0.7 + morgan: + specifier: ^1.10.0 + version: 1.10.1 + node-cron: + specifier: ^4.2.1 + version: 4.2.1 + nodemailer: + specifier: ^7.0.6 + version: 7.0.11 + ora: + specifier: ^5.4.1 + version: 5.4.1 + rate-limiter-flexible: + specifier: ^5.0.5 + version: 5.0.5 + socks-proxy-agent: + specifier: ^8.0.2 + version: 8.0.5 + string-similarity: + specifier: ^4.0.4 + version: 4.0.4 + table: + specifier: ^6.8.1 + version: 6.9.0 + uuid: + specifier: ^9.0.1 + version: 9.0.1 + winston: + specifier: ^3.11.0 + version: 3.18.3 + winston-daily-rotate-file: + specifier: ^4.7.1 + version: 4.7.1(winston@3.18.3) + devDependencies: + '@types/node': + specifier: ^20.8.9 + version: 20.19.25 + eslint: + specifier: ^8.53.0 + version: 8.57.1 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@8.57.1) + eslint-plugin-prettier: + specifier: ^5.5.4 + version: 5.5.4(eslint-config-prettier@10.1.8(eslint@8.57.1))(eslint@8.57.1)(prettier@3.7.4) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.25) + nodemon: + specifier: ^3.0.1 + version: 3.1.11 + prettier: + specifier: ^3.6.2 + version: 3.7.4 + prettier-plugin-tailwindcss: + specifier: ^0.7.2 + version: 0.7.2(prettier@3.7.4) + supertest: + specifier: ^6.3.3 + version: 6.3.4 + +packages: + + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.943.0': + resolution: {integrity: sha512-mEiv1g5BeZFIQjBrzM5nT//KYLOBwUkXtHzsufkV99TIEKW5qzgOgx9Q9O8IbFQk3c7C6HYkV/kNOUI3KGyH6g==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-cognito-identity@3.943.0': + resolution: {integrity: sha512-XkuokRF2IQ+VLBn0AwrwfFOkZ2c1IXACwQdn3CDnpBZpT1s2hgH3MX0DoH9+41w4ar2QCSI09uAJiv9PX4DLoQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/client-sso@3.943.0': + resolution: {integrity: sha512-kOTO2B8Ks2qX73CyKY8PAajtf5n39aMe2spoiOF5EkgSzGV7hZ/HONRDyADlyxwfsX39Q2F2SpPUaXzon32IGw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/core@3.943.0': + resolution: {integrity: sha512-8CBy2hI9ABF7RBVQuY1bgf/ue+WPmM/hl0adrXFlhnhkaQP0tFY5zhiy1Y+n7V+5f3/ORoHBmCCQmcHDDYJqJQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-cognito-identity@3.943.0': + resolution: {integrity: sha512-jZJ0uHjNlhfjx2ZX7YVYnh1wfSkLAvQmecGCSl9C6LJRNXy4uWFPbGjPqcA0tWp0WWIsUYhqjasgvCOMZIY8nw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-env@3.943.0': + resolution: {integrity: sha512-WnS5w9fK9CTuoZRVSIHLOMcI63oODg9qd1vXMYb7QGLGlfwUm4aG3hdu7i9XvYrpkQfE3dzwWLtXF4ZBuL1Tew==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-http@3.943.0': + resolution: {integrity: sha512-SA8bUcYDEACdhnhLpZNnWusBpdmj4Vl67Vxp3Zke7SvoWSYbuxa+tiDiC+c92Z4Yq6xNOuLPW912ZPb9/NsSkA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-ini@3.943.0': + resolution: {integrity: sha512-BcLDb8l4oVW+NkuqXMlO7TnM6lBOWW318ylf4FRED/ply5eaGxkQYqdGvHSqGSN5Rb3vr5Ek0xpzSjeYD7C8Kw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-login@3.943.0': + resolution: {integrity: sha512-9iCOVkiRW+evxiJE94RqosCwRrzptAVPhRhGWv4osfYDhjNAvUMyrnZl3T1bjqCoKNcETRKEZIU3dqYHnUkcwQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-node@3.943.0': + resolution: {integrity: sha512-14eddaH/gjCWoLSAELVrFOQNyswUYwWphIt+PdsJ/FqVfP4ay2HsiZVEIYbQtmrKHaoLJhiZKwBQRjcqJDZG0w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-process@3.943.0': + resolution: {integrity: sha512-GIY/vUkthL33AdjOJ8r9vOosKf/3X+X7LIiACzGxvZZrtoOiRq0LADppdiKIB48vTL63VvW+eRIOFAxE6UDekw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-sso@3.943.0': + resolution: {integrity: sha512-1c5G11syUrru3D9OO6Uk+ul5e2lX1adb+7zQNyluNaLPXP6Dina6Sy6DFGRLu7tM8+M7luYmbS3w63rpYpaL+A==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.943.0': + resolution: {integrity: sha512-VtyGKHxICSb4kKGuaqotxso8JVM8RjCS3UYdIMOxUt9TaFE/CZIfZKtjTr+IJ7M0P7t36wuSUb/jRLyNmGzUUA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/credential-providers@3.943.0': + resolution: {integrity: sha512-uZurSNsS01ehhrSwEPwcKdqp9lmd/x9q++BYO351bXyjSj1LzA/2lfUIxI2tCz/wAjJWOdnnlUdJj6P9I1uNvw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/eventstream-handler-node@3.936.0': + resolution: {integrity: sha512-4zIbhdRmol2KosIHmU31ATvNP0tkJhDlRj9GuawVJoEnMvJA1pd2U3SRdiOImJU3j8pT46VeS4YMmYxfjGHByg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-eventstream@3.936.0': + resolution: {integrity: sha512-XQSH8gzLkk8CDUDxyt4Rdm9owTpRIPdtg2yw9Y2Wl5iSI55YQSiC3x8nM3c4Y4WqReJprunFPK225ZUDoYCfZA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-host-header@3.936.0': + resolution: {integrity: sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-logger@3.936.0': + resolution: {integrity: sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.936.0': + resolution: {integrity: sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-user-agent@3.943.0': + resolution: {integrity: sha512-956n4kVEwFNXndXfhSAN5wO+KRgqiWEEY+ECwLvxmmO8uQ0NWOa8l6l65nTtyuiWzMX81c9BvlyNR5EgUeeUvA==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/middleware-websocket@3.936.0': + resolution: {integrity: sha512-bPe3rqeugyj/MmjP0yBSZox2v1Wa8Dv39KN+RxVbQroLO8VUitBo6xyZ0oZebhZ5sASwSg58aDcMlX0uFLQnTA==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.943.0': + resolution: {integrity: sha512-anFtB0p2FPuyUnbOULwGmKYqYKSq1M73c9uZ08jR/NCq6Trjq9cuF5TFTeHwjJyPRb4wMf2Qk859oiVfFqnQiw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/region-config-resolver@3.936.0': + resolution: {integrity: sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/token-providers@3.943.0': + resolution: {integrity: sha512-cRKyIzwfkS+XztXIFPoWORuaxlIswP+a83BJzelX4S1gUZ7FcXB4+lj9Jxjn8SbQhR4TPU3Owbpu+S7pd6IRbQ==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/types@3.936.0': + resolution: {integrity: sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-endpoints@3.936.0': + resolution: {integrity: sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-format-url@3.936.0': + resolution: {integrity: sha512-MS5eSEtDUFIAMHrJaMERiHAvDPdfxc/T869ZjDNFAIiZhyc037REw0aoTNeimNXDNy2txRNZJaAUn/kE4RwN+g==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-locate-window@3.893.0': + resolution: {integrity: sha512-T89pFfgat6c8nMmpI8eKjBcDcgJq36+m9oiXbcUzeU55MP9ZuGgBomGjGnHaEyF36jenW9gmg3NfZDm0AO2XPg==} + engines: {node: '>=18.0.0'} + + '@aws-sdk/util-user-agent-browser@3.936.0': + resolution: {integrity: sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==} + + '@aws-sdk/util-user-agent-node@3.943.0': + resolution: {integrity: sha512-gn+ILprVRrgAgTIBk2TDsJLRClzIOdStQFeFTcN0qpL8Z4GBCqMFhw7O7X+MM55Stt5s4jAauQ/VvoqmCADnQg==} + engines: {node: '>=18.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.930.0': + resolution: {integrity: sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==} + engines: {node: '>=18.0.0'} + + '@aws/lambda-invoke-store@0.2.2': + resolution: {integrity: sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==} + engines: {node: '>=18.0.0'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.27.1': + resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.27.1': + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.27.1': + resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@colors/colors@1.6.0': + resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} + engines: {node: '>=0.1.90'} + + '@dabh/diagnostics@2.0.8': + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + + '@eslint-community/eslint-utils@4.9.0': + resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@ioredis/commands@1.4.0': + resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@ldapjs/asn1@1.2.0': + resolution: {integrity: sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==} + deprecated: This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md + + '@ldapjs/asn1@2.0.0': + resolution: {integrity: sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==} + deprecated: This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md + + '@ldapjs/attribute@1.0.0': + resolution: {integrity: sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==} + deprecated: This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md + + '@ldapjs/change@1.0.0': + resolution: {integrity: sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==} + deprecated: This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md + + '@ldapjs/controls@2.1.0': + resolution: {integrity: sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==} + deprecated: This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md + + '@ldapjs/dn@1.1.0': + resolution: {integrity: sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==} + deprecated: This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md + + '@ldapjs/filter@2.1.1': + resolution: {integrity: sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==} + deprecated: This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md + + '@ldapjs/messages@1.3.0': + resolution: {integrity: sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==} + deprecated: This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md + + '@ldapjs/protocol@1.2.1': + resolution: {integrity: sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==} + deprecated: This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@smithy/abort-controller@4.2.5': + resolution: {integrity: sha512-j7HwVkBw68YW8UmFRcjZOmssE77Rvk0GWAIN1oFBhsaovQmZWYCIcGa9/pwRB0ExI8Sk9MWNALTjftjHZea7VA==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.3': + resolution: {integrity: sha512-ezHLe1tKLUxDJo2LHtDuEDyWXolw8WGOR92qb4bQdWq/zKenO5BvctZGrVJBK08zjezSk7bmbKFOXIVyChvDLw==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.18.7': + resolution: {integrity: sha512-axG9MvKhMWOhFbvf5y2DuyTxQueO0dkedY9QC3mAfndLosRI/9LJv8WaL0mw7ubNhsO4IuXX9/9dYGPFvHrqlw==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.5': + resolution: {integrity: sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.5': + resolution: {integrity: sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.5': + resolution: {integrity: sha512-HohfmCQZjppVnKX2PnXlf47CW3j92Ki6T/vkAT2DhBR47e89pen3s4fIa7otGTtrVxmj7q+IhH0RnC5kpR8wtw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.5': + resolution: {integrity: sha512-ibjQjM7wEXtECiT6my1xfiMH9IcEczMOS6xiCQXoUIYSj5b1CpBbJ3VYbdwDy8Vcg5JHN7eFpOCGk8nyZAltNQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.5': + resolution: {integrity: sha512-+elOuaYx6F2H6x1/5BQP5ugv12nfJl66GhxON8+dWVUEDJ9jah/A0tayVdkLRP0AeSac0inYkDz5qBFKfVp2Gg==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.5': + resolution: {integrity: sha512-G9WSqbST45bmIFaeNuP/EnC19Rhp54CcVdX9PDL1zyEB514WsDVXhlyihKlGXnRycmHNmVv88Bvvt4EYxWef/Q==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.6': + resolution: {integrity: sha512-3+RG3EA6BBJ/ofZUeTFJA7mHfSYrZtQIrDP9dI8Lf7X6Jbos2jptuLrAAteDiFVrmbEmLSuRG/bUKzfAXk7dhg==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.5': + resolution: {integrity: sha512-DpYX914YOfA3UDT9CN1BM787PcHfWRBB43fFGCYrZFUH0Jv+5t8yYl+Pd5PW4+QzoGEDvn5d5QIO4j2HyYZQSA==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.5': + resolution: {integrity: sha512-2L2erASEro1WC5nV+plwIMxrTXpvpfzl4e+Nre6vBVRR2HKeGGcvpJyyL3/PpiSg+cJG2KpTmZmq934Olb6e5A==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.5': + resolution: {integrity: sha512-Y/RabVa5vbl5FuHYV2vUCwvh/dqzrEY/K2yWPSqvhFUwIY0atLqO4TienjBXakoy4zrKAMCZwg+YEqmH7jaN7A==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.3.14': + resolution: {integrity: sha512-v0q4uTKgBM8dsqGjqsabZQyH85nFaTnFcgpWU1uydKFsdyyMzfvOkNum9G7VK+dOP01vUnoZxIeRiJ6uD0kjIg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.14': + resolution: {integrity: sha512-Z2DG8Ej7FyWG1UA+7HceINtSLzswUgs2np3sZX0YBBxCt+CXG4QUxv88ZDS3+2/1ldW7LqtSY1UO/6VQ1pND8Q==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.6': + resolution: {integrity: sha512-VkLoE/z7e2g8pirwisLz8XJWedUSY8my/qrp81VmAdyrhi94T+riBfwP+AOEEFR9rFTSonC/5D2eWNmFabHyGQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.5': + resolution: {integrity: sha512-bYrutc+neOyWxtZdbB2USbQttZN0mXaOyYLIsaTbJhFsfpXyGWUxJpEuO1rJ8IIJm2qH4+xJT0mxUSsEDTYwdQ==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.5': + resolution: {integrity: sha512-UTurh1C4qkVCtqggI36DGbLB2Kv8UlcFdMXDcWMbqVY2uRg0XmT9Pb4Vj6oSQ34eizO1fvR0RnFV4Axw4IrrAg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.5': + resolution: {integrity: sha512-CMnzM9R2WqlqXQGtIlsHMEZfXKJVTIrqCNoSd/QpAyp+Dw0a1Vps13l6ma1fH8g7zSPNsA59B/kWgeylFuA/lw==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.5': + resolution: {integrity: sha512-8iLN1XSE1rl4MuxvQ+5OSk/Zb5El7NJZ1td6Tn+8dQQHIjp59Lwl6bd0+nzw6SKm2wSSriH2v/I9LPzUic7EOg==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.5': + resolution: {integrity: sha512-RlaL+sA0LNMp03bf7XPbFmT5gN+w3besXSWMkA8rcmxLSVfiEXElQi4O2IWwPfxzcHkxqrwBFMbngB8yx/RvaQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.5': + resolution: {integrity: sha512-y98otMI1saoajeik2kLfGyRp11e5U/iJYH/wLCh3aTV/XutbGT9nziKGkgCaMD1ghK7p6htHMm6b6scl9JRUWg==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.5': + resolution: {integrity: sha512-031WCTdPYgiQRYNPXznHXof2YM0GwL6SeaSyTH/P72M1Vz73TvCNH2Nq8Iu2IEPq9QP2yx0/nrw5YmSeAi/AjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.5': + resolution: {integrity: sha512-8fEvK+WPE3wUAcDvqDQG1Vk3ANLR8Px979te96m84CbKAjBVf25rPYSzb4xU4hlTyho7VhOGnh5i62D/JVF0JQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.0': + resolution: {integrity: sha512-5WmZ5+kJgJDjwXXIzr1vDTG+RhF9wzSODQBfkrQ2VVkYALKGvZX1lgVSxEkgicSAFnFhPj5rudJV0zoinqS0bA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.5': + resolution: {integrity: sha512-xSUfMu1FT7ccfSXkoLl/QRQBi2rOvi3tiBZU2Tdy3I6cgvZ6SEi9QNey+lqps/sJRnogIS+lq+B1gxxbra2a/w==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.9.10': + resolution: {integrity: sha512-Jaoz4Jw1QYHc1EFww/E6gVtNjhoDU+gwRKqXP6C3LKYqqH2UQhP8tMP3+t/ePrhaze7fhLE8vS2q6vVxBANFTQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.9.0': + resolution: {integrity: sha512-MvUbdnXDTwykR8cB1WZvNNwqoWVaTRA0RLlLmf/cIFNMM2cKWz01X4Ly6SMC4Kks30r8tT3Cty0jmeWfiuyHTA==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.5': + resolution: {integrity: sha512-VaxMGsilqFnK1CeBX+LXnSuaMx4sTL/6znSZh2829txWieazdVxr54HmiyTsIbpOTLcf5nYpq9lpzmwRdxj6rQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.0': + resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.1': + resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.13': + resolution: {integrity: sha512-hlVLdAGrVfyNei+pKIgqDTxfu/ZI2NSyqj4IDxKd5bIsIqwR/dSlkxlPaYxFiIaDVrBy0he8orsFy+Cz119XvA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.16': + resolution: {integrity: sha512-F1t22IUiJLHrxW9W1CQ6B9PN+skZ9cqSuzB18Eh06HrJPbjsyZ7ZHecAKw80DQtyGTRcVfeukKaCRYebFwclbg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.5': + resolution: {integrity: sha512-3O63AAWu2cSNQZp+ayl9I3NapW1p1rR5mlVHcF6hAB1dPZUQFfRPYtplWX/3xrzWthPGj5FqB12taJJCfH6s8A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.5': + resolution: {integrity: sha512-6Y3+rvBF7+PZOc40ybeZMcGln6xJGVeY60E7jy9Mv5iKpMJpHgRE6dKy9ScsVxvfAYuEX4Q9a65DQX90KaQ3bA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.5': + resolution: {integrity: sha512-GBj3+EZBbN4NAqJ/7pAhsXdfzdlznOh8PydUijy6FpNIMnHPSMO2/rP4HKu+UFeikJxShERk528oy7GT79YiJg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.6': + resolution: {integrity: sha512-qWw/UM59TiaFrPevefOZ8CNBKbYEP6wBAIlLqxn3VAIo9rgnTNc4ASbVrqDmhuwI87usnjhdQrxodzAGFFzbRQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + + '@so-ric/colorspace@1.1.6': + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/node@20.19.25': + resolution: {integrity: sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/triple-beam@1.3.5': + resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + backoff@2.5.0: + resolution: {integrity: sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==} + engines: {node: '>= 0.6'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.9.0: + resolution: {integrity: sha512-Mh++g+2LPfzZToywfE1BUzvZbfOY52Nil0rn9H1CPC5DJ7fX+Vir7nToBeoiSbB1zTNeGYbELEvJESujgGrzXw==} + hasBin: true + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + bcryptjs@2.4.3: + resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bowser@2.13.1: + resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001759: + resolution: {integrity: sha512-Pzfx9fOKoKvevQf8oCXoyNRQ5QyxJj+3O0Rqx2V5oxT61KGx8+n6hV/IUyJeifUci2clnmmKVpvtiqRzgiWjSw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.0: + resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.264: + resolution: {integrity: sha512-1tEf0nLgltC3iy9wtlYDlQDc5Rg9lEKVjEmIHJ21rI9OcqkvD45K1oyNIRA4rR1z3LgJ7KeGzEBojVcV6m4qjA==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enabled@2.0.0: + resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.4: + resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + extsprintf@1.4.1: + resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fast-xml-parser@5.2.5: + resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} + hasBin: true + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fecha@4.2.3: + resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + file-stream-rotator@0.6.1: + resolution: {integrity: sha512-u+dBid4PvZw17PmDeRcNOtCP9CCK/9lRN2w+r1xIS7yOL9JFrIBKTvrYsxT4P0pGtThYTn++QS5ChHaUov3+zQ==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + fn.name@1.1.0: + resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + formidable@2.1.5: + resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + google-auth-library@10.5.0: + resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + gtoken@8.0.0: + resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==} + engines: {node: '>=18'} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + helmet@7.2.0: + resolution: {integrity: sha512-ZRiwvN089JfMXokizgqEPXsl2Guk094yExfoDXR0cBYWxtBbaSww/w+vT4WEJsBW2iTUi1GgZ6swmoug3Oy4Xw==} + engines: {node: '>=16.0.0'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {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. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inquirer@8.2.7: + resolution: {integrity: sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==} + engines: {node: '>=12.0.0'} + + ioredis@5.8.2: + resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + engines: {node: '>=12.22.0'} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kuler@2.0.0: + resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} + + ldapjs@3.0.7: + resolution: {integrity: sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==} + deprecated: This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + morgan@1.10.1: + resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} + engines: {node: '>= 0.8.0'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + nodemailer@7.0.11: + resolution: {integrity: sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==} + engines: {node: '>=6.0.0'} + + nodemon@3.1.11: + resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + one-time@1.0.0: + resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + precond@0.2.3: + resolution: {integrity: sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==} + engines: {node: '>= 0.6'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.7.4: + resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + process-warning@2.3.2: + resolution: {integrity: sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + rate-limiter-flexible@5.0.5: + resolution: {integrity: sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + send@0.19.1: + resolution: {integrity: sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-similarity@4.0.4: + resolution: {integrity: sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strnum@2.1.1: + resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + + superagent@8.1.2: + resolution: {integrity: sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==} + engines: {node: '>=6.4.0 <13 || >=14'} + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net + + supertest@6.3.4: + resolution: {integrity: sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==} + engines: {node: '>=6.4.0'} + deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + synckit@0.11.11: + resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} + engines: {node: ^14.18.0 || >=16.0.0} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + text-hex@1.0.0: + resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + triple-beam@1.4.1: + resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} + engines: {node: '>= 14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.1: + resolution: {integrity: sha512-R9NcHbbZ45RoWfTdhn1J9SS7zxNvlddv4YRrHTUaFdtjbmfncfedB45EC9IaqJQ97iAR1GZgOfyRQO+ExIF6EQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vasync@2.2.1: + resolution: {integrity: sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==} + engines: {'0': node >=0.6.0} + + verror@1.10.0: + resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} + engines: {'0': node >=0.6.0} + + verror@1.10.1: + resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} + engines: {node: '>=0.6.0'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + winston-daily-rotate-file@4.7.1: + resolution: {integrity: sha512-7LGPiYGBPNyGHLn9z33i96zx/bd71pjBn9tqQzO3I4Tayv94WPmBNwKC7CO1wPHdP9uvu+Md/1nr6VSH9h0iaA==} + engines: {node: '>=8'} + peerDependencies: + winston: ^3 + + winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} + + winston@3.18.3: + resolution: {integrity: sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==} + engines: {node: '>= 12.0.0'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.936.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-locate-window': 3.893.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.936.0 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.943.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.943.0 + '@aws-sdk/credential-provider-node': 3.943.0 + '@aws-sdk/eventstream-handler-node': 3.936.0 + '@aws-sdk/middleware-eventstream': 3.936.0 + '@aws-sdk/middleware-host-header': 3.936.0 + '@aws-sdk/middleware-logger': 3.936.0 + '@aws-sdk/middleware-recursion-detection': 3.936.0 + '@aws-sdk/middleware-user-agent': 3.943.0 + '@aws-sdk/middleware-websocket': 3.936.0 + '@aws-sdk/region-config-resolver': 3.936.0 + '@aws-sdk/token-providers': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@aws-sdk/util-user-agent-browser': 3.936.0 + '@aws-sdk/util-user-agent-node': 3.943.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.18.7 + '@smithy/eventstream-serde-browser': 4.2.5 + '@smithy/eventstream-serde-config-resolver': 4.3.5 + '@smithy/eventstream-serde-node': 4.2.5 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/hash-node': 4.2.5 + '@smithy/invalid-dependency': 4.2.5 + '@smithy/middleware-content-length': 4.2.5 + '@smithy/middleware-endpoint': 4.3.14 + '@smithy/middleware-retry': 4.4.14 + '@smithy/middleware-serde': 4.2.6 + '@smithy/middleware-stack': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/node-http-handler': 4.4.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.9.10 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.13 + '@smithy/util-defaults-mode-node': 4.2.16 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/util-stream': 4.5.6 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-cognito-identity@3.943.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.943.0 + '@aws-sdk/credential-provider-node': 3.943.0 + '@aws-sdk/middleware-host-header': 3.936.0 + '@aws-sdk/middleware-logger': 3.936.0 + '@aws-sdk/middleware-recursion-detection': 3.936.0 + '@aws-sdk/middleware-user-agent': 3.943.0 + '@aws-sdk/region-config-resolver': 3.936.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@aws-sdk/util-user-agent-browser': 3.936.0 + '@aws-sdk/util-user-agent-node': 3.943.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.18.7 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/hash-node': 4.2.5 + '@smithy/invalid-dependency': 4.2.5 + '@smithy/middleware-content-length': 4.2.5 + '@smithy/middleware-endpoint': 4.3.14 + '@smithy/middleware-retry': 4.4.14 + '@smithy/middleware-serde': 4.2.6 + '@smithy/middleware-stack': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/node-http-handler': 4.4.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.9.10 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.13 + '@smithy/util-defaults-mode-node': 4.2.16 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.943.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.943.0 + '@aws-sdk/middleware-host-header': 3.936.0 + '@aws-sdk/middleware-logger': 3.936.0 + '@aws-sdk/middleware-recursion-detection': 3.936.0 + '@aws-sdk/middleware-user-agent': 3.943.0 + '@aws-sdk/region-config-resolver': 3.936.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@aws-sdk/util-user-agent-browser': 3.936.0 + '@aws-sdk/util-user-agent-node': 3.943.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.18.7 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/hash-node': 4.2.5 + '@smithy/invalid-dependency': 4.2.5 + '@smithy/middleware-content-length': 4.2.5 + '@smithy/middleware-endpoint': 4.3.14 + '@smithy/middleware-retry': 4.4.14 + '@smithy/middleware-serde': 4.2.6 + '@smithy/middleware-stack': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/node-http-handler': 4.4.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.9.10 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.13 + '@smithy/util-defaults-mode-node': 4.2.16 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.943.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@aws-sdk/xml-builder': 3.930.0 + '@smithy/core': 3.18.7 + '@smithy/node-config-provider': 4.3.5 + '@smithy/property-provider': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/signature-v4': 5.3.5 + '@smithy/smithy-client': 4.9.10 + '@smithy/types': 4.9.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-cognito-identity@3.943.0': + dependencies: + '@aws-sdk/client-cognito-identity': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-env@3.943.0': + dependencies: + '@aws-sdk/core': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.943.0': + dependencies: + '@aws-sdk/core': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/node-http-handler': 4.4.5 + '@smithy/property-provider': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.9.10 + '@smithy/types': 4.9.0 + '@smithy/util-stream': 4.5.6 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.943.0': + dependencies: + '@aws-sdk/core': 3.943.0 + '@aws-sdk/credential-provider-env': 3.943.0 + '@aws-sdk/credential-provider-http': 3.943.0 + '@aws-sdk/credential-provider-login': 3.943.0 + '@aws-sdk/credential-provider-process': 3.943.0 + '@aws-sdk/credential-provider-sso': 3.943.0 + '@aws-sdk/credential-provider-web-identity': 3.943.0 + '@aws-sdk/nested-clients': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/credential-provider-imds': 4.2.5 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.943.0': + dependencies: + '@aws-sdk/core': 3.943.0 + '@aws-sdk/nested-clients': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.943.0': + dependencies: + '@aws-sdk/credential-provider-env': 3.943.0 + '@aws-sdk/credential-provider-http': 3.943.0 + '@aws-sdk/credential-provider-ini': 3.943.0 + '@aws-sdk/credential-provider-process': 3.943.0 + '@aws-sdk/credential-provider-sso': 3.943.0 + '@aws-sdk/credential-provider-web-identity': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/credential-provider-imds': 4.2.5 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.943.0': + dependencies: + '@aws-sdk/core': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.943.0': + dependencies: + '@aws-sdk/client-sso': 3.943.0 + '@aws-sdk/core': 3.943.0 + '@aws-sdk/token-providers': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.943.0': + dependencies: + '@aws-sdk/core': 3.943.0 + '@aws-sdk/nested-clients': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-providers@3.943.0': + dependencies: + '@aws-sdk/client-cognito-identity': 3.943.0 + '@aws-sdk/core': 3.943.0 + '@aws-sdk/credential-provider-cognito-identity': 3.943.0 + '@aws-sdk/credential-provider-env': 3.943.0 + '@aws-sdk/credential-provider-http': 3.943.0 + '@aws-sdk/credential-provider-ini': 3.943.0 + '@aws-sdk/credential-provider-login': 3.943.0 + '@aws-sdk/credential-provider-node': 3.943.0 + '@aws-sdk/credential-provider-process': 3.943.0 + '@aws-sdk/credential-provider-sso': 3.943.0 + '@aws-sdk/credential-provider-web-identity': 3.943.0 + '@aws-sdk/nested-clients': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.18.7 + '@smithy/credential-provider-imds': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/property-provider': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/eventstream-handler-node@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/eventstream-codec': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@aws/lambda-invoke-store': 0.2.2 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.943.0': + dependencies: + '@aws-sdk/core': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@smithy/core': 3.18.7 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-format-url': 3.936.0 + '@smithy/eventstream-codec': 4.2.5 + '@smithy/eventstream-serde-browser': 4.2.5 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/protocol-http': 5.3.5 + '@smithy/signature-v4': 5.3.5 + '@smithy/types': 4.9.0 + '@smithy/util-hex-encoding': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.943.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.943.0 + '@aws-sdk/middleware-host-header': 3.936.0 + '@aws-sdk/middleware-logger': 3.936.0 + '@aws-sdk/middleware-recursion-detection': 3.936.0 + '@aws-sdk/middleware-user-agent': 3.943.0 + '@aws-sdk/region-config-resolver': 3.936.0 + '@aws-sdk/types': 3.936.0 + '@aws-sdk/util-endpoints': 3.936.0 + '@aws-sdk/util-user-agent-browser': 3.936.0 + '@aws-sdk/util-user-agent-node': 3.943.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/core': 3.18.7 + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/hash-node': 4.2.5 + '@smithy/invalid-dependency': 4.2.5 + '@smithy/middleware-content-length': 4.2.5 + '@smithy/middleware-endpoint': 4.3.14 + '@smithy/middleware-retry': 4.4.14 + '@smithy/middleware-serde': 4.2.6 + '@smithy/middleware-stack': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/node-http-handler': 4.4.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/smithy-client': 4.9.10 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.13 + '@smithy/util-defaults-mode-node': 4.2.16 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/config-resolver': 4.4.3 + '@smithy/node-config-provider': 4.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.943.0': + dependencies: + '@aws-sdk/core': 3.943.0 + '@aws-sdk/nested-clients': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.936.0': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-endpoints': 3.2.5 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/querystring-builder': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.893.0': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.936.0': + dependencies: + '@aws-sdk/types': 3.936.0 + '@smithy/types': 4.9.0 + bowser: 2.13.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.943.0': + dependencies: + '@aws-sdk/middleware-user-agent': 3.943.0 + '@aws-sdk/types': 3.936.0 + '@smithy/node-config-provider': 4.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.930.0': + dependencies: + '@smithy/types': 4.9.0 + fast-xml-parser: 5.2.5 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.2': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3(supports-color@5.5.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@colors/colors@1.6.0': {} + + '@dabh/diagnostics@2.0.8': + dependencies: + '@so-ric/colorspace': 1.1.6 + enabled: 2.0.0 + kuler: 2.0.0 + + '@eslint-community/eslint-utils@4.9.0(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.3(supports-color@5.5.0) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@inquirer/external-editor@1.0.3(@types/node@20.19.25)': + dependencies: + chardet: 2.1.1 + iconv-lite: 0.7.0 + optionalDependencies: + '@types/node': 20.19.25 + + '@ioredis/commands@1.4.0': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.19.25) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.19.25 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 20.19.25 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.2.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.28.5 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.19.25 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@ldapjs/asn1@1.2.0': {} + + '@ldapjs/asn1@2.0.0': {} + + '@ldapjs/attribute@1.0.0': + dependencies: + '@ldapjs/asn1': 2.0.0 + '@ldapjs/protocol': 1.2.1 + process-warning: 2.3.2 + + '@ldapjs/change@1.0.0': + dependencies: + '@ldapjs/asn1': 2.0.0 + '@ldapjs/attribute': 1.0.0 + + '@ldapjs/controls@2.1.0': + dependencies: + '@ldapjs/asn1': 1.2.0 + '@ldapjs/protocol': 1.2.1 + + '@ldapjs/dn@1.1.0': + dependencies: + '@ldapjs/asn1': 2.0.0 + process-warning: 2.3.2 + + '@ldapjs/filter@2.1.1': + dependencies: + '@ldapjs/asn1': 2.0.0 + '@ldapjs/protocol': 1.2.1 + process-warning: 2.3.2 + + '@ldapjs/messages@1.3.0': + dependencies: + '@ldapjs/asn1': 2.0.0 + '@ldapjs/attribute': 1.0.0 + '@ldapjs/change': 1.0.0 + '@ldapjs/controls': 2.1.0 + '@ldapjs/dn': 1.1.0 + '@ldapjs/filter': 2.1.1 + '@ldapjs/protocol': 1.2.1 + process-warning: 2.3.2 + + '@ldapjs/protocol@1.2.1': {} + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.2.9': {} + + '@sinclair/typebox@0.27.8': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@smithy/abort-controller@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.3': + dependencies: + '@smithy/node-config-provider': 4.3.5 + '@smithy/types': 4.9.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.5 + '@smithy/util-middleware': 4.2.5 + tslib: 2.8.1 + + '@smithy/core@3.18.7': + dependencies: + '@smithy/middleware-serde': 4.2.6 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-stream': 4.5.6 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.5': + dependencies: + '@smithy/node-config-provider': 4.3.5 + '@smithy/property-provider': 4.2.5 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.5': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.9.0 + '@smithy/util-hex-encoding': 4.2.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.5': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.5': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.5': + dependencies: + '@smithy/eventstream-codec': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.6': + dependencies: + '@smithy/protocol-http': 5.3.5 + '@smithy/querystring-builder': 4.2.5 + '@smithy/types': 4.9.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.5': + dependencies: + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.3.14': + dependencies: + '@smithy/core': 3.18.7 + '@smithy/middleware-serde': 4.2.6 + '@smithy/node-config-provider': 4.3.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + '@smithy/url-parser': 4.2.5 + '@smithy/util-middleware': 4.2.5 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.14': + dependencies: + '@smithy/node-config-provider': 4.3.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/service-error-classification': 4.2.5 + '@smithy/smithy-client': 4.9.10 + '@smithy/types': 4.9.0 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-retry': 4.2.5 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.6': + dependencies: + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.5': + dependencies: + '@smithy/property-provider': 4.2.5 + '@smithy/shared-ini-file-loader': 4.4.0 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.5': + dependencies: + '@smithy/abort-controller': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/querystring-builder': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + + '@smithy/shared-ini-file-loader@4.4.0': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.5': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.5 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.9.10': + dependencies: + '@smithy/core': 3.18.7 + '@smithy/middleware-endpoint': 4.3.14 + '@smithy/middleware-stack': 4.2.5 + '@smithy/protocol-http': 5.3.5 + '@smithy/types': 4.9.0 + '@smithy/util-stream': 4.5.6 + tslib: 2.8.1 + + '@smithy/types@4.9.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.5': + dependencies: + '@smithy/querystring-parser': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.13': + dependencies: + '@smithy/property-provider': 4.2.5 + '@smithy/smithy-client': 4.9.10 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.16': + dependencies: + '@smithy/config-resolver': 4.4.3 + '@smithy/credential-provider-imds': 4.2.5 + '@smithy/node-config-provider': 4.3.5 + '@smithy/property-provider': 4.2.5 + '@smithy/smithy-client': 4.9.10 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.5': + dependencies: + '@smithy/node-config-provider': 4.3.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.5': + dependencies: + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.5': + dependencies: + '@smithy/service-error-classification': 4.2.5 + '@smithy/types': 4.9.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.6': + dependencies: + '@smithy/fetch-http-handler': 5.3.6 + '@smithy/node-http-handler': 4.4.5 + '@smithy/types': 4.9.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + tslib: 2.8.1 + + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + + '@so-ric/colorspace@1.1.6': + dependencies: + color: 5.0.3 + text-hex: 1.0.0 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 20.19.25 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/node@20.19.25': + dependencies: + undici-types: 6.21.0 + + '@types/stack-utils@2.0.3': {} + + '@types/triple-beam@1.3.5': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@ungap/structured-clone@1.3.0': {} + + abstract-logging@2.0.1: {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + agent-base@7.1.4: {} + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-flatten@1.1.1: {} + + asap@2.0.6: {} + + assert-plus@1.0.0: {} + + astral-regex@2.0.0: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-jest@29.7.0(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.28.5) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.27.1 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.28.0 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.5) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.5) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.5) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.5) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.5) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) + + babel-preset-jest@29.6.3(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + + backoff@2.5.0: + dependencies: + precond: 0.2.3 + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.9.0: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + bcryptjs@2.4.3: {} + + bignumber.js@9.3.1: {} + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bowser@2.13.1: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.0 + caniuse-lite: 1.0.30001759 + electron-to-chromium: 1.5.264 + node-releases: 2.0.27 + update-browserslist-db: 1.2.1(browserslist@4.28.1) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001759: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + chardet@2.1.1: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.3: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-width@3.0.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + cluster-key-slot@1.1.2: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-convert@3.1.3: + dependencies: + color-name: 2.1.0 + + color-name@1.1.4: {} + + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + color@5.0.3: + dependencies: + color-convert: 3.1.3 + color-string: 2.1.4 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@11.1.0: {} + + component-emitter@1.3.1: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + core-util-is@1.0.2: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + create-jest@29.7.0(@types/node@20.19.25): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.19.25) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-uri-to-buffer@4.0.1: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + dedent@1.7.0: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-newline@3.1.0: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + diff-sequences@29.6.3: {} + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.264: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + enabled@2.0.0: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-prettier@5.5.4(eslint-config-prettier@10.1.8(eslint@8.57.1))(eslint@8.57.1)(prettier@3.7.4): + dependencies: + eslint: 8.57.1 + prettier: 3.7.4 + prettier-linter-helpers: 1.0.0 + synckit: 0.11.11 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@8.57.1) + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@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.3.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@5.5.0) + 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.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + 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.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.1 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: {} + + extsprintf@1.4.1: {} + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.1.0: {} + + fast-xml-parser@5.2.5: + dependencies: + strnum: 2.1.1 + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fecha@4.2.3: {} + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + file-stream-rotator@0.6.1: + dependencies: + moment: 2.30.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + fn.name@1.1.0: {} + + follow-redirects@1.15.11: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + formidable@2.1.5: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + qs: 6.14.0 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + 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 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + google-auth-library@10.5.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.3 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + gtoken: 8.0.0 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + gtoken@8.0.0: + dependencies: + gaxios: 7.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + has-flag@3.0.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + helmet@7.2.0: {} + + html-escaper@2.0.2: {} + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inquirer@8.2.7(@types/node@20.19.25): + dependencies: + '@inquirer/external-editor': 1.0.3(@types/node@20.19.25) + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.2 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + transitivePeerDependencies: + - '@types/node' + + ioredis@5.8.2: + dependencies: + '@ioredis/commands': 1.4.0 + cluster-key-slot: 1.1.2 + debug: 4.4.3(supports-color@5.5.0) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-stream@2.0.1: {} + + is-unicode-supported@0.1.0: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.0 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@20.19.25): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.19.25) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@20.19.25) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@20.19.25): + dependencies: + '@babel/core': 7.28.5 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.19.25 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 20.19.25 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.27.1 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.11 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + chalk: 4.1.2 + cjs-module-lexer: 1.4.3 + collect-v8-coverage: 1.0.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.28.5 + '@babel/generator': 7.28.5 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) + '@babel/types': 7.28.5 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.19.25 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 20.19.25 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@20.19.25): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@20.19.25) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + kuler@2.0.0: {} + + ldapjs@3.0.7: + dependencies: + '@ldapjs/asn1': 2.0.0 + '@ldapjs/attribute': 1.0.0 + '@ldapjs/change': 1.0.0 + '@ldapjs/controls': 2.1.0 + '@ldapjs/dn': 1.1.0 + '@ldapjs/filter': 2.1.1 + '@ldapjs/messages': 1.3.0 + '@ldapjs/protocol': 1.2.1 + abstract-logging: 2.0.1 + assert-plus: 1.0.0 + backoff: 2.5.0 + once: 1.4.0 + vasync: 2.2.1 + verror: 1.10.1 + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + + lodash.merge@4.6.2: {} + + lodash.truncate@4.4.2: {} + + lodash@4.17.21: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + logform@2.7.0: + dependencies: + '@colors/colors': 1.6.0 + '@types/triple-beam': 1.3.5 + fecha: 4.2.3 + ms: 2.1.3 + safe-stable-stringify: 2.5.0 + triple-beam: 1.4.1 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mime@2.6.0: {} + + mimic-fn@2.1.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + + moment@2.30.1: {} + + morgan@1.10.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + + ms@2.0.0: {} + + ms@2.1.3: {} + + mute-stream@0.0.8: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + node-cron@4.2.1: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-int64@0.4.0: {} + + node-releases@2.0.27: {} + + nodemailer@7.0.11: {} + + nodemon@3.1.11: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.3 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + object-assign@4.1.1: {} + + object-hash@2.2.0: {} + + object-inspect@1.13.4: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + one-time@1.0.0: + dependencies: + fn.name: 1.1.0 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-to-regexp@0.1.12: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + precond@0.2.3: {} + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + + prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4): + dependencies: + prettier: 3.7.4 + + prettier@3.7.4: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + process-warning@2.3.2: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + pstree.remy@1.1.8: {} + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + range-parser@1.2.1: {} + + rate-limiter-flexible@5.0.5: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-is@18.3.1: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + + run-async@2.4.1: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + send@0.19.1: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.3 + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + smart-buffer@4.2.0: {} + + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@5.5.0) + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} + + stack-trace@0.0.10: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + standard-as-callback@2.1.0: {} + + statuses@2.0.1: {} + + statuses@2.0.2: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-similarity@4.0.4: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + + strnum@2.1.1: {} + + superagent@8.1.2: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@5.5.0) + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 2.1.5 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.0 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + supertest@6.3.4: + dependencies: + methods: 1.1.2 + superagent: 8.1.2 + transitivePeerDependencies: + - supports-color + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + synckit@0.11.11: + dependencies: + '@pkgr/core': 0.2.9 + + table@6.9.0: + dependencies: + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + text-hex@1.0.0: {} + + text-table@0.2.0: {} + + through@2.3.8: {} + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + triple-beam@1.4.1: {} + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + undefsafe@2.0.5: {} + + undici-types@6.21.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.1(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@9.0.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + vary@1.1.2: {} + + vasync@2.2.1: + dependencies: + verror: 1.10.0 + + verror@1.10.0: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.4.1 + + verror@1.10.1: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.4.1 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-streams-polyfill@3.3.3: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + winston-daily-rotate-file@4.7.1(winston@3.18.3): + dependencies: + file-stream-rotator: 0.6.1 + object-hash: 2.2.0 + triple-beam: 1.4.1 + winston: 3.18.3 + winston-transport: 4.9.0 + + winston-transport@4.9.0: + dependencies: + logform: 2.7.0 + readable-stream: 3.6.2 + triple-beam: 1.4.1 + + winston@3.18.3: + dependencies: + '@colors/colors': 1.6.0 + '@dabh/diagnostics': 2.0.8 + async: 3.2.6 + is-stream: 2.0.1 + logform: 2.7.0 + one-time: 1.0.0 + readable-stream: 3.6.2 + safe-stable-stringify: 2.5.0 + stack-trace: 0.0.10 + triple-beam: 1.4.1 + winston-transport: 4.9.0 + + word-wrap@1.2.5: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} diff --git a/resources/model-pricing/README.md b/resources/model-pricing/README.md new file mode 100644 index 0000000..d755de7 --- /dev/null +++ b/resources/model-pricing/README.md @@ -0,0 +1,37 @@ +# Model Pricing Data + +This directory contains a local copy of the mirrored model pricing data as a fallback mechanism. + +## Source +The original file is maintained by the LiteLLM project and mirrored into the `price-mirror` branch of this repository via GitHub Actions: +- Mirror branch (configurable via `PRICE_MIRROR_REPO`): https://raw.githubusercontent.com//price-mirror/model_prices_and_context_window.json +- Upstream source: https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json + +## Purpose +This local copy serves as a fallback when the remote file cannot be downloaded due to: +- Network restrictions +- Firewall rules +- DNS resolution issues +- GitHub being blocked in certain regions +- Docker container network limitations + +## Update Process +The pricingService will: +1. First attempt to download the latest version from GitHub +2. If download fails, use this local copy as fallback +3. Log a warning when using the fallback file + +## Manual Update +To manually update this file with the latest pricing data (if automation is unavailable): +```bash +curl -s https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json -o model_prices_and_context_window.json +``` + +## File Format +The file contains JSON data with model pricing information including: +- Model names and identifiers +- Input/output token costs +- Context window sizes +- Model capabilities + +Last updated: 2025-08-10 diff --git a/resources/model-pricing/model_prices_and_context_window.json b/resources/model-pricing/model_prices_and_context_window.json new file mode 100644 index 0000000..03265db --- /dev/null +++ b/resources/model-pricing/model_prices_and_context_window.json @@ -0,0 +1,5318 @@ +{ + "claude-3-5-haiku-20241022": { + "cache_creation_input_token_cost": 1e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_read_input_token_cost": 8e-8, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 8e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4e-6, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-3-5-haiku-latest": { + "cache_creation_input_token_cost": 1.25e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_read_input_token_cost": 1e-7, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 1e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 5e-6, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-3-5-sonnet-20240620": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 3e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-5-sonnet-20241022": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-10-01", + "input_cost_per_token": 3e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-5-sonnet-latest": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 3e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-7-sonnet-20250219": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2026-02-19", + "input_cost_per_token": 3e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-7-sonnet-latest": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_read_input_token_cost": 3e-7, + "deprecation_date": "2025-06-01", + "input_cost_per_token": 3e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-3-haiku-20240307": { + "cache_creation_input_token_cost": 3e-7, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_read_input_token_cost": 3e-8, + "input_cost_per_token": 2.5e-7, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.25e-6, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 264 + }, + "claude-3-opus-20240229": { + "cache_creation_input_token_cost": 1.875e-5, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_read_input_token_cost": 1.5e-6, + "deprecation_date": "2026-05-01", + "input_cost_per_token": 1.5e-5, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-5, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 395 + }, + "claude-3-opus-latest": { + "cache_creation_input_token_cost": 1.875e-5, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_read_input_token_cost": 1.5e-6, + "deprecation_date": "2025-03-01", + "input_cost_per_token": 1.5e-5, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 7.5e-5, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 395 + }, + "claude-4-opus-20250514": { + "cache_creation_input_token_cost": 1.875e-5, + "cache_creation_input_token_cost_above_1hr": 3e-5, + "cache_read_input_token_cost": 1.5e-6, + "input_cost_per_token": 1.5e-5, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-4-sonnet-20250514": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_creation_input_token_cost_above_200k_tokens": 3.75e-6, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 3e-7, + "input_cost_per_token": 3e-6, + "input_cost_per_token_above_200k_tokens": 3e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-haiku-4-5": { + "cache_creation_input_token_cost": 1.25e-6, + "cache_creation_input_token_cost_above_1hr": 2e-6, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 1e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 5e-6, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "claude-haiku-4-5-20251001": { + "cache_creation_input_token_cost": 1.25e-6, + "cache_creation_input_token_cost_above_1hr": 2e-6, + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 1e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 5e-6, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "claude-opus-4-1": { + "cache_creation_input_token_cost": 1.875e-5, + "cache_creation_input_token_cost_above_1hr": 3e-5, + "cache_read_input_token_cost": 1.5e-6, + "input_cost_per_token": 1.5e-5, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-1-20250805": { + "cache_creation_input_token_cost": 1.875e-5, + "cache_creation_input_token_cost_above_1hr": 3e-5, + "cache_read_input_token_cost": 1.5e-6, + "deprecation_date": "2026-08-05", + "input_cost_per_token": 1.5e-5, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-20250514": { + "cache_creation_input_token_cost": 1.875e-5, + "cache_creation_input_token_cost_above_1hr": 3e-5, + "cache_read_input_token_cost": 1.5e-6, + "deprecation_date": "2026-05-14", + "input_cost_per_token": 1.5e-5, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 32000, + "max_tokens": 32000, + "mode": "chat", + "output_cost_per_token": 7.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-5": { + "cache_creation_input_token_cost": 6.25e-6, + "cache_creation_input_token_cost_above_1hr": 1e-5, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 5e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-5-20251101": { + "cache_creation_input_token_cost": 6.25e-6, + "cache_creation_input_token_cost_above_1hr": 1e-5, + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 5e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 2.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-opus-4-6": { + "cache_creation_input_token_cost": 6.25e-6, + "cache_creation_input_token_cost_above_1hr": 1e-5, + "cache_creation_input_token_cost_above_200k_tokens": 6.25e-6, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 5e-7, + "input_cost_per_token": 5e-6, + "input_cost_per_token_above_200k_tokens": 5e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-5, + "output_cost_per_token_above_200k_tokens": 2.5e-5, + "provider_specific_entry": { + "fast": 6.0, + "us": 1.1 + }, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-opus-4-6-20260205": { + "cache_creation_input_token_cost": 6.25e-6, + "cache_creation_input_token_cost_above_1hr": 1e-5, + "cache_creation_input_token_cost_above_200k_tokens": 6.25e-6, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 5e-7, + "input_cost_per_token": 5e-6, + "input_cost_per_token_above_200k_tokens": 5e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-5, + "output_cost_per_token_above_200k_tokens": 2.5e-5, + "provider_specific_entry": { + "fast": 6.0, + "us": 1.1 + }, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-opus-4-6-thinking": { + "cache_creation_input_token_cost": 6.25e-6, + "cache_creation_input_token_cost_above_1hr": 1e-5, + "cache_creation_input_token_cost_above_200k_tokens": 6.25e-6, + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_above_200k_tokens": 5e-7, + "input_cost_per_token": 5e-6, + "input_cost_per_token_above_200k_tokens": 5e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2.5e-5, + "output_cost_per_token_above_200k_tokens": 2.5e-5, + "provider_specific_entry": { + "fast": 6.0, + "us": 1.1 + }, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-sonnet-4-20250514": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_creation_input_token_cost_above_200k_tokens": 3.75e-6, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 3e-7, + "deprecation_date": "2026-05-14", + "input_cost_per_token": 3e-6, + "input_cost_per_token_above_200k_tokens": 3e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 1000000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-sonnet-4-5": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_creation_input_token_cost_above_200k_tokens": 3.75e-6, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 3e-7, + "input_cost_per_token": 3e-6, + "input_cost_per_token_above_200k_tokens": 3e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-sonnet-4-5-20250929": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_creation_input_token_cost_above_200k_tokens": 3.75e-6, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 3e-7, + "input_cost_per_token": 3e-6, + "input_cost_per_token_above_200k_tokens": 3e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "tool_use_system_prompt_tokens": 346 + }, + "claude-sonnet-4-5-20250929-v1:0": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_creation_input_token_cost_above_200k_tokens": 3.75e-6, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 3e-7, + "input_cost_per_token": 3e-6, + "input_cost_per_token_above_200k_tokens": 3e-6, + "litellm_provider": "bedrock", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 159 + }, + "claude-sonnet-4-6": { + "cache_creation_input_token_cost": 3.75e-6, + "cache_creation_input_token_cost_above_1hr": 6e-6, + "cache_creation_input_token_cost_above_200k_tokens": 3.75e-6, + "cache_read_input_token_cost": 3e-7, + "cache_read_input_token_cost_above_200k_tokens": 3e-7, + "input_cost_per_token": 3e-6, + "input_cost_per_token_above_200k_tokens": 3e-6, + "litellm_provider": "anthropic", + "max_input_tokens": 200000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supports_assistant_prefill": true, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "tool_use_system_prompt_tokens": 346 + }, + "deepseek-chat": { + "cache_read_input_token_cost": 2.8e-8, + "input_cost_per_token": 2.8e-7, + "litellm_provider": "deepseek", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 4.2e-7, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": ["/v1/chat/completions"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "deepseek-reasoner": { + "cache_read_input_token_cost": 2.8e-8, + "input_cost_per_token": 2.8e-7, + "litellm_provider": "deepseek", + "max_input_tokens": 131072, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.2e-7, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": ["/v1/chat/completions"], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false + }, + "deepseek-v3-2-251201": { + "input_cost_per_token": 0.0, + "litellm_provider": "volcengine", + "max_input_tokens": 98304, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 0.0, + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "gemini-1.0-pro": { + "input_cost_per_character": 1.25e-7, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-7, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 32760, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 3.75e-7, + "output_cost_per_token": 1.5e-6, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#google_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-1.0-pro-001": { + "deprecation_date": "2025-04-09", + "input_cost_per_character": 1.25e-7, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-7, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 32760, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 3.75e-7, + "output_cost_per_token": 1.5e-6, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-1.0-pro-002": { + "deprecation_date": "2025-04-09", + "input_cost_per_character": 1.25e-7, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-7, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 32760, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 3.75e-7, + "output_cost_per_token": 1.5e-6, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-1.0-pro-vision": { + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-7, + "litellm_provider": "vertex_ai-vision-models", + "max_images_per_prompt": 16, + "max_input_tokens": 16384, + "max_output_tokens": 2048, + "max_tokens": 2048, + "max_video_length": 2, + "max_videos_per_prompt": 1, + "mode": "chat", + "output_cost_per_token": 1.5e-6, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.0-pro-vision-001": { + "deprecation_date": "2025-04-09", + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-7, + "litellm_provider": "vertex_ai-vision-models", + "max_images_per_prompt": 16, + "max_input_tokens": 16384, + "max_output_tokens": 2048, + "max_tokens": 2048, + "max_video_length": 2, + "max_videos_per_prompt": 1, + "mode": "chat", + "output_cost_per_token": 1.5e-6, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.0-ultra": { + "input_cost_per_character": 1.25e-7, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-7, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 8192, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_character": 3.75e-7, + "output_cost_per_token": 1.5e-6, + "source": "As of Jun, 2024. There is no available doc on vertex ai pricing gemini-1.0-ultra-001. Using gemini-1.0-pro pricing. Got max_tokens info here: https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-1.0-ultra-001": { + "input_cost_per_character": 1.25e-7, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-7, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 8192, + "max_output_tokens": 2048, + "max_tokens": 2048, + "mode": "chat", + "output_cost_per_character": 3.75e-7, + "output_cost_per_token": 1.5e-6, + "source": "As of Jun, 2024. There is no available doc on vertex ai pricing gemini-1.0-ultra-001. Using gemini-1.0-pro pricing. Got max_tokens info here: https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-1.5-flash": { + "deprecation_date": "2025-09-29", + "input_cost_per_audio_per_second": 2e-6, + "input_cost_per_audio_per_second_above_128k_tokens": 4e-6, + "input_cost_per_character": 1.875e-8, + "input_cost_per_character_above_128k_tokens": 2.5e-7, + "input_cost_per_image": 2e-5, + "input_cost_per_image_above_128k_tokens": 4e-5, + "input_cost_per_token": 7.5e-8, + "input_cost_per_token_above_128k_tokens": 1e-6, + "input_cost_per_video_per_second": 2e-5, + "input_cost_per_video_per_second_above_128k_tokens": 4e-5, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 7.5e-8, + "output_cost_per_character_above_128k_tokens": 1.5e-7, + "output_cost_per_token": 3e-7, + "output_cost_per_token_above_128k_tokens": 6e-7, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-flash-001": { + "deprecation_date": "2025-05-24", + "input_cost_per_audio_per_second": 2e-6, + "input_cost_per_audio_per_second_above_128k_tokens": 4e-6, + "input_cost_per_character": 1.875e-8, + "input_cost_per_character_above_128k_tokens": 2.5e-7, + "input_cost_per_image": 2e-5, + "input_cost_per_image_above_128k_tokens": 4e-5, + "input_cost_per_token": 7.5e-8, + "input_cost_per_token_above_128k_tokens": 1e-6, + "input_cost_per_video_per_second": 2e-5, + "input_cost_per_video_per_second_above_128k_tokens": 4e-5, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 7.5e-8, + "output_cost_per_character_above_128k_tokens": 1.5e-7, + "output_cost_per_token": 3e-7, + "output_cost_per_token_above_128k_tokens": 6e-7, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-flash-002": { + "deprecation_date": "2025-09-24", + "input_cost_per_audio_per_second": 2e-6, + "input_cost_per_audio_per_second_above_128k_tokens": 4e-6, + "input_cost_per_character": 1.875e-8, + "input_cost_per_character_above_128k_tokens": 2.5e-7, + "input_cost_per_image": 2e-5, + "input_cost_per_image_above_128k_tokens": 4e-5, + "input_cost_per_token": 7.5e-8, + "input_cost_per_token_above_128k_tokens": 1e-6, + "input_cost_per_video_per_second": 2e-5, + "input_cost_per_video_per_second_above_128k_tokens": 4e-5, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 7.5e-8, + "output_cost_per_character_above_128k_tokens": 1.5e-7, + "output_cost_per_token": 3e-7, + "output_cost_per_token_above_128k_tokens": 6e-7, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-1.5-flash", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-flash-exp-0827": { + "deprecation_date": "2025-09-29", + "input_cost_per_audio_per_second": 2e-6, + "input_cost_per_audio_per_second_above_128k_tokens": 4e-6, + "input_cost_per_character": 1.875e-8, + "input_cost_per_character_above_128k_tokens": 2.5e-7, + "input_cost_per_image": 2e-5, + "input_cost_per_image_above_128k_tokens": 4e-5, + "input_cost_per_token": 4.688e-9, + "input_cost_per_token_above_128k_tokens": 1e-6, + "input_cost_per_video_per_second": 2e-5, + "input_cost_per_video_per_second_above_128k_tokens": 4e-5, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 1.875e-8, + "output_cost_per_character_above_128k_tokens": 3.75e-8, + "output_cost_per_token": 4.6875e-9, + "output_cost_per_token_above_128k_tokens": 9.375e-9, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-flash-preview-0514": { + "deprecation_date": "2025-09-29", + "input_cost_per_audio_per_second": 2e-6, + "input_cost_per_audio_per_second_above_128k_tokens": 4e-6, + "input_cost_per_character": 1.875e-8, + "input_cost_per_character_above_128k_tokens": 2.5e-7, + "input_cost_per_image": 2e-5, + "input_cost_per_image_above_128k_tokens": 4e-5, + "input_cost_per_token": 7.5e-8, + "input_cost_per_token_above_128k_tokens": 1e-6, + "input_cost_per_video_per_second": 2e-5, + "input_cost_per_video_per_second_above_128k_tokens": 4e-5, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 1.875e-8, + "output_cost_per_character_above_128k_tokens": 3.75e-8, + "output_cost_per_token": 4.6875e-9, + "output_cost_per_token_above_128k_tokens": 9.375e-9, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-pro": { + "deprecation_date": "2025-09-29", + "input_cost_per_audio_per_second": 3.125e-5, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-5, + "input_cost_per_character": 3.125e-7, + "input_cost_per_character_above_128k_tokens": 6.25e-7, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_128k_tokens": 2.5e-6, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-6, + "output_cost_per_character_above_128k_tokens": 2.5e-6, + "output_cost_per_token": 5e-6, + "output_cost_per_token_above_128k_tokens": 1e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-pro-001": { + "deprecation_date": "2025-05-24", + "input_cost_per_audio_per_second": 3.125e-5, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-5, + "input_cost_per_character": 3.125e-7, + "input_cost_per_character_above_128k_tokens": 6.25e-7, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_128k_tokens": 2.5e-6, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-6, + "output_cost_per_character_above_128k_tokens": 2.5e-6, + "output_cost_per_token": 5e-6, + "output_cost_per_token_above_128k_tokens": 1e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-pro-002": { + "deprecation_date": "2025-09-24", + "input_cost_per_audio_per_second": 3.125e-5, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-5, + "input_cost_per_character": 3.125e-7, + "input_cost_per_character_above_128k_tokens": 6.25e-7, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_128k_tokens": 2.5e-6, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-6, + "output_cost_per_character_above_128k_tokens": 2.5e-6, + "output_cost_per_token": 5e-6, + "output_cost_per_token_above_128k_tokens": 1e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-1.5-pro", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-1.5-pro-preview-0215": { + "deprecation_date": "2025-09-29", + "input_cost_per_audio_per_second": 3.125e-5, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-5, + "input_cost_per_character": 3.125e-7, + "input_cost_per_character_above_128k_tokens": 6.25e-7, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 7.8125e-8, + "input_cost_per_token_above_128k_tokens": 1.5625e-7, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-6, + "output_cost_per_character_above_128k_tokens": 2.5e-6, + "output_cost_per_token": 3.125e-7, + "output_cost_per_token_above_128k_tokens": 6.25e-7, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gemini-1.5-pro-preview-0409": { + "deprecation_date": "2025-09-29", + "input_cost_per_audio_per_second": 3.125e-5, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-5, + "input_cost_per_character": 3.125e-7, + "input_cost_per_character_above_128k_tokens": 6.25e-7, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 7.8125e-8, + "input_cost_per_token_above_128k_tokens": 1.5625e-7, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-6, + "output_cost_per_character_above_128k_tokens": 2.5e-6, + "output_cost_per_token": 3.125e-7, + "output_cost_per_token_above_128k_tokens": 6.25e-7, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "gemini-1.5-pro-preview-0514": { + "deprecation_date": "2025-09-29", + "input_cost_per_audio_per_second": 3.125e-5, + "input_cost_per_audio_per_second_above_128k_tokens": 6.25e-5, + "input_cost_per_character": 3.125e-7, + "input_cost_per_character_above_128k_tokens": 6.25e-7, + "input_cost_per_image": 0.00032875, + "input_cost_per_image_above_128k_tokens": 0.0006575, + "input_cost_per_token": 7.8125e-8, + "input_cost_per_token_above_128k_tokens": 1.5625e-7, + "input_cost_per_video_per_second": 0.00032875, + "input_cost_per_video_per_second_above_128k_tokens": 0.0006575, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 1.25e-6, + "output_cost_per_character_above_128k_tokens": 2.5e-6, + "output_cost_per_token": 3.125e-7, + "output_cost_per_token_above_128k_tokens": 6.25e-7, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gemini-2.0-flash": { + "cache_read_input_token_cost": 2.5e-8, + "deprecation_date": "2026-03-31", + "input_cost_per_audio_token": 7e-7, + "input_cost_per_token": 1e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 4e-7, + "source": "https://ai.google.dev/pricing#2_0flash", + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text", "image"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-001": { + "cache_read_input_token_cost": 3.75e-8, + "deprecation_date": "2026-03-31", + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 1.5e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 6e-7, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text", "image"], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-exp": { + "cache_read_input_token_cost": 3.75e-8, + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 1.5e-7, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 6e-7, + "output_cost_per_token_above_128k_tokens": 0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text", "image"], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-exp-image-generation": { + "input_cost_per_token": 0.0, + "litellm_provider": "gemini", + "max_images_per_prompt": 3000, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "image_generation", + "output_cost_per_image": 0.039, + "output_cost_per_token": 0.0, + "source": "https://ai.google.dev/pricing", + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text", "image"], + "supports_vision": true + }, + "gemini-2.0-flash-lite": { + "cache_read_input_token_cost": 1.875e-8, + "deprecation_date": "2026-03-31", + "input_cost_per_audio_token": 7.5e-8, + "input_cost_per_token": 7.5e-8, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 50, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 3e-7, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-lite-001": { + "cache_read_input_token_cost": 1.875e-8, + "deprecation_date": "2026-03-31", + "input_cost_per_audio_token": 7.5e-8, + "input_cost_per_token": 7.5e-8, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 50, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 3e-7, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-live-preview-04-09": { + "cache_read_input_token_cost": 7.5e-8, + "input_cost_per_audio_token": 3e-6, + "input_cost_per_image": 3e-6, + "input_cost_per_token": 5e-7, + "input_cost_per_video_per_second": 3e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_audio_token": 1.2e-5, + "output_cost_per_token": 2e-6, + "rpm": 10, + "source": "https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/gemini#gemini-2-0-flash-live-preview-04-09", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini-2.0-flash-preview-image-generation": { + "cache_read_input_token_cost": 2.5e-8, + "deprecation_date": "2025-11-14", + "input_cost_per_audio_token": 7e-7, + "input_cost_per_token": 1e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 4e-7, + "source": "https://ai.google.dev/pricing#2_0flash", + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text", "image"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-thinking-exp": { + "cache_read_input_token_cost": 0.0, + "deprecation_date": "2025-12-02", + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text", "image"], + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-flash-thinking-exp-01-21": { + "cache_read_input_token_cost": 0.0, + "deprecation_date": "2025-12-02", + "input_cost_per_audio_per_second": 0, + "input_cost_per_audio_per_second_above_128k_tokens": 0, + "input_cost_per_character": 0, + "input_cost_per_character_above_128k_tokens": 0, + "input_cost_per_image": 0, + "input_cost_per_image_above_128k_tokens": 0, + "input_cost_per_token": 0, + "input_cost_per_token_above_128k_tokens": 0, + "input_cost_per_video_per_second": 0, + "input_cost_per_video_per_second_above_128k_tokens": 0, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_character_above_128k_tokens": 0, + "output_cost_per_token": 0, + "output_cost_per_token_above_128k_tokens": 0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-2.0-flash", + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text", "image"], + "supports_audio_output": false, + "supports_function_calling": false, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.0-pro-exp-02-05": { + "cache_read_input_token_cost": 3.125e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_200k_tokens": 2.5e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 2097152, + "max_output_tokens": 8192, + "max_pdf_size_mb": 30, + "max_tokens": 8192, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-computer-use-preview-10-2025": { + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_200k_tokens": 2.5e-6, + "litellm_provider": "vertex_ai-language-models", + "max_images_per_prompt": 3000, + "max_input_tokens": 128000, + "max_output_tokens": 64000, + "max_tokens": 64000, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/computer-use", + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-2.5-flash": { + "cache_read_input_token_cost": 3e-8, + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-6, + "output_cost_per_token": 2.5e-6, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-image": { + "cache_read_input_token_cost": 3e-8, + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 32768, + "max_output_tokens": 32768, + "max_pdf_size_mb": 30, + "max_tokens": 32768, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "image_generation", + "output_cost_per_image": 0.039, + "output_cost_per_image_token": 3e-5, + "output_cost_per_reasoning_token": 2.5e-6, + "output_cost_per_token": 2.5e-6, + "rpm": 100000, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-flash-image", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text", "image"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": false, + "tpm": 8000000 + }, + "gemini-2.5-flash-image-preview": { + "cache_read_input_token_cost": 7.5e-8, + "deprecation_date": "2026-01-15", + "input_cost_per_audio_token": 1e-6, + "input_cost_per_image_token": 3e-7, + "input_cost_per_token": 3e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "image_generation", + "output_cost_per_image": 0.039, + "output_cost_per_image_token": 3e-5, + "output_cost_per_reasoning_token": 3e-5, + "output_cost_per_token": 3e-5, + "rpm": 100000, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text", "image"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 8000000 + }, + "gemini-2.5-flash-lite": { + "cache_read_input_token_cost": 1e-8, + "input_cost_per_audio_token": 3e-7, + "input_cost_per_token": 1e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-7, + "output_cost_per_token": 4e-7, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-lite-preview-06-17": { + "cache_read_input_token_cost": 2.5e-8, + "deprecation_date": "2025-11-18", + "input_cost_per_audio_token": 5e-7, + "input_cost_per_token": 1e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-7, + "output_cost_per_token": 4e-7, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-lite-preview-09-2025": { + "cache_read_input_token_cost": 1e-8, + "input_cost_per_audio_token": 3e-7, + "input_cost_per_token": 1e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-7, + "output_cost_per_token": 4e-7, + "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-native-audio-latest": { + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "gemini", + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.5e-6, + "source": "https://ai.google.dev/pricing", + "supported_endpoints": ["/v1/realtime"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true + }, + "gemini-2.5-flash-native-audio-preview-09-2025": { + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "gemini", + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.5e-6, + "source": "https://ai.google.dev/pricing", + "supported_endpoints": ["/v1/realtime"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true + }, + "gemini-2.5-flash-native-audio-preview-12-2025": { + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "gemini", + "max_input_tokens": 1048576, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.5e-6, + "source": "https://ai.google.dev/pricing", + "supported_endpoints": ["/v1/realtime"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true + }, + "gemini-2.5-flash-preview-04-17": { + "cache_read_input_token_cost": 3.75e-8, + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 1.5e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 3.5e-6, + "output_cost_per_token": 6e-7, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-preview-05-20": { + "cache_read_input_token_cost": 7.5e-8, + "deprecation_date": "2025-11-18", + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-6, + "output_cost_per_token": 2.5e-6, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-preview-09-2025": { + "cache_read_input_token_cost": 7.5e-8, + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-6, + "output_cost_per_token": 2.5e-6, + "source": "https://developers.googleblog.com/en/continuing-to-bring-you-our-latest-models-with-an-improved-gemini-2-5-flash-and-flash-lite-release/", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-flash-preview-tts": { + "input_cost_per_token": 3e-7, + "litellm_provider": "gemini", + "mode": "audio_speech", + "output_cost_per_token": 2.5e-6, + "source": "https://ai.google.dev/pricing", + "supported_endpoints": ["/v1/audio/speech"] + }, + "gemini-2.5-pro": { + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_200k_tokens": 2.5e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro-exp-03-25": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_200k_tokens": 2.5e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro-preview-03-25": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, + "deprecation_date": "2025-12-02", + "input_cost_per_audio_token": 1.25e-6, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_200k_tokens": 2.5e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro-preview-05-06": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, + "deprecation_date": "2025-12-02", + "input_cost_per_audio_token": 1.25e-6, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_200k_tokens": 2.5e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supported_regions": ["global"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro-preview-06-05": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, + "input_cost_per_audio_token": 1.25e-6, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_200k_tokens": 2.5e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-2.5-pro-preview-tts": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, + "input_cost_per_audio_token": 7e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_200k_tokens": 2.5e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "source": "https://ai.google.dev/gemini-api/docs/pricing#gemini-2.5-pro-preview", + "supported_modalities": ["text"], + "supported_output_modalities": ["audio"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-3-flash": { + "cache_read_input_token_cost": 5e-8, + "cache_read_input_token_cost_priority": 9e-8, + "input_cost_per_audio_token": 1e-6, + "input_cost_per_audio_token_priority": 1.8e-6, + "input_cost_per_token": 5e-7, + "input_cost_per_token_priority": 9e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 3e-6, + "output_cost_per_token": 3e-6, + "output_cost_per_token_priority": 5.4e-6, + "source": "https://ai.google.dev/pricing/gemini-3", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-3-flash-preview": { + "cache_read_input_token_cost": 5e-8, + "cache_read_input_token_cost_priority": 9e-8, + "input_cost_per_audio_token": 1e-6, + "input_cost_per_audio_token_priority": 1.8e-6, + "input_cost_per_token": 5e-7, + "input_cost_per_token_priority": 9e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 3e-6, + "output_cost_per_token": 3e-6, + "output_cost_per_token_priority": 5.4e-6, + "source": "https://ai.google.dev/pricing/gemini-3", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-3-pro-image-preview": { + "input_cost_per_image": 0.0011, + "input_cost_per_token": 2e-6, + "input_cost_per_token_batches": 1e-6, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "image_generation", + "output_cost_per_image": 0.134, + "output_cost_per_image_token": 0.00012, + "output_cost_per_token": 1.2e-5, + "output_cost_per_token_batches": 6e-6, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text", "image"], + "supports_function_calling": false, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-3-pro-preview": { + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, + "cache_read_input_token_cost": 2e-7, + "cache_read_input_token_cost_above_200k_tokens": 4e-7, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-7, + "cache_read_input_token_cost_priority": 3.6e-7, + "input_cost_per_token": 2e-6, + "input_cost_per_token_above_200k_tokens": 4e-6, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-6, + "input_cost_per_token_batches": 1e-6, + "input_cost_per_token_priority": 3.6e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1.2e-5, + "output_cost_per_token_above_200k_tokens": 1.8e-5, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-5, + "output_cost_per_token_batches": 6e-6, + "output_cost_per_token_priority": 2.16e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-3.1-flash-image": { + "input_cost_per_image": 0.00056, + "input_cost_per_token": 5e-7, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "image_generation", + "output_cost_per_image": 0.0672, + "output_cost_per_image_token": 6e-5, + "output_cost_per_token": 3e-6, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text", "image"], + "supports_function_calling": false, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-3.1-flash-image-preview": { + "input_cost_per_image": 0.00056, + "input_cost_per_token": 5e-7, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 65536, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "image_generation", + "output_cost_per_image": 0.0672, + "output_cost_per_image_token": 6e-5, + "output_cost_per_token": 3e-6, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text", "image"], + "supports_function_calling": false, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-3.1-pro-high": { + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, + "cache_read_input_token_cost": 2e-7, + "cache_read_input_token_cost_above_200k_tokens": 4e-7, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-7, + "cache_read_input_token_cost_priority": 3.6e-7, + "input_cost_per_token": 2e-6, + "input_cost_per_token_above_200k_tokens": 4e-6, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-6, + "input_cost_per_token_batches": 1e-6, + "input_cost_per_token_priority": 3.6e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_image": 0.00012, + "output_cost_per_token": 1.2e-5, + "output_cost_per_token_above_200k_tokens": 1.8e-5, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-5, + "output_cost_per_token_batches": 6e-6, + "output_cost_per_token_priority": 2.16e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-3.1-pro-low": { + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, + "cache_read_input_token_cost": 2e-7, + "cache_read_input_token_cost_above_200k_tokens": 4e-7, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-7, + "cache_read_input_token_cost_priority": 3.6e-7, + "input_cost_per_token": 2e-6, + "input_cost_per_token_above_200k_tokens": 4e-6, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-6, + "input_cost_per_token_batches": 1e-6, + "input_cost_per_token_priority": 3.6e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_image": 0.00012, + "output_cost_per_token": 1.2e-5, + "output_cost_per_token_above_200k_tokens": 1.8e-5, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-5, + "output_cost_per_token_batches": 6e-6, + "output_cost_per_token_priority": 2.16e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-3.1-pro-preview": { + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, + "cache_read_input_token_cost": 2e-7, + "cache_read_input_token_cost_above_200k_tokens": 4e-7, + "cache_read_input_token_cost_above_200k_tokens_priority": 7.2e-7, + "cache_read_input_token_cost_priority": 3.6e-7, + "input_cost_per_token": 2e-6, + "input_cost_per_token_above_200k_tokens": 4e-6, + "input_cost_per_token_above_200k_tokens_priority": 7.2e-6, + "input_cost_per_token_batches": 1e-6, + "input_cost_per_token_priority": 3.6e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_image": 0.00012, + "output_cost_per_token": 1.2e-5, + "output_cost_per_token_above_200k_tokens": 1.8e-5, + "output_cost_per_token_above_200k_tokens_priority": 3.24e-5, + "output_cost_per_token_batches": 6e-6, + "output_cost_per_token_priority": 2.16e-5, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-3.1-pro-preview-customtools": { + "cache_creation_input_token_cost_above_200k_tokens": 2.5e-7, + "cache_read_input_token_cost": 2e-7, + "cache_read_input_token_cost_above_200k_tokens": 4e-7, + "input_cost_per_token": 2e-6, + "input_cost_per_token_above_200k_tokens": 4e-6, + "input_cost_per_token_batches": 1e-6, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65536, + "max_pdf_size_mb": 30, + "max_tokens": 65536, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_image": 0.00012, + "output_cost_per_token": 1.2e-5, + "output_cost_per_token_above_200k_tokens": 1.8e-5, + "output_cost_per_token_batches": 6e-6, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing#gemini-models", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-embedding-001": { + "input_cost_per_token": 1.5e-7, + "litellm_provider": "vertex_ai-embedding-models", + "max_input_tokens": 2048, + "max_tokens": 2048, + "mode": "embedding", + "output_cost_per_token": 0, + "output_vector_size": 3072, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models" + }, + "gemini-exp-1206": { + "cache_read_input_token_cost": 3e-8, + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-6, + "output_cost_per_token": 2.5e-6, + "rpm": 100000, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 8000000 + }, + "gemini-flash-experimental": { + "input_cost_per_character": 0, + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/gemini-experimental", + "supports_function_calling": false, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-flash-latest": { + "cache_read_input_token_cost": 3e-8, + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-6, + "output_cost_per_token": 2.5e-6, + "rpm": 100000, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 8000000 + }, + "gemini-flash-lite-latest": { + "cache_read_input_token_cost": 1e-8, + "input_cost_per_audio_token": 3e-7, + "input_cost_per_token": 1e-7, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_reasoning_token": 4e-7, + "output_cost_per_token": 4e-7, + "rpm": 15, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-lite", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions", "/v1/batch"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 250000 + }, + "gemini-live-2.5-flash-preview-native-audio-09-2025": { + "cache_read_input_token_cost": 7.5e-8, + "input_cost_per_audio_token": 3e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "vertex_ai-language-models", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_audio_token": 1.2e-5, + "output_cost_per_token": 2e-6, + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true, + "supports_web_search": true + }, + "gemini-pro": { + "input_cost_per_character": 1.25e-7, + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-7, + "input_cost_per_video_per_second": 0.002, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 32760, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 3.75e-7, + "output_cost_per_token": 1.5e-6, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-pro-experimental": { + "input_cost_per_character": 0, + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1000000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_character": 0, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/gemini-experimental", + "supports_function_calling": false, + "supports_parallel_function_calling": true, + "supports_tool_choice": true + }, + "gemini-pro-latest": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_above_200k_tokens": 2.5e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_above_200k_tokens": 2.5e-6, + "litellm_provider": "gemini", + "max_audio_length_hours": 8.4, + "max_audio_per_prompt": 1, + "max_images_per_prompt": 3000, + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_pdf_size_mb": 30, + "max_tokens": 65535, + "max_video_length": 1, + "max_videos_per_prompt": 10, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_above_200k_tokens": 1.5e-5, + "rpm": 2000, + "source": "https://cloud.google.com/vertex-ai/generative-ai/pricing", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions"], + "supported_modalities": ["text", "image", "audio", "video"], + "supported_output_modalities": ["text"], + "supports_audio_input": true, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_video_input": true, + "supports_vision": true, + "supports_web_search": true, + "tpm": 800000 + }, + "gemini-pro-vision": { + "input_cost_per_image": 0.0025, + "input_cost_per_token": 5e-7, + "litellm_provider": "vertex_ai-vision-models", + "max_images_per_prompt": 16, + "max_input_tokens": 16384, + "max_output_tokens": 2048, + "max_tokens": 2048, + "max_video_length": 2, + "max_videos_per_prompt": 1, + "mode": "chat", + "output_cost_per_token": 1.5e-6, + "source": "https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#foundation_models", + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gemini-robotics-er-1.5-preview": { + "cache_read_input_token_cost": 0, + "input_cost_per_audio_token": 1e-6, + "input_cost_per_token": 3e-7, + "litellm_provider": "vertex_ai-language-models", + "max_input_tokens": 1048576, + "max_output_tokens": 65535, + "max_tokens": 65535, + "mode": "chat", + "output_cost_per_reasoning_token": 2.5e-6, + "output_cost_per_token": 2.5e-6, + "source": "https://ai.google.dev/gemini-api/docs/models#gemini-robotics-er-1-5-preview", + "supported_endpoints": ["/v1/chat/completions", "/v1/completions"], + "supported_modalities": ["text", "image", "video", "audio"], + "supported_output_modalities": ["text"], + "supports_audio_output": false, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_url_context": true, + "supports_vision": true + }, + "gpt-3.5-turbo": { + "input_cost_per_token": 5e-7, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-6, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-0125": { + "input_cost_per_token": 5e-7, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-6, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-0301": { + "input_cost_per_token": 1.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 4097, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2e-6, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-0613": { + "input_cost_per_token": 1.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 4097, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2e-6, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-1106": { + "deprecation_date": "2026-09-28", + "input_cost_per_token": 1e-6, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 2e-6, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-16k": { + "input_cost_per_token": 3e-6, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 4e-6, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-16k-0613": { + "input_cost_per_token": 3e-6, + "litellm_provider": "openai", + "max_input_tokens": 16385, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 4e-6, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-3.5-turbo-instruct": { + "input_cost_per_token": 1.5e-6, + "litellm_provider": "text-completion-openai", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "completion", + "output_cost_per_token": 2e-6 + }, + "gpt-3.5-turbo-instruct-0914": { + "input_cost_per_token": 1.5e-6, + "litellm_provider": "text-completion-openai", + "max_input_tokens": 8192, + "max_output_tokens": 4097, + "max_tokens": 4097, + "mode": "completion", + "output_cost_per_token": 2e-6 + }, + "gpt-4": { + "input_cost_per_token": 3e-5, + "litellm_provider": "openai", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-5, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-0125-preview": { + "deprecation_date": "2026-03-26", + "input_cost_per_token": 1e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-0314": { + "input_cost_per_token": 3e-5, + "litellm_provider": "openai", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-5, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-0613": { + "deprecation_date": "2025-06-06", + "input_cost_per_token": 3e-5, + "litellm_provider": "openai", + "max_input_tokens": 8192, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 6e-5, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-1106-preview": { + "deprecation_date": "2026-03-26", + "input_cost_per_token": 1e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-1106-vision-preview": { + "deprecation_date": "2024-12-06", + "input_cost_per_token": 1e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-5, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4-32k": { + "input_cost_per_token": 6e-5, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00012, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-32k-0314": { + "input_cost_per_token": 6e-5, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00012, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-32k-0613": { + "input_cost_per_token": 6e-5, + "litellm_provider": "openai", + "max_input_tokens": 32768, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 0.00012, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-turbo": { + "input_cost_per_token": 1e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4-turbo-2024-04-09": { + "input_cost_per_token": 1e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4-turbo-preview": { + "input_cost_per_token": 1e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4-vision-preview": { + "deprecation_date": "2024-12-06", + "input_cost_per_token": 1e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 3e-5, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1": { + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_priority": 8.75e-7, + "input_cost_per_token": 2e-6, + "input_cost_per_token_batches": 1e-6, + "input_cost_per_token_priority": 3.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-6, + "output_cost_per_token_batches": 4e-6, + "output_cost_per_token_priority": 1.4e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1-2025-04-14": { + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 2e-6, + "input_cost_per_token_batches": 1e-6, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 8e-6, + "output_cost_per_token_batches": 4e-6, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1-mini": { + "cache_read_input_token_cost": 1e-7, + "cache_read_input_token_cost_priority": 1.75e-7, + "input_cost_per_token": 4e-7, + "input_cost_per_token_batches": 2e-7, + "input_cost_per_token_priority": 7e-7, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-6, + "output_cost_per_token_batches": 8e-7, + "output_cost_per_token_priority": 2.8e-6, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1-mini-2025-04-14": { + "cache_read_input_token_cost": 1e-7, + "input_cost_per_token": 4e-7, + "input_cost_per_token_batches": 2e-7, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 1.6e-6, + "output_cost_per_token_batches": 8e-7, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1-nano": { + "cache_read_input_token_cost": 2.5e-8, + "cache_read_input_token_cost_priority": 5e-8, + "input_cost_per_token": 1e-7, + "input_cost_per_token_batches": 5e-8, + "input_cost_per_token_priority": 2e-7, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-7, + "output_cost_per_token_batches": 2e-7, + "output_cost_per_token_priority": 8e-7, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.1-nano-2025-04-14": { + "cache_read_input_token_cost": 2.5e-8, + "input_cost_per_token": 1e-7, + "input_cost_per_token_batches": 5e-8, + "litellm_provider": "openai", + "max_input_tokens": 1047576, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 4e-7, + "output_cost_per_token_batches": 2e-7, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.5-preview": { + "cache_read_input_token_cost": 3.75e-5, + "input_cost_per_token": 7.5e-5, + "input_cost_per_token_batches": 3.75e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_batches": 7.5e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4.5-preview-2025-02-27": { + "cache_read_input_token_cost": 3.75e-5, + "deprecation_date": "2025-07-14", + "input_cost_per_token": 7.5e-5, + "input_cost_per_token_batches": 3.75e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 0.00015, + "output_cost_per_token_batches": 7.5e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o": { + "cache_read_input_token_cost": 1.25e-6, + "cache_read_input_token_cost_priority": 2.125e-6, + "input_cost_per_token": 2.5e-6, + "input_cost_per_token_batches": 1.25e-6, + "input_cost_per_token_priority": 4.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_batches": 5e-6, + "output_cost_per_token_priority": 1.7e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-2024-05-13": { + "input_cost_per_token": 5e-6, + "input_cost_per_token_batches": 2.5e-6, + "input_cost_per_token_priority": 8.75e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-5, + "output_cost_per_token_batches": 7.5e-6, + "output_cost_per_token_priority": 2.625e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-2024-08-06": { + "cache_read_input_token_cost": 1.25e-6, + "input_cost_per_token": 2.5e-6, + "input_cost_per_token_batches": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_batches": 5e-6, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-2024-11-20": { + "cache_read_input_token_cost": 1.25e-6, + "input_cost_per_token": 2.5e-6, + "input_cost_per_token_batches": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_batches": 5e-6, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-audio-preview": { + "input_cost_per_audio_token": 4e-5, + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 8e-5, + "output_cost_per_token": 1e-5, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-audio-preview-2024-10-01": { + "input_cost_per_audio_token": 4e-5, + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 8e-5, + "output_cost_per_token": 1e-5, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-audio-preview-2024-12-17": { + "input_cost_per_audio_token": 4e-5, + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 8e-5, + "output_cost_per_token": 1e-5, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-audio-preview-2025-06-03": { + "input_cost_per_audio_token": 4e-5, + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 8e-5, + "output_cost_per_token": 1e-5, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-mini": { + "cache_read_input_token_cost": 7.5e-8, + "cache_read_input_token_cost_priority": 1.25e-7, + "input_cost_per_token": 1.5e-7, + "input_cost_per_token_batches": 7.5e-8, + "input_cost_per_token_priority": 2.5e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6e-7, + "output_cost_per_token_batches": 3e-7, + "output_cost_per_token_priority": 1e-6, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-mini-2024-07-18": { + "cache_read_input_token_cost": 7.5e-8, + "input_cost_per_token": 1.5e-7, + "input_cost_per_token_batches": 7.5e-8, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6e-7, + "output_cost_per_token_batches": 3e-7, + "search_context_cost_per_query": { + "search_context_size_high": 0.03, + "search_context_size_low": 0.025, + "search_context_size_medium": 0.0275 + }, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-mini-audio-preview": { + "input_cost_per_audio_token": 1e-5, + "input_cost_per_token": 1.5e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 2e-5, + "output_cost_per_token": 6e-7, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-mini-audio-preview-2024-12-17": { + "input_cost_per_audio_token": 1e-5, + "input_cost_per_token": 1.5e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 2e-5, + "output_cost_per_token": 6e-7, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-mini-realtime-preview": { + "cache_creation_input_audio_token_cost": 3e-7, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_audio_token": 1e-5, + "input_cost_per_token": 6e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2e-5, + "output_cost_per_token": 2.4e-6, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-mini-realtime-preview-2024-12-17": { + "cache_creation_input_audio_token_cost": 3e-7, + "cache_read_input_token_cost": 3e-7, + "input_cost_per_audio_token": 1e-5, + "input_cost_per_token": 6e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2e-5, + "output_cost_per_token": 2.4e-6, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-mini-search-preview": { + "cache_read_input_token_cost": 7.5e-8, + "input_cost_per_token": 1.5e-7, + "input_cost_per_token_batches": 7.5e-8, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6e-7, + "output_cost_per_token_batches": 3e-7, + "search_context_cost_per_query": { + "search_context_size_high": 0.03, + "search_context_size_low": 0.025, + "search_context_size_medium": 0.0275 + }, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gpt-4o-mini-search-preview-2025-03-11": { + "cache_read_input_token_cost": 7.5e-8, + "input_cost_per_token": 1.5e-7, + "input_cost_per_token_batches": 7.5e-8, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 6e-7, + "output_cost_per_token_batches": 3e-7, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-mini-transcribe": { + "input_cost_per_audio_token": 3e-6, + "input_cost_per_token": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 16000, + "max_output_tokens": 2000, + "mode": "audio_transcription", + "output_cost_per_token": 5e-6, + "supported_endpoints": ["/v1/audio/transcriptions"] + }, + "gpt-4o-mini-transcribe-2025-03-20": { + "input_cost_per_audio_token": 3e-6, + "input_cost_per_token": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 16000, + "max_output_tokens": 2000, + "mode": "audio_transcription", + "output_cost_per_token": 5e-6, + "supported_endpoints": ["/v1/audio/transcriptions"] + }, + "gpt-4o-mini-transcribe-2025-12-15": { + "input_cost_per_audio_token": 3e-6, + "input_cost_per_token": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 16000, + "max_output_tokens": 2000, + "mode": "audio_transcription", + "output_cost_per_token": 5e-6, + "supported_endpoints": ["/v1/audio/transcriptions"] + }, + "gpt-4o-mini-tts": { + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "mode": "audio_speech", + "output_cost_per_audio_token": 1.2e-5, + "output_cost_per_second": 0.00025, + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/audio/speech"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["audio"] + }, + "gpt-4o-mini-tts-2025-03-20": { + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "mode": "audio_speech", + "output_cost_per_audio_token": 1.2e-5, + "output_cost_per_second": 0.00025, + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/audio/speech"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["audio"] + }, + "gpt-4o-mini-tts-2025-12-15": { + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "mode": "audio_speech", + "output_cost_per_audio_token": 1.2e-5, + "output_cost_per_second": 0.00025, + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/audio/speech"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["audio"] + }, + "gpt-4o-realtime-preview": { + "cache_read_input_token_cost": 2.5e-6, + "input_cost_per_audio_token": 4e-5, + "input_cost_per_token": 5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 8e-5, + "output_cost_per_token": 2e-5, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-realtime-preview-2024-10-01": { + "cache_creation_input_audio_token_cost": 2e-5, + "cache_read_input_token_cost": 2.5e-6, + "input_cost_per_audio_token": 0.0001, + "input_cost_per_token": 5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 0.0002, + "output_cost_per_token": 2e-5, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-realtime-preview-2024-12-17": { + "cache_read_input_token_cost": 2.5e-6, + "input_cost_per_audio_token": 4e-5, + "input_cost_per_token": 5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 8e-5, + "output_cost_per_token": 2e-5, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-realtime-preview-2025-06-03": { + "cache_read_input_token_cost": 2.5e-6, + "input_cost_per_audio_token": 4e-5, + "input_cost_per_token": 5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 8e-5, + "output_cost_per_token": 2e-5, + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-4o-search-preview": { + "cache_read_input_token_cost": 1.25e-6, + "input_cost_per_token": 2.5e-6, + "input_cost_per_token_batches": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_batches": 5e-6, + "search_context_cost_per_query": { + "search_context_size_high": 0.05, + "search_context_size_low": 0.03, + "search_context_size_medium": 0.035 + }, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gpt-4o-search-preview-2025-03-11": { + "cache_read_input_token_cost": 1.25e-6, + "input_cost_per_token": 2.5e-6, + "input_cost_per_token_batches": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_batches": 5e-6, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-4o-transcribe": { + "input_cost_per_audio_token": 6e-6, + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 16000, + "max_output_tokens": 2000, + "mode": "audio_transcription", + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/audio/transcriptions"] + }, + "gpt-4o-transcribe-diarize": { + "input_cost_per_audio_token": 6e-6, + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 16000, + "max_output_tokens": 2000, + "mode": "audio_transcription", + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/audio/transcriptions"] + }, + "gpt-5": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_flex": 6.25e-8, + "cache_read_input_token_cost_priority": 2.5e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_flex": 6.25e-7, + "input_cost_per_token_priority": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_flex": 5e-6, + "output_cost_per_token_priority": 2e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-2025-08-07": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_flex": 6.25e-8, + "cache_read_input_token_cost_priority": 2.5e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_flex": 6.25e-7, + "input_cost_per_token_priority": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_flex": 5e-6, + "output_cost_per_token_priority": 2e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-chat": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "gpt-5-chat-latest": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "gpt-5-codex": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-mini": { + "cache_read_input_token_cost": 2.5e-8, + "cache_read_input_token_cost_flex": 1.25e-8, + "cache_read_input_token_cost_priority": 4.5e-8, + "input_cost_per_token": 2.5e-7, + "input_cost_per_token_flex": 1.25e-7, + "input_cost_per_token_priority": 4.5e-7, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-6, + "output_cost_per_token_flex": 1e-6, + "output_cost_per_token_priority": 3.6e-6, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-mini-2025-08-07": { + "cache_read_input_token_cost": 2.5e-8, + "cache_read_input_token_cost_flex": 1.25e-8, + "cache_read_input_token_cost_priority": 4.5e-8, + "input_cost_per_token": 2.5e-7, + "input_cost_per_token_flex": 1.25e-7, + "input_cost_per_token_priority": 4.5e-7, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 2e-6, + "output_cost_per_token_flex": 1e-6, + "output_cost_per_token_priority": 3.6e-6, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-nano": { + "cache_read_input_token_cost": 5e-9, + "cache_read_input_token_cost_flex": 2.5e-9, + "input_cost_per_token": 5e-8, + "input_cost_per_token_flex": 2.5e-8, + "input_cost_per_token_priority": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4e-7, + "output_cost_per_token_flex": 2e-7, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-nano-2025-08-07": { + "cache_read_input_token_cost": 5e-9, + "cache_read_input_token_cost_flex": 2.5e-9, + "input_cost_per_token": 5e-8, + "input_cost_per_token_flex": 2.5e-8, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 4e-7, + "output_cost_per_token_flex": 2e-7, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5-pro": { + "input_cost_per_token": 1.5e-5, + "input_cost_per_token_batches": 7.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 272000, + "max_tokens": 272000, + "mode": "responses", + "output_cost_per_token": 0.00012, + "output_cost_per_token_batches": 6e-5, + "supported_endpoints": ["/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gpt-5-pro-2025-10-06": { + "input_cost_per_token": 1.5e-5, + "input_cost_per_token_batches": 7.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 272000, + "max_tokens": 272000, + "mode": "responses", + "output_cost_per_token": 0.00012, + "output_cost_per_token_batches": 6e-5, + "supported_endpoints": ["/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gpt-5-search-api": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gpt-5-search-api-2025-10-14": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gpt-5.1": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_priority": 2.5e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_priority": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_priority": 2e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text", "image"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5.1-2025-11-13": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_priority": 2.5e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_priority": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_priority": 2e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text", "image"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5.1-chat-latest": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_priority": 2.5e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_priority": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1e-5, + "output_cost_per_token_priority": 2e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text", "image"], + "supports_function_calling": false, + "supports_native_streaming": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": false, + "supports_vision": true + }, + "gpt-5.1-codex": { + "cache_read_input_token_cost": 1.25e-7, + "cache_read_input_token_cost_priority": 2.5e-7, + "input_cost_per_token": 1.25e-6, + "input_cost_per_token_priority": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1e-5, + "output_cost_per_token_priority": 2e-5, + "supported_endpoints": ["/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5.1-codex-max": { + "cache_read_input_token_cost": 1.25e-7, + "input_cost_per_token": 1.25e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5.1-codex-mini": { + "cache_read_input_token_cost": 2.5e-8, + "cache_read_input_token_cost_priority": 4.5e-8, + "input_cost_per_token": 2.5e-7, + "input_cost_per_token_priority": 4.5e-7, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 2e-6, + "output_cost_per_token_priority": 3.6e-6, + "supported_endpoints": ["/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5.2": { + "cache_read_input_token_cost": 1.75e-7, + "cache_read_input_token_cost_priority": 3.5e-7, + "input_cost_per_token": 1.75e-6, + "input_cost_per_token_priority": 3.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.4e-5, + "output_cost_per_token_priority": 2.8e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text", "image"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5.2-2025-12-11": { + "cache_read_input_token_cost": 1.75e-7, + "cache_read_input_token_cost_priority": 3.5e-7, + "input_cost_per_token": 1.75e-6, + "input_cost_per_token_priority": 3.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 1.4e-5, + "output_cost_per_token_priority": 2.8e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text", "image"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5.2-chat-latest": { + "cache_read_input_token_cost": 1.75e-7, + "cache_read_input_token_cost_priority": 3.5e-7, + "input_cost_per_token": 1.75e-6, + "input_cost_per_token_priority": 3.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_token": 1.4e-5, + "output_cost_per_token_priority": 2.8e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5.2-codex": { + "cache_read_input_token_cost": 1.75e-7, + "cache_read_input_token_cost_priority": 3.5e-7, + "input_cost_per_token": 1.75e-6, + "input_cost_per_token_priority": 3.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1.4e-5, + "output_cost_per_token_priority": 2.8e-5, + "supported_endpoints": ["/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5.2-pro": { + "input_cost_per_token": 2.1e-5, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 0.000168, + "supported_endpoints": ["/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gpt-5.2-pro-2025-12-11": { + "input_cost_per_token": 2.1e-5, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 0.000168, + "supported_endpoints": ["/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "gpt-5.3-codex": { + "cache_read_input_token_cost": 1.75e-7, + "cache_read_input_token_cost_priority": 3.5e-7, + "input_cost_per_token": 1.75e-6, + "input_cost_per_token_priority": 3.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1.4e-5, + "output_cost_per_token_priority": 2.8e-5, + "supported_endpoints": ["/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-5.3-codex-spark": { + "cache_read_input_token_cost": 1.75e-7, + "cache_read_input_token_cost_priority": 3.5e-7, + "input_cost_per_token": 1.75e-6, + "input_cost_per_token_priority": 3.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 272000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "responses", + "output_cost_per_token": 1.4e-5, + "output_cost_per_token_priority": 2.8e-5, + "supported_endpoints": ["/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": false, + "supports_tool_choice": true, + "supports_vision": true + }, + "gpt-audio": { + "input_cost_per_audio_token": 3.2e-5, + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 6.4e-5, + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/realtime", "/v1/batch"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "gpt-audio-1.5": { + "input_cost_per_audio_token": 3.2e-5, + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 6.4e-5, + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/chat/completions"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "gpt-audio-2025-08-28": { + "input_cost_per_audio_token": 3.2e-5, + "input_cost_per_token": 2.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 6.4e-5, + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/realtime", "/v1/batch"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "gpt-audio-mini": { + "input_cost_per_audio_token": 1e-5, + "input_cost_per_token": 6e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 2e-5, + "output_cost_per_token": 2.4e-6, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/realtime", "/v1/batch"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "gpt-audio-mini-2025-10-06": { + "input_cost_per_audio_token": 1e-5, + "input_cost_per_token": 6e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 2e-5, + "output_cost_per_token": 2.4e-6, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/realtime", "/v1/batch"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "gpt-audio-mini-2025-12-15": { + "input_cost_per_audio_token": 1e-5, + "input_cost_per_token": 6e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 16384, + "max_tokens": 16384, + "mode": "chat", + "output_cost_per_audio_token": 2e-5, + "output_cost_per_token": 2.4e-6, + "supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/realtime", "/v1/batch"], + "supported_modalities": ["text", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": false, + "supports_response_schema": false, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "gpt-image-1": { + "cache_read_input_image_token_cost": 2.5e-6, + "cache_read_input_token_cost": 1.25e-6, + "input_cost_per_image_token": 1e-5, + "input_cost_per_token": 5e-6, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_image_token": 4e-5, + "supported_endpoints": ["/v1/images/generations", "/v1/images/edits"] + }, + "gpt-image-1-mini": { + "cache_read_input_image_token_cost": 2.5e-7, + "cache_read_input_token_cost": 2e-7, + "input_cost_per_image_token": 2.5e-6, + "input_cost_per_token": 2e-6, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_image_token": 8e-6, + "supported_endpoints": ["/v1/images/generations", "/v1/images/edits"] + }, + "gpt-image-1.5": { + "cache_read_input_image_token_cost": 2e-6, + "cache_read_input_token_cost": 1.25e-6, + "input_cost_per_image_token": 8e-6, + "input_cost_per_token": 5e-6, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_image_token": 3.2e-5, + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/images/generations"], + "supports_pdf_input": true, + "supports_vision": true + }, + "gpt-image-1.5-2025-12-16": { + "cache_read_input_image_token_cost": 2e-6, + "cache_read_input_token_cost": 1.25e-6, + "input_cost_per_image_token": 8e-6, + "input_cost_per_token": 5e-6, + "litellm_provider": "openai", + "mode": "image_generation", + "output_cost_per_image_token": 3.2e-5, + "output_cost_per_token": 1e-5, + "supported_endpoints": ["/v1/images/generations"], + "supports_pdf_input": true, + "supports_vision": true + }, + "gpt-realtime": { + "cache_creation_input_audio_token_cost": 4e-7, + "cache_read_input_token_cost": 4e-7, + "input_cost_per_audio_token": 3.2e-5, + "input_cost_per_image": 5e-6, + "input_cost_per_token": 4e-6, + "litellm_provider": "openai", + "max_input_tokens": 32000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 6.4e-5, + "output_cost_per_token": 1.6e-5, + "supported_endpoints": ["/v1/realtime"], + "supported_modalities": ["text", "image", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-realtime-1.5": { + "cache_creation_input_audio_token_cost": 4e-7, + "cache_read_input_token_cost": 4e-7, + "input_cost_per_audio_token": 3.2e-5, + "input_cost_per_image": 5e-6, + "input_cost_per_token": 4e-6, + "litellm_provider": "openai", + "max_input_tokens": 32000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 6.4e-5, + "output_cost_per_token": 1.6e-5, + "supported_endpoints": ["/v1/realtime"], + "supported_modalities": ["text", "image", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-realtime-2025-08-28": { + "cache_creation_input_audio_token_cost": 4e-7, + "cache_read_input_token_cost": 4e-7, + "input_cost_per_audio_token": 3.2e-5, + "input_cost_per_image": 5e-6, + "input_cost_per_token": 4e-6, + "litellm_provider": "openai", + "max_input_tokens": 32000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 6.4e-5, + "output_cost_per_token": 1.6e-5, + "supported_endpoints": ["/v1/realtime"], + "supported_modalities": ["text", "image", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-realtime-mini": { + "cache_creation_input_audio_token_cost": 3e-7, + "cache_read_input_audio_token_cost": 3e-7, + "input_cost_per_audio_token": 1e-5, + "input_cost_per_token": 6e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2e-5, + "output_cost_per_token": 2.4e-6, + "supported_endpoints": ["/v1/realtime"], + "supported_modalities": ["text", "image", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-realtime-mini-2025-10-06": { + "cache_creation_input_audio_token_cost": 3e-7, + "cache_read_input_audio_token_cost": 3e-7, + "cache_read_input_token_cost": 6e-8, + "input_cost_per_audio_token": 1e-5, + "input_cost_per_image": 8e-7, + "input_cost_per_token": 6e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2e-5, + "output_cost_per_token": 2.4e-6, + "supported_endpoints": ["/v1/realtime"], + "supported_modalities": ["text", "image", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "gpt-realtime-mini-2025-12-15": { + "cache_creation_input_audio_token_cost": 3e-7, + "cache_read_input_audio_token_cost": 3e-7, + "cache_read_input_token_cost": 6e-8, + "input_cost_per_audio_token": 1e-5, + "input_cost_per_image": 8e-7, + "input_cost_per_token": 6e-7, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_audio_token": 2e-5, + "output_cost_per_token": 2.4e-6, + "supported_endpoints": ["/v1/realtime"], + "supported_modalities": ["text", "image", "audio"], + "supported_output_modalities": ["text", "audio"], + "supports_audio_input": true, + "supports_audio_output": true, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "o1-2024-12-17": { + "cache_read_input_token_cost": 7.5e-6, + "input_cost_per_token": 1.5e-5, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 6e-5, + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o1-mini": { + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 1.1e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4.4e-6, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_vision": true + }, + "o1-mini-2024-09-12": { + "cache_read_input_token_cost": 1.5e-6, + "deprecation_date": "2025-10-27", + "input_cost_per_token": 3e-6, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 1.2e-5, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": true + }, + "o1-preview": { + "cache_read_input_token_cost": 7.5e-6, + "input_cost_per_token": 1.5e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-5, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": true + }, + "o1-preview-2024-09-12": { + "cache_read_input_token_cost": 7.5e-6, + "input_cost_per_token": 1.5e-5, + "litellm_provider": "openai", + "max_input_tokens": 128000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 6e-5, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_vision": true + }, + "o1-pro": { + "input_cost_per_token": 0.00015, + "input_cost_per_token_batches": 7.5e-5, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 0.0006, + "output_cost_per_token_batches": 0.0003, + "supported_endpoints": ["/v1/responses", "/v1/batch"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o1-pro-2025-03-19": { + "input_cost_per_token": 0.00015, + "input_cost_per_token_batches": 7.5e-5, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 0.0006, + "output_cost_per_token_batches": 0.0003, + "supported_endpoints": ["/v1/responses", "/v1/batch"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3": { + "cache_read_input_token_cost": 5e-7, + "cache_read_input_token_cost_flex": 2.5e-7, + "cache_read_input_token_cost_priority": 8.75e-7, + "input_cost_per_token": 2e-6, + "input_cost_per_token_flex": 1e-6, + "input_cost_per_token_priority": 3.5e-6, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 8e-6, + "output_cost_per_token_flex": 4e-6, + "output_cost_per_token_priority": 1.4e-5, + "supported_endpoints": [ + "/v1/responses", + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3-2025-04-16": { + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 2e-6, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 8e-6, + "supported_endpoints": [ + "/v1/responses", + "/v1/chat/completions", + "/v1/completions", + "/v1/batch" + ], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3-deep-research": { + "cache_read_input_token_cost": 2.5e-6, + "input_cost_per_token": 1e-5, + "input_cost_per_token_batches": 5e-6, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 4e-5, + "output_cost_per_token_batches": 2e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3-deep-research-2025-06-26": { + "cache_read_input_token_cost": 2.5e-6, + "input_cost_per_token": 1e-5, + "input_cost_per_token_batches": 5e-6, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 4e-5, + "output_cost_per_token_batches": 2e-5, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3-mini": { + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 1.1e-6, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-6, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "o3-mini-2025-01-31": { + "cache_read_input_token_cost": 5.5e-7, + "input_cost_per_token": 1.1e-6, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-6, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "o3-pro": { + "input_cost_per_token": 2e-5, + "input_cost_per_token_batches": 1e-5, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-5, + "output_cost_per_token_batches": 4e-5, + "supported_endpoints": ["/v1/responses", "/v1/batch"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o3-pro-2025-06-10": { + "input_cost_per_token": 2e-5, + "input_cost_per_token_batches": 1e-5, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-5, + "output_cost_per_token_batches": 4e-5, + "supported_endpoints": ["/v1/responses", "/v1/batch"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o4-mini": { + "cache_read_input_token_cost": 2.75e-7, + "cache_read_input_token_cost_flex": 1.375e-7, + "cache_read_input_token_cost_priority": 5e-7, + "input_cost_per_token": 1.1e-6, + "input_cost_per_token_flex": 5.5e-7, + "input_cost_per_token_priority": 2e-6, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-6, + "output_cost_per_token_flex": 2.2e-6, + "output_cost_per_token_priority": 8e-6, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o4-mini-2025-04-16": { + "cache_read_input_token_cost": 2.75e-7, + "input_cost_per_token": 1.1e-6, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "chat", + "output_cost_per_token": 4.4e-6, + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_service_tier": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o4-mini-deep-research": { + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 2e-6, + "input_cost_per_token_batches": 1e-6, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-6, + "output_cost_per_token_batches": 4e-6, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "o4-mini-deep-research-2025-06-26": { + "cache_read_input_token_cost": 5e-7, + "input_cost_per_token": 2e-6, + "input_cost_per_token_batches": 1e-6, + "litellm_provider": "openai", + "max_input_tokens": 200000, + "max_output_tokens": 100000, + "max_tokens": 100000, + "mode": "responses", + "output_cost_per_token": 8e-6, + "output_cost_per_token_batches": 4e-6, + "supported_endpoints": ["/v1/chat/completions", "/v1/batch", "/v1/responses"], + "supported_modalities": ["text", "image"], + "supported_output_modalities": ["text"], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + } +} diff --git a/scripts/analyze-log-sessions.js b/scripts/analyze-log-sessions.js new file mode 100644 index 0000000..da76521 --- /dev/null +++ b/scripts/analyze-log-sessions.js @@ -0,0 +1,606 @@ +#!/usr/bin/env node + +/** + * 从日志文件分析Claude账户请求时间的CLI工具 + * 用于恢复会话窗口数据 + */ + +const fs = require('fs') +const path = require('path') +const readline = require('readline') +const zlib = require('zlib') +const redis = require('../src/models/redis') + +class LogSessionAnalyzer { + constructor() { + // 更新正则表达式以匹配实际的日志格式 + this.accountUsagePattern = + /🎯 Using sticky session shared account: (.+?) \(([a-f0-9-]{36})\) for session ([a-f0-9]+)/ + this.processingPattern = + /📡 Processing streaming API request with usage capture for key: (.+?), account: ([a-f0-9-]{36}), session: ([a-f0-9]+)/ + this.completedPattern = /🔗 ✅ Request completed in (\d+)ms for key: (.+)/ + this.usageRecordedPattern = + /🔗 📊 Stream usage recorded \(real\) - Model: (.+?), Input: (\d+), Output: (\d+), Cache Create: (\d+), Cache Read: (\d+), Total: (\d+) tokens/ + this.timestampPattern = /\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/ + this.accounts = new Map() + this.requestHistory = [] + this.sessions = new Map() // 记录会话信息 + } + + // 解析时间戳 + parseTimestamp(line) { + const match = line.match(this.timestampPattern) + if (match) { + return new Date(match[1]) + } + return null + } + + // 分析单个日志文件 + async analyzeLogFile(filePath) { + console.log(`📖 分析日志文件: ${filePath}`) + + let fileStream = fs.createReadStream(filePath) + + // 如果是gz文件,需要先解压 + if (filePath.endsWith('.gz')) { + console.log(' 🗜️ 检测到gz压缩文件,正在解压...') + fileStream = fileStream.pipe(zlib.createGunzip()) + } + + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }) + + let lineCount = 0 + let requestCount = 0 + let usageCount = 0 + + for await (const line of rl) { + lineCount++ + + // 解析时间戳 + const timestamp = this.parseTimestamp(line) + if (!timestamp) { + continue + } + + // 查找账户使用记录 + const accountUsageMatch = line.match(this.accountUsagePattern) + if (accountUsageMatch) { + const accountName = accountUsageMatch[1] + const accountId = accountUsageMatch[2] + const sessionId = accountUsageMatch[3] + + if (!this.accounts.has(accountId)) { + this.accounts.set(accountId, { + accountId, + accountName, + requests: [], + firstRequest: timestamp, + lastRequest: timestamp, + totalRequests: 0, + sessions: new Set() + }) + } + + const account = this.accounts.get(accountId) + account.sessions.add(sessionId) + + if (timestamp < account.firstRequest) { + account.firstRequest = timestamp + } + if (timestamp > account.lastRequest) { + account.lastRequest = timestamp + } + } + + // 查找请求处理记录 + const processingMatch = line.match(this.processingPattern) + if (processingMatch) { + const apiKeyName = processingMatch[1] + const accountId = processingMatch[2] + const sessionId = processingMatch[3] + + if (!this.accounts.has(accountId)) { + this.accounts.set(accountId, { + accountId, + accountName: 'Unknown', + requests: [], + firstRequest: timestamp, + lastRequest: timestamp, + totalRequests: 0, + sessions: new Set() + }) + } + + const account = this.accounts.get(accountId) + account.requests.push({ + timestamp, + apiKeyName, + sessionId, + type: 'processing' + }) + + account.sessions.add(sessionId) + account.totalRequests++ + requestCount++ + + if (timestamp > account.lastRequest) { + account.lastRequest = timestamp + } + + // 记录到全局请求历史 + this.requestHistory.push({ + timestamp, + accountId, + apiKeyName, + sessionId, + type: 'processing' + }) + } + + // 查找请求完成记录 + const completedMatch = line.match(this.completedPattern) + if (completedMatch) { + const duration = parseInt(completedMatch[1]) + const apiKeyName = completedMatch[2] + + // 记录到全局请求历史 + this.requestHistory.push({ + timestamp, + apiKeyName, + duration, + type: 'completed' + }) + } + + // 查找使用统计记录 + const usageMatch = line.match(this.usageRecordedPattern) + if (usageMatch) { + const model = usageMatch[1] + const inputTokens = parseInt(usageMatch[2]) + const outputTokens = parseInt(usageMatch[3]) + const cacheCreateTokens = parseInt(usageMatch[4]) + const cacheReadTokens = parseInt(usageMatch[5]) + const totalTokens = parseInt(usageMatch[6]) + + usageCount++ + + // 记录到全局请求历史 + this.requestHistory.push({ + timestamp, + type: 'usage', + model, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens + }) + } + } + + console.log( + ` 📊 解析完成: ${lineCount} 行, 找到 ${requestCount} 个请求记录, ${usageCount} 个使用统计` + ) + } + + // 分析日志目录中的所有文件 + async analyzeLogDirectory(logDir = './logs') { + console.log(`🔍 扫描日志目录: ${logDir}\n`) + + try { + const files = fs.readdirSync(logDir) + const logFiles = files + .filter( + (file) => + file.includes('claude-relay') && + (file.endsWith('.log') || + file.endsWith('.log.1') || + file.endsWith('.log.gz') || + file.match(/\.log\.\d+\.gz$/) || + file.match(/\.log\.\d+$/)) + ) + .sort() + .reverse() // 最新的文件优先 + + if (logFiles.length === 0) { + console.log('❌ 没有找到日志文件') + return + } + + console.log(`📁 找到 ${logFiles.length} 个日志文件:`) + logFiles.forEach((file) => console.log(` - ${file}`)) + console.log('') + + // 分析每个文件 + for (const file of logFiles) { + const filePath = path.join(logDir, file) + await this.analyzeLogFile(filePath) + } + } catch (error) { + console.error(`❌ 读取日志目录失败: ${error.message}`) + throw error + } + } + + // 分析单个日志文件(支持直接传入文件路径) + async analyzeSingleFile(filePath) { + console.log(`🔍 分析单个日志文件: ${filePath}\n`) + + try { + if (!fs.existsSync(filePath)) { + console.log('❌ 文件不存在') + return + } + + await this.analyzeLogFile(filePath) + } catch (error) { + console.error(`❌ 分析文件失败: ${error.message}`) + throw error + } + } + + // 计算会话窗口 + calculateSessionWindow(requestTime) { + const hour = requestTime.getHours() + const windowStartHour = Math.floor(hour / 5) * 5 + + const windowStart = new Date(requestTime) + windowStart.setHours(windowStartHour, 0, 0, 0) + + const windowEnd = new Date(windowStart) + windowEnd.setHours(windowEnd.getHours() + 5) + + return { windowStart, windowEnd } + } + + // 分析会话窗口 + analyzeSessionWindows() { + console.log('🕐 分析会话窗口...\n') + + const now = new Date() + const results = [] + + for (const [accountId, accountData] of this.accounts) { + const requests = accountData.requests.sort((a, b) => a.timestamp - b.timestamp) + + // 按会话窗口分组请求 + const windowGroups = new Map() + + for (const request of requests) { + const { windowStart, windowEnd } = this.calculateSessionWindow(request.timestamp) + const windowKey = `${windowStart.getTime()}-${windowEnd.getTime()}` + + if (!windowGroups.has(windowKey)) { + windowGroups.set(windowKey, { + windowStart, + windowEnd, + requests: [], + isActive: now >= windowStart && now < windowEnd + }) + } + + windowGroups.get(windowKey).requests.push(request) + } + + // 转换为数组并排序 + const windowArray = Array.from(windowGroups.values()).sort( + (a, b) => b.windowStart - a.windowStart + ) // 最新的窗口优先 + + const result = { + accountId, + accountName: accountData.accountName, + totalRequests: accountData.totalRequests, + firstRequest: accountData.firstRequest, + lastRequest: accountData.lastRequest, + sessions: accountData.sessions, + windows: windowArray, + currentActiveWindow: windowArray.find((w) => w.isActive) || null, + mostRecentWindow: windowArray[0] || null + } + + results.push(result) + } + + return results.sort((a, b) => b.lastRequest - a.lastRequest) + } + + // 显示分析结果 + displayResults(results) { + console.log('📊 分析结果:\n') + console.log('='.repeat(80)) + + for (const result of results) { + console.log(`🏢 账户: ${result.accountName || 'Unknown'} (${result.accountId})`) + console.log(` 总请求数: ${result.totalRequests}`) + console.log(` 会话数: ${result.sessions ? result.sessions.size : 0}`) + console.log(` 首次请求: ${result.firstRequest.toLocaleString()}`) + console.log(` 最后请求: ${result.lastRequest.toLocaleString()}`) + + if (result.currentActiveWindow) { + console.log( + ` ✅ 当前活跃窗口: ${result.currentActiveWindow.windowStart.toLocaleString()} - ${result.currentActiveWindow.windowEnd.toLocaleString()}` + ) + console.log(` 窗口内请求: ${result.currentActiveWindow.requests.length} 次`) + const progress = this.calculateWindowProgress( + result.currentActiveWindow.windowStart, + result.currentActiveWindow.windowEnd + ) + console.log(` 窗口进度: ${progress}%`) + } else if (result.mostRecentWindow) { + const window = result.mostRecentWindow + console.log( + ` ⏰ 最近窗口(已过期): ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}` + ) + console.log(` 窗口内请求: ${window.requests.length} 次`) + const hoursAgo = Math.round((new Date() - window.windowEnd) / (1000 * 60 * 60)) + console.log(` 过期时间: ${hoursAgo} 小时前`) + } else { + console.log(' ❌ 无会话窗口数据') + } + + // 显示最近几个窗口 + if (result.windows.length > 1) { + console.log(` 📈 历史窗口: ${result.windows.length} 个`) + const recentWindows = result.windows.slice(0, 3) + for (let i = 0; i < recentWindows.length; i++) { + const window = recentWindows[i] + const status = window.isActive ? '活跃' : '已过期' + console.log( + ` ${i + 1}. ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()} (${status}, ${window.requests.length}次请求)` + ) + } + } + + // 显示最近几个会话的API Key使用情况 + const accountData = this.accounts.get(result.accountId) + if (accountData && accountData.requests && accountData.requests.length > 0) { + const apiKeyStats = {} + + for (const req of accountData.requests) { + if (!apiKeyStats[req.apiKeyName]) { + apiKeyStats[req.apiKeyName] = 0 + } + apiKeyStats[req.apiKeyName]++ + } + + console.log(' 🔑 API Key使用统计:') + for (const [keyName, count] of Object.entries(apiKeyStats)) { + console.log(` - ${keyName}: ${count} 次`) + } + } + + console.log('') + } + + console.log('='.repeat(80)) + console.log(`总计: ${results.length} 个账户, ${this.requestHistory.length} 个日志记录\n`) + } + + // 计算窗口进度百分比 + calculateWindowProgress(windowStart, windowEnd) { + const now = new Date() + const totalDuration = windowEnd.getTime() - windowStart.getTime() + const elapsedTime = now.getTime() - windowStart.getTime() + return Math.max(0, Math.min(100, Math.round((elapsedTime / totalDuration) * 100))) + } + + // 更新Redis中的会话窗口数据 + async updateRedisSessionWindows(results, dryRun = true) { + if (dryRun) { + console.log('🧪 模拟模式 - 不会实际更新Redis数据\n') + } else { + console.log('💾 更新Redis中的会话窗口数据...\n') + await redis.connect() + } + + let updatedCount = 0 + let skippedCount = 0 + + for (const result of results) { + try { + const accountData = await redis.getClaudeAccount(result.accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + console.log(`⚠️ 账户 ${result.accountId} 在Redis中不存在,跳过`) + skippedCount++ + continue + } + + console.log(`🔄 处理账户: ${accountData.name || result.accountId}`) + + // 确定要设置的会话窗口 + let targetWindow = null + + if (result.currentActiveWindow) { + targetWindow = result.currentActiveWindow + console.log( + ` ✅ 使用当前活跃窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}` + ) + } else if (result.mostRecentWindow) { + const window = result.mostRecentWindow + const now = new Date() + + // 如果最近窗口是在过去24小时内的,可以考虑恢复 + const hoursSinceWindow = (now - window.windowEnd) / (1000 * 60 * 60) + + if (hoursSinceWindow <= 24) { + console.log( + ` 🕐 最近窗口在24小时内,但已过期: ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}` + ) + console.log(` ❌ 不恢复已过期窗口(${hoursSinceWindow.toFixed(1)}小时前过期)`) + } else { + console.log(' ⏰ 最近窗口超过24小时前,不予恢复') + } + } + + if (targetWindow && !dryRun) { + // 更新Redis中的会话窗口数据 + accountData.sessionWindowStart = targetWindow.windowStart.toISOString() + accountData.sessionWindowEnd = targetWindow.windowEnd.toISOString() + accountData.lastUsedAt = result.lastRequest.toISOString() + accountData.lastRequestTime = result.lastRequest.toISOString() + + await redis.setClaudeAccount(result.accountId, accountData) + updatedCount++ + + console.log(' ✅ 已更新会话窗口数据') + } else if (targetWindow) { + updatedCount++ + console.log( + ` 🧪 [模拟] 将设置会话窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}` + ) + } else { + skippedCount++ + console.log(' ⏭️ 跳过(无有效窗口)') + } + + console.log('') + } catch (error) { + console.error(`❌ 处理账户 ${result.accountId} 时出错: ${error.message}`) + skippedCount++ + } + } + + if (!dryRun) { + await redis.disconnect() + } + + console.log('📊 更新结果:') + console.log(` ✅ 已更新: ${updatedCount}`) + console.log(` ⏭️ 已跳过: ${skippedCount}`) + console.log(` 📋 总计: ${results.length}`) + } + + // 主分析函数 + async analyze(options = {}) { + const { logDir = './logs', singleFile = null, updateRedis = false, dryRun = true } = options + + try { + console.log('🔍 Claude账户会话窗口分析工具\n') + + // 分析日志文件 + if (singleFile) { + await this.analyzeSingleFile(singleFile) + } else { + await this.analyzeLogDirectory(logDir) + } + + if (this.accounts.size === 0) { + console.log('❌ 没有找到任何Claude账户的请求记录') + return [] + } + + // 分析会话窗口 + const results = this.analyzeSessionWindows() + + // 显示结果 + this.displayResults(results) + + // 更新Redis(如果需要) + if (updateRedis) { + await this.updateRedisSessionWindows(results, dryRun) + } + + return results + } catch (error) { + console.error('❌ 分析失败:', error) + throw error + } + } +} + +// 命令行参数解析 +function parseArgs() { + const args = process.argv.slice(2) + const options = { + logDir: './logs', + singleFile: null, + updateRedis: false, + dryRun: true + } + + for (const arg of args) { + if (arg.startsWith('--log-dir=')) { + options.logDir = arg.split('=')[1] + } else if (arg.startsWith('--file=')) { + options.singleFile = arg.split('=')[1] + } else if (arg === '--update-redis') { + options.updateRedis = true + } else if (arg === '--no-dry-run') { + options.dryRun = false + } else if (arg === '--help' || arg === '-h') { + showHelp() + process.exit(0) + } + } + + return options +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Claude账户会话窗口日志分析工具 + +从日志文件中分析Claude账户的请求时间,计算会话窗口,并可选择性地更新Redis数据。 + +用法: + node scripts/analyze-log-sessions.js [选项] + +选项: + --log-dir=PATH 日志文件目录 (默认: ./logs) + --file=PATH 分析单个日志文件 + --update-redis 更新Redis中的会话窗口数据 + --no-dry-run 实际执行Redis更新(默认为模拟模式) + --help, -h 显示此帮助信息 + +示例: + # 分析默认日志目录 + node scripts/analyze-log-sessions.js + + # 分析指定目录的日志 + node scripts/analyze-log-sessions.js --log-dir=/path/to/logs + + # 分析单个日志文件 + node scripts/analyze-log-sessions.js --file=/path/to/logfile.log + + # 模拟更新Redis数据(不实际更新) + node scripts/analyze-log-sessions.js --file=/path/to/logfile.log --update-redis + + # 实际更新Redis数据 + node scripts/analyze-log-sessions.js --file=/path/to/logfile.log --update-redis --no-dry-run + +会话窗口规则: + - Claude官方规定每5小时为一个会话窗口 + - 窗口按整点对齐(如 05:00-10:00, 10:00-15:00) + - 只有当前时间在窗口内的才被认为是活跃窗口 + - 工具会自动识别并恢复活跃的会话窗口 +`) +} + +// 主函数 +async function main() { + try { + const options = parseArgs() + + const analyzer = new LogSessionAnalyzer() + await analyzer.analyze(options) + + console.log('🎉 分析完成') + } catch (error) { + console.error('💥 程序执行失败:', error) + process.exit(1) + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + main() +} + +module.exports = LogSessionAnalyzer diff --git a/scripts/check-redis-keys.js b/scripts/check-redis-keys.js new file mode 100644 index 0000000..93d70fb --- /dev/null +++ b/scripts/check-redis-keys.js @@ -0,0 +1,53 @@ +/** + * 检查 Redis 中的所有键 + */ + +const redis = require('../src/models/redis') + +async function checkRedisKeys() { + console.log('🔍 检查 Redis 中的所有键...\n') + + try { + // 确保 Redis 已连接 + await redis.connect() + + // 获取所有键 + const allKeys = await redis.client.keys('*') + console.log(`找到 ${allKeys.length} 个键\n`) + + // 按类型分组 + const keysByType = {} + + allKeys.forEach((key) => { + const prefix = key.split(':')[0] + if (!keysByType[prefix]) { + keysByType[prefix] = [] + } + keysByType[prefix].push(key) + }) + + // 显示各类型的键 + Object.keys(keysByType) + .sort() + .forEach((type) => { + console.log(`\n📁 ${type}: ${keysByType[type].length} 个`) + + // 显示前 5 个键作为示例 + const keysToShow = keysByType[type].slice(0, 5) + keysToShow.forEach((key) => { + console.log(` - ${key}`) + }) + + if (keysByType[type].length > 5) { + console.log(` ... 还有 ${keysByType[type].length - 5} 个`) + } + }) + } catch (error) { + console.error('❌ 错误:', error) + console.error(error.stack) + } finally { + process.exit(0) + } +} + +checkRedisKeys() diff --git a/scripts/data-transfer-enhanced.js b/scripts/data-transfer-enhanced.js new file mode 100644 index 0000000..3c8655b --- /dev/null +++ b/scripts/data-transfer-enhanced.js @@ -0,0 +1,1304 @@ +#!/usr/bin/env node + +/** + * 增强版数据导出/导入工具 + * 支持加密数据的处理 + */ + +const fs = require('fs').promises +const crypto = require('crypto') +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') +const readline = require('readline') +const config = require('../config/config') + +// 解析命令行参数 +const args = process.argv.slice(2) +const command = args[0] +const params = {} + +args.slice(1).forEach((arg) => { + const [key, value] = arg.split('=') + params[key.replace('--', '')] = value || true +}) + +// 创建 readline 接口 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}) + +async function askConfirmation(question) { + return new Promise((resolve) => { + rl.question(`${question} (yes/no): `, (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') + }) + }) +} + +// Claude 账户解密函数 +function decryptClaudeData(encryptedData) { + if (!encryptedData || !config.security.encryptionKey) { + return encryptedData + } + + try { + if (encryptedData.includes(':')) { + const parts = encryptedData.split(':') + const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32) + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted + } + return encryptedData + } catch (error) { + logger.warn(`⚠️ Failed to decrypt data: ${error.message}`) + return encryptedData + } +} + +// Gemini 账户解密函数 +function decryptGeminiData(encryptedData) { + if (!encryptedData || !config.security.encryptionKey) { + return encryptedData + } + + try { + if (encryptedData.includes(':')) { + const parts = encryptedData.split(':') + const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32) + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted + } + return encryptedData + } catch (error) { + logger.warn(`⚠️ Failed to decrypt data: ${error.message}`) + return encryptedData + } +} + +// API Key 哈希函数(与apiKeyService保持一致) +function hashApiKey(apiKey) { + if (!apiKey || !config.security.encryptionKey) { + return apiKey + } + + return crypto + .createHash('sha256') + .update(apiKey + config.security.encryptionKey) + .digest('hex') +} + +// 检查是否为明文API Key(通过格式判断,不依赖前缀) +function isPlaintextApiKey(apiKey) { + if (!apiKey || typeof apiKey !== 'string') { + return false + } + + // SHA256哈希值固定为64个十六进制字符,如果是哈希值则返回false + if (apiKey.length === 64 && /^[a-f0-9]+$/i.test(apiKey)) { + return false // 已经是哈希值 + } + + // 其他情况都认为是明文API Key(包括sk-ant-、cr_、自定义前缀等) + return true +} + +// 数据加密函数(用于导入) +function encryptClaudeData(data) { + if (!data || !config.security.encryptionKey) { + return data + } + + const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32) + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` +} + +function encryptGeminiData(data) { + if (!data || !config.security.encryptionKey) { + return data + } + + const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32) + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv('aes-256-cbc', key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` +} + +// 导出使用统计数据 +async function exportUsageStats(keyId) { + try { + const stats = { + total: {}, + daily: {}, + monthly: {}, + hourly: {}, + models: {}, + // 费用统计(String 类型) + costTotal: null, + costDaily: {}, + costMonthly: {}, + costHourly: {}, + opusTotal: null, + opusWeekly: {} + } + + // 导出总统计(Hash) + const totalData = await redis.client.hgetall(`usage:${keyId}`) + if (totalData && Object.keys(totalData).length > 0) { + stats.total = totalData + } + + // 导出费用总统计(String) + const costTotal = await redis.client.get(`usage:cost:total:${keyId}`) + if (costTotal) { + stats.costTotal = costTotal + } + + // 导出 Opus 费用总统计(String) + const opusTotal = await redis.client.get(`usage:opus:total:${keyId}`) + if (opusTotal) { + stats.opusTotal = opusTotal + } + + // 导出每日统计(扫描现有 key,避免时区问题) + const dailyKeys = await redis.client.keys(`usage:daily:${keyId}:*`) + for (const key of dailyKeys) { + const date = key.split(':').pop() + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + stats.daily[date] = data + } + } + + // 导出每日费用(扫描现有 key) + const costDailyKeys = await redis.client.keys(`usage:cost:daily:${keyId}:*`) + for (const key of costDailyKeys) { + const date = key.split(':').pop() + const value = await redis.client.get(key) + if (value) { + stats.costDaily[date] = value + } + } + + // 导出每月统计(扫描现有 key) + const monthlyKeys = await redis.client.keys(`usage:monthly:${keyId}:*`) + for (const key of monthlyKeys) { + const month = key.split(':').pop() + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + stats.monthly[month] = data + } + } + + // 导出每月费用(扫描现有 key) + const costMonthlyKeys = await redis.client.keys(`usage:cost:monthly:${keyId}:*`) + for (const key of costMonthlyKeys) { + const month = key.split(':').pop() + const value = await redis.client.get(key) + if (value) { + stats.costMonthly[month] = value + } + } + + // 导出 Opus 周费用(扫描现有 key) + const opusWeeklyKeys = await redis.client.keys(`usage:opus:weekly:${keyId}:*`) + for (const key of opusWeeklyKeys) { + const week = key.split(':').pop() + const value = await redis.client.get(key) + if (value) { + stats.opusWeekly[week] = value + } + } + + // 导出小时统计(扫描现有 key) + // key 格式: usage:hourly:{keyId}:{YYYY-MM-DD}:{HH} + const hourlyKeys = await redis.client.keys(`usage:hourly:${keyId}:*`) + for (const key of hourlyKeys) { + const parts = key.split(':') + const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + stats.hourly[hourKey] = data + } + } + + // 导出小时费用(扫描现有 key) + // key 格式: usage:cost:hourly:{keyId}:{YYYY-MM-DD}:{HH} + const costHourlyKeys = await redis.client.keys(`usage:cost:hourly:${keyId}:*`) + for (const key of costHourlyKeys) { + const parts = key.split(':') + const hourKey = `${parts[parts.length - 2]}:${parts[parts.length - 1]}` // YYYY-MM-DD:HH + const value = await redis.client.get(key) + if (value) { + stats.costHourly[hourKey] = value + } + } + + // 导出模型统计(每日) + const modelDailyKeys = await redis.client.keys(`usage:${keyId}:model:daily:*`) + for (const key of modelDailyKeys) { + const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + const model = match[1] + const date = match[2] + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!stats.models[model]) { + stats.models[model] = { daily: {}, monthly: {} } + } + stats.models[model].daily[date] = data + } + } + } + + // 导出模型统计(每月) + const modelMonthlyKeys = await redis.client.keys(`usage:${keyId}:model:monthly:*`) + for (const key of modelMonthlyKeys) { + const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/) + if (match) { + const model = match[1] + const month = match[2] + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!stats.models[model]) { + stats.models[model] = { daily: {}, monthly: {} } + } + stats.models[model].monthly[month] = data + } + } + } + + return stats + } catch (error) { + logger.warn(`⚠️ Failed to export usage stats for ${keyId}: ${error.message}`) + return null + } +} + +// 导入使用统计数据 +async function importUsageStats(keyId, stats) { + try { + if (!stats) { + return + } + + const pipeline = redis.client.pipeline() + let importCount = 0 + + // 导入总统计(Hash) + if (stats.total && Object.keys(stats.total).length > 0) { + for (const [field, value] of Object.entries(stats.total)) { + pipeline.hset(`usage:${keyId}`, field, value) + } + importCount++ + } + + // 导入费用总统计(String) + if (stats.costTotal) { + pipeline.set(`usage:cost:total:${keyId}`, stats.costTotal) + importCount++ + } + + // 导入 Opus 费用总统计(String) + if (stats.opusTotal) { + pipeline.set(`usage:opus:total:${keyId}`, stats.opusTotal) + importCount++ + } + + // 导入每日统计(Hash) + if (stats.daily) { + for (const [date, data] of Object.entries(stats.daily)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:daily:${keyId}:${date}`, field, value) + } + importCount++ + } + } + + // 导入每日费用(String) + if (stats.costDaily) { + for (const [date, value] of Object.entries(stats.costDaily)) { + pipeline.set(`usage:cost:daily:${keyId}:${date}`, value) + importCount++ + } + } + + // 导入每月统计(Hash) + if (stats.monthly) { + for (const [month, data] of Object.entries(stats.monthly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:monthly:${keyId}:${month}`, field, value) + } + importCount++ + } + } + + // 导入每月费用(String) + if (stats.costMonthly) { + for (const [month, value] of Object.entries(stats.costMonthly)) { + pipeline.set(`usage:cost:monthly:${keyId}:${month}`, value) + importCount++ + } + } + + // 导入 Opus 周费用(String,不加 TTL 保留历史全量) + if (stats.opusWeekly) { + for (const [week, value] of Object.entries(stats.opusWeekly)) { + pipeline.set(`usage:opus:weekly:${keyId}:${week}`, value) + importCount++ + } + } + + // 导入小时统计(Hash) + if (stats.hourly) { + for (const [hour, data] of Object.entries(stats.hourly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:hourly:${keyId}:${hour}`, field, value) + } + importCount++ + } + } + + // 导入小时费用(String) + if (stats.costHourly) { + for (const [hour, value] of Object.entries(stats.costHourly)) { + pipeline.set(`usage:cost:hourly:${keyId}:${hour}`, value) + importCount++ + } + } + + // 导入模型统计(Hash) + if (stats.models) { + for (const [model, modelStats] of Object.entries(stats.models)) { + if (modelStats.daily) { + for (const [date, data] of Object.entries(modelStats.daily)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:${keyId}:model:daily:${model}:${date}`, field, value) + } + importCount++ + } + } + + if (modelStats.monthly) { + for (const [month, data] of Object.entries(modelStats.monthly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:${keyId}:model:monthly:${model}:${month}`, field, value) + } + importCount++ + } + } + } + } + + await pipeline.exec() + logger.info(` 📊 Imported ${importCount} usage stat entries for API Key ${keyId}`) + } catch (error) { + logger.warn(`⚠️ Failed to import usage stats for ${keyId}: ${error.message}`) + } +} + +// 数据脱敏函数 +function sanitizeData(data, type) { + const sanitized = { ...data } + + switch (type) { + case 'apikey': + if (sanitized.apiKey) { + sanitized.apiKey = `${sanitized.apiKey.substring(0, 10)}...[REDACTED]` + } + break + + case 'claude_account': + if (sanitized.email) { + sanitized.email = '[REDACTED]' + } + if (sanitized.password) { + sanitized.password = '[REDACTED]' + } + if (sanitized.accessToken) { + sanitized.accessToken = '[REDACTED]' + } + if (sanitized.refreshToken) { + sanitized.refreshToken = '[REDACTED]' + } + if (sanitized.claudeAiOauth) { + sanitized.claudeAiOauth = '[REDACTED]' + } + if (sanitized.proxyPassword) { + sanitized.proxyPassword = '[REDACTED]' + } + break + + case 'gemini_account': + if (sanitized.geminiOauth) { + sanitized.geminiOauth = '[REDACTED]' + } + if (sanitized.accessToken) { + sanitized.accessToken = '[REDACTED]' + } + if (sanitized.refreshToken) { + sanitized.refreshToken = '[REDACTED]' + } + if (sanitized.proxyPassword) { + sanitized.proxyPassword = '[REDACTED]' + } + break + + case 'admin': + if (sanitized.password) { + sanitized.password = '[REDACTED]' + } + break + } + + return sanitized +} + +// 导出数据 +async function exportData() { + try { + const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json` + const types = params.types ? params.types.split(',') : ['all'] + const shouldSanitize = params.sanitize === true + const shouldDecrypt = params.decrypt !== false // 默认解密 + + logger.info('🔄 Starting data export...') + logger.info(`📁 Output file: ${outputFile}`) + logger.info(`📋 Data types: ${types.join(', ')}`) + logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`) + logger.info(`🔓 Decrypt data: ${shouldDecrypt ? 'YES' : 'NO'}`) + + await redis.connect() + logger.success('✅ Connected to Redis') + + const exportDataObj = { + metadata: { + version: '2.0', + exportDate: new Date().toISOString(), + sanitized: shouldSanitize, + decrypted: shouldDecrypt, + types + }, + data: {} + } + + // 导出 API Keys + if (types.includes('all') || types.includes('apikeys')) { + logger.info('📤 Exporting API Keys...') + const keys = await redis.client.keys('apikey:*') + const apiKeys = [] + + for (const key of keys) { + if (key === 'apikey:hash_map') { + continue + } + + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + // 获取该 API Key 的 ID + const keyId = data.id + + // 导出使用统计数据 + if (keyId && (types.includes('all') || types.includes('stats'))) { + data.usageStats = await exportUsageStats(keyId) + } + + apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data) + } + } + + exportDataObj.data.apiKeys = apiKeys + logger.success(`✅ Exported ${apiKeys.length} API Keys`) + } + + // 导出 Claude 账户 + if (types.includes('all') || types.includes('accounts')) { + logger.info('📤 Exporting Claude accounts...') + const keys = await redis.client.keys('claude:account:*') + logger.info(`Found ${keys.length} Claude account keys in Redis`) + const accounts = [] + + for (const key of keys) { + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + // 解密敏感字段 + if (shouldDecrypt && !shouldSanitize) { + if (data.email) { + data.email = decryptClaudeData(data.email) + } + if (data.password) { + data.password = decryptClaudeData(data.password) + } + if (data.accessToken) { + data.accessToken = decryptClaudeData(data.accessToken) + } + if (data.refreshToken) { + data.refreshToken = decryptClaudeData(data.refreshToken) + } + if (data.claudeAiOauth) { + const decrypted = decryptClaudeData(data.claudeAiOauth) + try { + data.claudeAiOauth = JSON.parse(decrypted) + } catch (e) { + data.claudeAiOauth = decrypted + } + } + } + + accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data) + } + } + + exportDataObj.data.claudeAccounts = accounts + logger.success(`✅ Exported ${accounts.length} Claude accounts`) + + // 导出 Gemini 账户 + logger.info('📤 Exporting Gemini accounts...') + const geminiKeys = await redis.client.keys('gemini_account:*') + logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`) + const geminiAccounts = [] + + for (const key of geminiKeys) { + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + // 解密敏感字段 + if (shouldDecrypt && !shouldSanitize) { + if (data.geminiOauth) { + const decrypted = decryptGeminiData(data.geminiOauth) + try { + data.geminiOauth = JSON.parse(decrypted) + } catch (e) { + data.geminiOauth = decrypted + } + } + if (data.accessToken) { + data.accessToken = decryptGeminiData(data.accessToken) + } + if (data.refreshToken) { + data.refreshToken = decryptGeminiData(data.refreshToken) + } + } + + geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data) + } + } + + exportDataObj.data.geminiAccounts = geminiAccounts + logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`) + } + + // 导出管理员 + if (types.includes('all') || types.includes('admins')) { + logger.info('📤 Exporting admins...') + const keys = await redis.client.keys('admin:*') + const admins = [] + + for (const key of keys) { + if (key.includes('admin_username:')) { + continue + } + + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data) + } + } + + exportDataObj.data.admins = admins + logger.success(`✅ Exported ${admins.length} admins`) + } + + // 导出全局模型统计(如果需要) + if (types.includes('all') || types.includes('stats')) { + logger.info('📤 Exporting global model statistics...') + const globalStats = { + daily: {}, + monthly: {}, + hourly: {}, + // 新增:索引和全局统计 + monthlyMonths: [], // usage:model:monthly:months Set + globalTotal: null, // usage:global:total Hash + globalDaily: {}, // usage:global:daily:* Hash + globalMonthly: {} // usage:global:monthly:* Hash + } + + // 导出月份索引 + const monthlyMonths = await redis.client.smembers('usage:model:monthly:months') + if (monthlyMonths && monthlyMonths.length > 0) { + globalStats.monthlyMonths = monthlyMonths + logger.info(`📤 Found ${monthlyMonths.length} months in index`) + } + + // 导出全局统计 + const globalTotal = await redis.client.hgetall('usage:global:total') + if (globalTotal && Object.keys(globalTotal).length > 0) { + globalStats.globalTotal = globalTotal + logger.info('📤 Found global total stats') + } + + // 导出全局每日统计 + const globalDailyKeys = await redis.client.keys('usage:global:daily:*') + for (const key of globalDailyKeys) { + const date = key.replace('usage:global:daily:', '') + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + globalStats.globalDaily[date] = data + } + } + logger.info(`📤 Found ${Object.keys(globalStats.globalDaily).length} global daily stats`) + + // 导出全局每月统计 + const globalMonthlyKeys = await redis.client.keys('usage:global:monthly:*') + for (const key of globalMonthlyKeys) { + const month = key.replace('usage:global:monthly:', '') + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + globalStats.globalMonthly[month] = data + } + } + logger.info(`📤 Found ${Object.keys(globalStats.globalMonthly).length} global monthly stats`) + + // 导出全局每日模型统计 + const modelDailyPattern = 'usage:model:daily:*' + const modelDailyKeys = await redis.client.keys(modelDailyPattern) + for (const key of modelDailyKeys) { + const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + const model = match[1] + const date = match[2] + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!globalStats.daily[date]) { + globalStats.daily[date] = {} + } + globalStats.daily[date][model] = data + } + } + } + + // 导出全局每月模型统计 + const modelMonthlyPattern = 'usage:model:monthly:*' + const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern) + for (const key of modelMonthlyKeys) { + const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/) + if (match) { + const model = match[1] + const month = match[2] + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!globalStats.monthly[month]) { + globalStats.monthly[month] = {} + } + globalStats.monthly[month][model] = data + } + } + } + + // 导出全局每小时模型统计 + const globalHourlyPattern = 'usage:model:hourly:*' + const globalHourlyKeys = await redis.client.keys(globalHourlyPattern) + for (const key of globalHourlyKeys) { + const match = key.match(/usage:model:hourly:(.+):(\d{4}-\d{2}-\d{2}:\d{2})$/) + if (match) { + const model = match[1] + const hour = match[2] + const data = await redis.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!globalStats.hourly[hour]) { + globalStats.hourly[hour] = {} + } + globalStats.hourly[hour][model] = data + } + } + } + + exportDataObj.data.globalModelStats = globalStats + logger.success('✅ Exported global model statistics') + } + + // 写入文件 + await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2)) + + // 显示导出摘要 + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Export Complete!') + console.log('='.repeat(60)) + console.log(`Output file: ${outputFile}`) + console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`) + + if (exportDataObj.data.apiKeys) { + console.log(`API Keys: ${exportDataObj.data.apiKeys.length}`) + } + if (exportDataObj.data.claudeAccounts) { + console.log(`Claude Accounts: ${exportDataObj.data.claudeAccounts.length}`) + } + if (exportDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts: ${exportDataObj.data.geminiAccounts.length}`) + } + if (exportDataObj.data.admins) { + console.log(`Admins: ${exportDataObj.data.admins.length}`) + } + console.log('='.repeat(60)) + + if (shouldSanitize) { + logger.warn('⚠️ Sensitive data has been sanitized in this export.') + } + if (shouldDecrypt) { + logger.info('🔓 Encrypted data has been decrypted for portability.') + } + } catch (error) { + logger.error('💥 Export failed:', error) + process.exit(1) + } finally { + await redis.disconnect() + rl.close() + } +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Enhanced Data Transfer Tool for Claude Relay Service + +This tool handles encrypted data export/import between environments. + +Usage: + node scripts/data-transfer-enhanced.js [options] + +Commands: + export Export data from Redis to a JSON file + import Import data from a JSON file to Redis + +Export Options: + --output=FILE Output filename (default: backup-YYYY-MM-DD.json) + --types=TYPE,... Data types: apikeys,accounts,admins,stats,all (default: all) + stats: Include usage statistics with API keys + --sanitize Remove sensitive data from export + --decrypt=false Keep data encrypted (default: true - decrypt for portability) + +Import Options: + --input=FILE Input filename (required) + --force Overwrite existing data without asking + --skip-conflicts Skip conflicting data without asking + +Important Notes: + - The tool automatically handles encryption/decryption during import + - If importing decrypted data, it will be re-encrypted automatically + - If importing encrypted data, it will be stored as-is + - Sanitized exports cannot be properly imported (missing sensitive data) + - Automatic handling of plaintext API Keys + * Uses your configured API_KEY_PREFIX from config (sk-, cr_, etc.) + * Automatically detects plaintext vs hashed API Keys by format + * Plaintext API Keys are automatically hashed during import + * Hash mappings are created correctly for plaintext keys + * Supports custom prefixes and legacy format detection + * No manual conversion needed - just import your backup file + +Examples: + # Export all data with decryption (for migration) + node scripts/data-transfer-enhanced.js export + + # Export without decrypting (for backup) + node scripts/data-transfer-enhanced.js export --decrypt=false + + # Import data (auto-handles encryption and plaintext API keys) + node scripts/data-transfer-enhanced.js import --input=backup.json + + # Import with force overwrite + node scripts/data-transfer-enhanced.js import --input=backup.json --force +`) +} + +// 导入数据 +async function importData() { + try { + const inputFile = params.input + if (!inputFile) { + logger.error('❌ Please specify input file with --input=filename.json') + process.exit(1) + } + + const forceOverwrite = params.force === true + const skipConflicts = params['skip-conflicts'] === true + + logger.info('🔄 Starting data import...') + logger.info(`📁 Input file: ${inputFile}`) + logger.info( + `⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT'}` + ) + + // 读取文件 + const fileContent = await fs.readFile(inputFile, 'utf8') + const importDataObj = JSON.parse(fileContent) + + // 验证文件格式 + if (!importDataObj.metadata || !importDataObj.data) { + logger.error('❌ Invalid backup file format') + process.exit(1) + } + + logger.info(`📅 Backup date: ${importDataObj.metadata.exportDate}`) + logger.info(`🔒 Sanitized: ${importDataObj.metadata.sanitized ? 'YES' : 'NO'}`) + logger.info(`🔓 Decrypted: ${importDataObj.metadata.decrypted ? 'YES' : 'NO'}`) + + if (importDataObj.metadata.sanitized) { + logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!') + const proceed = await askConfirmation('Continue with sanitized data?') + if (!proceed) { + logger.info('❌ Import cancelled') + return + } + } + + // 显示导入摘要 + console.log(`\n${'='.repeat(60)}`) + console.log('📋 Import Summary:') + console.log('='.repeat(60)) + if (importDataObj.data.apiKeys) { + console.log(`API Keys to import: ${importDataObj.data.apiKeys.length}`) + } + if (importDataObj.data.claudeAccounts) { + console.log(`Claude Accounts to import: ${importDataObj.data.claudeAccounts.length}`) + } + if (importDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts to import: ${importDataObj.data.geminiAccounts.length}`) + } + if (importDataObj.data.admins) { + console.log(`Admins to import: ${importDataObj.data.admins.length}`) + } + console.log(`${'='.repeat(60)}\n`) + + // 确认导入 + const confirmed = await askConfirmation('⚠️ Proceed with import?') + if (!confirmed) { + logger.info('❌ Import cancelled') + return + } + + // 连接 Redis + await redis.connect() + logger.success('✅ Connected to Redis') + + const stats = { + imported: 0, + skipped: 0, + errors: 0 + } + + // 导入 API Keys + if (importDataObj.data.apiKeys) { + logger.info('\n📥 Importing API Keys...') + for (const apiKey of importDataObj.data.apiKeys) { + try { + const exists = await redis.client.exists(`apikey:${apiKey.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 保存使用统计数据以便单独导入 + const { usageStats } = apiKey + + // 从apiKey对象中删除usageStats字段,避免存储到主键中 + const apiKeyData = { ...apiKey } + delete apiKeyData.usageStats + + // 检查并处理API Key哈希 + let plainTextApiKey = null + let hashedApiKey = null + + if (apiKeyData.apiKey && isPlaintextApiKey(apiKeyData.apiKey)) { + // 如果是明文API Key,保存明文并计算哈希 + plainTextApiKey = apiKeyData.apiKey + hashedApiKey = hashApiKey(plainTextApiKey) + logger.info(`🔐 Detected plaintext API Key for: ${apiKey.name} (${apiKey.id})`) + } else if (apiKeyData.apiKey) { + // 如果已经是哈希值,直接使用 + hashedApiKey = apiKeyData.apiKey + logger.info(`🔍 Using existing hashed API Key for: ${apiKey.name} (${apiKey.id})`) + } + + // API Key字段始终存储哈希值 + if (hashedApiKey) { + apiKeyData.apiKey = hashedApiKey + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(apiKeyData)) { + pipeline.hset(`apikey:${apiKey.id}`, field, value) + } + await pipeline.exec() + + // 更新哈希映射:hash_map的key必须是哈希值 + if (!importDataObj.metadata.sanitized && hashedApiKey) { + await redis.client.hset('apikey:hash_map', hashedApiKey, apiKey.id) + logger.info( + `📝 Updated hash mapping: ${hashedApiKey.substring(0, 8)}... -> ${apiKey.id}` + ) + } + + // 导入使用统计数据 + if (usageStats) { + await importUsageStats(apiKey.id, usageStats) + } + + logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入 Claude 账户 + if (importDataObj.data.claudeAccounts) { + logger.info('\n📥 Importing Claude accounts...') + for (const account of importDataObj.data.claudeAccounts) { + try { + const exists = await redis.client.exists(`claude:account:${account.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `Claude account "${account.name}" (${account.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 复制账户数据以避免修改原始数据 + const accountData = { ...account } + + // 如果数据已解密且不是脱敏数据,需要重新加密 + if (importDataObj.metadata.decrypted && !importDataObj.metadata.sanitized) { + logger.info(`🔐 Re-encrypting sensitive data for Claude account: ${account.name}`) + + if (accountData.email) { + accountData.email = encryptClaudeData(accountData.email) + } + if (accountData.password) { + accountData.password = encryptClaudeData(accountData.password) + } + if (accountData.accessToken) { + accountData.accessToken = encryptClaudeData(accountData.accessToken) + } + if (accountData.refreshToken) { + accountData.refreshToken = encryptClaudeData(accountData.refreshToken) + } + if (accountData.claudeAiOauth) { + // 如果是对象,先序列化再加密 + const oauthStr = + typeof accountData.claudeAiOauth === 'object' + ? JSON.stringify(accountData.claudeAiOauth) + : accountData.claudeAiOauth + accountData.claudeAiOauth = encryptClaudeData(oauthStr) + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(accountData)) { + if (field === 'claudeAiOauth' && typeof value === 'object') { + // 确保对象被序列化 + pipeline.hset(`claude:account:${account.id}`, field, JSON.stringify(value)) + } else { + pipeline.hset(`claude:account:${account.id}`, field, value) + } + } + await pipeline.exec() + + logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入 Gemini 账户 + if (importDataObj.data.geminiAccounts) { + logger.info('\n📥 Importing Gemini accounts...') + for (const account of importDataObj.data.geminiAccounts) { + try { + const exists = await redis.client.exists(`gemini_account:${account.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `Gemini account "${account.name}" (${account.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 复制账户数据以避免修改原始数据 + const accountData = { ...account } + + // 如果数据已解密且不是脱敏数据,需要重新加密 + if (importDataObj.metadata.decrypted && !importDataObj.metadata.sanitized) { + logger.info(`🔐 Re-encrypting sensitive data for Gemini account: ${account.name}`) + + if (accountData.geminiOauth) { + const oauthStr = + typeof accountData.geminiOauth === 'object' + ? JSON.stringify(accountData.geminiOauth) + : accountData.geminiOauth + accountData.geminiOauth = encryptGeminiData(oauthStr) + } + if (accountData.accessToken) { + accountData.accessToken = encryptGeminiData(accountData.accessToken) + } + if (accountData.refreshToken) { + accountData.refreshToken = encryptGeminiData(accountData.refreshToken) + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(accountData)) { + pipeline.hset(`gemini_account:${account.id}`, field, value) + } + await pipeline.exec() + + logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入管理员账户 + if (importDataObj.data.admins) { + logger.info('\n📥 Importing admins...') + for (const admin of importDataObj.data.admins) { + try { + const exists = await redis.client.exists(`admin:${admin.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing admin: ${admin.username} (${admin.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `Admin "${admin.username}" (${admin.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(admin)) { + pipeline.hset(`admin:${admin.id}`, field, value) + } + await pipeline.exec() + + // 更新用户名映射 + await redis.client.set(`admin_username:${admin.username}`, admin.id) + + logger.success(`✅ Imported admin: ${admin.username} (${admin.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import admin ${admin.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入全局模型统计 + if (importDataObj.data.globalModelStats) { + logger.info('\n📥 Importing global model statistics...') + try { + const globalStats = importDataObj.data.globalModelStats + const pipeline = redis.client.pipeline() + let globalStatCount = 0 + + // 导入月份索引 + if (globalStats.monthlyMonths && globalStats.monthlyMonths.length > 0) { + for (const month of globalStats.monthlyMonths) { + pipeline.sadd('usage:model:monthly:months', month) + } + logger.info(`📥 Importing ${globalStats.monthlyMonths.length} months to index`) + } + + // 导入全局统计 + if (globalStats.globalTotal) { + for (const [field, value] of Object.entries(globalStats.globalTotal)) { + pipeline.hset('usage:global:total', field, value) + } + logger.info('📥 Importing global total stats') + } + + // 导入全局每日统计 + if (globalStats.globalDaily) { + for (const [date, data] of Object.entries(globalStats.globalDaily)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:global:daily:${date}`, field, value) + } + } + logger.info( + `📥 Importing ${Object.keys(globalStats.globalDaily).length} global daily stats` + ) + } + + // 导入全局每月统计 + if (globalStats.globalMonthly) { + for (const [month, data] of Object.entries(globalStats.globalMonthly)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:global:monthly:${month}`, field, value) + } + } + logger.info( + `📥 Importing ${Object.keys(globalStats.globalMonthly).length} global monthly stats` + ) + } + + // 导入每日统计 + if (globalStats.daily) { + for (const [date, models] of Object.entries(globalStats.daily)) { + for (const [model, data] of Object.entries(models)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:model:daily:${model}:${date}`, field, value) + } + globalStatCount++ + } + } + } + + // 导入每月统计 + if (globalStats.monthly) { + for (const [month, models] of Object.entries(globalStats.monthly)) { + for (const [model, data] of Object.entries(models)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:model:monthly:${model}:${month}`, field, value) + } + globalStatCount++ + } + // 同时更新月份索引(兼容旧格式导出文件) + pipeline.sadd('usage:model:monthly:months', month) + } + } + + // 导入每小时统计 + if (globalStats.hourly) { + for (const [hour, models] of Object.entries(globalStats.hourly)) { + for (const [model, data] of Object.entries(models)) { + for (const [field, value] of Object.entries(data)) { + pipeline.hset(`usage:model:hourly:${model}:${hour}`, field, value) + } + globalStatCount++ + } + } + } + + await pipeline.exec() + logger.success(`✅ Imported ${globalStatCount} global model stat entries`) + stats.imported += globalStatCount + } catch (error) { + logger.error('❌ Failed to import global model stats:', error.message) + stats.errors++ + } + } + + // 显示导入结果 + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Import Complete!') + console.log('='.repeat(60)) + console.log(`Successfully imported: ${stats.imported}`) + console.log(`Skipped: ${stats.skipped}`) + console.log(`Errors: ${stats.errors}`) + console.log('='.repeat(60)) + } catch (error) { + logger.error('💥 Import failed:', error) + process.exit(1) + } finally { + await redis.disconnect() + rl.close() + } +} + +// 主函数 +async function main() { + if (!command || command === '--help' || command === 'help') { + showHelp() + process.exit(0) + } + + switch (command) { + case 'export': + await exportData() + break + + case 'import': + await importData() + break + + default: + logger.error(`❌ Unknown command: ${command}`) + showHelp() + process.exit(1) + } +} + +// 运行 +main().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/data-transfer.js b/scripts/data-transfer.js new file mode 100644 index 0000000..39ca10d --- /dev/null +++ b/scripts/data-transfer.js @@ -0,0 +1,738 @@ +#!/usr/bin/env node + +/** + * 数据导出/导入工具 + * + * 使用方法: + * 导出: node scripts/data-transfer.js export --output=backup.json [options] + * 导入: node scripts/data-transfer.js import --input=backup.json [options] + * + * 选项: + * --types: 要导出/导入的数据类型(apikeys,accounts,admins,all) + * --sanitize: 导出时脱敏敏感数据 + * --force: 导入时强制覆盖已存在的数据 + * --skip-conflicts: 导入时跳过冲突的数据 + */ + +const fs = require('fs').promises +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') +const readline = require('readline') + +// 解析命令行参数 +const args = process.argv.slice(2) +const command = args[0] +const params = {} + +args.slice(1).forEach((arg) => { + const [key, value] = arg.split('=') + params[key.replace('--', '')] = value || true +}) + +// 创建 readline 接口 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}) + +async function askConfirmation(question) { + return new Promise((resolve) => { + rl.question(`${question} (yes/no): `, (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') + }) + }) +} + +// 数据脱敏函数 +function sanitizeData(data, type) { + const sanitized = { ...data } + + switch (type) { + case 'apikey': + // 隐藏 API Key 的大部分内容 + if (sanitized.apiKey) { + sanitized.apiKey = `${sanitized.apiKey.substring(0, 10)}...[REDACTED]` + } + break + + case 'claude_account': + case 'gemini_account': + // 隐藏 OAuth tokens + if (sanitized.accessToken) { + sanitized.accessToken = '[REDACTED]' + } + if (sanitized.refreshToken) { + sanitized.refreshToken = '[REDACTED]' + } + if (sanitized.claudeAiOauth) { + sanitized.claudeAiOauth = '[REDACTED]' + } + // 隐藏代理密码 + if (sanitized.proxyPassword) { + sanitized.proxyPassword = '[REDACTED]' + } + break + + case 'admin': + // 隐藏管理员密码 + if (sanitized.password) { + sanitized.password = '[REDACTED]' + } + break + } + + return sanitized +} + +// CSV 字段映射配置 +const CSV_FIELD_MAPPING = { + // 基本信息 + id: 'ID', + name: '名称', + description: '描述', + isActive: '状态', + createdAt: '创建时间', + lastUsedAt: '最后使用时间', + createdBy: '创建者', + + // API Key 信息 + apiKey: 'API密钥', + tokenLimit: '令牌限制', + + // 过期设置 + expirationMode: '过期模式', + expiresAt: '过期时间', + activationDays: '激活天数', + activationUnit: '激活单位', + isActivated: '已激活', + activatedAt: '激活时间', + + // 权限设置 + permissions: '服务权限', + + // 限制设置 + rateLimitWindow: '速率窗口(分钟)', + rateLimitRequests: '请求次数限制', + rateLimitCost: '费用限制(美元)', + concurrencyLimit: '并发限制', + dailyCostLimit: '日费用限制(美元)', + totalCostLimit: '总费用限制(美元)', + weeklyOpusCostLimit: '周Claude费用限制(美元)', + + // 账户绑定 + claudeAccountId: 'Claude专属账户', + claudeConsoleAccountId: 'Claude控制台账户', + geminiAccountId: 'Gemini专属账户', + openaiAccountId: 'OpenAI专属账户', + azureOpenaiAccountId: 'Azure OpenAI专属账户', + bedrockAccountId: 'Bedrock专属账户', + + // 限制配置 + enableModelRestriction: '启用模型限制', + restrictedModels: '限制的模型', + enableClientRestriction: '启用客户端限制', + allowedClients: '允许的客户端', + + // 标签和用户 + tags: '标签', + userId: '用户ID', + userUsername: '用户名', + + // 其他信息 + icon: '图标' +} + +// 数据格式化函数 +function formatCSVValue(key, value, shouldSanitize = false) { + if (!value || value === '' || value === 'null' || value === 'undefined') { + return '' + } + + switch (key) { + case 'apiKey': + if (shouldSanitize && value.length > 10) { + return `${value.substring(0, 10)}...[已脱敏]` + } + return value + + case 'isActive': + case 'isActivated': + case 'enableModelRestriction': + case 'enableClientRestriction': + return value === 'true' ? '是' : '否' + + case 'expirationMode': + return value === 'activation' ? '首次使用后激活' : value === 'fixed' ? '固定时间' : value + + case 'activationUnit': + return value === 'hours' ? '小时' : value === 'days' ? '天' : value + + case 'permissions': + switch (value) { + case 'all': + return '全部服务' + case 'claude': + return '仅Claude' + case 'gemini': + return '仅Gemini' + case 'openai': + return '仅OpenAI' + default: + return value + } + + case 'restrictedModels': + case 'allowedClients': + case 'tags': + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed.join('; ') : value + } catch { + return value + } + + case 'createdAt': + case 'lastUsedAt': + case 'activatedAt': + case 'expiresAt': + if (value) { + try { + return new Date(value).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } catch { + return value + } + } + return '' + + case 'rateLimitWindow': + case 'rateLimitRequests': + case 'concurrencyLimit': + case 'activationDays': + case 'tokenLimit': + return value === '0' || value === 0 ? '无限制' : value + + case 'rateLimitCost': + case 'dailyCostLimit': + case 'totalCostLimit': + case 'weeklyOpusCostLimit': + return value === '0' || value === 0 ? '无限制' : `$${value}` + + default: + return value + } +} + +// 转义 CSV 字段 +function escapeCSVField(field) { + if (field === null || field === undefined) { + return '' + } + + const str = String(field) + + // 如果包含逗号、引号或换行符,需要用引号包围 + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + // 先转义引号(双引号变成两个双引号) + const escaped = str.replace(/"/g, '""') + return `"${escaped}"` + } + + return str +} + +// 转换数据为 CSV 格式 +function convertToCSV(exportDataObj, shouldSanitize = false) { + if (!exportDataObj.data.apiKeys || exportDataObj.data.apiKeys.length === 0) { + throw new Error('CSV format only supports API Keys export. Please use --types=apikeys') + } + + const { apiKeys } = exportDataObj.data + const fields = Object.keys(CSV_FIELD_MAPPING) + const headers = Object.values(CSV_FIELD_MAPPING) + + // 生成标题行 + const csvLines = [headers.map(escapeCSVField).join(',')] + + // 生成数据行 + for (const apiKey of apiKeys) { + const row = fields.map((field) => { + const value = formatCSVValue(field, apiKey[field], shouldSanitize) + return escapeCSVField(value) + }) + csvLines.push(row.join(',')) + } + + return csvLines.join('\n') +} + +// 导出数据 +async function exportData() { + try { + const format = params.format || 'json' + const fileExtension = format === 'csv' ? '.csv' : '.json' + const defaultFileName = `backup-${new Date().toISOString().split('T')[0]}${fileExtension}` + const outputFile = params.output || defaultFileName + const types = params.types ? params.types.split(',') : ['all'] + const shouldSanitize = params.sanitize === true + + // CSV 格式验证 + if (format === 'csv' && !types.includes('apikeys') && !types.includes('all')) { + logger.error('❌ CSV format only supports API Keys export. Please use --types=apikeys') + process.exit(1) + } + + logger.info('🔄 Starting data export...') + logger.info(`📁 Output file: ${outputFile}`) + logger.info(`📋 Data types: ${types.join(', ')}`) + logger.info(`📄 Output format: ${format.toUpperCase()}`) + logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`) + + // 连接 Redis + await redis.connect() + logger.success('✅ Connected to Redis') + + const exportDataObj = { + metadata: { + version: '1.0', + exportDate: new Date().toISOString(), + sanitized: shouldSanitize, + types + }, + data: {} + } + + // 导出 API Keys + if (types.includes('all') || types.includes('apikeys')) { + logger.info('📤 Exporting API Keys...') + const keys = await redis.client.keys('apikey:*') + const apiKeys = [] + + for (const key of keys) { + if (key === 'apikey:hash_map') { + continue + } + + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data) + } + } + + exportDataObj.data.apiKeys = apiKeys + logger.success(`✅ Exported ${apiKeys.length} API Keys`) + } + + // 导出 Claude 账户 + if (types.includes('all') || types.includes('accounts')) { + logger.info('📤 Exporting Claude accounts...') + // 注意:Claude 账户使用 claude:account: 前缀,不是 claude_account: + const keys = await redis.client.keys('claude:account:*') + logger.info(`Found ${keys.length} Claude account keys in Redis`) + const accounts = [] + + for (const key of keys) { + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + // 解析 JSON 字段(如果存在) + if (data.claudeAiOauth) { + try { + data.claudeAiOauth = JSON.parse(data.claudeAiOauth) + } catch (e) { + // 保持原样 + } + } + accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data) + } + } + + exportDataObj.data.claudeAccounts = accounts + logger.success(`✅ Exported ${accounts.length} Claude accounts`) + + // 导出 Gemini 账户 + logger.info('📤 Exporting Gemini accounts...') + const geminiKeys = await redis.client.keys('gemini_account:*') + logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`) + const geminiAccounts = [] + + for (const key of geminiKeys) { + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data) + } + } + + exportDataObj.data.geminiAccounts = geminiAccounts + logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`) + } + + // 导出管理员 + if (types.includes('all') || types.includes('admins')) { + logger.info('📤 Exporting admins...') + const keys = await redis.client.keys('admin:*') + const admins = [] + + for (const key of keys) { + if (key.includes('admin_username:')) { + continue + } + + // 使用 hgetall 而不是 get,因为数据存储在哈希表中 + const data = await redis.client.hgetall(key) + + if (data && Object.keys(data).length > 0) { + admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data) + } + } + + exportDataObj.data.admins = admins + logger.success(`✅ Exported ${admins.length} admins`) + } + + // 根据格式写入文件 + let fileContent + if (format === 'csv') { + fileContent = convertToCSV(exportDataObj, shouldSanitize) + // 添加 UTF-8 BOM 以便 Excel 正确识别中文 + fileContent = `\ufeff${fileContent}` + await fs.writeFile(outputFile, fileContent, 'utf8') + } else { + await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2)) + } + + // 显示导出摘要 + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Export Complete!') + console.log('='.repeat(60)) + console.log(`Output file: ${outputFile}`) + console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`) + + if (exportDataObj.data.apiKeys) { + console.log(`API Keys: ${exportDataObj.data.apiKeys.length}`) + } + if (exportDataObj.data.claudeAccounts) { + console.log(`Claude Accounts: ${exportDataObj.data.claudeAccounts.length}`) + } + if (exportDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts: ${exportDataObj.data.geminiAccounts.length}`) + } + if (exportDataObj.data.admins) { + console.log(`Admins: ${exportDataObj.data.admins.length}`) + } + console.log('='.repeat(60)) + + if (shouldSanitize) { + logger.warn('⚠️ Sensitive data has been sanitized in this export.') + } + } catch (error) { + logger.error('💥 Export failed:', error) + process.exit(1) + } finally { + await redis.disconnect() + rl.close() + } +} + +// 导入数据 +async function importData() { + try { + const inputFile = params.input + if (!inputFile) { + logger.error('❌ Please specify input file with --input=filename.json') + process.exit(1) + } + + const forceOverwrite = params.force === true + const skipConflicts = params['skip-conflicts'] === true + + logger.info('🔄 Starting data import...') + logger.info(`📁 Input file: ${inputFile}`) + logger.info( + `⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT'}` + ) + + // 读取文件 + const fileContent = await fs.readFile(inputFile, 'utf8') + const importDataObj = JSON.parse(fileContent) + + // 验证文件格式 + if (!importDataObj.metadata || !importDataObj.data) { + logger.error('❌ Invalid backup file format') + process.exit(1) + } + + logger.info(`📅 Backup date: ${importDataObj.metadata.exportDate}`) + logger.info(`🔒 Sanitized: ${importDataObj.metadata.sanitized ? 'YES' : 'NO'}`) + + if (importDataObj.metadata.sanitized) { + logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!') + const proceed = await askConfirmation('Continue with sanitized data?') + if (!proceed) { + logger.info('❌ Import cancelled') + return + } + } + + // 显示导入摘要 + console.log(`\n${'='.repeat(60)}`) + console.log('📋 Import Summary:') + console.log('='.repeat(60)) + if (importDataObj.data.apiKeys) { + console.log(`API Keys to import: ${importDataObj.data.apiKeys.length}`) + } + if (importDataObj.data.claudeAccounts) { + console.log(`Claude Accounts to import: ${importDataObj.data.claudeAccounts.length}`) + } + if (importDataObj.data.geminiAccounts) { + console.log(`Gemini Accounts to import: ${importDataObj.data.geminiAccounts.length}`) + } + if (importDataObj.data.admins) { + console.log(`Admins to import: ${importDataObj.data.admins.length}`) + } + console.log(`${'='.repeat(60)}\n`) + + // 确认导入 + const confirmed = await askConfirmation('⚠️ Proceed with import?') + if (!confirmed) { + logger.info('❌ Import cancelled') + return + } + + // 连接 Redis + await redis.connect() + logger.success('✅ Connected to Redis') + + const stats = { + imported: 0, + skipped: 0, + errors: 0 + } + + // 导入 API Keys + if (importDataObj.data.apiKeys) { + logger.info('\n📥 Importing API Keys...') + for (const apiKey of importDataObj.data.apiKeys) { + try { + const exists = await redis.client.exists(`apikey:${apiKey.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(apiKey)) { + pipeline.hset(`apikey:${apiKey.id}`, field, value) + } + await pipeline.exec() + + // 更新哈希映射 + if (apiKey.apiKey && !importDataObj.metadata.sanitized) { + await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id) + } + + logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入 Claude 账户 + if (importDataObj.data.claudeAccounts) { + logger.info('\n📥 Importing Claude accounts...') + for (const account of importDataObj.data.claudeAccounts) { + try { + const exists = await redis.client.exists(`claude_account:${account.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `Claude account "${account.name}" (${account.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(account)) { + // 如果是对象,需要序列化 + if (field === 'claudeAiOauth' && typeof value === 'object') { + pipeline.hset(`claude_account:${account.id}`, field, JSON.stringify(value)) + } else { + pipeline.hset(`claude_account:${account.id}`, field, value) + } + } + await pipeline.exec() + logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message) + stats.errors++ + } + } + } + + // 导入 Gemini 账户 + if (importDataObj.data.geminiAccounts) { + logger.info('\n📥 Importing Gemini accounts...') + for (const account of importDataObj.data.geminiAccounts) { + try { + const exists = await redis.client.exists(`gemini_account:${account.id}`) + + if (exists && !forceOverwrite) { + if (skipConflicts) { + logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`) + stats.skipped++ + continue + } else { + const overwrite = await askConfirmation( + `Gemini account "${account.name}" (${account.id}) exists. Overwrite?` + ) + if (!overwrite) { + stats.skipped++ + continue + } + } + } + + // 使用 hset 存储到哈希表 + const pipeline = redis.client.pipeline() + for (const [field, value] of Object.entries(account)) { + pipeline.hset(`gemini_account:${account.id}`, field, value) + } + await pipeline.exec() + logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`) + stats.imported++ + } catch (error) { + logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message) + stats.errors++ + } + } + } + + // 显示导入结果 + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Import Complete!') + console.log('='.repeat(60)) + console.log(`Successfully imported: ${stats.imported}`) + console.log(`Skipped: ${stats.skipped}`) + console.log(`Errors: ${stats.errors}`) + console.log('='.repeat(60)) + } catch (error) { + logger.error('💥 Import failed:', error) + process.exit(1) + } finally { + await redis.disconnect() + rl.close() + } +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Data Transfer Tool for Claude Relay Service + +This tool allows you to export and import data between environments. + +Usage: + node scripts/data-transfer.js [options] + +Commands: + export Export data from Redis to a JSON file + import Import data from a JSON file to Redis + +Export Options: + --output=FILE Output filename (default: backup-YYYY-MM-DD.json/.csv) + --types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all) + --format=FORMAT Output format: json,csv (default: json) + --sanitize Remove sensitive data from export + +Import Options: + --input=FILE Input filename (required) + --force Overwrite existing data without asking + --skip-conflicts Skip conflicting data without asking + +Examples: + # Export all data + node scripts/data-transfer.js export + + # Export only API keys with sanitized data + node scripts/data-transfer.js export --types=apikeys --sanitize + + # Import data, skip conflicts + node scripts/data-transfer.js import --input=backup.json --skip-conflicts + + # Export specific data types + node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json + + # Export API keys to CSV format + node scripts/data-transfer.js export --types=apikeys --format=csv --sanitize + + # Export to CSV with custom filename + node scripts/data-transfer.js export --types=apikeys --format=csv --output=api-keys.csv +`) +} + +// 主函数 +async function main() { + if (!command || command === '--help' || command === 'help') { + showHelp() + process.exit(0) + } + + switch (command) { + case 'export': + await exportData() + break + + case 'import': + await importData() + break + + default: + logger.error(`❌ Unknown command: ${command}`) + showHelp() + process.exit(1) + } +} + +// 运行 +main().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/debug-redis-keys.js b/scripts/debug-redis-keys.js new file mode 100644 index 0000000..4cd7e9d --- /dev/null +++ b/scripts/debug-redis-keys.js @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +/** + * Redis 键调试工具 + * 用于查看 Redis 中存储的所有键和数据结构 + */ + +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') + +async function debugRedisKeys() { + try { + logger.info('🔄 Connecting to Redis...') + await redis.connect() + logger.success('✅ Connected to Redis') + + // 获取所有键 + const allKeys = await redis.client.keys('*') + logger.info(`\n📊 Total keys in Redis: ${allKeys.length}\n`) + + // 按类型分组 + const keysByType = { + apiKeys: [], + claudeAccounts: [], + geminiAccounts: [], + admins: [], + sessions: [], + usage: [], + other: [] + } + + // 分类键 + for (const key of allKeys) { + if (key.startsWith('apikey:')) { + keysByType.apiKeys.push(key) + } else if (key.startsWith('claude_account:')) { + keysByType.claudeAccounts.push(key) + } else if (key.startsWith('gemini_account:')) { + keysByType.geminiAccounts.push(key) + } else if (key.startsWith('admin:') || key.startsWith('admin_username:')) { + keysByType.admins.push(key) + } else if (key.startsWith('session:')) { + keysByType.sessions.push(key) + } else if ( + key.includes('usage') || + key.includes('rate_limit') || + key.includes('concurrency') + ) { + keysByType.usage.push(key) + } else { + keysByType.other.push(key) + } + } + + // 显示分类结果 + console.log('='.repeat(60)) + console.log('📂 Keys by Category:') + console.log('='.repeat(60)) + console.log(`API Keys: ${keysByType.apiKeys.length}`) + console.log(`Claude Accounts: ${keysByType.claudeAccounts.length}`) + console.log(`Gemini Accounts: ${keysByType.geminiAccounts.length}`) + console.log(`Admins: ${keysByType.admins.length}`) + console.log(`Sessions: ${keysByType.sessions.length}`) + console.log(`Usage/Rate Limit: ${keysByType.usage.length}`) + console.log(`Other: ${keysByType.other.length}`) + console.log('='.repeat(60)) + + // 详细显示每个类别的键 + if (keysByType.apiKeys.length > 0) { + console.log('\n🔑 API Keys:') + for (const key of keysByType.apiKeys.slice(0, 5)) { + console.log(` - ${key}`) + } + if (keysByType.apiKeys.length > 5) { + console.log(` ... and ${keysByType.apiKeys.length - 5} more`) + } + } + + if (keysByType.claudeAccounts.length > 0) { + console.log('\n🤖 Claude Accounts:') + for (const key of keysByType.claudeAccounts) { + console.log(` - ${key}`) + } + } + + if (keysByType.geminiAccounts.length > 0) { + console.log('\n💎 Gemini Accounts:') + for (const key of keysByType.geminiAccounts) { + console.log(` - ${key}`) + } + } + + if (keysByType.other.length > 0) { + console.log('\n❓ Other Keys:') + for (const key of keysByType.other.slice(0, 10)) { + console.log(` - ${key}`) + } + if (keysByType.other.length > 10) { + console.log(` ... and ${keysByType.other.length - 10} more`) + } + } + + // 检查数据类型 + console.log(`\n${'='.repeat(60)}`) + console.log('🔍 Checking Data Types:') + console.log('='.repeat(60)) + + // 随机检查几个键的类型 + const sampleKeys = allKeys.slice(0, Math.min(10, allKeys.length)) + for (const key of sampleKeys) { + const type = await redis.client.type(key) + console.log(`${key} => ${type}`) + } + } catch (error) { + logger.error('💥 Debug failed:', error) + } finally { + await redis.disconnect() + logger.info('👋 Disconnected from Redis') + } +} + +// 运行调试 +debugRedisKeys().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/fix-inquirer.js b/scripts/fix-inquirer.js new file mode 100644 index 0000000..c21592b --- /dev/null +++ b/scripts/fix-inquirer.js @@ -0,0 +1,29 @@ +#!/usr/bin/env node + +/** + * 修复 inquirer ESM 问题 + * 降级到支持 CommonJS 的版本 + */ + +const { execSync } = require('child_process') + +console.log('🔧 修复 inquirer ESM 兼容性问题...\n') + +try { + // 卸载当前版本 + console.log('📦 卸载当前 inquirer 版本...') + execSync('npm uninstall inquirer', { stdio: 'inherit' }) + + // 安装兼容 CommonJS 的版本 (8.x 是最后支持 CommonJS 的主要版本) + console.log('\n📦 安装兼容版本 inquirer@8.2.6...') + execSync('npm install inquirer@8.2.6', { stdio: 'inherit' }) + + console.log('\n✅ 修复完成!') + console.log('\n现在可以正常使用 CLI 工具了:') + console.log(' npm run cli admin') + console.log(' npm run cli keys') + console.log(' npm run cli status') +} catch (error) { + console.error('❌ 修复失败:', error.message) + process.exit(1) +} diff --git a/scripts/fix-usage-stats.js b/scripts/fix-usage-stats.js new file mode 100644 index 0000000..af9cf98 --- /dev/null +++ b/scripts/fix-usage-stats.js @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +/** + * 数据迁移脚本:修复历史使用统计数据 + * + * 功能: + * 1. 统一 totalTokens 和 allTokens 字段 + * 2. 确保 allTokens 包含所有类型的 tokens + * 3. 修复历史数据的不一致性 + * + * 使用方法: + * node scripts/fix-usage-stats.js [--dry-run] + */ + +require('dotenv').config() +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') + +// 解析命令行参数 +const args = process.argv.slice(2) +const isDryRun = args.includes('--dry-run') + +async function fixUsageStats() { + try { + logger.info('🔧 开始修复使用统计数据...') + if (isDryRun) { + logger.info('📝 DRY RUN 模式 - 不会实际修改数据') + } + + // 连接到 Redis + await redis.connect() + logger.success('✅ 已连接到 Redis') + + const client = redis.getClientSafe() + + // 统计信息 + const stats = { + totalKeys: 0, + fixedTotalKeys: 0, + fixedDailyKeys: 0, + fixedMonthlyKeys: 0, + fixedModelKeys: 0, + errors: 0 + } + + // 1. 修复 API Key 级别的总统计 + logger.info('\n📊 修复 API Key 总统计数据...') + const apiKeyPattern = 'apikey:*' + const apiKeys = await client.keys(apiKeyPattern) + stats.totalKeys = apiKeys.length + + for (const apiKeyKey of apiKeys) { + const keyId = apiKeyKey.replace('apikey:', '') + const usageKey = `usage:${keyId}` + + try { + const usageData = await client.hgetall(usageKey) + if (usageData && Object.keys(usageData).length > 0) { + const inputTokens = parseInt(usageData.totalInputTokens) || 0 + const outputTokens = parseInt(usageData.totalOutputTokens) || 0 + const cacheCreateTokens = parseInt(usageData.totalCacheCreateTokens) || 0 + const cacheReadTokens = parseInt(usageData.totalCacheReadTokens) || 0 + const currentAllTokens = parseInt(usageData.totalAllTokens) || 0 + + // 计算正确的 allTokens + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + logger.info(` 修复 ${keyId}: ${currentAllTokens} -> ${correctAllTokens}`) + + if (!isDryRun) { + await client.hset(usageKey, 'totalAllTokens', correctAllTokens) + } + stats.fixedTotalKeys++ + } + } + } catch (error) { + logger.error(` 错误处理 ${keyId}: ${error.message}`) + stats.errors++ + } + } + + // 2. 修复每日统计数据 + logger.info('\n📅 修复每日统计数据...') + const dailyPattern = 'usage:daily:*' + const dailyKeys = await client.keys(dailyPattern) + + for (const dailyKey of dailyKeys) { + try { + const data = await client.hgetall(dailyKey) + if (data && Object.keys(data).length > 0) { + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const currentAllTokens = parseInt(data.allTokens) || 0 + + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(dailyKey, 'allTokens', correctAllTokens) + } + stats.fixedDailyKeys++ + } + } + } catch (error) { + logger.error(` 错误处理 ${dailyKey}: ${error.message}`) + stats.errors++ + } + } + + // 3. 修复每月统计数据 + logger.info('\n📆 修复每月统计数据...') + const monthlyPattern = 'usage:monthly:*' + const monthlyKeys = await client.keys(monthlyPattern) + + for (const monthlyKey of monthlyKeys) { + try { + const data = await client.hgetall(monthlyKey) + if (data && Object.keys(data).length > 0) { + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const currentAllTokens = parseInt(data.allTokens) || 0 + + const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(monthlyKey, 'allTokens', correctAllTokens) + } + stats.fixedMonthlyKeys++ + } + } + } catch (error) { + logger.error(` 错误处理 ${monthlyKey}: ${error.message}`) + stats.errors++ + } + } + + // 4. 修复模型级别的统计数据 + logger.info('\n🤖 修复模型级别统计数据...') + const modelPatterns = [ + 'usage:model:daily:*', + 'usage:model:monthly:*', + 'usage:*:model:daily:*', + 'usage:*:model:monthly:*' + ] + + for (const pattern of modelPatterns) { + const modelKeys = await client.keys(pattern) + + for (const modelKey of modelKeys) { + try { + const data = await client.hgetall(modelKey) + if (data && Object.keys(data).length > 0) { + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const currentAllTokens = parseInt(data.allTokens) || 0 + + const correctAllTokens = + inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) { + if (!isDryRun) { + await client.hset(modelKey, 'allTokens', correctAllTokens) + } + stats.fixedModelKeys++ + } + } + } catch (error) { + logger.error(` 错误处理 ${modelKey}: ${error.message}`) + stats.errors++ + } + } + } + + // 5. 验证修复结果 + if (!isDryRun) { + logger.info('\n✅ 验证修复结果...') + + // 随机抽样验证 + const sampleSize = Math.min(5, apiKeys.length) + for (let i = 0; i < sampleSize; i++) { + const randomIndex = Math.floor(Math.random() * apiKeys.length) + const keyId = apiKeys[randomIndex].replace('apikey:', '') + const usage = await redis.getUsageStats(keyId) + + logger.info(` 样本 ${keyId}:`) + logger.info(` Total tokens: ${usage.total.tokens}`) + logger.info(` All tokens: ${usage.total.allTokens}`) + logger.info(` 一致性: ${usage.total.tokens === usage.total.allTokens ? '✅' : '❌'}`) + } + } + + // 打印统计结果 + logger.info('\n📊 修复统计:') + logger.info(` 总 API Keys: ${stats.totalKeys}`) + logger.info(` 修复的总统计: ${stats.fixedTotalKeys}`) + logger.info(` 修复的日统计: ${stats.fixedDailyKeys}`) + logger.info(` 修复的月统计: ${stats.fixedMonthlyKeys}`) + logger.info(` 修复的模型统计: ${stats.fixedModelKeys}`) + logger.info(` 错误数: ${stats.errors}`) + + if (isDryRun) { + logger.info('\n💡 这是 DRY RUN - 没有实际修改数据') + logger.info(' 运行不带 --dry-run 参数来实际执行修复') + } else { + logger.success('\n✅ 数据修复完成!') + } + } catch (error) { + logger.error('❌ 修复过程出错:', error) + process.exit(1) + } finally { + await redis.disconnect() + } +} + +// 执行修复 +fixUsageStats().catch((error) => { + logger.error('❌ 未处理的错误:', error) + process.exit(1) +}) diff --git a/scripts/generate-test-data.js b/scripts/generate-test-data.js new file mode 100755 index 0000000..6429f90 --- /dev/null +++ b/scripts/generate-test-data.js @@ -0,0 +1,280 @@ +#!/usr/bin/env node + +/** + * 历史数据生成脚本 + * 用于测试不同时间范围的Token统计功能 + * + * 使用方法: + * node scripts/generate-test-data.js [--clean] + * + * 选项: + * --clean: 清除所有测试数据 + */ + +const redis = require('../src/models/redis') +const logger = require('../src/utils/logger') + +// 解析命令行参数 +const args = process.argv.slice(2) +const shouldClean = args.includes('--clean') + +// 模拟的模型列表 +const models = [ + 'claude-sonnet-4-20250514', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + 'claude-3-opus-20240229' +] + +// 生成指定日期的数据 +async function generateDataForDate(apiKeyId, date, dayOffset) { + const client = redis.getClientSafe() + const dateStr = date.toISOString().split('T')[0] + const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` + + // 根据日期偏移量调整数据量(越近的日期数据越多) + const requestCount = Math.max(5, 20 - dayOffset * 2) // 5-20个请求 + + logger.info(`📊 Generating ${requestCount} requests for ${dateStr}`) + + for (let i = 0; i < requestCount; i++) { + // 随机选择模型 + const model = models[Math.floor(Math.random() * models.length)] + + // 生成随机Token数据 + const inputTokens = Math.floor(Math.random() * 2000) + 500 // 500-2500 + const outputTokens = Math.floor(Math.random() * 3000) + 1000 // 1000-4000 + const cacheCreateTokens = Math.random() > 0.7 ? Math.floor(Math.random() * 1000) : 0 // 30%概率有缓存创建 + const cacheReadTokens = Math.random() > 0.5 ? Math.floor(Math.random() * 500) : 0 // 50%概率有缓存读取 + + const coreTokens = inputTokens + outputTokens + const allTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 更新各种统计键 + const totalKey = `usage:${apiKeyId}` + const dailyKey = `usage:daily:${apiKeyId}:${dateStr}` + const monthlyKey = `usage:monthly:${apiKeyId}:${month}` + const modelDailyKey = `usage:model:daily:${model}:${dateStr}` + const modelMonthlyKey = `usage:model:monthly:${model}:${month}` + const keyModelDailyKey = `usage:${apiKeyId}:model:daily:${model}:${dateStr}` + const keyModelMonthlyKey = `usage:${apiKeyId}:model:monthly:${model}:${month}` + + await Promise.all([ + // 总计数据 + client.hincrby(totalKey, 'totalTokens', coreTokens), + client.hincrby(totalKey, 'totalInputTokens', inputTokens), + client.hincrby(totalKey, 'totalOutputTokens', outputTokens), + client.hincrby(totalKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(totalKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(totalKey, 'totalAllTokens', allTokens), + client.hincrby(totalKey, 'totalRequests', 1), + + // 每日统计 + client.hincrby(dailyKey, 'tokens', coreTokens), + client.hincrby(dailyKey, 'inputTokens', inputTokens), + client.hincrby(dailyKey, 'outputTokens', outputTokens), + client.hincrby(dailyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(dailyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(dailyKey, 'allTokens', allTokens), + client.hincrby(dailyKey, 'requests', 1), + + // 每月统计 + client.hincrby(monthlyKey, 'tokens', coreTokens), + client.hincrby(monthlyKey, 'inputTokens', inputTokens), + client.hincrby(monthlyKey, 'outputTokens', outputTokens), + client.hincrby(monthlyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(monthlyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(monthlyKey, 'allTokens', allTokens), + client.hincrby(monthlyKey, 'requests', 1), + + // 模型统计 - 每日 + client.hincrby(modelDailyKey, 'totalInputTokens', inputTokens), + client.hincrby(modelDailyKey, 'totalOutputTokens', outputTokens), + client.hincrby(modelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(modelDailyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(modelDailyKey, 'totalAllTokens', allTokens), + client.hincrby(modelDailyKey, 'requests', 1), + + // 模型统计 - 每月 + client.hincrby(modelMonthlyKey, 'totalInputTokens', inputTokens), + client.hincrby(modelMonthlyKey, 'totalOutputTokens', outputTokens), + client.hincrby(modelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(modelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(modelMonthlyKey, 'totalAllTokens', allTokens), + client.hincrby(modelMonthlyKey, 'requests', 1), + + // API Key级别的模型统计 - 每日 + // 同时存储带total前缀和不带前缀的字段,保持兼容性 + client.hincrby(keyModelDailyKey, 'inputTokens', inputTokens), + client.hincrby(keyModelDailyKey, 'outputTokens', outputTokens), + client.hincrby(keyModelDailyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelDailyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(keyModelDailyKey, 'allTokens', allTokens), + client.hincrby(keyModelDailyKey, 'totalInputTokens', inputTokens), + client.hincrby(keyModelDailyKey, 'totalOutputTokens', outputTokens), + client.hincrby(keyModelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelDailyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(keyModelDailyKey, 'totalAllTokens', allTokens), + client.hincrby(keyModelDailyKey, 'requests', 1), + + // API Key级别的模型统计 - 每月 + client.hincrby(keyModelMonthlyKey, 'inputTokens', inputTokens), + client.hincrby(keyModelMonthlyKey, 'outputTokens', outputTokens), + client.hincrby(keyModelMonthlyKey, 'cacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelMonthlyKey, 'cacheReadTokens', cacheReadTokens), + client.hincrby(keyModelMonthlyKey, 'allTokens', allTokens), + client.hincrby(keyModelMonthlyKey, 'totalInputTokens', inputTokens), + client.hincrby(keyModelMonthlyKey, 'totalOutputTokens', outputTokens), + client.hincrby(keyModelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens), + client.hincrby(keyModelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens), + client.hincrby(keyModelMonthlyKey, 'totalAllTokens', allTokens), + client.hincrby(keyModelMonthlyKey, 'requests', 1) + ]) + } +} + +// 清除测试数据 +async function cleanTestData() { + const client = redis.getClientSafe() + const apiKeyService = require('../src/services/apiKeyService') + + logger.info('🧹 Cleaning test data...') + + // 获取所有API Keys + const allKeys = await apiKeyService.getAllApiKeysFast() + + // 找出所有测试 API Keys + const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key')) + + for (const testKey of testKeys) { + const apiKeyId = testKey.id + + // 获取所有相关的键 + const patterns = [ + `usage:${apiKeyId}`, + `usage:daily:${apiKeyId}:*`, + `usage:monthly:${apiKeyId}:*`, + `usage:${apiKeyId}:model:daily:*`, + `usage:${apiKeyId}:model:monthly:*` + ] + + for (const pattern of patterns) { + const keys = await client.keys(pattern) + if (keys.length > 0) { + await client.del(...keys) + logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`) + } + } + + // 删除 API Key 本身 + await apiKeyService.deleteApiKey(apiKeyId) + logger.info(`🗑️ Deleted test API Key: ${testKey.name} (${apiKeyId})`) + } + + // 清除模型统计 + const modelPatterns = ['usage:model:daily:*', 'usage:model:monthly:*'] + + for (const pattern of modelPatterns) { + const keys = await client.keys(pattern) + if (keys.length > 0) { + await client.del(...keys) + logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`) + } + } +} + +// 主函数 +async function main() { + try { + await redis.connect() + logger.success('✅ Connected to Redis') + + // 创建测试API Keys + const apiKeyService = require('../src/services/apiKeyService') + const testApiKeys = [] + const createdKeys = [] + + // 总是创建新的测试 API Keys + logger.info('📝 Creating test API Keys...') + + for (let i = 1; i <= 3; i++) { + const newKey = await apiKeyService.generateApiKey({ + name: `Test API Key ${i}`, + description: `Test key for historical data generation ${i}`, + tokenLimit: 10000000, + concurrencyLimit: 10, + rateLimitWindow: 60, + rateLimitRequests: 100 + }) + + testApiKeys.push(newKey.id) + createdKeys.push(newKey) + logger.success(`✅ Created test API Key: ${newKey.name} (${newKey.id})`) + logger.info(` 🔑 API Key: ${newKey.apiKey}`) + } + + if (shouldClean) { + await cleanTestData() + logger.success('✅ Test data cleaned successfully') + return + } + + // 生成历史数据 + const now = new Date() + + for (const apiKeyId of testApiKeys) { + logger.info(`\n🔄 Generating data for API Key: ${apiKeyId}`) + + // 生成过去30天的数据 + for (let dayOffset = 0; dayOffset < 30; dayOffset++) { + const date = new Date(now) + date.setDate(date.getDate() - dayOffset) + + await generateDataForDate(apiKeyId, date, dayOffset) + } + + logger.success(`✅ Generated 30 days of historical data for API Key: ${apiKeyId}`) + } + + // 显示统计摘要 + logger.info('\n📊 Test Data Summary:') + logger.info('='.repeat(60)) + + for (const apiKeyId of testApiKeys) { + const totalKey = `usage:${apiKeyId}` + const totalData = await redis.getClientSafe().hgetall(totalKey) + + if (totalData && Object.keys(totalData).length > 0) { + logger.info(`\nAPI Key: ${apiKeyId}`) + logger.info(` Total Requests: ${totalData.totalRequests || 0}`) + logger.info(` Total Tokens (Core): ${totalData.totalTokens || 0}`) + logger.info(` Total Tokens (All): ${totalData.totalAllTokens || 0}`) + logger.info(` Input Tokens: ${totalData.totalInputTokens || 0}`) + logger.info(` Output Tokens: ${totalData.totalOutputTokens || 0}`) + logger.info(` Cache Create Tokens: ${totalData.totalCacheCreateTokens || 0}`) + logger.info(` Cache Read Tokens: ${totalData.totalCacheReadTokens || 0}`) + } + } + + logger.info(`\n${'='.repeat(60)}`) + logger.success('\n✅ Test data generation completed!') + logger.info('\n📋 Created API Keys:') + for (const key of createdKeys) { + logger.info(`- ${key.name}: ${key.apiKey}`) + } + logger.info('\n💡 Tips:') + logger.info('- Check the admin panel to see the different time ranges') + logger.info('- Use --clean flag to remove all test data and API Keys') + logger.info('- The script generates more recent data to simulate real usage patterns') + } catch (error) { + logger.error('❌ Error:', error) + } finally { + await redis.disconnect() + } +} + +// 运行脚本 +main().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/manage-session-windows.js b/scripts/manage-session-windows.js new file mode 100644 index 0000000..781af5c --- /dev/null +++ b/scripts/manage-session-windows.js @@ -0,0 +1,561 @@ +#!/usr/bin/env node + +/** + * 会话窗口管理脚本 + * 用于调试、恢复和管理Claude账户的会话窗口 + */ + +const redis = require('../src/models/redis') +const claudeAccountService = require('../src/services/account/claudeAccountService') +const readline = require('readline') + +// 创建readline接口 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}) + +// 辅助函数:询问用户输入 +function askQuestion(question) { + return new Promise((resolve) => { + rl.question(question, resolve) + }) +} + +// 辅助函数:解析时间输入 +function parseTimeInput(input) { + const now = new Date() + + // 如果是 HH:MM 格式 + const timeMatch = input.match(/^(\d{1,2}):(\d{2})$/) + if (timeMatch) { + const hour = parseInt(timeMatch[1]) + const minute = parseInt(timeMatch[2]) + + if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) { + const time = new Date(now) + time.setHours(hour, minute, 0, 0) + return time + } + } + + // 如果是相对时间(如 "2小时前") + const relativeMatch = input.match(/^(\d+)(小时|分钟)前$/) + if (relativeMatch) { + const amount = parseInt(relativeMatch[1]) + const unit = relativeMatch[2] + const time = new Date(now) + + if (unit === '小时') { + time.setHours(time.getHours() - amount) + } else if (unit === '分钟') { + time.setMinutes(time.getMinutes() - amount) + } + + return time + } + + // 如果是 ISO 格式或其他日期格式 + const parsedDate = new Date(input) + if (!isNaN(parsedDate.getTime())) { + return parsedDate + } + + return null +} + +// 辅助函数:显示可用的时间窗口选项 +function showTimeWindowOptions() { + const now = new Date() + console.log('\n⏰ 可用的5小时时间窗口:') + + for (let hour = 0; hour < 24; hour += 5) { + const start = hour + const end = hour + 5 + const startStr = `${String(start).padStart(2, '0')}:00` + const endStr = `${String(end).padStart(2, '0')}:00` + + const currentHour = now.getHours() + const isActive = currentHour >= start && currentHour < end + const status = isActive ? ' 🟢 (当前活跃)' : '' + + console.log(` ${start / 5 + 1}. ${startStr} - ${endStr}${status}`) + } + console.log('') +} + +const commands = { + // 调试所有账户的会话窗口状态 + async debug() { + console.log('🔍 开始调试会话窗口状态...\n') + + const accounts = await redis.getAllClaudeAccounts() + console.log(`📊 找到 ${accounts.length} 个Claude账户\n`) + + const stats = { + total: accounts.length, + hasWindow: 0, + hasLastUsed: 0, + canRecover: 0, + expired: 0 + } + + for (const account of accounts) { + console.log(`🏢 ${account.name} (${account.id})`) + console.log(` 状态: ${account.isActive === 'true' ? '✅ 活跃' : '❌ 禁用'}`) + + if (account.sessionWindowStart && account.sessionWindowEnd) { + stats.hasWindow++ + const windowStart = new Date(account.sessionWindowStart) + const windowEnd = new Date(account.sessionWindowEnd) + const now = new Date() + const isActive = now < windowEnd + + console.log(` 会话窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 窗口状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`) + + // 只有在窗口已过期时才显示可恢复窗口 + if (!isActive && account.lastUsedAt) { + const lastUsed = new Date(account.lastUsedAt) + const recoveredWindowStart = claudeAccountService._calculateSessionWindowStart(lastUsed) + const recoveredWindowEnd = + claudeAccountService._calculateSessionWindowEnd(recoveredWindowStart) + + if (now < recoveredWindowEnd) { + stats.canRecover++ + console.log( + ` 可恢复窗口: ✅ ${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()}` + ) + } else { + stats.expired++ + console.log( + ` 可恢复窗口: ❌ 已过期 (${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()})` + ) + } + } + } else { + console.log(' 会话窗口: ❌ 无') + + // 没有会话窗口时,检查是否有可恢复的窗口 + if (account.lastUsedAt) { + const lastUsed = new Date(account.lastUsedAt) + const now = new Date() + const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsed) + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart) + + if (now < windowEnd) { + stats.canRecover++ + console.log( + ` 可恢复窗口: ✅ ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}` + ) + } else { + stats.expired++ + console.log( + ` 可恢复窗口: ❌ 已过期 (${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()})` + ) + } + } + } + + if (account.lastUsedAt) { + stats.hasLastUsed++ + const lastUsed = new Date(account.lastUsedAt) + const now = new Date() + const minutesAgo = Math.round((now - lastUsed) / (1000 * 60)) + + console.log(` 最后使用: ${lastUsed.toLocaleString()} (${minutesAgo}分钟前)`) + } else { + console.log(' 最后使用: ❌ 无记录') + } + + console.log('') + } + + console.log('📈 汇总统计:') + console.log(` 总账户数: ${stats.total}`) + console.log(` 有会话窗口: ${stats.hasWindow}`) + console.log(` 有使用记录: ${stats.hasLastUsed}`) + console.log(` 可以恢复: ${stats.canRecover}`) + console.log(` 窗口已过期: ${stats.expired}`) + }, + + // 初始化会话窗口(默认行为) + async init() { + console.log('🔄 初始化会话窗口...\n') + const result = await claudeAccountService.initializeSessionWindows() + + console.log('\n📊 初始化结果:') + console.log(` 总账户数: ${result.total}`) + console.log(` 成功初始化: ${result.initialized}`) + console.log(` 已跳过(已有窗口): ${result.skipped}`) + console.log(` 窗口已过期: ${result.expired}`) + console.log(` 无使用数据: ${result.noData}`) + + if (result.error) { + console.log(` 错误: ${result.error}`) + } + }, + + // 强制重新计算所有会话窗口 + async force() { + console.log('🔄 强制重新计算所有会话窗口...\n') + const result = await claudeAccountService.initializeSessionWindows(true) + + console.log('\n📊 强制重算结果:') + console.log(` 总账户数: ${result.total}`) + console.log(` 成功初始化: ${result.initialized}`) + console.log(` 窗口已过期: ${result.expired}`) + console.log(` 无使用数据: ${result.noData}`) + + if (result.error) { + console.log(` 错误: ${result.error}`) + } + }, + + // 清除所有会话窗口 + async clear() { + console.log('🗑️ 清除所有会话窗口...\n') + + const accounts = await redis.getAllClaudeAccounts() + let clearedCount = 0 + + for (const account of accounts) { + if (account.sessionWindowStart || account.sessionWindowEnd) { + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + + await redis.setClaudeAccount(account.id, account) + clearedCount++ + + console.log(`✅ 清除账户 ${account.name} (${account.id}) 的会话窗口`) + } + } + + console.log(`\n📊 清除完成: 共清除 ${clearedCount} 个账户的会话窗口`) + }, + + // 创建测试会话窗口(将lastUsedAt设置为当前时间) + async test() { + console.log('🧪 创建测试会话窗口...\n') + + const accounts = await redis.getAllClaudeAccounts() + if (accounts.length === 0) { + console.log('❌ 没有找到Claude账户') + return + } + + const now = new Date() + let updatedCount = 0 + + for (const account of accounts) { + if (account.isActive === 'true') { + // 设置为当前时间(模拟刚刚使用) + account.lastUsedAt = now.toISOString() + + // 计算新的会话窗口 + const windowStart = claudeAccountService._calculateSessionWindowStart(now) + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart) + + account.sessionWindowStart = windowStart.toISOString() + account.sessionWindowEnd = windowEnd.toISOString() + account.lastRequestTime = now.toISOString() + + await redis.setClaudeAccount(account.id, account) + updatedCount++ + + console.log(`✅ 为账户 ${account.name} 创建测试会话窗口:`) + console.log(` 窗口时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 最后使用: ${now.toLocaleString()}\n`) + } + } + + console.log(`📊 测试完成: 为 ${updatedCount} 个活跃账户创建了测试会话窗口`) + }, + + // 手动设置账户的会话窗口 + async set() { + console.log('🔧 手动设置会话窗口...\n') + + // 获取所有账户 + const accounts = await redis.getAllClaudeAccounts() + if (accounts.length === 0) { + console.log('❌ 没有找到Claude账户') + return + } + + // 显示账户列表 + console.log('📋 可用的Claude账户:') + accounts.forEach((account, index) => { + const status = account.isActive === 'true' ? '✅' : '❌' + const hasWindow = account.sessionWindowStart ? '🕐' : '➖' + console.log(` ${index + 1}. ${status} ${hasWindow} ${account.name} (${account.id})`) + }) + + // 让用户选择账户 + const accountIndex = await askQuestion('\n请选择账户 (输入编号): ') + const selectedIndex = parseInt(accountIndex) - 1 + + if (selectedIndex < 0 || selectedIndex >= accounts.length) { + console.log('❌ 无效的账户编号') + return + } + + const selectedAccount = accounts[selectedIndex] + console.log(`\n🎯 已选择账户: ${selectedAccount.name}`) + + // 显示当前会话窗口状态 + if (selectedAccount.sessionWindowStart && selectedAccount.sessionWindowEnd) { + const windowStart = new Date(selectedAccount.sessionWindowStart) + const windowEnd = new Date(selectedAccount.sessionWindowEnd) + const now = new Date() + const isActive = now >= windowStart && now < windowEnd + + console.log('📊 当前会话窗口:') + console.log(` 时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`) + } else { + console.log('📊 当前会话窗口: ❌ 无') + } + + // 显示设置选项 + console.log('\n🛠️ 设置选项:') + console.log(' 1. 使用预设时间窗口') + console.log(' 2. 自定义最后使用时间') + console.log(' 3. 直接设置窗口时间') + console.log(' 4. 清除会话窗口') + + const option = await askQuestion('\n请选择设置方式 (1-4): ') + + switch (option) { + case '1': + await setPresetWindow(selectedAccount) + break + case '2': + await setCustomLastUsed(selectedAccount) + break + case '3': + await setDirectWindow(selectedAccount) + break + case '4': + await clearAccountWindow(selectedAccount) + break + default: + console.log('❌ 无效的选项') + return + } + }, + + // 显示帮助信息 + help() { + console.log('🔧 会话窗口管理脚本\n') + console.log('用法: node scripts/manage-session-windows.js \n') + console.log('命令:') + console.log(' debug - 调试所有账户的会话窗口状态') + console.log(' init - 初始化会话窗口(跳过已有窗口的账户)') + console.log(' force - 强制重新计算所有会话窗口') + console.log(' test - 创建测试会话窗口(设置当前时间为使用时间)') + console.log(' set - 手动设置特定账户的会话窗口 🆕') + console.log(' clear - 清除所有会话窗口') + console.log(' help - 显示此帮助信息\n') + console.log('示例:') + console.log(' node scripts/manage-session-windows.js debug') + console.log(' node scripts/manage-session-windows.js set') + console.log(' node scripts/manage-session-windows.js test') + console.log(' node scripts/manage-session-windows.js force') + } +} + +// 设置函数实现 + +// 使用预设时间窗口 +async function setPresetWindow(account) { + showTimeWindowOptions() + + const windowChoice = await askQuestion('请选择时间窗口 (1-5): ') + const windowIndex = parseInt(windowChoice) - 1 + + if (windowIndex < 0 || windowIndex >= 5) { + console.log('❌ 无效的窗口选择') + return + } + + const now = new Date() + const startHour = windowIndex * 5 + + // 创建窗口开始时间 + const windowStart = new Date(now) + windowStart.setHours(startHour, 0, 0, 0) + + // 创建窗口结束时间 + const windowEnd = new Date(windowStart) + windowEnd.setHours(windowEnd.getHours() + 5) + + // 如果选择的窗口已经过期,则设置为明天的同一时间段 + if (windowEnd <= now) { + windowStart.setDate(windowStart.getDate() + 1) + windowEnd.setDate(windowEnd.getDate() + 1) + } + + // 询问是否要设置为当前时间作为最后使用时间 + const setLastUsed = await askQuestion('是否设置当前时间为最后使用时间? (y/N): ') + + // 更新账户数据 + account.sessionWindowStart = windowStart.toISOString() + account.sessionWindowEnd = windowEnd.toISOString() + account.lastRequestTime = now.toISOString() + + if (setLastUsed.toLowerCase() === 'y' || setLastUsed.toLowerCase() === 'yes') { + account.lastUsedAt = now.toISOString() + } + + await redis.setClaudeAccount(account.id, account) + + console.log('\n✅ 已设置会话窗口:') + console.log(` 账户: ${account.name}`) + console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '⏰ 未来窗口'}`) +} + +// 自定义最后使用时间 +async function setCustomLastUsed(account) { + console.log('\n📝 设置最后使用时间:') + console.log('支持的时间格式:') + console.log(' - HH:MM (如: 14:30)') + console.log(' - 相对时间 (如: 2小时前, 30分钟前)') + console.log(' - ISO格式 (如: 2025-07-28T14:30:00)') + + const timeInput = await askQuestion('\n请输入最后使用时间: ') + const lastUsedTime = parseTimeInput(timeInput) + + if (!lastUsedTime) { + console.log('❌ 无效的时间格式') + return + } + + // 基于最后使用时间计算会话窗口 + const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsedTime) + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart) + + // 更新账户数据 + account.lastUsedAt = lastUsedTime.toISOString() + account.sessionWindowStart = windowStart.toISOString() + account.sessionWindowEnd = windowEnd.toISOString() + account.lastRequestTime = lastUsedTime.toISOString() + + await redis.setClaudeAccount(account.id, account) + + console.log('\n✅ 已设置会话窗口:') + console.log(` 账户: ${account.name}`) + console.log(` 最后使用: ${lastUsedTime.toLocaleString()}`) + console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`) + + const now = new Date() + console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '❌ 已过期'}`) +} + +// 直接设置窗口时间 +async function setDirectWindow(account) { + console.log('\n⏰ 直接设置窗口时间:') + + const startInput = await askQuestion('请输入窗口开始时间 (HH:MM): ') + const startTime = parseTimeInput(startInput) + + if (!startTime) { + console.log('❌ 无效的开始时间格式') + return + } + + // 自动计算结束时间(开始时间+5小时) + const endTime = new Date(startTime) + endTime.setHours(endTime.getHours() + 5) + + // 如果跨天,询问是否确认 + if (endTime.getDate() !== startTime.getDate()) { + const confirm = await askQuestion(`窗口将跨天到次日 ${endTime.getHours()}:00,确认吗? (y/N): `) + if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') { + console.log('❌ 已取消设置') + return + } + } + + const now = new Date() + + // 更新账户数据 + account.sessionWindowStart = startTime.toISOString() + account.sessionWindowEnd = endTime.toISOString() + account.lastRequestTime = now.toISOString() + + // 询问是否更新最后使用时间 + const updateLastUsed = await askQuestion('是否将最后使用时间设置为窗口开始时间? (y/N): ') + if (updateLastUsed.toLowerCase() === 'y' || updateLastUsed.toLowerCase() === 'yes') { + account.lastUsedAt = startTime.toISOString() + } + + await redis.setClaudeAccount(account.id, account) + + console.log('\n✅ 已设置会话窗口:') + console.log(` 账户: ${account.name}`) + console.log(` 窗口: ${startTime.toLocaleString()} - ${endTime.toLocaleString()}`) + console.log( + ` 状态: ${now >= startTime && now < endTime ? '✅ 活跃' : now < startTime ? '⏰ 未来窗口' : '❌ 已过期'}` + ) +} + +// 清除账户会话窗口 +async function clearAccountWindow(account) { + const confirm = await askQuestion(`确认清除账户 "${account.name}" 的会话窗口吗? (y/N): `) + + if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') { + console.log('❌ 已取消操作') + return + } + + // 清除会话窗口相关数据 + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + + await redis.setClaudeAccount(account.id, account) + + console.log(`\n✅ 已清除账户 "${account.name}" 的会话窗口`) +} + +async function main() { + const command = process.argv[2] || 'help' + + if (!commands[command]) { + console.error(`❌ 未知命令: ${command}`) + commands.help() + process.exit(1) + } + + if (command === 'help') { + commands.help() + return + } + + try { + // 连接Redis + await redis.connect() + + // 执行命令 + await commands[command]() + } catch (error) { + console.error('❌ 执行失败:', error) + process.exit(1) + } finally { + await redis.disconnect() + rl.close() + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + main().then(() => { + console.log('\n🎉 操作完成') + process.exit(0) + }) +} + +module.exports = { commands } diff --git a/scripts/manage.js b/scripts/manage.js new file mode 100644 index 0000000..df8a14f --- /dev/null +++ b/scripts/manage.js @@ -0,0 +1,333 @@ +#!/usr/bin/env node + +const { spawn, exec } = require('child_process') +const fs = require('fs') +const path = require('path') +const process = require('process') + +const PID_FILE = path.join(__dirname, '..', 'claude-relay-service.pid') +const LOG_FILE = path.join(__dirname, '..', 'logs', 'service.log') +const ERROR_LOG_FILE = path.join(__dirname, '..', 'logs', 'service-error.log') +const APP_FILE = path.join(__dirname, '..', 'src', 'app.js') + +class ServiceManager { + constructor() { + this.ensureLogDir() + } + + ensureLogDir() { + const logDir = path.dirname(LOG_FILE) + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }) + } + } + + getPid() { + try { + if (fs.existsSync(PID_FILE)) { + const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim()) + return pid + } + } catch (error) { + console.error('读取PID文件失败:', error.message) + } + return null + } + + isProcessRunning(pid) { + try { + process.kill(pid, 0) + return true + } catch (error) { + return false + } + } + + writePid(pid) { + try { + fs.writeFileSync(PID_FILE, pid.toString()) + console.log(`✅ PID ${pid} 已保存到 ${PID_FILE}`) + } catch (error) { + console.error('写入PID文件失败:', error.message) + } + } + + removePidFile() { + try { + if (fs.existsSync(PID_FILE)) { + fs.unlinkSync(PID_FILE) + console.log('🗑️ 已清理PID文件') + } + } catch (error) { + console.error('清理PID文件失败:', error.message) + } + } + + getStatus() { + const pid = this.getPid() + if (pid && this.isProcessRunning(pid)) { + return { running: true, pid } + } + return { running: false, pid: null } + } + + start(daemon = false) { + const status = this.getStatus() + if (status.running) { + console.log(`⚠️ 服务已在运行中 (PID: ${status.pid})`) + return false + } + + console.log('🚀 启动 Claude Relay Service...') + + if (daemon) { + // 后台运行模式 - 使用nohup实现真正的后台运行 + const { exec: execChild } = require('child_process') + + const command = `nohup node "${APP_FILE}" > "${LOG_FILE}" 2> "${ERROR_LOG_FILE}" & echo $!` + + execChild(command, (error, stdout) => { + if (error) { + console.error('❌ 后台启动失败:', error.message) + return + } + + const pid = parseInt(stdout.trim()) + if (pid && !isNaN(pid)) { + this.writePid(pid) + console.log(`🔄 服务已在后台启动 (PID: ${pid})`) + console.log(`📝 日志文件: ${LOG_FILE}`) + console.log(`❌ 错误日志: ${ERROR_LOG_FILE}`) + console.log('✅ 终端现在可以安全关闭') + } else { + console.error('❌ 无法获取进程ID') + } + }) + + // 给exec一点时间执行 + setTimeout(() => { + process.exit(0) + }, 1000) + } else { + // 前台运行模式 + const child = spawn('node', [APP_FILE], { + stdio: 'inherit' + }) + + console.log(`🔄 服务已启动 (PID: ${child.pid})`) + + this.writePid(child.pid) + + // 监听进程退出 + child.on('exit', (code, signal) => { + this.removePidFile() + if (code !== 0) { + console.log(`💥 进程退出 (代码: ${code}, 信号: ${signal})`) + } + }) + + child.on('error', (error) => { + console.error('❌ 启动失败:', error.message) + this.removePidFile() + }) + } + + return true + } + + stop() { + const status = this.getStatus() + if (!status.running) { + console.log('⚠️ 服务未在运行') + this.removePidFile() // 清理可能存在的过期PID文件 + return false + } + + console.log(`🛑 停止服务 (PID: ${status.pid})...`) + + try { + // 优雅关闭:先发送SIGTERM + process.kill(status.pid, 'SIGTERM') + + // 等待进程退出 + let attempts = 0 + const maxAttempts = 30 // 30秒超时 + + const checkExit = setInterval(() => { + attempts++ + if (!this.isProcessRunning(status.pid)) { + clearInterval(checkExit) + console.log('✅ 服务已停止') + this.removePidFile() + return + } + + if (attempts >= maxAttempts) { + clearInterval(checkExit) + console.log('⚠️ 优雅关闭超时,强制终止进程...') + try { + process.kill(status.pid, 'SIGKILL') + console.log('✅ 服务已强制停止') + } catch (error) { + console.error('❌ 强制停止失败:', error.message) + } + this.removePidFile() + } + }, 1000) + } catch (error) { + console.error('❌ 停止服务失败:', error.message) + this.removePidFile() + return false + } + + return true + } + + restart(daemon = false) { + console.log('🔄 重启服务...') + this.stop() + // 等待停止完成 + setTimeout(() => { + this.start(daemon) + }, 2000) + + return true + } + + status() { + const status = this.getStatus() + if (status.running) { + console.log(`✅ 服务正在运行 (PID: ${status.pid})`) + + // 显示进程信息 + exec(`ps -p ${status.pid} -o pid,ppid,pcpu,pmem,etime,cmd --no-headers`, (error, stdout) => { + if (!error && stdout.trim()) { + console.log('\n📊 进程信息:') + console.log('PID\tPPID\tCPU%\tMEM%\tTIME\t\tCOMMAND') + console.log(stdout.trim()) + } + }) + } else { + console.log('❌ 服务未运行') + } + return status.running + } + + logs(lines = 50) { + console.log(`📖 最近 ${lines} 行日志:\n`) + + exec(`tail -n ${lines} ${LOG_FILE}`, (error, stdout) => { + if (error) { + console.error('读取日志失败:', error.message) + return + } + console.log(stdout) + }) + } + + help() { + console.log(` +🔧 Claude Relay Service 进程管理器 + +用法: npm run service [options] + +重要提示: + 如果要传递参数,请在npm run命令中使用 -- 分隔符 + npm run service -- [options] + +命令: + start [-d|--daemon] 启动服务 (-d: 后台运行) + stop 停止服务 + restart [-d|--daemon] 重启服务 (-d: 后台运行) + status 查看服务状态 + logs [lines] 查看日志 (默认50行) + help 显示帮助信息 + +命令缩写: + s, start 启动服务 + r, restart 重启服务 + st, status 查看状态 + l, log, logs 查看日志 + halt, stop 停止服务 + h, help 显示帮助 + +示例: + npm run service start # 前台启动 + npm run service -- start -d # 后台启动(正确方式) + npm run service:start:d # 后台启动(推荐快捷方式) + npm run service:daemon # 后台启动(推荐快捷方式) + npm run service stop # 停止服务 + npm run service -- restart -d # 后台重启(正确方式) + npm run service:restart:d # 后台重启(推荐快捷方式) + npm run service status # 查看状态 + npm run service logs # 查看日志 + npm run service -- logs 100 # 查看最近100行日志 + +推荐的快捷方式(无需 -- 分隔符): + npm run service:start:d # 等同于 npm run service -- start -d + npm run service:restart:d # 等同于 npm run service -- restart -d + npm run service:daemon # 等同于 npm run service -- start -d + +直接使用脚本(推荐): + node scripts/manage.js start -d # 后台启动 + node scripts/manage.js restart -d # 后台重启 + node scripts/manage.js status # 查看状态 + node scripts/manage.js logs 100 # 查看最近100行日志 + +文件位置: + PID文件: ${PID_FILE} + 日志文件: ${LOG_FILE} + 错误日志: ${ERROR_LOG_FILE} + `) + } +} + +// 主程序 +function main() { + const manager = new ServiceManager() + const args = process.argv.slice(2) + const command = args[0] + const isDaemon = args.includes('-d') || args.includes('--daemon') + + switch (command) { + case 'start': + case 's': + manager.start(isDaemon) + break + case 'stop': + case 'halt': + manager.stop() + break + case 'restart': + case 'r': + manager.restart(isDaemon) + break + case 'status': + case 'st': + manager.status() + break + case 'logs': + case 'log': + case 'l': { + const lines = parseInt(args[1]) || 50 + manager.logs(lines) + break + } + case 'help': + case '--help': + case '-h': + case 'h': + manager.help() + break + default: + console.log('❌ 未知命令:', command) + manager.help() + process.exit(1) + } +} + +if (require.main === module) { + main() +} + +module.exports = ServiceManager diff --git a/scripts/manage.sh b/scripts/manage.sh new file mode 100755 index 0000000..11ef918 --- /dev/null +++ b/scripts/manage.sh @@ -0,0 +1,1830 @@ +#!/bin/bash + +# Claude Relay Service 管理脚本 +# 用于安装、更新、卸载、启动、停止、重启服务 +# 可以使用 crs 快捷命令调用 + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;36m' # 改为青色(Cyan),更易读 +MAGENTA='\033[0;35m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +# 默认配置 +DEFAULT_INSTALL_DIR="$HOME/claude-relay-service" +DEFAULT_REDIS_HOST="localhost" +DEFAULT_REDIS_PORT="6379" +DEFAULT_REDIS_PASSWORD="" +DEFAULT_APP_PORT="3000" + +# 全局变量 +INSTALL_DIR="" +APP_DIR="" +REDIS_HOST="" +REDIS_PORT="" +REDIS_PASSWORD="" +APP_PORT="" +PUBLIC_IP_CACHE_FILE="/tmp/.crs_public_ip_cache" +PUBLIC_IP_CACHE_DURATION=3600 # 1小时缓存 + +# 打印带颜色的消息 +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# 检测操作系统 +detect_os() { + if [[ "$OSTYPE" == "linux-gnu"* ]]; then + if [ -f /etc/debian_version ]; then + OS="debian" + PACKAGE_MANAGER="apt-get" + elif [ -f /etc/redhat-release ]; then + OS="redhat" + PACKAGE_MANAGER="yum" + elif [ -f /etc/arch-release ]; then + OS="arch" + PACKAGE_MANAGER="pacman" + else + OS="unknown" + fi + elif [[ "$OSTYPE" == "darwin"* ]]; then + OS="macos" + PACKAGE_MANAGER="brew" + else + OS="unknown" + fi +} + +# 检查命令是否存在 +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# 检查端口是否被占用 +check_port() { + local port=$1 + if command_exists lsof; then + lsof -i ":$port" >/dev/null 2>&1 + elif command_exists netstat; then + netstat -tuln | grep ":$port " >/dev/null 2>&1 + elif command_exists ss; then + ss -tuln | grep ":$port " >/dev/null 2>&1 + else + return 1 + fi +} + +# 生成随机字符串 +generate_random_string() { + local length=$1 + if command_exists openssl; then + openssl rand -hex $((length/2)) + else + cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w $length | head -n 1 + fi +} + +# 获取公网IP +get_public_ip() { + local cached_ip="" + local cache_age=0 + + # 检查缓存 + if [ -f "$PUBLIC_IP_CACHE_FILE" ]; then + local current_time=$(date +%s) + local cache_time=$(stat -c %Y "$PUBLIC_IP_CACHE_FILE" 2>/dev/null || stat -f %m "$PUBLIC_IP_CACHE_FILE" 2>/dev/null || echo 0) + cache_age=$((current_time - cache_time)) + + if [ $cache_age -lt $PUBLIC_IP_CACHE_DURATION ]; then + cached_ip=$(cat "$PUBLIC_IP_CACHE_FILE" 2>/dev/null) + if [ -n "$cached_ip" ]; then + echo "$cached_ip" + return 0 + fi + fi + fi + + # 获取新的公网IP + local public_ip="" + if command_exists curl; then + public_ip=$(curl -s --connect-timeout 5 https://ipinfo.io/json | grep -o '"ip":"[^"]*"' | cut -d'"' -f4 2>/dev/null) + elif command_exists wget; then + public_ip=$(wget -qO- --timeout=5 https://ipinfo.io/json | grep -o '"ip":"[^"]*"' | cut -d'"' -f4 2>/dev/null) + fi + + # 如果获取失败,尝试备用API + if [ -z "$public_ip" ]; then + if command_exists curl; then + public_ip=$(curl -s --connect-timeout 5 https://api.ipify.org 2>/dev/null) + elif command_exists wget; then + public_ip=$(wget -qO- --timeout=5 https://api.ipify.org 2>/dev/null) + fi + fi + + # 保存到缓存 + if [ -n "$public_ip" ]; then + echo "$public_ip" > "$PUBLIC_IP_CACHE_FILE" + echo "$public_ip" + else + echo "localhost" + fi +} + +# 检查Node.js版本 +check_node_version() { + if ! command_exists node; then + return 1 + fi + + local node_version=$(node -v | sed 's/v//') + local major_version=$(echo $node_version | cut -d. -f1) + + if [ "$major_version" -lt 18 ]; then + return 1 + fi + + return 0 +} + +# 安装Node.js 18+ +install_nodejs() { + print_info "开始安装 Node.js 18+" + + case $OS in + "debian") + # 使用 NodeSource 仓库 + curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - + sudo $PACKAGE_MANAGER install -y nodejs + ;; + "redhat") + curl -fsSL https://rpm.nodesource.com/setup_18.x | sudo bash - + sudo $PACKAGE_MANAGER install -y nodejs + ;; + "arch") + sudo $PACKAGE_MANAGER -S --noconfirm nodejs npm + ;; + "macos") + if ! command_exists brew; then + print_error "请先安装 Homebrew: https://brew.sh" + return 1 + fi + brew install node@18 + ;; + *) + print_error "不支持的操作系统,请手动安装 Node.js 18+" + return 1 + ;; + esac + + # 验证安装 + if check_node_version; then + print_success "Node.js 安装成功: $(node -v)" + return 0 + else + print_error "Node.js 安装失败或版本不符合要求" + return 1 + fi +} + +# 安装基础依赖 +install_dependencies() { + print_info "检查并安装基础依赖..." + + local deps_to_install=() + + # 检查 git + if ! command_exists git; then + deps_to_install+=("git") + fi + + # 检查其他基础工具 + case $OS in + "debian"|"redhat") + if ! command_exists curl; then + deps_to_install+=("curl") + fi + if ! command_exists wget; then + deps_to_install+=("wget") + fi + if ! command_exists lsof; then + deps_to_install+=("lsof") + fi + ;; + esac + + # 安装缺失的依赖 + if [ ${#deps_to_install[@]} -gt 0 ]; then + print_info "需要安装: ${deps_to_install[*]}" + case $OS in + "debian") + sudo $PACKAGE_MANAGER update + sudo $PACKAGE_MANAGER install -y "${deps_to_install[@]}" + ;; + "redhat") + sudo $PACKAGE_MANAGER install -y "${deps_to_install[@]}" + ;; + "arch") + sudo $PACKAGE_MANAGER -S --noconfirm "${deps_to_install[@]}" + ;; + "macos") + brew install "${deps_to_install[@]}" + ;; + esac + fi + + # 检查 Node.js + if ! check_node_version; then + print_warning "未检测到 Node.js 18+ 版本" + install_nodejs || return 1 + else + print_success "Node.js 版本检查通过: $(node -v)" + fi + + # 检查 npm + if ! command_exists npm; then + print_error "npm 未安装" + return 1 + else + print_success "npm 版本: $(npm -v)" + fi + + return 0 +} + +# 检查Redis +check_redis() { + print_info "检查 Redis 配置..." + + # 交互式询问Redis配置 + echo -e "\n${BLUE}Redis 配置${NC}" + echo -n "Redis 地址 (默认: $DEFAULT_REDIS_HOST): " + read input + REDIS_HOST=${input:-$DEFAULT_REDIS_HOST} + + echo -n "Redis 端口 (默认: $DEFAULT_REDIS_PORT): " + read input + REDIS_PORT=${input:-$DEFAULT_REDIS_PORT} + + echo -n "Redis 密码 (默认: 无密码): " + read -s input + echo + REDIS_PASSWORD=${input:-$DEFAULT_REDIS_PASSWORD} + + # 测试Redis连接 + print_info "测试 Redis 连接..." + if command_exists redis-cli; then + local redis_args=(-h "$REDIS_HOST" -p "$REDIS_PORT") + if [ -n "$REDIS_PASSWORD" ]; then + redis_args+=(-a "$REDIS_PASSWORD") + fi + + if redis-cli "${redis_args[@]}" ping 2>/dev/null | grep -q "PONG"; then + print_success "Redis 连接成功" + return 0 + else + print_error "Redis 连接失败" + return 1 + fi + else + print_warning "redis-cli 未安装,跳过连接测试" + # 仅检查端口是否开放 + if check_port $REDIS_PORT; then + print_info "检测到端口 $REDIS_PORT 已开放" + return 0 + else + print_warning "端口 $REDIS_PORT 未开放,请确保 Redis 正在运行" + return 1 + fi + fi +} + +# 安装本地Redis(可选) +install_local_redis() { + print_info "是否需要在本地安装 Redis?(y/N): " + read -n 1 install_redis + echo + + if [[ ! "$install_redis" =~ ^[Yy]$ ]]; then + return 0 + fi + + case $OS in + "debian") + sudo $PACKAGE_MANAGER update + sudo $PACKAGE_MANAGER install -y redis-server + sudo systemctl start redis-server + sudo systemctl enable redis-server + ;; + "redhat") + sudo $PACKAGE_MANAGER install -y redis + sudo systemctl start redis + sudo systemctl enable redis + ;; + "arch") + sudo $PACKAGE_MANAGER -S --noconfirm redis + sudo systemctl start redis + sudo systemctl enable redis + ;; + "macos") + brew install redis + brew services start redis + ;; + *) + print_error "不支持的操作系统,请手动安装 Redis" + return 1 + ;; + esac + + print_success "Redis 安装完成" + return 0 +} + + +# 检查是否已安装 +check_installation() { + if [ -d "$APP_DIR" ] && [ -f "$APP_DIR/package.json" ]; then + return 0 + fi + return 1 +} + +# 将安装路径持久化到本地(用于后续 update/status 自动识别自定义安装目录) +persist_install_path() { + local conf_dir="$HOME/.config/crs" + local conf_file="$conf_dir/install.conf" + + mkdir -p "$conf_dir" 2>/dev/null || true + if ! { echo "INSTALL_DIR=\"$INSTALL_DIR\"" > "$conf_file" && echo "APP_DIR=\"$APP_DIR\"" >> "$conf_file"; }; then + print_warning "无法写入 $conf_file,后续 update 可能找不到安装目录" + return 1 + fi + return 0 +} + +# 安装服务 +install_service() { + print_info "开始安装 Claude Relay Service..." + + # 询问安装目录 + echo -n "安装目录 (默认: $DEFAULT_INSTALL_DIR): " + read input + INSTALL_DIR=${input:-$DEFAULT_INSTALL_DIR} + APP_DIR="$INSTALL_DIR/app" + + # 询问服务端口 + echo -n "服务端口 (默认: $DEFAULT_APP_PORT): " + read input + APP_PORT=${input:-$DEFAULT_APP_PORT} + + # 检查端口是否被占用 + if check_port $APP_PORT; then + print_warning "端口 $APP_PORT 已被占用" + echo -n "是否继续?(y/N): " + read -n 1 continue_install + echo + if [[ ! "$continue_install" =~ ^[Yy]$ ]]; then + return 1 + fi + fi + + # 检查是否已安装 + if check_installation; then + print_warning "检测到已安装的服务" + echo -n "是否要重新安装?(y/N): " + read -n 1 reinstall + echo + if [[ ! "$reinstall" =~ ^[Yy]$ ]]; then + return 0 + fi + fi + + # 创建安装目录 + mkdir -p "$INSTALL_DIR" + + # 克隆项目 + print_info "克隆项目代码..." + if [ -d "$APP_DIR" ]; then + rm -rf "$APP_DIR" + fi + + if ! git clone https://github.com/Wei-Shaw/claude-relay-service.git "$APP_DIR"; then + print_error "克隆项目失败" + return 1 + fi + + # 进入项目目录 + cd "$APP_DIR" + + # 安装npm依赖 + print_info "安装项目依赖..." + npm install + + # 确保脚本有执行权限(仅在权限不正确时设置) + if [ -f "$APP_DIR/scripts/manage.sh" ] && [ ! -x "$APP_DIR/scripts/manage.sh" ]; then + chmod +x "$APP_DIR/scripts/manage.sh" + print_success "已设置脚本执行权限" + fi + + # 创建配置文件 + print_info "创建配置文件..." + + # 复制示例配置 + if [ -f "config/config.example.js" ]; then + cp config/config.example.js config/config.js + fi + + # 创建.env文件 + cat > .env << EOF +# 环境变量配置 +NODE_ENV=production +PORT=$APP_PORT + +# JWT配置 +JWT_SECRET=$(generate_random_string 64) + +# 加密配置 +ENCRYPTION_KEY=$(generate_random_string 32) + +# Redis配置 +REDIS_HOST=$REDIS_HOST +REDIS_PORT=$REDIS_PORT +REDIS_PASSWORD=$REDIS_PASSWORD + +# 日志配置 +LOG_LEVEL=info +EOF + + # 运行setup命令 + print_info "运行初始化设置..." + npm run setup + + # 获取预构建的前端文件 + print_info "获取预构建的前端文件..." + + # 创建目标目录 + mkdir -p web/admin-spa/dist + + # 从 web-dist 分支获取构建好的文件 + if git ls-remote --heads origin web-dist | grep -q web-dist; then + print_info "从 web-dist 分支下载前端文件..." + + # 创建临时目录用于 clone + TEMP_CLONE_DIR=$(mktemp -d) + + # 使用 sparse-checkout 来只获取需要的文件 + git clone --depth 1 --branch web-dist --single-branch \ + https://github.com/Wei-Shaw/claude-relay-service.git \ + "$TEMP_CLONE_DIR" 2>/dev/null || { + # 如果 HTTPS 失败,尝试使用当前仓库的 remote URL + REPO_URL=$(git config --get remote.origin.url) + git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR" + } + + # 复制文件到目标目录(排除 .git 和 README.md) + rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" web/admin-spa/dist/ 2>/dev/null || { + # 如果没有 rsync,使用 cp + cp -r "$TEMP_CLONE_DIR"/* web/admin-spa/dist/ 2>/dev/null + rm -rf web/admin-spa/dist/.git 2>/dev/null + rm -f web/admin-spa/dist/README.md 2>/dev/null + } + + # 清理临时目录 + rm -rf "$TEMP_CLONE_DIR" + + print_success "前端文件下载完成" + else + print_warning "web-dist 分支不存在,尝试本地构建..." + + # 检查是否有 Node.js 和 npm + if command_exists npm; then + # 回退到原始构建方式 + if [ -f "web/admin-spa/package.json" ]; then + print_info "开始本地构建前端..." + cd web/admin-spa + npm install + npm run build + cd ../.. + print_success "前端本地构建完成" + else + print_error "无法找到前端项目文件" + fi + else + print_error "无法获取前端文件,且本地环境不支持构建" + print_info "请确保仓库已正确配置 web-dist 分支" + fi + fi + + # 创建软链接 + create_symlink + + print_success "安装完成!" + + # 自动启动服务 + print_info "正在启动服务..." + start_service + + # 等待服务启动 + sleep 3 + + # 显示状态 + show_status + + # 获取公网IP + local public_ip=$(get_public_ip) + + echo -e "\n${GREEN}服务已成功安装并启动!${NC}" + echo -e "\n${YELLOW}访问地址:${NC}" + echo -e " 本地 Web: ${GREEN}http://localhost:$APP_PORT/web${NC}" + echo -e " 本地 API: ${GREEN}http://localhost:$APP_PORT/api/v1${NC}" + if [ "$public_ip" != "localhost" ]; then + echo -e " 公网 Web: ${GREEN}http://$public_ip:$APP_PORT/web${NC}" + echo -e " 公网 API: ${GREEN}http://$public_ip:$APP_PORT/api/v1${NC}" + fi + echo -e "\n${YELLOW}管理命令:${NC}" + echo " 查看状态: crs status" + echo " 停止服务: crs stop" + echo " 重启服务: crs restart" +} + + +# 更新服务 +update_service() { + if ! check_installation; then + print_error "服务未安装,请先运行: $0 install" + return 1 + fi + + print_info "更新 Claude Relay Service..." + + cd "$APP_DIR" + + # 保存当前运行状态 + local was_running=false + if pgrep -f "node.*src/app.js" > /dev/null; then + was_running=true + print_info "检测到服务正在运行,将在更新后自动重启..." + stop_service + fi + + # 备份配置文件(只备份.env,config.js可从example恢复) + print_info "备份配置文件..." + if [ -f ".env" ]; then + cp .env .env.backup.$(date +%Y%m%d%H%M%S) + fi + + # 检查本地修改 + print_info "检查本地文件修改..." + local has_changes=false + if git status --porcelain | grep -v "^??" | grep -q .; then + has_changes=true + print_warning "检测到本地文件已修改:" + git status --short | grep -v "^??" + echo "" + echo -e "${YELLOW}警告:更新将使用远程版本覆盖本地修改!${NC}" + + # 创建本地修改的备份 + local backup_branch="backup-$(date +%Y%m%d-%H%M%S)" + print_info "创建本地修改备份分支: $backup_branch" + git stash push -m "Backup before update $(date +%Y-%m-%d)" >/dev/null 2>&1 + git branch "$backup_branch" 2>/dev/null || true + + echo -e "${GREEN}已创建备份分支: $backup_branch${NC}" + echo "如需恢复,可执行: git checkout $backup_branch" + echo "" + + echo -n "是否继续更新?(y/N): " + read -n 1 confirm_update + echo + + if [[ ! "$confirm_update" =~ ^[Yy]$ ]]; then + print_info "已取消更新" + # 恢复 stash 的修改 + git stash pop >/dev/null 2>&1 || true + # 如果之前在运行,重新启动服务 + if [ "$was_running" = true ]; then + print_info "重新启动服务..." + start_service + fi + return 0 + fi + fi + + # 获取最新代码(强制使用远程版本) + print_info "获取最新代码..." + + # 先获取远程更新 + if ! git fetch origin main; then + print_error "获取远程代码失败,请检查网络连接" + return 1 + fi + + # 强制重置到远程版本 + print_info "应用远程更新..." + if ! git reset --hard origin/main; then + print_error "重置到远程版本失败" + # 尝试恢复 + print_info "尝试恢复..." + git reset --hard HEAD + return 1 + fi + + # 清理未跟踪的文件(可选,保留用户新建的文件) + # git clean -fd # 注释掉,避免删除用户的新文件 + + print_success "代码已更新到最新版本" + + # 更新依赖 + print_info "更新依赖..." + npm install + + # 确保脚本有执行权限(仅在权限不正确时设置) + if [ -f "$APP_DIR/scripts/manage.sh" ] && [ ! -x "$APP_DIR/scripts/manage.sh" ]; then + chmod +x "$APP_DIR/scripts/manage.sh" + fi + + # 获取最新的预构建前端文件 + print_info "更新前端文件..." + + # 创建目标目录 + mkdir -p web/admin-spa/dist + + # 清理旧的前端文件(保留用户自定义文件) + if [ -d "web/admin-spa/dist" ]; then + print_info "清理旧的前端文件..." + # 只删除已知的前端文件,保留用户可能添加的自定义文件 + rm -rf web/admin-spa/dist/assets 2>/dev/null + rm -f web/admin-spa/dist/index.html 2>/dev/null + rm -f web/admin-spa/dist/favicon.ico 2>/dev/null + fi + + # 从 web-dist 分支获取构建好的文件 + if git ls-remote --heads origin web-dist | grep -q web-dist; then + print_info "从 web-dist 分支下载最新前端文件..." + + # 创建临时目录用于 clone + TEMP_CLONE_DIR=$(mktemp -d) + + # 添加错误处理 + if [ ! -d "$TEMP_CLONE_DIR" ]; then + print_error "无法创建临时目录" + return 1 + fi + + # 使用 sparse-checkout 来只获取需要的文件,添加重试机制 + local clone_success=false + for attempt in 1 2 3; do + print_info "尝试下载前端文件 (第 $attempt 次)..." + + if git clone --depth 1 --branch web-dist --single-branch \ + https://github.com/Wei-Shaw/claude-relay-service.git \ + "$TEMP_CLONE_DIR" 2>/dev/null; then + clone_success=true + break + fi + + # 如果 HTTPS 失败,尝试使用当前仓库的 remote URL + REPO_URL=$(git config --get remote.origin.url) + if git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR" 2>/dev/null; then + clone_success=true + break + fi + + if [ $attempt -lt 3 ]; then + print_warning "下载失败,等待 2 秒后重试..." + sleep 2 + fi + done + + if [ "$clone_success" = false ]; then + print_error "无法下载前端文件" + rm -rf "$TEMP_CLONE_DIR" + return 1 + fi + + # 复制文件到目标目录(排除 .git 和 README.md) + rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" web/admin-spa/dist/ 2>/dev/null || { + # 如果没有 rsync,使用 cp + cp -r "$TEMP_CLONE_DIR"/* web/admin-spa/dist/ 2>/dev/null + rm -rf web/admin-spa/dist/.git 2>/dev/null + rm -f web/admin-spa/dist/README.md 2>/dev/null + } + + # 清理临时目录 + rm -rf "$TEMP_CLONE_DIR" + + print_success "前端文件更新完成" + else + print_warning "web-dist 分支不存在,尝试本地构建..." + + # 检查是否有 Node.js 和 npm + if command_exists npm; then + # 回退到原始构建方式 + if [ -f "web/admin-spa/package.json" ]; then + print_info "开始本地构建前端..." + cd web/admin-spa + npm install + npm run build + cd ../.. + print_success "前端本地构建完成" + else + print_error "无法找到前端项目文件" + fi + else + print_error "无法获取前端文件,且本地环境不支持构建" + print_info "请确保仓库已正确配置 web-dist 分支" + fi + fi + + # 更新软链接到最新版本 + create_symlink + + # 持久化安装路径,便于后续 update/status 自动识别 + persist_install_path || true + + # 如果之前在运行,则重新启动服务 + if [ "$was_running" = true ]; then + print_info "重新启动服务..." + start_service + fi + + print_success "更新完成!" + + # 显示更新摘要 + echo "" + echo -e "${BLUE}=== 更新摘要 ===${NC}" + + # 显示版本信息 + if [ -f "$APP_DIR/VERSION" ]; then + echo -e "当前版本: ${GREEN}$(cat "$APP_DIR/VERSION")${NC}" + fi + + # 显示最新的提交信息 + local latest_commit=$(git log -1 --oneline 2>/dev/null) + if [ -n "$latest_commit" ]; then + echo -e "最新提交: ${GREEN}$latest_commit${NC}" + fi + + # 显示备份信息 + echo -e "\n${YELLOW}配置文件备份:${NC}" + ls -la .env.backup.* 2>/dev/null | tail -3 || echo " 无备份文件" + + # 提醒用户检查配置 + echo -e "\n${YELLOW}提示:${NC}" + echo " - 配置文件已自动备份" + echo " - 如有本地修改已保存到备份分支" + echo " - 建议检查 .env 和 config/config.js 配置" + + echo -e "\n${BLUE}==================${NC}" +} + +# 卸载服务 +uninstall_service() { + if [ -z "$INSTALL_DIR" ]; then + echo -n "请输入安装目录 (默认: $DEFAULT_INSTALL_DIR): " + read input + INSTALL_DIR=${input:-$DEFAULT_INSTALL_DIR} + APP_DIR="$INSTALL_DIR/app" + fi + + if [ ! -d "$INSTALL_DIR" ]; then + print_error "安装目录不存在" + return 1 + fi + + print_warning "即将卸载 Claude Relay Service" + echo -n "确定要卸载吗?(y/N): " + read -n 1 confirm + echo + + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + return 0 + fi + + # 停止服务 + stop_service + + # 备份数据 + echo -n "是否备份数据?(y/N): " + read -n 1 backup + echo + + if [[ "$backup" =~ ^[Yy]$ ]]; then + local backup_dir="$HOME/claude-relay-backup-$(date +%Y%m%d%H%M%S)" + mkdir -p "$backup_dir" + + # Redis使用系统默认位置,不需要备份 + + # 备份配置文件 + if [ -f "$APP_DIR/.env" ]; then + cp "$APP_DIR/.env" "$backup_dir/" + fi + if [ -f "$APP_DIR/config/config.js" ]; then + cp "$APP_DIR/config/config.js" "$backup_dir/" + fi + + print_success "数据已备份到: $backup_dir" + fi + + # 删除安装目录 + rm -rf "$INSTALL_DIR" + + print_success "卸载完成!" +} + +# 启动服务 +start_service() { + if ! check_installation; then + print_error "服务未安装,请先运行: $0 install" + return 1 + fi + + print_info "启动服务..." + + cd "$APP_DIR" + + # 检查是否已运行 + if pgrep -f "node.*src/app.js" > /dev/null; then + print_warning "服务已在运行" + return 0 + fi + + # 确保日志目录存在 + mkdir -p "$APP_DIR/logs" + + # 检查pm2是否可用并且不是从package.json脚本调用的 + if command_exists pm2 && [ "$1" != "--no-pm2" ]; then + print_info "使用 pm2 启动服务..." + # 直接使用pm2启动,避免循环调用 + pm2 start "$APP_DIR/src/app.js" --name "claude-relay" --log "$APP_DIR/logs/pm2.log" 2>/dev/null + sleep 2 + + # 检查是否启动成功 + if pm2 list 2>/dev/null | grep -q "claude-relay"; then + print_success "服务已通过 pm2 启动" + pm2 save 2>/dev/null || true + else + print_warning "pm2 启动失败,尝试直接启动..." + start_service_direct + fi + else + start_service_direct + fi + + sleep 2 + + # 验证服务是否成功启动 + if pgrep -f "node.*src/app.js" > /dev/null; then + show_status + else + print_error "服务启动失败,请查看日志: $APP_DIR/logs/service.log" + if [ -f "$APP_DIR/logs/service.log" ]; then + echo "最近的错误日志:" + tail -n 20 "$APP_DIR/logs/service.log" + fi + return 1 + fi +} + +# 直接启动服务(不使用pm2) +start_service_direct() { + print_info "使用后台进程启动服务..." + + # 使用setsid创建新会话,确保进程完全脱离终端 + if command_exists setsid; then + # setsid方式(推荐) + setsid nohup node "$APP_DIR/src/app.js" > "$APP_DIR/logs/service.log" 2>&1 < /dev/null & + local pid=$! + sleep 1 + + # 获取实际的子进程PID + local real_pid=$(pgrep -f "node.*src/app.js" | head -1) + if [ -n "$real_pid" ]; then + echo $real_pid > "$APP_DIR/.pid" + print_success "服务已在后台启动 (PID: $real_pid)" + else + echo $pid > "$APP_DIR/.pid" + print_success "服务已在后台启动 (PID: $pid)" + fi + else + # 备用方式:使用nohup和disown + nohup node "$APP_DIR/src/app.js" > "$APP_DIR/logs/service.log" 2>&1 < /dev/null & + local pid=$! + disown $pid 2>/dev/null || true + echo $pid > "$APP_DIR/.pid" + print_success "服务已在后台启动 (PID: $pid)" + fi +} + +# 停止服务 +stop_service() { + print_info "停止服务..." + + # 尝试使用pm2停止 + if command_exists pm2 && [ -n "$APP_DIR" ] && [ -d "$APP_DIR" ]; then + cd "$APP_DIR" 2>/dev/null + pm2 stop claude-relay 2>/dev/null || true + pm2 delete claude-relay 2>/dev/null || true + fi + + # 使用PID文件停止 + if [ -f "$APP_DIR/.pid" ]; then + local pid=$(cat "$APP_DIR/.pid") + if kill -0 $pid 2>/dev/null; then + kill $pid + rm -f "$APP_DIR/.pid" + fi + fi + + # 强制停止所有相关进程 + pkill -f "node.*src/app.js" 2>/dev/null || true + + # 等待进程完全退出(最多等待10秒) + local wait_count=0 + while pgrep -f "node.*src/app.js" > /dev/null; do + if [ $wait_count -ge 10 ]; then + print_warning "进程停止超时,尝试强制终止..." + pkill -9 -f "node.*src/app.js" 2>/dev/null || true + sleep 1 + break + fi + sleep 1 + wait_count=$((wait_count + 1)) + done + + # 最终确认进程已停止 + if pgrep -f "node.*src/app.js" > /dev/null; then + print_error "无法完全停止服务进程" + return 1 + fi + + print_success "服务已停止" +} + +# 重启服务 +restart_service() { + print_info "重启服务..." + + # 停止服务并检查结果 + if ! stop_service; then + print_error "停止服务失败" + return 1 + fi + + # 短暂等待,确保端口释放 + sleep 1 + + # 启动服务,如果失败则重试 + local retry_count=0 + while [ $retry_count -lt 3 ]; do + # 清除可能的僵尸进程检测 + if ! pgrep -f "node.*src/app.js" > /dev/null; then + # 进程确实已停止,可以启动 + if start_service; then + return 0 + fi + fi + + retry_count=$((retry_count + 1)) + if [ $retry_count -lt 3 ]; then + print_warning "启动失败,等待2秒后重试(第 $retry_count 次)..." + sleep 2 + fi + done + + print_error "重启服务失败" + return 1 +} + +# 更新模型价格 +update_model_pricing() { + if ! check_installation; then + print_error "服务未安装,请先运行: $0 install" + return 1 + fi + + print_info "更新模型价格数据..." + + cd "$APP_DIR" + + # 运行更新脚本 + if npm run update:pricing; then + print_success "模型价格数据更新完成" + + # 显示更新后的信息 + if [ -f "data/model_pricing.json" ]; then + local model_count=$(grep -o '"[^"]*"\s*:' data/model_pricing.json | wc -l) + local file_size=$(du -h data/model_pricing.json | cut -f1) + echo -e "\n更新信息:" + echo -e " 模型数量: ${GREEN}$model_count${NC}" + echo -e " 文件大小: ${GREEN}$file_size${NC}" + echo -e " 文件位置: $APP_DIR/data/model_pricing.json" + fi + else + print_error "模型价格数据更新失败" + return 1 + fi +} + +# 切换分支 +switch_branch() { + if ! check_installation; then + print_error "服务未安装,请先运行: $0 install" + return 1 + fi + + cd "$APP_DIR" + + # 获取当前分支 + local current_branch=$(git branch --show-current 2>/dev/null) + if [ -z "$current_branch" ]; then + print_error "无法获取当前分支信息" + return 1 + fi + + print_info "当前分支: ${GREEN}$current_branch${NC}" + + # 获取所有远程分支 + print_info "获取远程分支列表..." + git fetch origin --prune >/dev/null 2>&1 + + # 显示可用分支 + echo -e "\n${YELLOW}可用分支:${NC}" + local branches=$(git branch -r | grep -v HEAD | sed 's/origin\///' | sed 's/^ *//') + local branch_array=() + local i=1 + + while IFS= read -r branch; do + if [ "$branch" = "$current_branch" ]; then + echo -e " $i) $branch ${GREEN}(当前)${NC}" + else + echo " $i) $branch" + fi + branch_array+=("$branch") + ((i++)) + done <<< "$branches" + + echo "" + echo -n "请选择要切换的分支 (输入编号或分支名,0 取消): " + read branch_choice + + # 处理用户输入 + local target_branch="" + if [ "$branch_choice" = "0" ]; then + print_info "已取消切换" + return 0 + elif [[ "$branch_choice" =~ ^[0-9]+$ ]]; then + # 用户输入的是编号 + local index=$((branch_choice - 1)) + if [ $index -ge 0 ] && [ $index -lt ${#branch_array[@]} ]; then + target_branch="${branch_array[$index]}" + else + print_error "无效的编号" + return 1 + fi + else + # 用户输入的是分支名 + target_branch="$branch_choice" + # 验证分支是否存在 + if ! echo "$branches" | grep -q "^$target_branch$"; then + print_error "分支 '$target_branch' 不存在" + return 1 + fi + fi + + # 如果是同一个分支,无需切换 + if [ "$target_branch" = "$current_branch" ]; then + print_info "已经在分支 $target_branch 上" + return 0 + fi + + print_info "准备切换到分支: ${GREEN}$target_branch${NC}" + + # 保存当前运行状态 + local was_running=false + if pgrep -f "node.*src/app.js" > /dev/null; then + was_running=true + print_info "检测到服务正在运行,将在切换后自动重启..." + stop_service + fi + + # 处理本地修改(主要是权限变更导致的) + print_info "检查本地修改..." + + # 先重置所有权限相关的修改(特别是manage.sh的权限) + git status --porcelain | while read -r line; do + local file=$(echo "$line" | awk '{print $2}') + if [ -n "$file" ]; then + # 检查是否只是权限变更 + if git diff --summary "$file" 2>/dev/null | grep -q "mode change"; then + print_info "重置文件权限变更: $file" + git checkout HEAD -- "$file" 2>/dev/null || true + fi + fi + done + + # 检查是否还有其他实质性修改 + if git status --porcelain | grep -v "^??" | grep -q .; then + print_warning "检测到本地文件修改:" + git status --short | grep -v "^??" + echo "" + echo -n "是否要保存这些修改?(y/N): " + read -n 1 save_changes + echo + + if [[ "$save_changes" =~ ^[Yy]$ ]]; then + # 暂存修改 + print_info "暂存本地修改..." + git stash push -m "Branch switch from $current_branch to $target_branch $(date +%Y-%m-%d)" >/dev/null 2>&1 + else + # 丢弃修改 + print_info "丢弃本地修改..." + git reset --hard HEAD >/dev/null 2>&1 + fi + fi + + # 切换分支 + print_info "切换分支..." + + # 检查本地是否已有该分支 + if git show-ref --verify --quiet "refs/heads/$target_branch"; then + # 本地已有分支,切换并更新 + if ! git checkout "$target_branch" 2>/dev/null; then + print_error "切换分支失败" + return 1 + fi + + # 更新到最新 + print_info "更新到远程最新版本..." + git pull origin "$target_branch" --rebase 2>/dev/null || { + # 如果rebase失败,使用reset + print_warning "更新失败,强制同步到远程版本..." + git fetch origin "$target_branch" + git reset --hard "origin/$target_branch" + } + else + # 创建并切换到新分支 + if ! git checkout -b "$target_branch" "origin/$target_branch" 2>/dev/null; then + print_error "创建并切换分支失败" + return 1 + fi + fi + + print_success "已切换到分支: $target_branch" + + # 确保脚本有执行权限(切换分支后必须执行) + if [ -f "$APP_DIR/scripts/manage.sh" ]; then + chmod +x "$APP_DIR/scripts/manage.sh" + print_info "已设置脚本执行权限" + fi + + # 更新依赖(如果package.json有变化) + if git diff "$current_branch..$target_branch" --name-only | grep -q "package.json"; then + print_info "检测到 package.json 变化,更新依赖..." + npm install + fi + + # 更新前端文件(如果切换到不同版本) + if [ "$target_branch" != "$current_branch" ]; then + print_info "更新前端文件..." + + # 创建目标目录 + mkdir -p web/admin-spa/dist + + # 清理旧的前端文件 + if [ -d "web/admin-spa/dist" ]; then + rm -rf web/admin-spa/dist/* 2>/dev/null || true + fi + + # 尝试从对应的 web-dist 分支获取前端文件 + if git ls-remote --heads origin "web-dist-$target_branch" | grep -q "web-dist-$target_branch"; then + print_info "从 web-dist-$target_branch 分支下载前端文件..." + local web_branch="web-dist-$target_branch" + elif git ls-remote --heads origin web-dist | grep -q web-dist; then + print_info "从 web-dist 分支下载前端文件..." + local web_branch="web-dist" + else + print_warning "未找到预构建的前端文件" + web_branch="" + fi + + if [ -n "$web_branch" ]; then + # 创建临时目录用于 clone + TEMP_CLONE_DIR=$(mktemp -d) + + # 下载前端文件 + if git clone --depth 1 --branch "$web_branch" --single-branch \ + https://github.com/Wei-Shaw/claude-relay-service.git \ + "$TEMP_CLONE_DIR" 2>/dev/null; then + + # 复制文件到目标目录 + rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" web/admin-spa/dist/ 2>/dev/null || { + cp -r "$TEMP_CLONE_DIR"/* web/admin-spa/dist/ 2>/dev/null + rm -rf web/admin-spa/dist/.git 2>/dev/null + rm -f web/admin-spa/dist/README.md 2>/dev/null + } + + print_success "前端文件更新完成" + else + print_warning "下载前端文件失败" + fi + + # 清理临时目录 + rm -rf "$TEMP_CLONE_DIR" + fi + fi + + # 检查是否有暂存的修改可以恢复 + if [[ "$save_changes" =~ ^[Yy]$ ]] && git stash list | grep -q "Branch switch from $current_branch to $target_branch"; then + echo "" + echo -n "是否要恢复之前暂存的修改?(y/N): " + read -n 1 restore_stash + echo + + if [[ "$restore_stash" =~ ^[Yy]$ ]]; then + print_info "恢复暂存的修改..." + git stash pop >/dev/null 2>&1 || print_warning "恢复修改时出现冲突,请手动解决" + fi + fi + + # 如果之前在运行,则重新启动服务 + if [ "$was_running" = true ]; then + print_info "重新启动服务..." + start_service + fi + + # 显示切换后的信息 + echo "" + echo -e "${GREEN}=== 分支切换完成 ===${NC}" + echo -e "当前分支: ${GREEN}$target_branch${NC}" + + # 显示版本信息 + if [ -f "$APP_DIR/VERSION" ]; then + echo -e "当前版本: ${GREEN}$(cat "$APP_DIR/VERSION")${NC}" + fi + + # 显示最新提交 + local latest_commit=$(git log -1 --oneline 2>/dev/null) + if [ -n "$latest_commit" ]; then + echo -e "最新提交: ${GREEN}$latest_commit${NC}" + fi + + echo "" + print_info "提示:如遇到问题,可以运行 'crs update' 强制更新到最新版本" +} + +# 显示状态 +show_status() { + echo -e "\n${BLUE}=== Claude Relay Service 状态 ===${NC}" + + # 获取实际端口 + local actual_port="$APP_PORT" + if [ -z "$actual_port" ] && [ -f "$APP_DIR/.env" ]; then + actual_port=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2) + fi + actual_port=${actual_port:-3000} + + # 检查进程 + local pid=$(pgrep -f "node.*src/app.js" | head -1) + if [ -n "$pid" ]; then + echo -e "服务状态: ${GREEN}运行中${NC}" + echo "进程 PID: $pid" + + # 显示进程信息 + if command_exists ps; then + local proc_info=$(ps -p $pid -o comm,etime,rss --no-headers 2>/dev/null) + if [ -n "$proc_info" ]; then + echo "进程信息: $proc_info" + fi + fi + echo "服务端口: $actual_port" + + # 获取公网IP + local public_ip=$(get_public_ip) + + # 显示访问地址 + echo -e "\n访问地址:" + echo -e " 本地 Web: ${GREEN}http://localhost:$actual_port/web${NC}" + echo -e " 本地 API: ${GREEN}http://localhost:$actual_port/api/v1${NC}" + if [ "$public_ip" != "localhost" ]; then + echo -e " 公网 Web: ${GREEN}http://$public_ip:$actual_port/web${NC}" + echo -e " 公网 API: ${GREEN}http://$public_ip:$actual_port/api/v1${NC}" + fi + else + echo -e "服务状态: ${RED}未运行${NC}" + fi + + # 显示安装信息 + if [ -n "$INSTALL_DIR" ] && [ -d "$INSTALL_DIR" ]; then + echo -e "\n安装目录: $INSTALL_DIR" + elif [ -d "$DEFAULT_INSTALL_DIR" ]; then + echo -e "\n安装目录: $DEFAULT_INSTALL_DIR" + fi + + # Redis状态 + if command_exists redis-cli; then + echo -e "\nRedis 状态:" + local redis_cmd="redis-cli" + if [ -n "$REDIS_HOST" ]; then + redis_cmd="$redis_cmd -h $REDIS_HOST" + fi + if [ -n "$REDIS_PORT" ]; then + redis_cmd="$redis_cmd -p $REDIS_PORT" + fi + if [ -n "$REDIS_PASSWORD" ]; then + redis_cmd="$redis_cmd -a '$REDIS_PASSWORD'" + fi + + if $redis_cmd ping 2>/dev/null | grep -q "PONG"; then + echo -e " 连接状态: ${GREEN}正常${NC}" + else + echo -e " 连接状态: ${RED}异常${NC}" + fi + fi + + echo -e "\n${BLUE}===========================${NC}" +} + +# 显示帮助 +show_help() { + echo "Claude Relay Service 管理脚本" + echo "" + echo "用法: $0 [命令]" + echo "" + echo "命令:" + echo " install - 安装服务" + echo " update - 更新服务" + echo " uninstall - 卸载服务" + echo " start - 启动服务" + echo " stop - 停止服务" + echo " restart - 重启服务" + echo " status - 查看状态" + echo " switch-branch - 切换分支" + echo " update-pricing - 更新模型价格数据" + echo " symlink - 创建 crs 快捷命令" + echo " help - 显示帮助" + echo "" +} + +# 交互式菜单 +show_menu() { + clear + echo -e "${BOLD}======================================${NC}" + echo -e "${BOLD} Claude Relay Service (CRS) 管理工具 ${NC}" + echo -e "${BOLD}======================================${NC}" + echo "" + + # 显示当前状态 + echo -e "${YELLOW}当前状态:${NC}" + if check_installation; then + echo -e " 安装状态: ${GREEN}已安装${NC} (目录: $INSTALL_DIR)" + + # 获取实际端口 + local actual_port="$APP_PORT" + if [ -z "$actual_port" ] && [ -f "$APP_DIR/.env" ]; then + actual_port=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2) + fi + actual_port=${actual_port:-3000} + + # 检查服务状态 + local pid=$(pgrep -f "node.*src/app.js" | head -1) + if [ -n "$pid" ]; then + echo -e " 运行状态: ${GREEN}运行中${NC}" + echo -e " 进程 PID: $pid" + echo -e " 服务端口: $actual_port" + + # 获取公网IP + local public_ip=$(get_public_ip) + if [ "$public_ip" != "localhost" ]; then + echo -e " 公网地址: ${GREEN}http://$public_ip:$actual_port/web${NC}" + else + echo -e " Web 界面: ${GREEN}http://localhost:$actual_port/web${NC}" + fi + else + echo -e " 运行状态: ${RED}未运行${NC}" + fi + else + echo -e " 安装状态: ${RED}未安装${NC}" + fi + + # Redis状态 + if command_exists redis-cli && [ -n "$REDIS_HOST" ]; then + local redis_cmd="redis-cli -h $REDIS_HOST -p ${REDIS_PORT:-6379}" + if [ -n "$REDIS_PASSWORD" ]; then + redis_cmd="$redis_cmd -a '$REDIS_PASSWORD'" + fi + + if $redis_cmd ping 2>/dev/null | grep -q "PONG"; then + echo -e " Redis 状态: ${GREEN}连接正常${NC}" + else + echo -e " Redis 状态: ${RED}连接异常${NC}" + fi + fi + + echo "" + echo -e "${BOLD}--------------------------------------${NC}" + echo -e "${YELLOW}请选择操作:${NC}" + echo "" + + if ! check_installation; then + echo " 1) 安装服务" + echo " 2) 退出" + echo "" + echo -n "请输入选项 [1-2]: " + else + echo " 1) 查看状态" + echo " 2) 启动服务" + echo " 3) 停止服务" + echo " 4) 重启服务" + echo " 5) 更新服务" + echo " 6) 切换分支" + echo " 7) 更新模型价格" + echo " 8) 卸载服务" + echo " 9) 退出" + echo "" + echo -n "请输入选项 [1-9]: " + fi +} + +# 处理菜单选择 +handle_menu_choice() { + local choice=$1 + + if ! check_installation; then + case $choice in + 1) + echo "" + # 检查依赖 + if ! install_dependencies; then + print_error "依赖安装失败" + echo -n "按回车键继续..." + read + return 1 + fi + + # 检查Redis + if ! check_redis; then + print_warning "Redis 连接失败" + install_local_redis + + # 重新测试连接 + REDIS_HOST="localhost" + REDIS_PORT="6379" + if ! check_redis; then + print_error "Redis 配置失败,请手动安装并配置 Redis" + echo -n "按回车键继续..." + read + return 1 + fi + fi + + # 安装服务 + install_service + + # 创建软链接 + create_symlink + + echo -n "按回车键继续..." + read + ;; + 2) + echo "退出管理工具" + exit 0 + ;; + *) + print_error "无效选项" + sleep 1 + ;; + esac + else + case $choice in + 1) + echo "" + show_status + echo -n "按回车键继续..." + read + ;; + 2) + echo "" + start_service + echo -n "按回车键继续..." + read + ;; + 3) + echo "" + stop_service + echo -n "按回车键继续..." + read + ;; + 4) + echo "" + restart_service + echo -n "按回车键继续..." + read + ;; + 5) + echo "" + update_service + echo -n "按回车键继续..." + read + ;; + 6) + echo "" + switch_branch + echo -n "按回车键继续..." + read + ;; + 7) + echo "" + update_model_pricing + echo -n "按回车键继续..." + read + ;; + 8) + echo "" + uninstall_service + if [ $? -eq 0 ]; then + exit 0 + fi + ;; + 9) + echo "退出管理工具" + exit 0 + ;; + *) + print_error "无效选项" + sleep 1 + ;; + esac + fi +} + +# 创建软链接 +create_symlink() { + # 获取脚本的绝对路径 + local script_path="" + + # 优先使用项目中的 manage.sh(在 app/scripts 目录下) + if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/scripts/manage.sh" ]; then + script_path="$APP_DIR/scripts/manage.sh" + # 确保脚本有执行权限 + chmod +x "$script_path" 2>/dev/null || sudo chmod +x "$script_path" 2>/dev/null || true + elif [ -f "/app/scripts/manage.sh" ] && [ "$(basename "$0")" = "manage.sh" ]; then + # Docker 容器中的路径 + script_path="/app/scripts/manage.sh" + elif command_exists realpath; then + script_path="$(realpath "$0")" + elif command_exists readlink && readlink -f "$0" >/dev/null 2>&1; then + script_path="$(readlink -f "$0")" + else + # 备用方法:使用pwd和脚本名 + script_path="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" + fi + + local symlink_path="/usr/bin/crs" + + print_info "创建命令行快捷方式..." + print_info "APP_DIR: $APP_DIR" + print_info "脚本路径: $script_path" + + # 检查脚本文件是否存在 + if [ ! -f "$script_path" ]; then + print_error "找不到脚本文件: $script_path" + print_info "当前目录: $(pwd)" + print_info "脚本参数 \$0: $0" + if [ -n "$APP_DIR" ]; then + print_info "检查项目目录结构:" + ls -la "$APP_DIR/" 2>/dev/null | head -5 + if [ -d "$APP_DIR/scripts" ]; then + print_info "scripts 目录内容:" + ls -la "$APP_DIR/scripts/" 2>/dev/null | grep manage.sh + fi + fi + return 1 + fi + + # 如果已存在,直接删除并重新创建(默认使用代码中的最新版本) + if [ -L "$symlink_path" ] || [ -f "$symlink_path" ]; then + print_info "更新已存在的软链接..." + sudo rm -f "$symlink_path" 2>/dev/null || { + print_error "删除旧文件失败" + return 1 + } + fi + + # 创建软链接 + if sudo ln -s "$script_path" "$symlink_path"; then + print_success "已创建快捷命令 'crs'" + echo "您现在可以在任何地方使用 'crs' 命令管理服务" + + # 验证软链接 + if [ -L "$symlink_path" ]; then + print_info "软链接验证成功" + else + print_warning "软链接验证失败" + fi + else + print_error "创建软链接失败" + print_info "请手动执行以下命令:" + echo " sudo ln -s '$script_path' '$symlink_path'" + return 1 + fi +} + +# 加载已安装的配置 +load_config() { + # 1) 优先使用外部显式提供的 APP_DIR + if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/package.json" ]; then + : + else + # 2) 若提供了 INSTALL_DIR,则据此推导 APP_DIR + if [ -n "$INSTALL_DIR" ]; then + if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then + APP_DIR="$INSTALL_DIR/app" + elif [ -f "$INSTALL_DIR/package.json" ]; then + APP_DIR="$INSTALL_DIR" + fi + fi + + # 3) 尝试从持久化配置读取安装位置 + if [ -z "$APP_DIR" ]; then + local conf_file="$HOME/.config/crs/install.conf" + if [ -f "$conf_file" ]; then + local conf_install_dir + local conf_app_dir + conf_install_dir=$(awk -F= '/^INSTALL_DIR=/{sub(/^"/,"",$2); sub(/"$/, "", $2); print $2}' "$conf_file" 2>/dev/null) + conf_app_dir=$(awk -F= '/^APP_DIR=/{sub(/^"/,"",$2); sub(/"$/, "", $2); print $2}' "$conf_file" 2>/dev/null) + + if [ -n "$conf_app_dir" ] && [ -f "$conf_app_dir/package.json" ]; then + APP_DIR="$conf_app_dir" + [ -z "$INSTALL_DIR" ] && INSTALL_DIR="$(cd "$conf_app_dir/.." 2>/dev/null && pwd)" + elif [ -n "$conf_install_dir" ]; then + if [ -d "$conf_install_dir/app" ] && [ -f "$conf_install_dir/app/package.json" ]; then + INSTALL_DIR="$conf_install_dir" + APP_DIR="$conf_install_dir/app" + elif [ -f "$conf_install_dir/package.json" ]; then + INSTALL_DIR="$conf_install_dir" + APP_DIR="$conf_install_dir" + fi + fi + fi + fi + + # 4) 基于脚本自身路径推导(处理从 app/scripts/manage.sh 或软链调用的情形) + if [ -z "$APP_DIR" ]; then + local script_path="" + if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/scripts/manage.sh" ]; then + script_path="$APP_DIR/scripts/manage.sh" + elif command_exists realpath; then + script_path="$(realpath "$0" 2>/dev/null)" + elif command_exists readlink && readlink -f "$0" >/dev/null 2>&1; then + script_path="$(readlink -f "$0")" + else + script_path="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")" + fi + local script_dir="$(cd "$(dirname "$script_path")" && pwd)" + local parent_dir="$(cd "$script_dir/.." && pwd)" + if [ -f "$parent_dir/package.json" ]; then + APP_DIR="$parent_dir" + INSTALL_DIR="$(cd "$parent_dir/.." 2>/dev/null && pwd)" + elif [ -f "$parent_dir/app/package.json" ]; then + APP_DIR="$parent_dir/app" + INSTALL_DIR="$parent_dir" + fi + fi + + # 5) 退回到默认目录逻辑 + if [ -z "$INSTALL_DIR" ]; then + if [ -d "$DEFAULT_INSTALL_DIR" ]; then + INSTALL_DIR="$DEFAULT_INSTALL_DIR" + fi + fi + if [ -n "$INSTALL_DIR" ] && [ -z "$APP_DIR" ]; then + if [ -d "$INSTALL_DIR/app" ] && [ -f "$INSTALL_DIR/app/package.json" ]; then + APP_DIR="$INSTALL_DIR/app" + elif [ -f "$INSTALL_DIR/package.json" ]; then + APP_DIR="$INSTALL_DIR" + else + APP_DIR="$INSTALL_DIR/app" + fi + fi + fi + + # 6) 加载 .env 配置(如存在) + if [ -n "$APP_DIR" ] && [ -f "$APP_DIR/.env" ]; then + export $(cat "$APP_DIR/.env" | grep -v '^#' | xargs) + APP_PORT=$(grep "^PORT=" "$APP_DIR/.env" 2>/dev/null | cut -d'=' -f2) + fi +} + +# 主函数 +main() { + # 检测操作系统 + detect_os + + if [ "$OS" == "unknown" ]; then + print_error "不支持的操作系统" + exit 1 + fi + + # 加载配置 + load_config + + # 处理命令 + case "$1" in + install) + # 检查依赖 + if ! install_dependencies; then + print_error "依赖安装失败" + exit 1 + fi + + # 检查Redis + if ! check_redis; then + print_warning "Redis 连接失败" + install_local_redis + + # 重新测试连接 + REDIS_HOST="localhost" + REDIS_PORT="6379" + if ! check_redis; then + print_error "Redis 配置失败,请手动安装并配置 Redis" + exit 1 + fi + fi + + # 安装服务 + install_service + + # 创建软链接 + create_symlink + ;; + update) + update_service + ;; + uninstall) + uninstall_service + ;; + start) + start_service + ;; + stop) + stop_service + ;; + restart) + restart_service + ;; + status) + show_status + ;; + switch-branch) + switch_branch + ;; + update-pricing) + update_model_pricing + ;; + symlink) + # 单独创建软链接 + # 确保 APP_DIR 已设置 + if [ -z "$APP_DIR" ]; then + print_error "请先安装项目后再创建软链接" + print_info "运行: $0 install" + exit 1 + fi + create_symlink + ;; + help) + show_help + ;; + "") + # 无参数时显示交互式菜单 + while true; do + show_menu + read choice + handle_menu_choice "$choice" + done + ;; + *) + print_error "未知命令: $1" + echo "" + show_help + ;; + esac +} + +# 运行主函数 +main "$@" diff --git a/scripts/migrate-apikey-expiry.js b/scripts/migrate-apikey-expiry.js new file mode 100644 index 0000000..b418169 --- /dev/null +++ b/scripts/migrate-apikey-expiry.js @@ -0,0 +1,193 @@ +#!/usr/bin/env node + +/** + * 数据迁移脚本:为现有 API Key 设置默认有效期 + * + * 使用方法: + * node scripts/migrate-apikey-expiry.js [--days=30] [--dry-run] + * + * 参数: + * --days: 设置默认有效期天数(默认30天) + * --dry-run: 仅模拟运行,不实际修改数据 + */ + +const redis = require('../src/models/redis') +const apiKeyService = require('../src/services/apiKeyService') +const logger = require('../src/utils/logger') +const readline = require('readline') + +// 解析命令行参数 +const args = process.argv.slice(2) +const params = {} +args.forEach((arg) => { + const [key, value] = arg.split('=') + params[key.replace('--', '')] = value || true +}) + +const DEFAULT_DAYS = params.days ? parseInt(params.days) : 30 +const DRY_RUN = params['dry-run'] === true + +// 创建 readline 接口用于用户确认 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}) + +async function askConfirmation(question) { + return new Promise((resolve) => { + rl.question(`${question} (yes/no): `, (answer) => { + resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y') + }) + }) +} + +async function migrateApiKeys() { + try { + logger.info('🔄 Starting API Key expiry migration...') + logger.info(`📅 Default expiry period: ${DEFAULT_DAYS} days`) + logger.info(`🔍 Mode: ${DRY_RUN ? 'DRY RUN (no changes will be made)' : 'LIVE RUN'}`) + + // 连接 Redis + await redis.connect() + logger.success('✅ Connected to Redis') + + // 获取所有 API Keys + const apiKeys = await apiKeyService.getAllApiKeysFast() + logger.info(`📊 Found ${apiKeys.length} API Keys in total`) + + // 统计信息 + const stats = { + total: apiKeys.length, + needsMigration: 0, + alreadyHasExpiry: 0, + migrated: 0, + errors: 0 + } + + // 需要迁移的 Keys + const keysToMigrate = [] + + // 分析每个 API Key + for (const key of apiKeys) { + if (!key.expiresAt || key.expiresAt === 'null' || key.expiresAt === '') { + keysToMigrate.push(key) + stats.needsMigration++ + logger.info(`📌 API Key "${key.name}" (${key.id}) needs migration`) + } else { + stats.alreadyHasExpiry++ + const expiryDate = new Date(key.expiresAt) + logger.info( + `✓ API Key "${key.name}" (${key.id}) already has expiry: ${expiryDate.toLocaleString()}` + ) + } + } + + if (keysToMigrate.length === 0) { + logger.success('✨ No API Keys need migration!') + return + } + + // 显示迁移摘要 + console.log(`\n${'='.repeat(60)}`) + console.log('📋 Migration Summary:') + console.log('='.repeat(60)) + console.log(`Total API Keys: ${stats.total}`) + console.log(`Already have expiry: ${stats.alreadyHasExpiry}`) + console.log(`Need migration: ${stats.needsMigration}`) + console.log(`Default expiry: ${DEFAULT_DAYS} days from now`) + console.log(`${'='.repeat(60)}\n`) + + // 如果不是 dry run,请求确认 + if (!DRY_RUN) { + const confirmed = await askConfirmation( + `⚠️ This will set expiry dates for ${keysToMigrate.length} API Keys. Continue?` + ) + + if (!confirmed) { + logger.warn('❌ Migration cancelled by user') + return + } + } + + // 计算新的过期时间 + const newExpiryDate = new Date() + newExpiryDate.setDate(newExpiryDate.getDate() + DEFAULT_DAYS) + const newExpiryISO = newExpiryDate.toISOString() + + logger.info(`\n🚀 Starting migration... New expiry date: ${newExpiryDate.toLocaleString()}`) + + // 执行迁移 + for (const key of keysToMigrate) { + try { + if (!DRY_RUN) { + // 直接更新 Redis 中的数据 + // 使用 hset 更新单个字段 + await redis.client.hset(`apikey:${key.id}`, 'expiresAt', newExpiryISO) + logger.success(`✅ Migrated: "${key.name}" (${key.id})`) + } else { + logger.info(`[DRY RUN] Would migrate: "${key.name}" (${key.id})`) + } + stats.migrated++ + } catch (error) { + logger.error(`❌ Error migrating "${key.name}" (${key.id}):`, error.message) + stats.errors++ + } + } + + // 显示最终结果 + console.log(`\n${'='.repeat(60)}`) + console.log('✅ Migration Complete!') + console.log('='.repeat(60)) + console.log(`Successfully migrated: ${stats.migrated}`) + console.log(`Errors: ${stats.errors}`) + console.log(`New expiry date: ${newExpiryDate.toLocaleString()}`) + console.log(`${'='.repeat(60)}\n`) + + if (DRY_RUN) { + logger.warn('⚠️ This was a DRY RUN. No actual changes were made.') + logger.info('💡 Run without --dry-run flag to apply changes.') + } + } catch (error) { + logger.error('💥 Migration failed:', error) + process.exit(1) + } finally { + // 清理 + rl.close() + await redis.disconnect() + logger.info('👋 Disconnected from Redis') + } +} + +// 显示帮助信息 +if (params.help) { + console.log(` +API Key Expiry Migration Script + +This script adds expiry dates to existing API Keys that don't have one. + +Usage: + node scripts/migrate-apikey-expiry.js [options] + +Options: + --days=NUMBER Set default expiry days (default: 30) + --dry-run Simulate the migration without making changes + --help Show this help message + +Examples: + # Set 30-day expiry for all API Keys without expiry + node scripts/migrate-apikey-expiry.js + + # Set 90-day expiry + node scripts/migrate-apikey-expiry.js --days=90 + + # Test run without making changes + node scripts/migrate-apikey-expiry.js --dry-run +`) + process.exit(0) +} + +// 运行迁移 +migrateApiKeys().catch((error) => { + logger.error('💥 Unexpected error:', error) + process.exit(1) +}) diff --git a/scripts/migrate-usage-index.js b/scripts/migrate-usage-index.js new file mode 100644 index 0000000..bcbb162 --- /dev/null +++ b/scripts/migrate-usage-index.js @@ -0,0 +1,138 @@ +/** + * 历史数据索引迁移脚本 + * 为现有的 usage 数据建立索引,加速查询 + */ +const Redis = require('ioredis') +const config = require('../config/config') + +const redis = new Redis({ + host: config.redis.host, + port: config.redis.port, + password: config.redis.password, + db: config.redis.db || 0 +}) + +async function migrate() { + console.log('开始迁移历史数据索引...') + console.log('Redis DB:', config.redis.db || 0) + + const stats = { + dailyIndex: 0, + hourlyIndex: 0, + modelDailyIndex: 0, + modelHourlyIndex: 0 + } + + // 1. 迁移 usage:daily:{keyId}:{date} 索引 + console.log('\n1. 迁移 usage:daily 索引...') + let cursor = '0' + do { + const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:daily:*', 'COUNT', 500) + cursor = newCursor + + const pipeline = redis.pipeline() + for (const key of keys) { + // usage:daily:{keyId}:{date} + const match = key.match(/^usage:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + const [, keyId, date] = match + pipeline.sadd(`usage:daily:index:${date}`, keyId) + pipeline.expire(`usage:daily:index:${date}`, 86400 * 32) + stats.dailyIndex++ + } + } + if (keys.length > 0) { + await pipeline.exec() + } + } while (cursor !== '0') + console.log(` 已处理 ${stats.dailyIndex} 条`) + + // 2. 迁移 usage:hourly:{keyId}:{date}:{hour} 索引 + console.log('\n2. 迁移 usage:hourly 索引...') + cursor = '0' + do { + const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:hourly:*', 'COUNT', 500) + cursor = newCursor + + const pipeline = redis.pipeline() + for (const key of keys) { + // usage:hourly:{keyId}:{date}:{hour} + const match = key.match(/^usage:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/) + if (match) { + const [, keyId, hourKey] = match + pipeline.sadd(`usage:hourly:index:${hourKey}`, keyId) + pipeline.expire(`usage:hourly:index:${hourKey}`, 86400 * 7) + stats.hourlyIndex++ + } + } + if (keys.length > 0) { + await pipeline.exec() + } + } while (cursor !== '0') + console.log(` 已处理 ${stats.hourlyIndex} 条`) + + // 3. 迁移 usage:model:daily:{model}:{date} 索引 + console.log('\n3. 迁移 usage:model:daily 索引...') + cursor = '0' + do { + const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:model:daily:*', 'COUNT', 500) + cursor = newCursor + + const pipeline = redis.pipeline() + for (const key of keys) { + // usage:model:daily:{model}:{date} + const match = key.match(/^usage:model:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + const [, model, date] = match + pipeline.sadd(`usage:model:daily:index:${date}`, model) + pipeline.expire(`usage:model:daily:index:${date}`, 86400 * 32) + stats.modelDailyIndex++ + } + } + if (keys.length > 0) { + await pipeline.exec() + } + } while (cursor !== '0') + console.log(` 已处理 ${stats.modelDailyIndex} 条`) + + // 4. 迁移 usage:model:hourly:{model}:{date}:{hour} 索引 + console.log('\n4. 迁移 usage:model:hourly 索引...') + cursor = '0' + do { + const [newCursor, keys] = await redis.scan( + cursor, + 'MATCH', + 'usage:model:hourly:*', + 'COUNT', + 500 + ) + cursor = newCursor + + const pipeline = redis.pipeline() + for (const key of keys) { + // usage:model:hourly:{model}:{date}:{hour} + const match = key.match(/^usage:model:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/) + if (match) { + const [, model, hourKey] = match + pipeline.sadd(`usage:model:hourly:index:${hourKey}`, model) + pipeline.expire(`usage:model:hourly:index:${hourKey}`, 86400 * 7) + stats.modelHourlyIndex++ + } + } + if (keys.length > 0) { + await pipeline.exec() + } + } while (cursor !== '0') + console.log(` 已处理 ${stats.modelHourlyIndex} 条`) + + console.log('\n迁移完成!') + console.log('统计:', stats) + + redis.disconnect() +} + +migrate().catch((err) => { + console.error('迁移失败:', err) + redis.disconnect() + process.exit(1) +}) diff --git a/scripts/monitor-enhanced.sh b/scripts/monitor-enhanced.sh new file mode 100755 index 0000000..fc7c8c4 --- /dev/null +++ b/scripts/monitor-enhanced.sh @@ -0,0 +1,273 @@ +#!/bin/bash + +# Claude Relay Service - 增强版实时监控脚本 +# 结合并发监控和系统状态的完整监控方案 + +# 加载环境变量 +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + +echo "🔍 Claude Relay Service - 增强版实时监控" +echo "按 Ctrl+C 退出 | 按 's' 切换详细/简单模式" +echo "========================================" + +# 获取服务配置 +SERVICE_HOST=${HOST:-127.0.0.1} +SERVICE_PORT=${PORT:-3000} + +# 如果HOST是0.0.0.0,客户端应该连接localhost +if [ "$SERVICE_HOST" = "0.0.0.0" ]; then + SERVICE_HOST="127.0.0.1" +fi + +SERVICE_URL="http://${SERVICE_HOST}:${SERVICE_PORT}" + +# 获取Redis配置 +REDIS_HOST=${REDIS_HOST:-127.0.0.1} +REDIS_PORT=${REDIS_PORT:-6379} +REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT" + +if [ ! -z "$REDIS_PASSWORD" ]; then + REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD" +fi + +# 检查Redis连接 +if ! $REDIS_CMD ping > /dev/null 2>&1; then + echo "❌ Redis连接失败,请检查Redis服务是否运行" + echo " 配置: $REDIS_HOST:$REDIS_PORT" + exit 1 +fi + +# 显示模式: simple(简单) / detailed(详细) +DISPLAY_MODE="simple" + +# 获取API Key详细信息 +get_api_key_info() { + local api_key_id=$1 + local api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null) + local concurrency_limit=$($REDIS_CMD hget "apikey:$api_key_id" concurrencyLimit 2>/dev/null) + local token_limit=$($REDIS_CMD hget "apikey:$api_key_id" tokenLimit 2>/dev/null) + local created_at=$($REDIS_CMD hget "apikey:$api_key_id" createdAt 2>/dev/null) + + if [ -z "$api_key_name" ]; then + api_key_name="Unknown" + fi + + if [ -z "$concurrency_limit" ] || [ "$concurrency_limit" = "0" ]; then + concurrency_limit="无限制" + fi + + if [ -z "$token_limit" ] || [ "$token_limit" = "0" ]; then + token_limit="无限制" + else + token_limit=$(printf "%'d" $token_limit) + fi + + echo "$api_key_name|$concurrency_limit|$token_limit|$created_at" +} + +# 获取使用统计信息 +get_usage_stats() { + local api_key_id=$1 + local today=$(date '+%Y-%m-%d') + local current_month=$(date '+%Y-%m') + + # 获取总体使用量 + local total_requests=$($REDIS_CMD hget "usage:$api_key_id" totalRequests 2>/dev/null) + local total_tokens=$($REDIS_CMD hget "usage:$api_key_id" totalTokens 2>/dev/null) + + # 获取今日使用量 + local daily_requests=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" requests 2>/dev/null) + local daily_tokens=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" tokens 2>/dev/null) + + total_requests=${total_requests:-0} + total_tokens=${total_tokens:-0} + daily_requests=${daily_requests:-0} + daily_tokens=${daily_tokens:-0} + + echo "$total_requests|$total_tokens|$daily_requests|$daily_tokens" +} + +# 格式化数字 +format_number() { + local num=$1 + if [ "$num" -ge 1000000 ]; then + echo "$(echo "scale=1; $num / 1000000" | bc 2>/dev/null)M" + elif [ "$num" -ge 1000 ]; then + echo "$(echo "scale=1; $num / 1000" | bc 2>/dev/null)K" + else + echo "$num" + fi +} + +# 获取系统信息 +get_system_info() { + # Redis信息 + local redis_info=$($REDIS_CMD info server 2>/dev/null) + local redis_memory_info=$($REDIS_CMD info memory 2>/dev/null) + + local redis_version=$(echo "$redis_info" | grep redis_version | cut -d: -f2 | tr -d '\r' 2>/dev/null) + local redis_uptime=$(echo "$redis_info" | grep uptime_in_seconds | cut -d: -f2 | tr -d '\r' 2>/dev/null) + local used_memory=$(echo "$redis_memory_info" | grep used_memory_human | cut -d: -f2 | tr -d '\r' 2>/dev/null) + + local redis_uptime_hours=0 + if [ ! -z "$redis_uptime" ]; then + redis_uptime_hours=$((redis_uptime / 3600)) + fi + + # 服务状态 + local service_status="unknown" + local service_uptime="0" + if command -v curl > /dev/null 2>&1; then + local health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null) + if [ $? -eq 0 ]; then + service_status=$(echo "$health_response" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 | head -1) + service_uptime=$(echo "$health_response" | grep -o '"uptime":[^,}]*' | cut -d: -f2 | head -1) + fi + fi + + local service_uptime_hours="0" + if [ ! -z "$service_uptime" ] && [ "$service_uptime" != "null" ]; then + service_uptime_hours=$(echo "scale=1; $service_uptime / 3600" | bc 2>/dev/null) + fi + + echo "$redis_version|$redis_uptime_hours|$used_memory|$service_status|$service_uptime_hours" +} + +# 主监控函数 +monitor_enhanced() { + while true; do + clear + echo "🔍 Claude Relay Service - 增强版实时监控 | $(date '+%Y-%m-%d %H:%M:%S')" + echo "模式: $DISPLAY_MODE | 服务: $SERVICE_URL | Redis: $REDIS_HOST:$REDIS_PORT" + echo "========================================" + + # 获取系统信息 + local system_info=$(get_system_info) + local redis_version=$(echo "$system_info" | cut -d'|' -f1) + local redis_uptime=$(echo "$system_info" | cut -d'|' -f2) + local redis_memory=$(echo "$system_info" | cut -d'|' -f3) + local service_status=$(echo "$system_info" | cut -d'|' -f4) + local service_uptime=$(echo "$system_info" | cut -d'|' -f5) + + # 系统状态概览 + echo "🏥 系统状态概览:" + if [ "$service_status" = "healthy" ]; then + echo " ✅ 服务: 健康 (运行 ${service_uptime}h)" + else + echo " ⚠️ 服务: 异常 ($service_status)" + fi + echo " 📊 Redis: v${redis_version} (运行 ${redis_uptime}h, 内存 ${redis_memory})" + echo "" + + # 获取并发信息 + local concurrency_keys=$($REDIS_CMD --scan --pattern "concurrency:*" 2>/dev/null) + local total_concurrent=0 + local active_keys=0 + local concurrent_details="" + + if [ ! -z "$concurrency_keys" ]; then + for key in $concurrency_keys; do + local count=$($REDIS_CMD get "$key" 2>/dev/null) + if [ ! -z "$count" ] && [ "$count" -gt 0 ]; then + local api_key_id=${key#concurrency:} + local key_info=$(get_api_key_info "$api_key_id") + local key_name=$(echo "$key_info" | cut -d'|' -f1) + local concurrency_limit=$(echo "$key_info" | cut -d'|' -f2) + + concurrent_details="${concurrent_details}${key_name}:${count}/${concurrency_limit} " + total_concurrent=$((total_concurrent + count)) + active_keys=$((active_keys + 1)) + fi + done + fi + + # 并发状态显示 + echo "📊 当前并发状态:" + if [ $total_concurrent -eq 0 ]; then + echo " 💤 无活跃并发连接" + else + echo " 🔥 总并发: $total_concurrent 个连接 ($active_keys 个API Key)" + if [ "$DISPLAY_MODE" = "detailed" ]; then + echo " 📋 详情: $concurrent_details" + fi + fi + echo "" + + # API Key统计 + local total_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map" | wc -l) + local total_accounts=$($REDIS_CMD keys "claude:account:*" 2>/dev/null | wc -l) + + echo "📋 资源统计:" + echo " 🔑 API Keys: $total_keys 个" + echo " 🏢 Claude账户: $total_accounts 个" + + # 详细模式显示更多信息 + if [ "$DISPLAY_MODE" = "detailed" ]; then + echo "" + echo "📈 使用统计 (今日/总计):" + + # 获取所有API Key + local api_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map") + local total_daily_requests=0 + local total_daily_tokens=0 + local total_requests=0 + local total_tokens=0 + + if [ ! -z "$api_keys" ]; then + for key in $api_keys; do + local api_key_id=${key#apikey:} + local key_info=$(get_api_key_info "$api_key_id") + local key_name=$(echo "$key_info" | cut -d'|' -f1) + local usage_info=$(get_usage_stats "$api_key_id") + + local key_total_requests=$(echo "$usage_info" | cut -d'|' -f1) + local key_total_tokens=$(echo "$usage_info" | cut -d'|' -f2) + local key_daily_requests=$(echo "$usage_info" | cut -d'|' -f3) + local key_daily_tokens=$(echo "$usage_info" | cut -d'|' -f4) + + total_daily_requests=$((total_daily_requests + key_daily_requests)) + total_daily_tokens=$((total_daily_tokens + key_daily_tokens)) + total_requests=$((total_requests + key_total_requests)) + total_tokens=$((total_tokens + key_total_tokens)) + + if [ $((key_daily_requests + key_total_requests)) -gt 0 ]; then + echo " 📱 $key_name: ${key_daily_requests}req/$(format_number $key_daily_tokens) | ${key_total_requests}req/$(format_number $key_total_tokens)" + fi + done + fi + + echo " 🌍 系统总计: ${total_daily_requests}req/$(format_number $total_daily_tokens) | ${total_requests}req/$(format_number $total_tokens)" + fi + + echo "" + echo "🔄 刷新间隔: 5秒 | 按 Ctrl+C 退出 | 按 Enter 切换详细/简单模式" + + # 非阻塞读取用户输入 + read -t 5 user_input + if [ $? -eq 0 ]; then + case "$user_input" in + "s"|"S"|"") + if [ "$DISPLAY_MODE" = "simple" ]; then + DISPLAY_MODE="detailed" + else + DISPLAY_MODE="simple" + fi + ;; + esac + fi + done +} + +# 信号处理 +cleanup() { + echo "" + echo "👋 监控已停止" + exit 0 +} + +trap cleanup SIGINT SIGTERM + +# 开始监控 +monitor_enhanced \ No newline at end of file diff --git a/scripts/reset-request-detail-retention-hours.js b/scripts/reset-request-detail-retention-hours.js new file mode 100644 index 0000000..2313c44 --- /dev/null +++ b/scripts/reset-request-detail-retention-hours.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +let dotenvLoaded = false +try { + require('dotenv').config() + dotenvLoaded = true +} catch (_error) { + dotenvLoaded = false +} + +const Redis = require('ioredis') + +const CONFIG_KEY = 'claude_relay_config' +const REQUEST_DETAIL_KEY_PATTERN = 'request_detail:*' +const DEFAULT_RETENTION_HOURS = 6 +const MAX_RETENTION_HOURS = 720 +const SCAN_COUNT = 200 + +const args = process.argv.slice(2) +const params = {} +args.forEach((arg) => { + const [key, value] = arg.split('=') + params[key.replace(/^--/, '')] = value ?? true +}) + +const isDryRun = params['dry-run'] === true +const requestedHours = Number.parseInt(params.hours, 10) +const targetHours = Number.isFinite(requestedHours) ? requestedHours : DEFAULT_RETENTION_HOURS + +if (!Number.isInteger(targetHours) || targetHours < 1 || targetHours > MAX_RETENTION_HOURS) { + console.error( + `requestDetailRetentionHours must be an integer between 1 and ${MAX_RETENTION_HOURS}` + ) + process.exit(1) +} + +async function scanRequestDetailKeys(client) { + let cursor = '0' + const keys = [] + + do { + const [nextCursor, batch] = await client.scan( + cursor, + 'MATCH', + REQUEST_DETAIL_KEY_PATTERN, + 'COUNT', + SCAN_COUNT + ) + cursor = nextCursor + if (Array.isArray(batch) && batch.length > 0) { + keys.push(...batch) + } + } while (cursor !== '0') + + return keys +} + +async function resetRequestDetailRetentionHours() { + let client = null + + try { + console.log('🔄 Resetting request detail retention configuration...') + console.log(`🕒 Target request detail retention: ${targetHours} hour(s)`) + if (isDryRun) { + console.log('📝 DRY RUN mode enabled; no data will be modified') + } + + if (dotenvLoaded) { + console.log('📄 Loaded .env configuration') + } + + client = new Redis({ + host: process.env.REDIS_HOST || '127.0.0.1', + port: Number.parseInt(process.env.REDIS_PORT, 10) || 6379, + password: process.env.REDIS_PASSWORD || undefined, + db: Number.parseInt(process.env.REDIS_DB, 10) || 0, + tls: process.env.REDIS_ENABLE_TLS === 'true' || process.env.REDIS_TLS === 'true' ? {} : false, + lazyConnect: true + }) + await client.connect() + + const rawConfig = await client.get(CONFIG_KEY) + const currentConfig = rawConfig ? JSON.parse(rawConfig) : {} + const requestDetailKeys = await scanRequestDetailKeys(client) + + console.log(`📦 Found ${requestDetailKeys.length} request detail Redis key(s)`) + console.log( + `⚙️ Current config: requestDetailRetentionDays=${currentConfig.requestDetailRetentionDays ?? 'unset'}, requestDetailRetentionHours=${currentConfig.requestDetailRetentionHours ?? 'unset'}` + ) + + const nextConfig = { + ...currentConfig, + requestDetailRetentionHours: targetHours, + updatedAt: new Date().toISOString(), + updatedBy: 'request-detail-retention-hours-reset-script' + } + delete nextConfig.requestDetailRetentionDays + + if (!isDryRun) { + if (requestDetailKeys.length > 0) { + for (let index = 0; index < requestDetailKeys.length; index += SCAN_COUNT) { + const batch = requestDetailKeys.slice(index, index + SCAN_COUNT) + // Use UNLINK to avoid blocking Redis with a large DEL. + await client.unlink(...batch) + } + } + + await client.set(CONFIG_KEY, JSON.stringify(nextConfig)) + } + + console.log( + `${isDryRun ? '📝 Would delete' : '🧹 Deleted'} ${requestDetailKeys.length} request detail key(s)` + ) + console.log( + `${isDryRun ? '📝 Would write' : '✅ Wrote'} requestDetailRetentionHours=${targetHours} and removed requestDetailRetentionDays` + ) + } catch (error) { + console.error('❌ Failed to reset request detail retention configuration:', error) + process.exitCode = 1 + } finally { + if (client) { + await client.quit() + } + } +} + +resetRequestDetailRetentionHours() diff --git a/scripts/setup.js b/scripts/setup.js new file mode 100644 index 0000000..1ad76a9 --- /dev/null +++ b/scripts/setup.js @@ -0,0 +1,128 @@ +const fs = require('fs') +const path = require('path') +const crypto = require('crypto') +const chalk = require('chalk') +const ora = require('ora') + +const config = require('../config/config') + +async function setup() { + console.log(chalk.blue.bold('\n🚀 Claude Relay Service 初始化设置\n')) + + const spinner = ora('正在进行初始化设置...').start() + + try { + // 1. 创建必要目录 + const directories = ['logs', 'data', 'temp'] + + directories.forEach((dir) => { + const dirPath = path.join(__dirname, '..', dir) + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }) + } + }) + + // 2. 生成环境配置文件 + if (!fs.existsSync(path.join(__dirname, '..', '.env'))) { + const envTemplate = fs.readFileSync(path.join(__dirname, '..', '.env.example'), 'utf8') + + // 生成随机密钥 + const jwtSecret = crypto.randomBytes(64).toString('hex') + const encryptionKey = crypto.randomBytes(32).toString('hex') + + const envContent = envTemplate + .replace('your-jwt-secret-here', jwtSecret) + .replace('your-encryption-key-here', encryptionKey) + + fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent) + } + + // 3. 生成或使用环境变量中的管理员凭据 + const adminUsername = + process.env.ADMIN_USERNAME || `cr_admin_${crypto.randomBytes(4).toString('hex')}` + const adminPassword = + process.env.ADMIN_PASSWORD || + crypto + .randomBytes(16) + .toString('base64') + .replace(/[^a-zA-Z0-9]/g, '') + .substring(0, 16) + + // 如果使用了环境变量,显示提示 + if (process.env.ADMIN_USERNAME || process.env.ADMIN_PASSWORD) { + console.log(chalk.yellow('\n📌 使用环境变量中的管理员凭据')) + } + + // 4. 创建初始化完成标记文件 + const initData = { + initializedAt: new Date().toISOString(), + adminUsername, + adminPassword, + version: '1.0.0' + } + + fs.writeFileSync( + path.join(__dirname, '..', 'data', 'init.json'), + JSON.stringify(initData, null, 2) + ) + + spinner.succeed('初始化设置完成') + + console.log(chalk.green('\n✅ 设置完成!\n')) + console.log(chalk.yellow('📋 重要信息:\n')) + console.log(` 管理员用户名: ${chalk.cyan(adminUsername)}`) + console.log(` 管理员密码: ${chalk.cyan(adminPassword)}`) + + // 如果是自动生成的凭据,强调需要保存 + if (!process.env.ADMIN_USERNAME && !process.env.ADMIN_PASSWORD) { + console.log(chalk.red('\n⚠️ 请立即保存这些凭据!首次登录后建议修改密码。')) + console.log( + chalk.yellow( + '\n💡 提示: 也可以通过环境变量 ADMIN_USERNAME 和 ADMIN_PASSWORD 预设管理员凭据。\n' + ) + ) + } else { + console.log(chalk.green('\n✅ 已使用预设的管理员凭据。\n')) + } + + console.log(chalk.blue('🚀 启动服务:\n')) + console.log(' npm start - 启动生产服务') + console.log(' npm run dev - 启动开发服务') + console.log(' npm run cli admin - 管理员CLI工具\n') + + console.log(chalk.blue('🌐 访问地址:\n')) + console.log(` Web管理界面: http://localhost:${config.server.port}/web`) + console.log(` API端点: http://localhost:${config.server.port}/api/v1/messages`) + console.log(` 健康检查: http://localhost:${config.server.port}/health\n`) + } catch (error) { + spinner.fail('初始化设置失败') + console.error(chalk.red('❌ 错误:'), error.message) + process.exit(1) + } +} + +// 检查是否已初始化 +function checkInitialized() { + const initFile = path.join(__dirname, '..', 'data', 'init.json') + if (fs.existsSync(initFile)) { + const initData = JSON.parse(fs.readFileSync(initFile, 'utf8')) + console.log(chalk.yellow('⚠️ 服务已经初始化过了!')) + console.log(` 初始化时间: ${new Date(initData.initializedAt).toLocaleString()}`) + console.log(` 管理员用户名: ${initData.adminUsername}`) + console.log('\n如需重新初始化,请删除 data/init.json 文件后再运行此命令。') + console.log(chalk.red('\n⚠️ 重要提示:')) + console.log(' 1. 删除 init.json 文件后运行 npm run setup') + console.log(' 2. 生成新的账号密码后,需要重启服务才能生效') + console.log(' 3. 使用 npm run service:restart 重启服务\n') + return true + } + return false +} + +if (require.main === module) { + if (!checkInitialized()) { + setup() + } +} + +module.exports = { setup, checkInitialized } diff --git a/scripts/status-unified.sh b/scripts/status-unified.sh new file mode 100755 index 0000000..878f9c4 --- /dev/null +++ b/scripts/status-unified.sh @@ -0,0 +1,262 @@ +#!/bin/bash + +# Claude Relay Service - 统一状态检查脚本 +# 提供完整的系统状态概览 + +# 加载环境变量 +if [ -f .env ]; then + export $(grep -v '^#' .env | xargs) +fi + +# 参数处理 +DETAIL_MODE=false +if [ "$1" = "--detail" ] || [ "$1" = "-d" ]; then + DETAIL_MODE=true +fi + +echo "🔍 Claude Relay Service - 系统状态检查" +if [ "$DETAIL_MODE" = true ]; then + echo "模式: 详细信息" +else + echo "模式: 概览 (使用 --detail 查看详细信息)" +fi +echo "========================================" + +# 获取服务配置 +SERVICE_HOST=${HOST:-127.0.0.1} +SERVICE_PORT=${PORT:-3000} + +if [ "$SERVICE_HOST" = "0.0.0.0" ]; then + SERVICE_HOST="127.0.0.1" +fi + +SERVICE_URL="http://${SERVICE_HOST}:${SERVICE_PORT}" + +# 获取Redis配置 +REDIS_HOST=${REDIS_HOST:-127.0.0.1} +REDIS_PORT=${REDIS_PORT:-6379} +REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT" + +if [ ! -z "$REDIS_PASSWORD" ]; then + REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD" +fi + +# 检查Redis连接 +echo "🔍 连接检查:" +if $REDIS_CMD ping > /dev/null 2>&1; then + echo " ✅ Redis连接正常 ($REDIS_HOST:$REDIS_PORT)" +else + echo " ❌ Redis连接失败 ($REDIS_HOST:$REDIS_PORT)" + exit 1 +fi + +# 检查服务状态 +if command -v curl > /dev/null 2>&1; then + health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null) + if [ $? -eq 0 ]; then + health_status=$(echo "$health_response" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 | head -1) + if [ "$health_status" = "healthy" ]; then + echo " ✅ 服务状态正常 ($SERVICE_URL)" + else + echo " ⚠️ 服务状态异常: $health_status ($SERVICE_URL)" + fi + else + echo " ❌ 服务无法访问 ($SERVICE_URL)" + fi +else + echo " ⚠️ curl命令不可用,无法检查服务状态" +fi + +echo "" + +# 格式化数字函数 +format_number() { + local num=$1 + if [ "$num" -ge 1000000 ]; then + echo "$(echo "scale=1; $num / 1000000" | bc 2>/dev/null)M" + elif [ "$num" -ge 1000 ]; then + echo "$(echo "scale=1; $num / 1000" | bc 2>/dev/null)K" + else + echo "$num" + fi +} + +# 系统信息 +echo "🏥 系统信息:" + +# Redis信息 +redis_info=$($REDIS_CMD info server 2>/dev/null) +redis_memory_info=$($REDIS_CMD info memory 2>/dev/null) + +redis_version=$(echo "$redis_info" | grep redis_version | cut -d: -f2 | tr -d '\r' 2>/dev/null) +redis_uptime=$(echo "$redis_info" | grep uptime_in_seconds | cut -d: -f2 | tr -d '\r' 2>/dev/null) +used_memory=$(echo "$redis_memory_info" | grep used_memory_human | cut -d: -f2 | tr -d '\r' 2>/dev/null) + +if [ ! -z "$redis_version" ]; then + echo " 📊 Redis版本: $redis_version" +fi + +if [ ! -z "$redis_uptime" ]; then + uptime_hours=$((redis_uptime / 3600)) + echo " ⏱️ Redis运行时间: $uptime_hours 小时" +fi + +if [ ! -z "$used_memory" ]; then + echo " 💾 Redis内存使用: $used_memory" +fi + +# 服务信息 +if command -v curl > /dev/null 2>&1; then + health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null) + if [ $? -eq 0 ]; then + uptime=$(echo "$health_response" | grep -o '"uptime":[^,}]*' | cut -d: -f2 | head -1) + + if [ ! -z "$uptime" ] && [ "$uptime" != "null" ]; then + uptime_hours=$(echo "scale=1; $uptime / 3600" | bc 2>/dev/null) + if [ ! -z "$uptime_hours" ]; then + echo " ⏰ 服务运行时间: $uptime_hours 小时" + fi + fi + + # 检查端口 + if netstat -ln 2>/dev/null | grep -q ":${SERVICE_PORT} "; then + echo " 🔌 端口${SERVICE_PORT}: 正在监听" + else + echo " ❌ 端口${SERVICE_PORT}: 未监听" + fi + fi +fi + +echo "" + +# 并发状态 +echo "📊 并发状态:" +concurrency_keys=$($REDIS_CMD --scan --pattern "concurrency:*" 2>/dev/null) + +if [ -z "$concurrency_keys" ]; then + echo " 💤 当前无活跃并发连接" +else + total_concurrent=0 + active_keys=0 + + for key in $concurrency_keys; do + count=$($REDIS_CMD get "$key" 2>/dev/null) + if [ ! -z "$count" ] && [ "$count" -gt 0 ]; then + api_key_id=${key#concurrency:} + + if [ "$DETAIL_MODE" = true ]; then + api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null) + concurrency_limit=$($REDIS_CMD hget "apikey:$api_key_id" concurrencyLimit 2>/dev/null) + + if [ -z "$api_key_name" ]; then + api_key_name="Unknown" + fi + + if [ -z "$concurrency_limit" ] || [ "$concurrency_limit" = "0" ]; then + limit_text="无限制" + else + limit_text="$concurrency_limit" + fi + + echo " 🔑 $api_key_name: $count 个并发 (限制: $limit_text)" + fi + + total_concurrent=$((total_concurrent + count)) + active_keys=$((active_keys + 1)) + fi + done + + echo " 📈 总计: $total_concurrent 个活跃并发连接 ($active_keys 个API Key)" +fi + +echo "" + +# 资源统计 +echo "📋 资源统计:" + +total_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map" | wc -l) +total_accounts=$($REDIS_CMD keys "claude:account:*" 2>/dev/null | wc -l) + +echo " 🔑 API Key总数: $total_keys" +echo " 🏢 Claude账户数: $total_accounts" + +# 详细模式下的使用统计 +if [ "$DETAIL_MODE" = true ]; then + echo "" + echo "📈 使用统计:" + + today=$(date '+%Y-%m-%d') + current_month=$(date '+%Y-%m') + + # 系统总体统计 + total_daily_requests=0 + total_daily_tokens=0 + total_requests=0 + total_tokens=0 + + api_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map") + + if [ ! -z "$api_keys" ]; then + echo " 📱 API Key详情:" + + for key in $api_keys; do + api_key_id=${key#apikey:} + + # API Key基本信息 + api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null) + token_limit=$($REDIS_CMD hget "apikey:$api_key_id" tokenLimit 2>/dev/null) + created_at=$($REDIS_CMD hget "apikey:$api_key_id" createdAt 2>/dev/null) + + # 使用统计 + key_total_requests=$($REDIS_CMD hget "usage:$api_key_id" totalRequests 2>/dev/null) + key_total_tokens=$($REDIS_CMD hget "usage:$api_key_id" totalTokens 2>/dev/null) + key_daily_requests=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" requests 2>/dev/null) + key_daily_tokens=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" tokens 2>/dev/null) + + # 默认值处理 + api_key_name=${api_key_name:-"Unknown"} + token_limit=${token_limit:-0} + key_total_requests=${key_total_requests:-0} + key_total_tokens=${key_total_tokens:-0} + key_daily_requests=${key_daily_requests:-0} + key_daily_tokens=${key_daily_tokens:-0} + + # 格式化Token限制 + if [ "$token_limit" = "0" ]; then + limit_text="无限制" + else + limit_text=$(format_number $token_limit) + fi + + # 创建时间格式化 + if [ ! -z "$created_at" ]; then + created_date=$(echo "$created_at" | cut -d'T' -f1) + else + created_date="未知" + fi + + echo " • $api_key_name (创建: $created_date, 限制: $limit_text)" + echo " 今日: ${key_daily_requests}请求 / $(format_number $key_daily_tokens)tokens" + echo " 总计: ${key_total_requests}请求 / $(format_number $key_total_tokens)tokens" + echo "" + + # 累计统计 + total_daily_requests=$((total_daily_requests + key_daily_requests)) + total_daily_tokens=$((total_daily_tokens + key_daily_tokens)) + total_requests=$((total_requests + key_total_requests)) + total_tokens=$((total_tokens + key_total_tokens)) + done + fi + + echo " 🌍 系统总计:" + echo " 今日: ${total_daily_requests}请求 / $(format_number $total_daily_tokens)tokens" + echo " 总计: ${total_requests}请求 / $(format_number $total_tokens)tokens" +fi + +echo "" +echo "✅ 状态检查完成 - $(date '+%Y-%m-%d %H:%M:%S')" + +if [ "$DETAIL_MODE" = false ]; then + echo "" + echo "💡 使用 'npm run status -- --detail' 查看详细信息" +fi \ No newline at end of file diff --git a/scripts/test-account-display.js b/scripts/test-account-display.js new file mode 100644 index 0000000..de5daad --- /dev/null +++ b/scripts/test-account-display.js @@ -0,0 +1,143 @@ +/** + * 测试账号显示问题是否已修复 + */ + +const axios = require('axios') +const config = require('../config/config') + +// 从 init.json 读取管理员凭据 +const fs = require('fs') +const path = require('path') + +async function testAccountDisplay() { + console.log('🔍 测试账号显示问题...\n') + + try { + // 读取管理员凭据 + const initPath = path.join(__dirname, '..', 'config', 'init.json') + if (!fs.existsSync(initPath)) { + console.error('❌ 找不到 init.json 文件,请运行 npm run setup') + process.exit(1) + } + + const initData = JSON.parse(fs.readFileSync(initPath, 'utf8')) + const adminUser = initData.admins?.[0] + if (!adminUser) { + console.error('❌ 没有找到管理员账号') + process.exit(1) + } + + const baseURL = `http://localhost:${config.server.port}` + + // 登录获取 token + console.log('🔐 登录管理员账号...') + const loginResp = await axios.post(`${baseURL}/admin/login`, { + username: adminUser.username, + password: adminUser.password + }) + + if (!loginResp.data.success) { + console.error('❌ 登录失败') + process.exit(1) + } + + const { token } = loginResp.data + console.log('✅ 登录成功\n') + + // 设置请求头 + const headers = { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + } + + // 获取 Claude OAuth 账号 + console.log('📋 获取 Claude OAuth 账号...') + const claudeResp = await axios.get(`${baseURL}/admin/claude-accounts`, { headers }) + const claudeAccounts = claudeResp.data.data || [] + + console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`) + + // 分类显示 + const claudeDedicated = claudeAccounts.filter((a) => a.accountType === 'dedicated') + const claudeGroup = claudeAccounts.filter((a) => a.accountType === 'group') + const claudeShared = claudeAccounts.filter((a) => a.accountType === 'shared') + + console.log(`- 专属账号: ${claudeDedicated.length} 个`) + console.log(`- 分组账号: ${claudeGroup.length} 个`) + console.log(`- 共享账号: ${claudeShared.length} 个`) + + // 检查 platform 字段 + console.log('\n检查 platform 字段:') + claudeAccounts.slice(0, 3).forEach((acc) => { + console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`) + }) + + // 获取 Claude Console 账号 + console.log('\n📋 获取 Claude Console 账号...') + const consoleResp = await axios.get(`${baseURL}/admin/claude-console-accounts`, { headers }) + const consoleAccounts = consoleResp.data.data || [] + + console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`) + + // 分类显示 + const consoleDedicated = consoleAccounts.filter((a) => a.accountType === 'dedicated') + const consoleGroup = consoleAccounts.filter((a) => a.accountType === 'group') + const consoleShared = consoleAccounts.filter((a) => a.accountType === 'shared') + + console.log(`- 专属账号: ${consoleDedicated.length} 个`) + console.log(`- 分组账号: ${consoleGroup.length} 个`) + console.log(`- 共享账号: ${consoleShared.length} 个`) + + // 检查 platform 字段 + console.log('\n检查 platform 字段:') + consoleAccounts.slice(0, 3).forEach((acc) => { + console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`) + }) + + // 获取账号分组 + console.log('\n📋 获取账号分组...') + const groupsResp = await axios.get(`${baseURL}/admin/account-groups`, { headers }) + const groups = groupsResp.data.data || [] + + console.log(`找到 ${groups.length} 个账号分组`) + + const claudeGroups = groups.filter((g) => g.platform === 'claude') + const geminiGroups = groups.filter((g) => g.platform === 'gemini') + + console.log(`- Claude 分组: ${claudeGroups.length} 个`) + console.log(`- Gemini 分组: ${geminiGroups.length} 个`) + + // 测试结果总结 + console.log('\n📊 测试结果总结:') + console.log('✅ Claude OAuth 账号已包含 platform 字段') + console.log('✅ Claude Console 账号已包含 platform 字段') + console.log('✅ 账号分组功能正常') + + const totalDedicated = claudeDedicated.length + consoleDedicated.length + const totalGroups = claudeGroups.length + + if (totalDedicated > 0) { + console.log(`\n✅ 共有 ${totalDedicated} 个专属账号应该显示在下拉框中`) + } else { + console.log('\n⚠️ 没有找到专属账号,请在账号管理页面设置账号类型为"专属账户"') + } + + if (totalGroups > 0) { + console.log(`✅ 共有 ${totalGroups} 个分组应该显示在下拉框中`) + } + + console.log('\n💡 请在浏览器中测试创建/编辑 API Key,检查下拉框是否正确显示三个类别:') + console.log(' 1. 调度分组') + console.log(' 2. Claude OAuth 账号') + console.log(' 3. Claude Console 账号') + } catch (error) { + console.error('❌ 测试失败:', error.message) + if (error.response) { + console.error('响应数据:', error.response.data) + } + } finally { + process.exit(0) + } +} + +testAccountDisplay() diff --git a/scripts/test-api-response.js b/scripts/test-api-response.js new file mode 100644 index 0000000..7ed3f0e --- /dev/null +++ b/scripts/test-api-response.js @@ -0,0 +1,141 @@ +/** + * 测试 API 响应中的账号数据 + */ + +const redis = require('../src/models/redis') +const claudeAccountService = require('../src/services/account/claudeAccountService') +const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService') +const accountGroupService = require('../src/services/accountGroupService') + +async function testApiResponse() { + console.log('🔍 测试 API 响应数据...\n') + + try { + // 确保 Redis 已连接 + await redis.connect() + + // 1. 测试 Claude OAuth 账号服务 + console.log('📋 测试 Claude OAuth 账号服务...') + const claudeAccounts = await claudeAccountService.getAllAccounts() + console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`) + + // 检查前3个账号的数据结构 + console.log('\n账号数据结构示例:') + claudeAccounts.slice(0, 3).forEach((acc) => { + console.log(`\n账号: ${acc.name}`) + console.log(` - ID: ${acc.id}`) + console.log(` - accountType: ${acc.accountType}`) + console.log(` - platform: ${acc.platform}`) + console.log(` - status: ${acc.status}`) + console.log(` - isActive: ${acc.isActive}`) + }) + + // 统计专属账号 + const claudeDedicated = claudeAccounts.filter((a) => a.accountType === 'dedicated') + const claudeGroup = claudeAccounts.filter((a) => a.accountType === 'group') + + console.log('\n统计结果:') + console.log(` - 专属账号: ${claudeDedicated.length} 个`) + console.log(` - 分组账号: ${claudeGroup.length} 个`) + + // 2. 测试 Claude Console 账号服务 + console.log('\n\n📋 测试 Claude Console 账号服务...') + const consoleAccounts = await claudeConsoleAccountService.getAllAccounts() + console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`) + + // 检查前3个账号的数据结构 + console.log('\n账号数据结构示例:') + consoleAccounts.slice(0, 3).forEach((acc) => { + console.log(`\n账号: ${acc.name}`) + console.log(` - ID: ${acc.id}`) + console.log(` - accountType: ${acc.accountType}`) + console.log(` - platform: ${acc.platform}`) + console.log(` - status: ${acc.status}`) + console.log(` - isActive: ${acc.isActive}`) + }) + + // 统计专属账号 + const consoleDedicated = consoleAccounts.filter((a) => a.accountType === 'dedicated') + const consoleGroup = consoleAccounts.filter((a) => a.accountType === 'group') + + console.log('\n统计结果:') + console.log(` - 专属账号: ${consoleDedicated.length} 个`) + console.log(` - 分组账号: ${consoleGroup.length} 个`) + + // 3. 测试账号分组服务 + console.log('\n\n📋 测试账号分组服务...') + const groups = await accountGroupService.getAllGroups() + console.log(`找到 ${groups.length} 个账号分组`) + + // 显示分组信息 + groups.forEach((group) => { + console.log(`\n分组: ${group.name}`) + console.log(` - ID: ${group.id}`) + console.log(` - platform: ${group.platform}`) + console.log(` - memberCount: ${group.memberCount}`) + }) + + // 4. 验证结果 + console.log('\n\n📊 验证结果:') + + // 检查 platform 字段 + const claudeWithPlatform = claudeAccounts.filter((a) => a.platform === 'claude') + const consoleWithPlatform = consoleAccounts.filter((a) => a.platform === 'claude-console') + + if (claudeWithPlatform.length === claudeAccounts.length) { + console.log('✅ 所有 Claude OAuth 账号都有正确的 platform 字段') + } else { + console.log( + `⚠️ 只有 ${claudeWithPlatform.length}/${claudeAccounts.length} 个 Claude OAuth 账号有正确的 platform 字段` + ) + } + + if (consoleWithPlatform.length === consoleAccounts.length) { + console.log('✅ 所有 Claude Console 账号都有正确的 platform 字段') + } else { + console.log( + `⚠️ 只有 ${consoleWithPlatform.length}/${consoleAccounts.length} 个 Claude Console 账号有正确的 platform 字段` + ) + } + + // 总结 + const totalDedicated = claudeDedicated.length + consoleDedicated.length + const totalGroup = claudeGroup.length + consoleGroup.length + const totalGroups = groups.filter((g) => g.platform === 'claude').length + + console.log('\n📈 总结:') + console.log( + `- 专属账号总数: ${totalDedicated} 个 (Claude OAuth: ${claudeDedicated.length}, Console: ${consoleDedicated.length})` + ) + console.log( + `- 分组账号总数: ${totalGroup} 个 (Claude OAuth: ${claudeGroup.length}, Console: ${consoleGroup.length})` + ) + console.log(`- 账号分组总数: ${totalGroups} 个`) + + if (totalDedicated + totalGroups > 0) { + console.log('\n✅ 前端下拉框应该能够显示:') + if (totalGroups > 0) { + console.log(' - 调度分组') + } + if (claudeDedicated.length > 0) { + console.log(' - Claude OAuth 专属账号 (仅 dedicated 类型)') + } + if (consoleDedicated.length > 0) { + console.log(' - Claude Console 专属账号 (仅 dedicated 类型)') + } + } else { + console.log('\n⚠️ 没有找到任何专属账号或分组,请检查账号配置') + } + + console.log('\n💡 说明:') + console.log('- 专属账号下拉框只显示 accountType="dedicated" 的账号') + console.log('- accountType="group" 的账号通过分组调度,不在专属账号中显示') + } catch (error) { + console.error('❌ 测试失败:', error) + console.error(error.stack) + } finally { + process.exit(0) + } +} + +testApiResponse() diff --git a/scripts/test-bedrock-models.js b/scripts/test-bedrock-models.js new file mode 100644 index 0000000..ad325b8 --- /dev/null +++ b/scripts/test-bedrock-models.js @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +const bedrockRelayService = require('../src/services/relay/bedrockRelayService') + +async function testBedrockModels() { + try { + console.log('🧪 测试Bedrock模型配置...') + + // 测试可用模型列表 + const models = await bedrockRelayService.getAvailableModels() + console.log(`📋 找到 ${models.length} 个可用模型:`) + models.forEach((model) => { + console.log(` - ${model.id} (${model.name})`) + }) + + // 测试默认模型 + console.log(`\n🎯 系统默认模型: ${bedrockRelayService.defaultModel}`) + console.log(`🎯 系统默认小模型: ${bedrockRelayService.defaultSmallModel}`) + + console.log('\n✅ Bedrock模型配置测试完成') + process.exit(0) + } catch (error) { + console.error('❌ Bedrock模型测试失败:', error) + process.exit(1) + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + testBedrockModels() +} + +module.exports = { testBedrockModels } diff --git a/scripts/test-billing-events.js b/scripts/test-billing-events.js new file mode 100755 index 0000000..68edc3f --- /dev/null +++ b/scripts/test-billing-events.js @@ -0,0 +1,340 @@ +#!/usr/bin/env node + +/** + * 计费事件测试脚本 + * + * 用于测试计费事件的发布和消费功能 + * + * 使用方法: + * node scripts/test-billing-events.js [command] + * + * 命令: + * publish - 发布测试事件 + * consume - 消费事件(测试模式) + * info - 查看队列状态 + * clear - 清空队列(危险操作) + */ + +const path = require('path') +const Redis = require('ioredis') + +// 加载配置 +require('dotenv').config({ path: path.join(__dirname, '../.env') }) + +const config = { + host: process.env.REDIS_HOST || 'localhost', + port: parseInt(process.env.REDIS_PORT) || 6379, + password: process.env.REDIS_PASSWORD || '', + db: parseInt(process.env.REDIS_DB) || 0 +} + +const redis = new Redis(config) +const STREAM_KEY = 'billing:events' + +// ======================================== +// 命令实现 +// ======================================== + +/** + * 发布测试事件 + */ +async function publishTestEvent() { + console.log('📤 Publishing test billing event...') + + const testEvent = { + eventId: `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + eventType: 'usage.recorded', + timestamp: new Date().toISOString(), + version: '1.0', + apiKey: { + id: 'test-key-123', + name: 'Test API Key', + userId: 'test-user-456' + }, + usage: { + model: 'claude-sonnet-4-20250514', + inputTokens: 1500, + outputTokens: 800, + cacheCreateTokens: 200, + cacheReadTokens: 100, + ephemeral5mTokens: 150, + ephemeral1hTokens: 50, + totalTokens: 2600 + }, + cost: { + total: 0.0156, + currency: 'USD', + breakdown: { + input: 0.0045, + output: 0.012, + cacheCreate: 0.00075, + cacheRead: 0.00003, + ephemeral5m: 0.0005625, + ephemeral1h: 0.0001875 + } + }, + account: { + id: 'test-account-789', + type: 'claude-official' + }, + context: { + isLongContext: false, + requestTimestamp: new Date().toISOString() + } + } + + try { + const messageId = await redis.xadd( + STREAM_KEY, + 'MAXLEN', + '~', + 100000, + '*', + 'data', + JSON.stringify(testEvent) + ) + + console.log('✅ Event published successfully!') + console.log(` Message ID: ${messageId}`) + console.log(` Event ID: ${testEvent.eventId}`) + console.log(` Cost: $${testEvent.cost.total}`) + } catch (error) { + console.error('❌ Failed to publish event:', error.message) + process.exit(1) + } +} + +/** + * 消费事件(测试模式,不创建消费者组) + */ +async function consumeTestEvents() { + console.log('📬 Consuming test events...') + console.log(' Press Ctrl+C to stop\n') + + let isRunning = true + + process.on('SIGINT', () => { + console.log('\n⏹️ Stopping consumer...') + isRunning = false + }) + + let lastId = '0' // 从头开始 + + while (isRunning) { + try { + // 使用 XREAD 而不是 XREADGROUP(测试模式) + const messages = await redis.xread('BLOCK', 5000, 'COUNT', 10, 'STREAMS', STREAM_KEY, lastId) + + if (!messages || messages.length === 0) { + continue + } + + const [streamKey, entries] = messages[0] + console.log(`📬 Received ${entries.length} messages from ${streamKey}\n`) + + for (const [messageId, fields] of entries) { + try { + const data = {} + for (let i = 0; i < fields.length; i += 2) { + data[fields[i]] = fields[i + 1] + } + + const event = JSON.parse(data.data) + + console.log(`📊 Event: ${event.eventId}`) + console.log(` API Key: ${event.apiKey.name} (${event.apiKey.id})`) + console.log(` Model: ${event.usage.model}`) + console.log(` Tokens: ${event.usage.totalTokens}`) + console.log(` Cost: $${event.cost.total.toFixed(6)}`) + console.log(` Timestamp: ${event.timestamp}`) + console.log('') + + lastId = messageId // 更新位置 + } catch (parseError) { + console.error(`❌ Failed to parse message ${messageId}:`, parseError.message) + } + } + } catch (error) { + if (isRunning) { + console.error('❌ Error consuming messages:', error.message) + await new Promise((resolve) => setTimeout(resolve, 5000)) + } + } + } + + console.log('👋 Consumer stopped') +} + +/** + * 查看队列状态 + */ +async function showQueueInfo() { + console.log('📊 Queue Information\n') + + try { + // Stream 长度 + const length = await redis.xlen(STREAM_KEY) + console.log(`Stream: ${STREAM_KEY}`) + console.log(`Length: ${length} messages\n`) + + if (length === 0) { + console.log('ℹ️ Queue is empty') + return + } + + // Stream 详细信息 + const info = await redis.xinfo('STREAM', STREAM_KEY) + const infoObj = {} + for (let i = 0; i < info.length; i += 2) { + infoObj[info[i]] = info[i + 1] + } + + console.log('Stream Details:') + console.log(` First Entry ID: ${infoObj['first-entry'] ? infoObj['first-entry'][0] : 'N/A'}`) + console.log(` Last Entry ID: ${infoObj['last-entry'] ? infoObj['last-entry'][0] : 'N/A'}`) + console.log(` Consumer Groups: ${infoObj.groups || 0}\n`) + + // 消费者组信息 + if (infoObj.groups > 0) { + console.log('Consumer Groups:') + const groups = await redis.xinfo('GROUPS', STREAM_KEY) + + for (let i = 0; i < groups.length; i++) { + const group = groups[i] + const groupObj = {} + for (let j = 0; j < group.length; j += 2) { + groupObj[group[j]] = group[j + 1] + } + + console.log(`\n Group: ${groupObj.name}`) + console.log(` Consumers: ${groupObj.consumers}`) + console.log(` Pending: ${groupObj.pending}`) + console.log(` Last Delivered ID: ${groupObj['last-delivered-id']}`) + + // 消费者详情 + if (groupObj.consumers > 0) { + const consumers = await redis.xinfo('CONSUMERS', STREAM_KEY, groupObj.name) + console.log(' Consumer Details:') + + for (let k = 0; k < consumers.length; k++) { + const consumer = consumers[k] + const consumerObj = {} + for (let l = 0; l < consumer.length; l += 2) { + consumerObj[consumer[l]] = consumer[l + 1] + } + + console.log(` - ${consumerObj.name}`) + console.log(` Pending: ${consumerObj.pending}`) + console.log(` Idle: ${Math.round(consumerObj.idle / 1000)}s`) + } + } + } + } + + // 最新 5 条消息 + console.log('\n📬 Latest 5 Messages:') + const latest = await redis.xrevrange(STREAM_KEY, '+', '-', 'COUNT', 5) + + if (latest.length === 0) { + console.log(' No messages') + } else { + for (const [messageId, fields] of latest) { + const data = {} + for (let i = 0; i < fields.length; i += 2) { + data[fields[i]] = fields[i + 1] + } + + try { + const event = JSON.parse(data.data) + console.log(`\n ${messageId}`) + console.log(` Event ID: ${event.eventId}`) + console.log(` Model: ${event.usage.model}`) + console.log(` Cost: $${event.cost.total.toFixed(6)}`) + console.log(` Time: ${event.timestamp}`) + } catch (e) { + console.log(`\n ${messageId} (Parse Error)`) + } + } + } + } catch (error) { + console.error('❌ Failed to get queue info:', error.message) + process.exit(1) + } +} + +/** + * 清空队列(危险操作) + */ +async function clearQueue() { + console.log('⚠️ WARNING: This will delete all messages in the queue!') + console.log(` Stream: ${STREAM_KEY}`) + + // 简单的确认机制 + const readline = require('readline') + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }) + + rl.question('Type "yes" to confirm: ', async (answer) => { + if (answer.toLowerCase() === 'yes') { + try { + await redis.del(STREAM_KEY) + console.log('✅ Queue cleared successfully') + } catch (error) { + console.error('❌ Failed to clear queue:', error.message) + } + } else { + console.log('❌ Operation cancelled') + } + rl.close() + redis.quit() + }) +} + +// ======================================== +// CLI 处理 +// ======================================== + +async function main() { + const command = process.argv[2] || 'info' + + console.log('🔧 Billing Events Test Tool\n') + + try { + switch (command) { + case 'publish': + await publishTestEvent() + break + + case 'consume': + await consumeTestEvents() + break + + case 'info': + await showQueueInfo() + break + + case 'clear': + await clearQueue() + return // clearQueue 会自己关闭连接 + + default: + console.error(`❌ Unknown command: ${command}`) + console.log('\nAvailable commands:') + console.log(' publish - Publish a test event') + console.log(' consume - Consume events (test mode)') + console.log(' info - Show queue status') + console.log(' clear - Clear the queue (dangerous)') + process.exit(1) + } + + await redis.quit() + } catch (error) { + console.error('💥 Fatal error:', error) + await redis.quit() + process.exit(1) + } +} + +main() diff --git a/scripts/test-dedicated-accounts.js b/scripts/test-dedicated-accounts.js new file mode 100644 index 0000000..7b22202 --- /dev/null +++ b/scripts/test-dedicated-accounts.js @@ -0,0 +1,133 @@ +/** + * 测试专属账号显示问题 + */ + +const redis = require('../src/models/redis') + +async function testDedicatedAccounts() { + console.log('🔍 检查专属账号...\n') + + try { + // 确保 Redis 已连接 + await redis.connect() + + // 获取所有 Claude 账号 + const claudeKeys = await redis.client.keys('claude:account:*') + console.log(`找到 ${claudeKeys.length} 个 Claude 账号\n`) + + const dedicatedAccounts = [] + const groupAccounts = [] + const sharedAccounts = [] + + for (const key of claudeKeys) { + const account = await redis.client.hgetall(key) + const accountType = account.accountType || 'shared' + + const accountInfo = { + id: account.id, + name: account.name, + accountType, + status: account.status, + isActive: account.isActive, + createdAt: account.createdAt + } + + if (accountType === 'dedicated') { + dedicatedAccounts.push(accountInfo) + } else if (accountType === 'group') { + groupAccounts.push(accountInfo) + } else { + sharedAccounts.push(accountInfo) + } + } + + console.log('📊 账号统计:') + console.log(`- 专属账号: ${dedicatedAccounts.length} 个`) + console.log(`- 分组账号: ${groupAccounts.length} 个`) + console.log(`- 共享账号: ${sharedAccounts.length} 个`) + console.log('') + + if (dedicatedAccounts.length > 0) { + console.log('✅ 专属账号列表:') + dedicatedAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) + console.log('') + } else { + console.log('⚠️ 没有找到专属账号!') + console.log('💡 提示: 请确保在账号管理页面将账号类型设置为"专属账户"') + console.log('') + } + + if (groupAccounts.length > 0) { + console.log('📁 分组账号列表:') + groupAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) + console.log('') + } + + // 检查分组 + const groupKeys = await redis.client.keys('account_group:*') + console.log(`\n找到 ${groupKeys.length} 个账号分组`) + + if (groupKeys.length > 0) { + console.log('📋 分组列表:') + for (const key of groupKeys) { + const group = await redis.client.hgetall(key) + console.log( + ` - ${group.name} (平台: ${group.platform}, 成员数: ${group.memberCount || 0})` + ) + } + } + + // 检查 Claude Console 账号 + const consoleKeys = await redis.client.keys('claude_console_account:*') + console.log(`\n找到 ${consoleKeys.length} 个 Claude Console 账号`) + + const dedicatedConsoleAccounts = [] + const groupConsoleAccounts = [] + + for (const key of consoleKeys) { + const account = await redis.client.hgetall(key) + const accountType = account.accountType || 'shared' + + if (accountType === 'dedicated') { + dedicatedConsoleAccounts.push({ + id: account.id, + name: account.name, + accountType, + status: account.status + }) + } else if (accountType === 'group') { + groupConsoleAccounts.push({ + id: account.id, + name: account.name, + accountType, + status: account.status + }) + } + } + + if (dedicatedConsoleAccounts.length > 0) { + console.log('\n✅ Claude Console 专属账号:') + dedicatedConsoleAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) + } + + if (groupConsoleAccounts.length > 0) { + console.log('\n📁 Claude Console 分组账号:') + groupConsoleAccounts.forEach((acc) => { + console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`) + }) + } + } catch (error) { + console.error('❌ 错误:', error) + console.error(error.stack) + } finally { + process.exit(0) + } +} + +testDedicatedAccounts() diff --git a/scripts/test-gemini-refresh.js b/scripts/test-gemini-refresh.js new file mode 100644 index 0000000..2ac6386 --- /dev/null +++ b/scripts/test-gemini-refresh.js @@ -0,0 +1,145 @@ +#!/usr/bin/env node + +/** + * 测试 Gemini token 刷新功能 + */ + +const path = require('path') +const dotenv = require('dotenv') + +// 加载环境变量 +dotenv.config({ path: path.join(__dirname, '..', '.env') }) + +const redis = require('../src/models/redis') +const geminiAccountService = require('../src/services/account/geminiAccountService') +const crypto = require('crypto') +const config = require('../config/config') + +// 加密相关常量(与 geminiAccountService 保持一致) +const ALGORITHM = 'aes-256-cbc' +const ENCRYPTION_SALT = 'gemini-account-salt' // 注意:是 'gemini-account-salt' 不是其他值! + +// 生成加密密钥 +function generateEncryptionKey() { + return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) +} + +// 解密函数(用于调试) +function debugDecrypt(text) { + if (!text) { + return { success: false, error: 'Empty text' } + } + try { + const key = generateEncryptionKey() + const ivHex = text.substring(0, 32) + const encryptedHex = text.substring(33) + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + return { success: true, value: decrypted.toString() } + } catch (error) { + return { success: false, error: error.message } + } +} + +async function testGeminiTokenRefresh() { + try { + console.log('🚀 开始测试 Gemini token 刷新功能...\n') + + // 显示配置信息 + console.log('📋 配置信息:') + console.log(` 加密密钥: ${config.security.encryptionKey}`) + console.log(` 加密盐值: ${ENCRYPTION_SALT}`) + console.log() + + // 1. 连接 Redis + console.log('📡 连接 Redis...') + await redis.connect() + console.log('✅ Redis 连接成功\n') + + // 2. 获取所有 Gemini 账户 + console.log('🔍 获取 Gemini 账户列表...') + const accounts = await geminiAccountService.getAllAccounts() + const geminiAccounts = accounts.filter((acc) => acc.platform === 'gemini') + + if (geminiAccounts.length === 0) { + console.log('❌ 没有找到 Gemini 账户') + process.exit(1) + } + + console.log(`✅ 找到 ${geminiAccounts.length} 个 Gemini 账户\n`) + + // 3. 测试每个账户的 token 刷新 + for (const account of geminiAccounts) { + console.log(`\n📋 测试账户: ${account.name} (${account.id})`) + console.log(` 状态: ${account.status}`) + + try { + // 获取原始账户数据(用于调试) + const client = redis.getClient() + const rawData = await client.hgetall(`gemini_account:${account.id}`) + + console.log(' 📊 原始数据检查:') + console.log(` refreshToken 存在: ${rawData.refreshToken ? '是' : '否'}`) + if (rawData.refreshToken) { + console.log(` refreshToken 长度: ${rawData.refreshToken.length}`) + console.log(` refreshToken 前50字符: ${rawData.refreshToken.substring(0, 50)}...`) + + // 尝试手动解密 + const decryptResult = debugDecrypt(rawData.refreshToken) + if (decryptResult.success) { + console.log(' ✅ 手动解密成功') + console.log(` 解密后前20字符: ${decryptResult.value.substring(0, 20)}...`) + } else { + console.log(` ❌ 手动解密失败: ${decryptResult.error}`) + } + } + + // 获取完整账户信息(包括解密的 token) + const fullAccount = await geminiAccountService.getAccount(account.id) + + if (!fullAccount.refreshToken) { + console.log(' ⚠️ 跳过:该账户无 refresh token\n') + continue + } + + console.log(' ✅ 找到 refresh token') + console.log( + ` 📝 解密后的 refresh token 前20字符: ${fullAccount.refreshToken.substring(0, 20)}...` + ) + + console.log(' 🔄 开始刷新 token...') + const startTime = Date.now() + + // 执行 token 刷新 + const newTokens = await geminiAccountService.refreshAccountToken(account.id) + + const duration = Date.now() - startTime + console.log(` ✅ Token 刷新成功!耗时: ${duration}ms`) + console.log(` 📅 新的过期时间: ${new Date(newTokens.expiry_date).toLocaleString()}`) + console.log(` 🔑 Access Token: ${newTokens.access_token.substring(0, 20)}...`) + + // 验证账户状态已更新 + const updatedAccount = await geminiAccountService.getAccount(account.id) + console.log(` 📊 账户状态: ${updatedAccount.status}`) + } catch (error) { + console.log(` ❌ Token 刷新失败: ${error.message}`) + console.log(' 🔍 错误详情:', error) + } + } + + console.log('\n✅ 测试完成!') + } catch (error) { + console.error('❌ 测试失败:', error) + } finally { + // 断开 Redis 连接 + await redis.disconnect() + process.exit(0) + } +} + +// 运行测试 +testGeminiTokenRefresh() diff --git a/scripts/test-group-scheduling.js b/scripts/test-group-scheduling.js new file mode 100644 index 0000000..a604e6d --- /dev/null +++ b/scripts/test-group-scheduling.js @@ -0,0 +1,549 @@ +/** + * 分组调度功能测试脚本 + * 用于测试账户分组管理和调度逻辑的正确性 + */ + +require('dotenv').config() +const { v4: uuidv4 } = require('uuid') +const redis = require('../src/models/redis') +const accountGroupService = require('../src/services/accountGroupService') +const claudeAccountService = require('../src/services/account/claudeAccountService') +const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService') +const apiKeyService = require('../src/services/apiKeyService') +const unifiedClaudeScheduler = require('../src/services/scheduler/unifiedClaudeScheduler') + +// 测试配置 +const TEST_PREFIX = 'test_group_' +const CLEANUP_ON_FINISH = true // 测试完成后是否清理数据 + +// 测试数据存储 +const testData = { + groups: [], + accounts: [], + apiKeys: [] +} + +// 颜色输出 +const colors = { + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + reset: '\x1b[0m' +} + +function log(message, type = 'info') { + const color = + { + success: colors.green, + error: colors.red, + warning: colors.yellow, + info: colors.blue + }[type] || colors.reset + + console.log(`${color}${message}${colors.reset}`) +} + +async function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +// 清理测试数据 +async function cleanup() { + log('\n🧹 清理测试数据...', 'info') + + // 删除测试API Keys + for (const apiKey of testData.apiKeys) { + try { + await apiKeyService.deleteApiKey(apiKey.id) + log(`✅ 删除测试API Key: ${apiKey.name}`, 'success') + } catch (error) { + log(`❌ 删除API Key失败: ${error.message}`, 'error') + } + } + + // 删除测试账户 + for (const account of testData.accounts) { + try { + if (account.type === 'claude') { + await claudeAccountService.deleteAccount(account.id) + } else if (account.type === 'claude-console') { + await claudeConsoleAccountService.deleteAccount(account.id) + } + log(`✅ 删除测试账户: ${account.name}`, 'success') + } catch (error) { + log(`❌ 删除账户失败: ${error.message}`, 'error') + } + } + + // 删除测试分组 + for (const group of testData.groups) { + try { + await accountGroupService.deleteGroup(group.id) + log(`✅ 删除测试分组: ${group.name}`, 'success') + } catch (error) { + // 可能因为还有成员而删除失败,先移除所有成员 + if (error.message.includes('分组内还有账户')) { + const members = await accountGroupService.getGroupMembers(group.id) + for (const memberId of members) { + await accountGroupService.removeAccountFromGroup(memberId, group.id) + } + // 重试删除 + await accountGroupService.deleteGroup(group.id) + log(`✅ 删除测试分组: ${group.name} (清空成员后)`, 'success') + } else { + log(`❌ 删除分组失败: ${error.message}`, 'error') + } + } + } +} + +// 测试1: 创建分组 +async function test1_createGroups() { + log('\n📝 测试1: 创建账户分组', 'info') + + try { + // 创建Claude分组 + const claudeGroup = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}Claude组`, + platform: 'claude', + description: '测试用Claude账户分组' + }) + testData.groups.push(claudeGroup) + log(`✅ 创建Claude分组成功: ${claudeGroup.name} (ID: ${claudeGroup.id})`, 'success') + + // 创建Gemini分组 + const geminiGroup = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}Gemini组`, + platform: 'gemini', + description: '测试用Gemini账户分组' + }) + testData.groups.push(geminiGroup) + log(`✅ 创建Gemini分组成功: ${geminiGroup.name} (ID: ${geminiGroup.id})`, 'success') + + // 验证分组信息 + const allGroups = await accountGroupService.getAllGroups() + const testGroups = allGroups.filter((g) => g.name.startsWith(TEST_PREFIX)) + + if (testGroups.length === 2) { + log(`✅ 分组创建验证通过,共创建 ${testGroups.length} 个测试分组`, 'success') + } else { + throw new Error(`分组数量不正确,期望2个,实际${testGroups.length}个`) + } + } catch (error) { + log(`❌ 测试1失败: ${error.message}`, 'error') + throw error + } +} + +// 测试2: 创建账户并添加到分组 +async function test2_createAccountsAndAddToGroup() { + log('\n📝 测试2: 创建账户并添加到分组', 'info') + + try { + const claudeGroup = testData.groups.find((g) => g.platform === 'claude') + + // 创建Claude OAuth账户 + const claudeAccount1 = await claudeAccountService.createAccount({ + name: `${TEST_PREFIX}Claude账户1`, + email: 'test1@example.com', + refreshToken: 'test_refresh_token_1', + accountType: 'group' + }) + testData.accounts.push({ ...claudeAccount1, type: 'claude' }) + log(`✅ 创建Claude OAuth账户1成功: ${claudeAccount1.name}`, 'success') + + const claudeAccount2 = await claudeAccountService.createAccount({ + name: `${TEST_PREFIX}Claude账户2`, + email: 'test2@example.com', + refreshToken: 'test_refresh_token_2', + accountType: 'group' + }) + testData.accounts.push({ ...claudeAccount2, type: 'claude' }) + log(`✅ 创建Claude OAuth账户2成功: ${claudeAccount2.name}`, 'success') + + // 创建Claude Console账户 + const consoleAccount = await claudeConsoleAccountService.createAccount({ + name: `${TEST_PREFIX}Console账户`, + apiUrl: 'https://api.example.com', + apiKey: 'test_api_key', + accountType: 'group' + }) + testData.accounts.push({ ...consoleAccount, type: 'claude-console' }) + log(`✅ 创建Claude Console账户成功: ${consoleAccount.name}`, 'success') + + // 添加账户到分组 + await accountGroupService.addAccountToGroup(claudeAccount1.id, claudeGroup.id, 'claude') + log('✅ 添加账户1到分组成功', 'success') + + await accountGroupService.addAccountToGroup(claudeAccount2.id, claudeGroup.id, 'claude') + log('✅ 添加账户2到分组成功', 'success') + + await accountGroupService.addAccountToGroup(consoleAccount.id, claudeGroup.id, 'claude') + log('✅ 添加Console账户到分组成功', 'success') + + // 验证分组成员 + const members = await accountGroupService.getGroupMembers(claudeGroup.id) + if (members.length === 3) { + log(`✅ 分组成员验证通过,共有 ${members.length} 个成员`, 'success') + } else { + throw new Error(`分组成员数量不正确,期望3个,实际${members.length}个`) + } + } catch (error) { + log(`❌ 测试2失败: ${error.message}`, 'error') + throw error + } +} + +// 测试3: 平台一致性验证 +async function test3_platformConsistency() { + log('\n📝 测试3: 平台一致性验证', 'info') + + try { + const geminiGroup = testData.groups.find((g) => g.platform === 'gemini') + + // 尝试将Claude账户添加到Gemini分组(应该失败) + const claudeAccount = testData.accounts.find((a) => a.type === 'claude') + + try { + await accountGroupService.addAccountToGroup(claudeAccount.id, geminiGroup.id, 'claude') + throw new Error('平台验证失败:Claude账户不应该能添加到Gemini分组') + } catch (error) { + if (error.message.includes('平台与分组平台不匹配')) { + log(`✅ 平台一致性验证通过:${error.message}`, 'success') + } else { + throw error + } + } + } catch (error) { + log(`❌ 测试3失败: ${error.message}`, 'error') + throw error + } +} + +// 测试4: API Key绑定分组 +async function test4_apiKeyBindGroup() { + log('\n📝 测试4: API Key绑定分组', 'info') + + try { + const claudeGroup = testData.groups.find((g) => g.platform === 'claude') + + // 创建绑定到分组的API Key + const apiKey = await apiKeyService.generateApiKey({ + name: `${TEST_PREFIX}API Key`, + description: '测试分组调度的API Key', + claudeAccountId: `group:${claudeGroup.id}`, + permissions: 'claude' + }) + testData.apiKeys.push(apiKey) + log(`✅ 创建API Key成功: ${apiKey.name} (绑定到分组: ${claudeGroup.name})`, 'success') + + // 验证API Key信息 + const keyInfo = await redis.getApiKey(apiKey.id) + if (keyInfo && keyInfo.claudeAccountId === `group:${claudeGroup.id}`) { + log('✅ API Key分组绑定验证通过', 'success') + } else { + throw new Error('API Key分组绑定信息不正确') + } + } catch (error) { + log(`❌ 测试4失败: ${error.message}`, 'error') + throw error + } +} + +// 测试5: 分组调度负载均衡 +async function test5_groupSchedulingLoadBalance() { + log('\n📝 测试5: 分组调度负载均衡', 'info') + + try { + const apiKey = testData.apiKeys[0] + + // 记录每个账户被选中的次数 + const selectionCount = {} + const totalSelections = 30 + + for (let i = 0; i < totalSelections; i++) { + // 模拟不同的会话 + const sessionHash = uuidv4() + + const result = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + + if (!selectionCount[result.accountId]) { + selectionCount[result.accountId] = 0 + } + selectionCount[result.accountId]++ + + // 短暂延迟,模拟真实请求间隔 + await sleep(50) + } + + // 分析选择分布 + log(`\n📊 负载均衡分布统计 (共${totalSelections}次选择):`, 'info') + const accounts = Object.keys(selectionCount) + + for (const accountId of accounts) { + const count = selectionCount[accountId] + const percentage = ((count / totalSelections) * 100).toFixed(1) + const accountInfo = testData.accounts.find((a) => a.id === accountId) + log(` ${accountInfo.name}: ${count}次 (${percentage}%)`, 'info') + } + + // 验证是否实现了负载均衡 + const counts = Object.values(selectionCount) + const avgCount = totalSelections / accounts.length + const variance = + counts.reduce((sum, count) => sum + Math.pow(count - avgCount, 2), 0) / counts.length + const stdDev = Math.sqrt(variance) + + log(`\n 平均选择次数: ${avgCount.toFixed(1)}`, 'info') + log(` 标准差: ${stdDev.toFixed(1)}`, 'info') + + // 如果标准差小于平均值的50%,认为负载均衡效果良好 + if (stdDev < avgCount * 0.5) { + log('✅ 负载均衡验证通过,分布相对均匀', 'success') + } else { + log('⚠️ 负载分布不够均匀,但这可能是正常的随机波动', 'warning') + } + } catch (error) { + log(`❌ 测试5失败: ${error.message}`, 'error') + throw error + } +} + +// 测试6: 会话粘性测试 +async function test6_stickySession() { + log('\n📝 测试6: 会话粘性(Sticky Session)测试', 'info') + + try { + const apiKey = testData.apiKeys[0] + const sessionHash = `test_session_${uuidv4()}` + + // 第一次选择 + const firstSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + + log(` 首次选择账户: ${firstSelection.accountId}`, 'info') + + // 使用相同的sessionHash多次请求 + let consistentCount = 0 + const testCount = 10 + + for (let i = 0; i < testCount; i++) { + const selection = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + + if (selection.accountId === firstSelection.accountId) { + consistentCount++ + } + + await sleep(100) + } + + log(` 会话一致性: ${consistentCount}/${testCount} 次选择了相同账户`, 'info') + + if (consistentCount === testCount) { + log('✅ 会话粘性验证通过,同一会话始终选择相同账户', 'success') + } else { + throw new Error(`会话粘性失败,只有${consistentCount}/${testCount}次选择了相同账户`) + } + } catch (error) { + log(`❌ 测试6失败: ${error.message}`, 'error') + throw error + } +} + +// 测试7: 账户可用性检查 +async function test7_accountAvailability() { + log('\n📝 测试7: 账户可用性检查', 'info') + + try { + const apiKey = testData.apiKeys[0] + const accounts = testData.accounts.filter( + (a) => a.type === 'claude' || a.type === 'claude-console' + ) + + // 禁用第一个账户 + const firstAccount = accounts[0] + if (firstAccount.type === 'claude') { + await claudeAccountService.updateAccount(firstAccount.id, { isActive: false }) + } else { + await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: false }) + } + log(` 已禁用账户: ${firstAccount.name}`, 'info') + + // 多次选择,验证不会选择到禁用的账户 + const selectionResults = [] + for (let i = 0; i < 20; i++) { + const sessionHash = uuidv4() // 每次使用新会话 + const result = await unifiedClaudeScheduler.selectAccountForApiKey( + { + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }, + sessionHash + ) + + selectionResults.push(result.accountId) + } + + // 检查是否选择了禁用的账户 + const selectedDisabled = selectionResults.includes(firstAccount.id) + + if (!selectedDisabled) { + log('✅ 账户可用性验证通过,未选择禁用的账户', 'success') + } else { + throw new Error('错误:选择了已禁用的账户') + } + + // 重新启用账户 + if (firstAccount.type === 'claude') { + await claudeAccountService.updateAccount(firstAccount.id, { isActive: true }) + } else { + await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: true }) + } + } catch (error) { + log(`❌ 测试7失败: ${error.message}`, 'error') + throw error + } +} + +// 测试8: 分组成员管理 +async function test8_groupMemberManagement() { + log('\n📝 测试8: 分组成员管理', 'info') + + try { + const claudeGroup = testData.groups.find((g) => g.platform === 'claude') + const account = testData.accounts.find((a) => a.type === 'claude') + + // 获取账户所属分组 + const accountGroups = await accountGroupService.getAccountGroup(account.id) + const hasTargetGroup = accountGroups.some((group) => group.id === claudeGroup.id) + if (hasTargetGroup) { + log('✅ 账户分组查询验证通过', 'success') + } else { + throw new Error('账户分组查询结果不正确') + } + + // 从分组移除账户 + await accountGroupService.removeAccountFromGroup(account.id, claudeGroup.id) + log(` 从分组移除账户: ${account.name}`, 'info') + + // 验证账户已不在分组中 + const membersAfterRemove = await accountGroupService.getGroupMembers(claudeGroup.id) + if (!membersAfterRemove.includes(account.id)) { + log('✅ 账户移除验证通过', 'success') + } else { + throw new Error('账户移除失败') + } + + // 重新添加账户 + await accountGroupService.addAccountToGroup(account.id, claudeGroup.id, 'claude') + log(' 重新添加账户到分组', 'info') + } catch (error) { + log(`❌ 测试8失败: ${error.message}`, 'error') + throw error + } +} + +// 测试9: 空分组处理 +async function test9_emptyGroupHandling() { + log('\n📝 测试9: 空分组处理', 'info') + + try { + // 创建一个空分组 + const emptyGroup = await accountGroupService.createGroup({ + name: `${TEST_PREFIX}空分组`, + platform: 'claude', + description: '测试空分组' + }) + testData.groups.push(emptyGroup) + + // 创建绑定到空分组的API Key + const apiKey = await apiKeyService.generateApiKey({ + name: `${TEST_PREFIX}空分组API Key`, + claudeAccountId: `group:${emptyGroup.id}`, + permissions: 'claude' + }) + testData.apiKeys.push(apiKey) + + // 尝试从空分组选择账户(应该失败) + try { + await unifiedClaudeScheduler.selectAccountForApiKey({ + id: apiKey.id, + claudeAccountId: apiKey.claudeAccountId, + name: apiKey.name + }) + throw new Error('空分组选择账户应该失败') + } catch (error) { + if (error.message.includes('has no members')) { + log(`✅ 空分组处理验证通过:${error.message}`, 'success') + } else { + throw error + } + } + } catch (error) { + log(`❌ 测试9失败: ${error.message}`, 'error') + throw error + } +} + +// 主测试函数 +async function runTests() { + log('\n🚀 开始分组调度功能测试\n', 'info') + + try { + // 连接Redis + await redis.connect() + log('✅ Redis连接成功', 'success') + + // 执行测试 + await test1_createGroups() + await test2_createAccountsAndAddToGroup() + await test3_platformConsistency() + await test4_apiKeyBindGroup() + await test5_groupSchedulingLoadBalance() + await test6_stickySession() + await test7_accountAvailability() + await test8_groupMemberManagement() + await test9_emptyGroupHandling() + + log('\n🎉 所有测试通过!分组调度功能工作正常', 'success') + } catch (error) { + log(`\n❌ 测试失败: ${error.message}`, 'error') + console.error(error) + } finally { + // 清理测试数据 + if (CLEANUP_ON_FINISH) { + await cleanup() + } else { + log('\n⚠️ 测试数据未清理,请手动清理', 'warning') + } + + // 关闭Redis连接 + await redis.disconnect() + process.exit(0) + } +} + +// 运行测试 +runTests() diff --git a/scripts/test-model-mapping.js b/scripts/test-model-mapping.js new file mode 100644 index 0000000..ade02e7 --- /dev/null +++ b/scripts/test-model-mapping.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +const bedrockRelayService = require('../src/services/relay/bedrockRelayService') + +function testModelMapping() { + console.log('🧪 测试模型映射功能...') + + // 测试用例 + const testCases = [ + // 标准Claude模型名 + 'claude-3-5-haiku-20241022', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-sonnet', + 'claude-3-5-haiku', + 'claude-sonnet-4', + 'claude-opus-4-1', + 'claude-3-7-sonnet', + + // 已经是Bedrock格式的 + 'us.anthropic.claude-sonnet-4-20250514-v1:0', + 'anthropic.claude-3-5-haiku-20241022-v1:0', + + // 未知模型 + 'unknown-model' + ] + + console.log('\n📋 模型映射测试结果:') + testCases.forEach((testModel) => { + const mappedModel = bedrockRelayService._mapToBedrockModel(testModel) + const isChanged = mappedModel !== testModel + const status = isChanged ? '🔄' : '✅' + + console.log(`${status} ${testModel}`) + if (isChanged) { + console.log(` → ${mappedModel}`) + } + }) + + console.log('\n✅ 模型映射测试完成') +} + +// 如果直接运行此脚本 +if (require.main === module) { + testModelMapping() +} + +module.exports = { testModelMapping } diff --git a/scripts/test-official-models.js b/scripts/test-official-models.js new file mode 100644 index 0000000..d87953f --- /dev/null +++ b/scripts/test-official-models.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * 官方模型版本识别测试 - 最终版 v2 + */ + +const { isOpus45OrNewer } = require('../src/utils/modelHelper') + +// 官方模型 +const officialModels = [ + { name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false }, + { name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false }, + { name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false }, + { name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true } +] + +// 非 Opus 模型 +const nonOpusModels = [ + { name: 'claude-sonnet-4-20250514', desc: 'Sonnet 4' }, + { name: 'claude-sonnet-4-5-20250929', desc: 'Sonnet 4.5' }, + { name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' }, + { name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' }, + { name: 'claude-3-haiku-20240307', desc: 'Haiku 3' }, + { name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' } +] + +// 其他格式测试 +const otherFormats = [ + { name: 'claude-opus-4.5', expected: true, desc: 'Opus 4.5 点分隔' }, + { name: 'claude-opus-4-5', expected: true, desc: 'Opus 4.5 横线分隔' }, + { name: 'opus-4.5', expected: true, desc: 'Opus 4.5 无前缀' }, + { name: 'opus-4-5', expected: true, desc: 'Opus 4-5 无前缀' }, + { name: 'opus-latest', expected: true, desc: 'Opus latest' }, + { name: 'claude-opus-5', expected: true, desc: 'Opus 5 (未来)' }, + { name: 'claude-opus-5-0', expected: true, desc: 'Opus 5.0 (未来)' }, + { name: 'opus-4.0', expected: false, desc: 'Opus 4.0' }, + { name: 'opus-4.1', expected: false, desc: 'Opus 4.1' }, + { name: 'opus-4.4', expected: false, desc: 'Opus 4.4' }, + { name: 'opus-4', expected: false, desc: 'Opus 4' }, + { name: 'opus-4-0', expected: false, desc: 'Opus 4-0' }, + { name: 'opus-4-1', expected: false, desc: 'Opus 4-1' }, + { name: 'opus-4-4', expected: false, desc: 'Opus 4-4' }, + { name: 'opus', expected: false, desc: '仅 opus' }, + { name: null, expected: false, desc: 'null' }, + { name: '', expected: false, desc: '空字符串' } +] + +console.log('='.repeat(90)) +console.log('官方模型版本识别测试 - 最终版 v2') +console.log('='.repeat(90)) +console.log() + +let passed = 0 +let failed = 0 + +// 测试官方 Opus 模型 +console.log('📌 官方 Opus 模型:') +for (const m of officialModels) { + const result = isOpus45OrNewer(m.name) + const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL' + if (result === m.expectPro) { + passed++ + } else { + failed++ + } + const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌' + console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`) +} + +console.log() +console.log('📌 非 Opus 模型 (不受此函数影响):') +for (const m of nonOpusModels) { + const result = isOpus45OrNewer(m.name) + console.log( + ` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}` + ) + if (result) { + failed++ // 非 Opus 模型不应返回 true + } +} + +console.log() +console.log('📌 其他格式测试:') +for (const m of otherFormats) { + const result = isOpus45OrNewer(m.name) + const status = result === m.expected ? '✅ PASS' : '❌ FAIL' + if (result === m.expected) { + passed++ + } else { + failed++ + } + const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name + console.log( + ` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}` + ) +} + +console.log() +console.log('='.repeat(90)) +console.log('测试结果:', passed, '通过,', failed, '失败') +console.log('='.repeat(90)) + +if (failed > 0) { + console.log('\n❌ 有测试失败,请检查函数逻辑') + process.exit(1) +} else { + console.log('\n✅ 所有测试通过!函数可以安全使用') + process.exit(0) +} diff --git a/scripts/test-pricing-fallback.js b/scripts/test-pricing-fallback.js new file mode 100644 index 0000000..039d00e --- /dev/null +++ b/scripts/test-pricing-fallback.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node + +const fs = require('fs') +const path = require('path') + +// 测试定价服务的fallback机制 +async function testPricingFallback() { + console.log('🧪 Testing pricing service fallback mechanism...\n') + + // 备份现有的模型定价文件 + const dataDir = path.join(process.cwd(), 'data') + const pricingFile = path.join(dataDir, 'model_pricing.json') + const backupFile = path.join(dataDir, 'model_pricing.backup.json') + + // 1. 备份现有文件 + if (fs.existsSync(pricingFile)) { + console.log('📦 Backing up existing pricing file...') + fs.copyFileSync(pricingFile, backupFile) + } + + try { + // 2. 删除现有定价文件以触发fallback + if (fs.existsSync(pricingFile)) { + console.log('🗑️ Removing existing pricing file to test fallback...') + fs.unlinkSync(pricingFile) + } + + // 3. 初始化定价服务 + console.log('🚀 Initializing pricing service...\n') + + // 清除require缓存以确保重新加载 + delete require.cache[require.resolve('../src/services/pricingService')] + const pricingService = require('../src/services/pricingService') + + // 模拟网络失败,强制使用fallback + const originalDownload = pricingService._downloadFromRemote + pricingService._downloadFromRemote = function () { + return Promise.reject(new Error('Simulated network failure for testing')) + } + + // 初始化服务 + await pricingService.initialize() + + // 4. 验证fallback是否工作 + console.log('\n📊 Verifying fallback data...') + const status = pricingService.getStatus() + console.log(` - Initialized: ${status.initialized}`) + console.log(` - Model count: ${status.modelCount}`) + console.log(` - Last updated: ${status.lastUpdated}`) + + // 5. 测试获取模型定价 + const testModels = ['claude-3-opus-20240229', 'gpt-4', 'gemini-pro'] + console.log('\n💰 Testing model pricing retrieval:') + + for (const model of testModels) { + const pricing = pricingService.getModelPricing(model) + if (pricing) { + console.log(` ✅ ${model}: Found pricing data`) + } else { + console.log(` ❌ ${model}: No pricing data`) + } + } + + // 6. 验证文件是否被创建 + if (fs.existsSync(pricingFile)) { + console.log('\n✅ Fallback successfully created pricing file in data directory') + const fileStats = fs.statSync(pricingFile) + console.log(` - File size: ${(fileStats.size / 1024).toFixed(2)} KB`) + } else { + console.log('\n❌ Fallback failed to create pricing file') + } + + // 恢复原始下载函数 + pricingService._downloadFromRemote = originalDownload + } finally { + // 7. 恢复备份文件 + if (fs.existsSync(backupFile)) { + console.log('\n📦 Restoring original pricing file...') + fs.copyFileSync(backupFile, pricingFile) + fs.unlinkSync(backupFile) + } + } + + console.log('\n✨ Fallback mechanism test completed!') +} + +// 运行测试 +testPricingFallback().catch((error) => { + console.error('❌ Test failed:', error) + process.exit(1) +}) diff --git a/scripts/test-web-dist.sh b/scripts/test-web-dist.sh new file mode 100644 index 0000000..48964fc --- /dev/null +++ b/scripts/test-web-dist.sh @@ -0,0 +1,227 @@ +#!/bin/bash + +# 测试 web-dist 分支构建和获取流程 +# 用于验证 CI/CD 流程和 manage.sh 的修改 + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;36m' +NC='\033[0m' # No Color + +print_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# 测试构建并推送到 web-dist 分支 +test_build_and_push() { + print_info "开始测试构建和推送流程..." + + # 检查是否在项目根目录 + if [ ! -f "package.json" ] || [ ! -d "web/admin-spa" ]; then + print_error "请在项目根目录运行此脚本" + return 1 + fi + + # 构建前端 + print_info "构建前端..." + cd web/admin-spa + + # 检查 node_modules + if [ ! -d "node_modules" ]; then + print_info "安装前端依赖..." + npm install + fi + + # 执行构建 + npm run build + + if [ ! -d "dist" ]; then + print_error "构建失败,dist 目录不存在" + cd ../.. + return 1 + fi + + print_success "前端构建成功" + cd ../.. + + # 创建临时目录保存构建产物 + TEMP_DIR=$(mktemp -d) + print_info "复制构建产物到临时目录: $TEMP_DIR" + cp -r web/admin-spa/dist/* "$TEMP_DIR/" + + # 配置 git + git config user.name "Test User" + git config user.email "test@example.com" + + # 检查 web-dist 分支是否存在 + print_info "检查 web-dist 分支..." + if git ls-remote --heads origin web-dist | grep -q web-dist; then + print_info "web-dist 分支已存在,获取最新版本" + git fetch origin web-dist:web-dist + git checkout web-dist + else + print_info "创建新的 web-dist 分支" + git checkout --orphan web-dist + fi + + # 清空当前目录(保留 .git) + git rm -rf . 2>/dev/null || true + + # 复制构建产物 + cp -r "$TEMP_DIR"/* . + + # 添加 README + cat > README.md << EOF +# Claude Relay Service - Web Frontend Build + +This branch contains the pre-built frontend assets for Claude Relay Service. + +**DO NOT EDIT FILES IN THIS BRANCH DIRECTLY** + +These files are automatically generated by the CI/CD pipeline. + +Test Build Date: $(date -u +"%Y-%m-%d %H:%M:%S UTC") +EOF + + # 提交 + git add -A + git commit -m "test: frontend build test $(date +%Y%m%d%H%M%S)" + + print_success "本地 web-dist 分支创建成功" + print_warning "注意:这只是本地测试,没有推送到远程仓库" + print_info "如需推送,请运行: git push origin web-dist --force" + + # 切换回主分支 + git checkout main + + # 清理临时目录 + rm -rf "$TEMP_DIR" + + print_success "测试完成" +} + +# 测试从 web-dist 分支获取文件 +test_fetch_from_web_dist() { + print_info "测试从 web-dist 分支获取文件..." + + # 创建测试目录 + TEST_DIR="test-web-dist-fetch" + rm -rf "$TEST_DIR" + mkdir -p "$TEST_DIR" + + # 检查远程 web-dist 分支 + if ! git ls-remote --heads origin web-dist | grep -q web-dist; then + print_warning "远程 web-dist 分支不存在" + print_info "尝试使用本地 web-dist 分支..." + + # 检查本地分支 + if ! git branch | grep -q web-dist; then + print_error "本地和远程都没有 web-dist 分支" + rm -rf "$TEST_DIR" + return 1 + fi + fi + + print_info "克隆 web-dist 分支到测试目录..." + + # 创建临时目录用于 clone + TEMP_CLONE_DIR=$(mktemp -d) + + # 获取仓库 URL + REPO_URL=$(git config --get remote.origin.url) + + # 克隆 web-dist 分支 + if git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR" 2>/dev/null; then + print_success "成功克隆 web-dist 分支" + + # 复制文件(排除 .git 和 README.md) + if command -v rsync >/dev/null 2>&1; then + rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" "$TEST_DIR/" + else + cp -r "$TEMP_CLONE_DIR"/* "$TEST_DIR/" 2>/dev/null + rm -rf "$TEST_DIR/.git" 2>/dev/null + rm -f "$TEST_DIR/README.md" 2>/dev/null + fi + + print_success "文件复制成功" + print_info "测试目录内容:" + ls -la "$TEST_DIR" | head -10 + + # 验证关键文件 + if [ -f "$TEST_DIR/index.html" ]; then + print_success "✓ index.html 文件存在" + else + print_error "✗ index.html 文件不存在" + fi + + if [ -d "$TEST_DIR/assets" ]; then + print_success "✓ assets 目录存在" + else + print_error "✗ assets 目录不存在" + fi + + else + print_error "克隆 web-dist 分支失败" + print_info "可能需要先运行: test_build_and_push" + fi + + # 清理 + rm -rf "$TEMP_CLONE_DIR" + rm -rf "$TEST_DIR" + + print_success "获取测试完成" +} + +# 显示帮助 +show_help() { + echo "用法: $0 [命令]" + echo "" + echo "命令:" + echo " build - 测试构建并创建本地 web-dist 分支" + echo " fetch - 测试从 web-dist 分支获取文件" + echo " all - 运行所有测试" + echo " help - 显示帮助" + echo "" +} + +# 主函数 +main() { + case "$1" in + build) + test_build_and_push + ;; + fetch) + test_fetch_from_web_dist + ;; + all) + test_build_and_push + echo "" + test_fetch_from_web_dist + ;; + help) + show_help + ;; + *) + print_error "未知命令: $1" + echo "" + show_help + ;; + esac +} + +# 运行主函数 +main "$@" \ No newline at end of file diff --git a/scripts/test-window-remaining.js b/scripts/test-window-remaining.js new file mode 100644 index 0000000..79551ee --- /dev/null +++ b/scripts/test-window-remaining.js @@ -0,0 +1,78 @@ +const axios = require('axios') + +const BASE_URL = 'http://localhost:3312' + +// 你需要替换为一个有效的 API Key +const API_KEY = 'cr_your_api_key_here' + +async function testWindowRemaining() { + try { + console.log('🔍 测试时间窗口剩余时间功能...\n') + + // 第一步:获取 API Key ID + console.log('1. 获取 API Key ID...') + const idResponse = await axios.post(`${BASE_URL}/api-stats/api/get-key-id`, { + apiKey: API_KEY + }) + + if (!idResponse.data.success) { + throw new Error('Failed to get API Key ID') + } + + const apiId = idResponse.data.data.id + console.log(` ✅ API Key ID: ${apiId}\n`) + + // 第二步:查询统计数据 + console.log('2. 查询统计数据(包含时间窗口信息)...') + const statsResponse = await axios.post(`${BASE_URL}/api-stats/api/user-stats`, { + apiId + }) + + if (!statsResponse.data.success) { + throw new Error('Failed to get stats data') + } + + const stats = statsResponse.data.data + console.log(` ✅ 成功获取统计数据\n`) + + // 第三步:检查时间窗口信息 + console.log('3. 时间窗口信息:') + console.log(` - 窗口时长: ${stats.limits.rateLimitWindow} 分钟`) + console.log(` - 请求限制: ${stats.limits.rateLimitRequests || '无限制'}`) + console.log(` - Token限制: ${stats.limits.tokenLimit || '无限制'}`) + console.log(` - 当前请求数: ${stats.limits.currentWindowRequests}`) + console.log(` - 当前Token数: ${stats.limits.currentWindowTokens}`) + + if (stats.limits.windowStartTime) { + const startTime = new Date(stats.limits.windowStartTime) + const endTime = new Date(stats.limits.windowEndTime) + + console.log(`\n ⏰ 时间窗口状态:`) + console.log(` - 窗口开始时间: ${startTime.toLocaleString()}`) + console.log(` - 窗口结束时间: ${endTime.toLocaleString()}`) + console.log(` - 剩余时间: ${stats.limits.windowRemainingSeconds} 秒`) + + if (stats.limits.windowRemainingSeconds > 0) { + const minutes = Math.floor(stats.limits.windowRemainingSeconds / 60) + const seconds = stats.limits.windowRemainingSeconds % 60 + console.log(` - 格式化剩余时间: ${minutes}分${seconds}秒`) + console.log(` - 窗口状态: 🟢 活跃中`) + } else { + console.log(` - 窗口状态: 🔴 已过期(下次请求时重置)`) + } + } else { + console.log(`\n ⏰ 时间窗口状态: ⚪ 未启动(还没有任何请求)`) + } + + console.log('\n✅ 测试完成!时间窗口剩余时间功能正常工作。') + } catch (error) { + console.error('❌ 测试失败:', error.message) + if (error.response) { + console.error(' 响应数据:', error.response.data) + } + process.exit(1) + } +} + +// 运行测试 +testWindowRemaining() diff --git a/scripts/update-model-pricing.js b/scripts/update-model-pricing.js new file mode 100644 index 0000000..3e670f2 --- /dev/null +++ b/scripts/update-model-pricing.js @@ -0,0 +1,278 @@ +#!/usr/bin/env node + +/** + * 手动更新模型价格数据脚本 + * 从价格镜像分支下载最新的模型价格和上下文窗口信息 + */ + +const fs = require('fs') +const path = require('path') +const https = require('https') +const crypto = require('crypto') +const pricingSource = require('../config/pricingSource') + +// 颜色输出 +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[36m', + magenta: '\x1b[35m' +} + +// 日志函数 +const log = { + info: (msg) => console.log(`${colors.blue}[INFO]${colors.reset} ${msg}`), + success: (msg) => console.log(`${colors.green}[SUCCESS]${colors.reset} ${msg}`), + error: (msg) => console.error(`${colors.red}[ERROR]${colors.reset} ${msg}`), + warn: (msg) => console.warn(`${colors.yellow}[WARNING]${colors.reset} ${msg}`) +} + +// 配置 +const config = { + dataDir: path.join(process.cwd(), 'data'), + pricingFile: path.join(process.cwd(), 'data', 'model_pricing.json'), + hashFile: path.join(process.cwd(), 'data', 'model_pricing.sha256'), + pricingUrl: pricingSource.pricingUrl, + fallbackFile: path.join( + process.cwd(), + 'resources', + 'model-pricing', + 'model_prices_and_context_window.json' + ), + backupFile: path.join(process.cwd(), 'data', 'model_pricing.backup.json'), + timeout: 30000 // 30秒超时 +} + +// 创建数据目录 +function ensureDataDir() { + if (!fs.existsSync(config.dataDir)) { + fs.mkdirSync(config.dataDir, { recursive: true }) + log.info('Created data directory') + } +} + +// 备份现有文件 +function backupExistingFile() { + if (fs.existsSync(config.pricingFile)) { + try { + fs.copyFileSync(config.pricingFile, config.backupFile) + log.info('Backed up existing pricing file') + return true + } catch (error) { + log.warn(`Failed to backup existing file: ${error.message}`) + return false + } + } + return false +} + +// 恢复备份 +function restoreBackup() { + if (fs.existsSync(config.backupFile)) { + try { + fs.copyFileSync(config.backupFile, config.pricingFile) + log.info('Restored from backup') + return true + } catch (error) { + log.error(`Failed to restore backup: ${error.message}`) + return false + } + } + return false +} + +// 下载价格数据 +function downloadPricingData() { + return new Promise((resolve, reject) => { + log.info('正在从价格镜像分支拉取最新的模型价格数据...') + log.info(`拉取地址: ${config.pricingUrl}`) + + const request = https.get(config.pricingUrl, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)) + return + } + + let data = '' + let downloadedBytes = 0 + + response.on('data', (chunk) => { + data += chunk + downloadedBytes += chunk.length + // 显示下载进度 + process.stdout.write(`\rDownloading... ${Math.round(downloadedBytes / 1024)}KB`) + }) + + response.on('end', () => { + process.stdout.write('\n') // 换行 + try { + const jsonData = JSON.parse(data) + + // 验证数据结构 + if (typeof jsonData !== 'object' || Object.keys(jsonData).length === 0) { + throw new Error('Invalid pricing data structure') + } + + // 保存到文件 + const formattedJson = JSON.stringify(jsonData, null, 2) + fs.writeFileSync(config.pricingFile, formattedJson) + + const hash = crypto.createHash('sha256').update(formattedJson).digest('hex') + fs.writeFileSync(config.hashFile, `${hash}\n`) + + const modelCount = Object.keys(jsonData).length + const fileSize = Math.round(fs.statSync(config.pricingFile).size / 1024) + + log.success(`Downloaded pricing data for ${modelCount} models (${fileSize}KB)`) + + // 显示一些统计信息 + const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length + const gptModels = Object.keys(jsonData).filter((k) => k.includes('gpt')).length + const geminiModels = Object.keys(jsonData).filter((k) => k.includes('gemini')).length + + log.info('Model breakdown:') + log.info(` - Claude models: ${claudeModels}`) + log.info(` - GPT models: ${gptModels}`) + log.info(` - Gemini models: ${geminiModels}`) + log.info(` - Other models: ${modelCount - claudeModels - gptModels - geminiModels}`) + + resolve(jsonData) + } catch (error) { + reject(new Error(`Failed to parse pricing data: ${error.message}`)) + } + }) + }) + + request.on('error', (error) => { + reject(new Error(`Network error: ${error.message}`)) + }) + + request.setTimeout(config.timeout, () => { + request.destroy() + reject(new Error(`Download timeout after ${config.timeout / 1000} seconds`)) + }) + }) +} + +// 使用 fallback 文件 +function useFallback() { + log.warn('Attempting to use fallback pricing data...') + + if (!fs.existsSync(config.fallbackFile)) { + log.error(`Fallback file not found: ${config.fallbackFile}`) + return false + } + + try { + const fallbackData = fs.readFileSync(config.fallbackFile, 'utf8') + const jsonData = JSON.parse(fallbackData) + + // 保存到data目录 + fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2)) + + const modelCount = Object.keys(jsonData).length + log.warn(`Using fallback pricing data for ${modelCount} models`) + log.info('Note: Fallback data may be outdated. Try updating again later.') + + return true + } catch (error) { + log.error(`Failed to use fallback: ${error.message}`) + return false + } +} + +// 显示当前状态 +function showCurrentStatus() { + if (fs.existsSync(config.pricingFile)) { + const stats = fs.statSync(config.pricingFile) + const fileAge = Date.now() - stats.mtime.getTime() + const ageInHours = Math.round(fileAge / (60 * 60 * 1000)) + const ageInDays = Math.floor(ageInHours / 24) + + let ageString = '' + if (ageInDays > 0) { + ageString = `${ageInDays} day${ageInDays > 1 ? 's' : ''} and ${ageInHours % 24} hour${ageInHours % 24 !== 1 ? 's' : ''}` + } else { + ageString = `${ageInHours} hour${ageInHours !== 1 ? 's' : ''}` + } + + log.info(`Current pricing file age: ${ageString}`) + + try { + const data = JSON.parse(fs.readFileSync(config.pricingFile, 'utf8')) + log.info(`Current file contains ${Object.keys(data).length} models`) + } catch (error) { + log.warn('Current file exists but could not be parsed') + } + } else { + log.info('No existing pricing file found') + } +} + +// 主函数 +async function main() { + console.log(`${colors.bright}${colors.blue}======================================${colors.reset}`) + console.log(`${colors.bright} Model Pricing Update Tool${colors.reset}`) + console.log( + `${colors.bright}${colors.blue}======================================${colors.reset}\n` + ) + + // 显示当前状态 + showCurrentStatus() + console.log('') + + // 确保数据目录存在 + ensureDataDir() + + // 备份现有文件 + const hasBackup = backupExistingFile() + + try { + // 尝试下载最新数据 + await downloadPricingData() + + // 清理备份文件(成功下载后) + if (hasBackup && fs.existsSync(config.backupFile)) { + fs.unlinkSync(config.backupFile) + log.info('Cleaned up backup file') + } + + console.log(`\n${colors.green}✅ Model pricing updated successfully!${colors.reset}`) + process.exit(0) + } catch (error) { + log.error(`Download failed: ${error.message}`) + + // 尝试恢复备份 + if (hasBackup) { + if (restoreBackup()) { + log.info('Original file restored') + } + } + + // 尝试使用 fallback + if (useFallback()) { + console.log( + `\n${colors.yellow}⚠️ Using fallback data (update completed with warnings)${colors.reset}` + ) + process.exit(0) + } else { + console.log(`\n${colors.red}❌ Failed to update model pricing${colors.reset}`) + process.exit(1) + } + } +} + +// 处理未捕获的错误 +process.on('unhandledRejection', (error) => { + log.error(`Unhandled error: ${error.message}`) + process.exit(1) +}) + +// 运行主函数 +main().catch((error) => { + log.error(`Fatal error: ${error.message}`) + process.exit(1) +}) diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..e6875b4 --- /dev/null +++ b/src/app.js @@ -0,0 +1,954 @@ +const express = require('express') +const cors = require('cors') +const helmet = require('helmet') +const compression = require('compression') +const path = require('path') +const fs = require('fs') +const bcrypt = require('bcryptjs') + +const config = require('../config/config') +const logger = require('./utils/logger') +const redis = require('./models/redis') +const pricingService = require('./services/pricingService') +const cacheMonitor = require('./utils/cacheMonitor') +const { getSafeMessage } = require('./utils/errorSanitizer') + +// Import routes +const apiRoutes = require('./routes/api') +const unifiedRoutes = require('./routes/unified') +const adminRoutes = require('./routes/admin') +const webRoutes = require('./routes/web') +const apiStatsRoutes = require('./routes/apiStats') +const geminiRoutes = require('./routes/geminiRoutes') +const openaiGeminiRoutes = require('./routes/openaiGeminiRoutes') +const standardGeminiRoutes = require('./routes/standardGeminiRoutes') +const openaiClaudeRoutes = require('./routes/openaiClaudeRoutes') +const openaiRoutes = require('./routes/openaiRoutes') +const droidRoutes = require('./routes/droidRoutes') +const userRoutes = require('./routes/userRoutes') +const azureOpenaiRoutes = require('./routes/azureOpenaiRoutes') +const webhookRoutes = require('./routes/webhook') + +// Import middleware +const { + corsMiddleware, + requestLogger, + securityMiddleware, + errorHandler, + globalRateLimit, + requestSizeLimit +} = require('./middleware/auth') +const { browserFallbackMiddleware } = require('./middleware/browserFallback') + +class Application { + constructor() { + this.app = express() + this.server = null + } + + async initialize() { + try { + // 🔗 连接Redis + logger.info('🔄 Connecting to Redis...') + await redis.connect() + logger.success('Redis connected successfully') + + // 📊 检查数据迁移(版本 > 1.1.250 时执行) + const { getAppVersion, versionGt } = require('./utils/commonHelper') + const currentVersion = getAppVersion() + const migratedVersion = await redis.getMigratedVersion() + if (versionGt(currentVersion, '1.1.250') && versionGt(currentVersion, migratedVersion)) { + logger.info(`🔄 检测到新版本 ${currentVersion},检查数据迁移...`) + try { + if (await redis.needsGlobalStatsMigration()) { + await redis.migrateGlobalStats() + } + await redis.cleanupSystemMetrics() // 清理过期的系统分钟统计 + } catch (err) { + logger.error('⚠️ 数据迁移出错,但不影响启动:', err.message) + } + await redis.setMigratedVersion(currentVersion) + logger.success(`✅ 数据迁移完成,版本: ${currentVersion}`) + } + + // 📅 后台检查月份索引完整性(不阻塞启动) + redis.ensureMonthlyMonthsIndex().catch((err) => { + logger.error('📅 月份索引检查失败:', err.message) + }) + + // 📊 后台异步迁移 usage 索引(不阻塞启动) + redis.migrateUsageIndex().catch((err) => { + logger.error('📊 Background usage index migration failed:', err) + }) + + // 📊 迁移 alltime 模型统计(阻塞式,确保数据完整) + await redis.migrateAlltimeModelStats() + + // 💳 初始化账户余额查询服务(Provider 注册) + try { + const accountBalanceService = require('./services/account/accountBalanceService') + const { registerAllProviders } = require('./services/balanceProviders') + registerAllProviders(accountBalanceService) + logger.info('✅ 账户余额查询服务已初始化') + } catch (error) { + logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message) + } + + // 💰 初始化价格服务 + logger.info('🔄 Initializing pricing service...') + await pricingService.initialize() + + // 📋 初始化模型服务 + logger.info('🔄 Initializing model service...') + const modelService = require('./services/modelService') + await modelService.initialize() + + // 📊 初始化缓存监控 + await this.initializeCacheMonitoring() + + // 🔧 初始化管理员凭据 + logger.info('🔄 Initializing admin credentials...') + await this.initializeAdmin() + + // 🔒 安全启动:清理无效/伪造的管理员会话 + logger.info('🔒 Cleaning up invalid admin sessions...') + await this.cleanupInvalidSessions() + + // 💰 初始化费用数据 + logger.info('💰 Checking cost data initialization...') + const costInitService = require('./services/costInitService') + const needsInit = await costInitService.needsInitialization() + if (needsInit) { + logger.info('💰 Initializing cost data for all API Keys...') + const result = await costInitService.initializeAllCosts() + logger.info( + `💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors` + ) + } + + // 💰 启动回填:本周 Claude 周费用(用于 API Key 维度周限额) + try { + logger.info('💰 Backfilling current-week Claude weekly cost...') + const weeklyClaudeCostInitService = require('./services/weeklyClaudeCostInitService') + await weeklyClaudeCostInitService.backfillCurrentWeekClaudeCosts() + } catch (error) { + logger.warn('⚠️ Weekly Claude cost backfill failed (startup continues):', error.message) + } + + // 🕐 初始化Claude账户会话窗口 + logger.info('🕐 Initializing Claude account session windows...') + const claudeAccountService = require('./services/account/claudeAccountService') + await claudeAccountService.initializeSessionWindows() + + // 📊 初始化费用排序索引服务 + logger.info('📊 Initializing cost rank service...') + const costRankService = require('./services/costRankService') + await costRankService.initialize() + + // 🔍 初始化 API Key 索引服务(用于分页查询优化) + logger.info('🔍 Initializing API Key index service...') + const apiKeyIndexService = require('./services/apiKeyIndexService') + apiKeyIndexService.init(redis) + await apiKeyIndexService.checkAndRebuild() + + // 📁 确保账户分组反向索引存在(后台执行,不阻塞启动) + const accountGroupService = require('./services/accountGroupService') + accountGroupService.ensureReverseIndexes().catch((err) => { + logger.error('📁 Account group reverse index migration failed:', err) + }) + + // 超早期拦截 /admin-next/ 请求 - 在所有中间件之前 + this.app.use((req, res, next) => { + if (req.path === '/admin-next/' && req.method === 'GET') { + logger.warn('🚨 INTERCEPTING /admin-next/ request at the very beginning!') + const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist') + const indexPath = path.join(adminSpaPath, 'index.html') + + if (fs.existsSync(indexPath)) { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + return res.sendFile(indexPath) + } else { + logger.error('❌ index.html not found at:', indexPath) + return res.status(404).send('index.html not found') + } + } + next() + }) + + // 🛡️ 安全中间件 + this.app.use( + helmet({ + contentSecurityPolicy: false, // 允许内联样式和脚本 + crossOriginEmbedderPolicy: false + }) + ) + + // 🌐 CORS + if (config.web.enableCors) { + this.app.use(cors()) + } else { + this.app.use(corsMiddleware) + } + + // 🆕 兜底中间件:处理Chrome插件兼容性(必须在认证之前) + this.app.use(browserFallbackMiddleware) + + // 📦 压缩 - 排除流式响应(SSE) + this.app.use( + compression({ + filter: (req, res) => { + // 不压缩 Server-Sent Events + if (res.getHeader('Content-Type') === 'text/event-stream') { + return false + } + // 使用默认的压缩判断 + return compression.filter(req, res) + } + }) + ) + + // 🚦 全局速率限制(仅在生产环境启用) + if (process.env.NODE_ENV === 'production') { + this.app.use(globalRateLimit) + } + + // 📏 请求大小限制 + this.app.use(requestSizeLimit) + + // 📝 请求日志(使用自定义logger而不是morgan) + this.app.use(requestLogger) + + // 🐛 HTTP调试拦截器(仅在启用调试时生效) + if (process.env.DEBUG_HTTP_TRAFFIC === 'true') { + try { + const { debugInterceptor } = require('./middleware/debugInterceptor') + this.app.use(debugInterceptor) + logger.info('🐛 HTTP调试拦截器已启用 - 日志输出到 logs/http-debug-*.log') + } catch (error) { + logger.warn('⚠️ 无法加载HTTP调试拦截器:', error.message) + } + } + + // 🔧 基础中间件 + this.app.use( + express.json({ + limit: '100mb', + verify: (req, res, buf, encoding) => { + // 验证JSON格式 + if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) { + throw new Error('Invalid JSON: empty body') + } + } + }) + ) + this.app.use(express.urlencoded({ extended: true, limit: '100mb' })) + this.app.use(securityMiddleware) + + // 🎯 信任代理 + if (config.server.trustProxy) { + this.app.set('trust proxy', 1) + } + + // 调试中间件 - 拦截所有 /admin-next 请求 + this.app.use((req, res, next) => { + if (req.path.startsWith('/admin-next')) { + logger.info( + `🔍 DEBUG: Incoming request - method: ${req.method}, path: ${req.path}, originalUrl: ${req.originalUrl}` + ) + } + next() + }) + + // 🎨 新版管理界面静态文件服务(必须在其他路由之前) + const adminSpaPath = path.join(__dirname, '..', 'web', 'admin-spa', 'dist') + if (fs.existsSync(adminSpaPath)) { + // 处理不带斜杠的路径,重定向到带斜杠的路径 + this.app.get('/admin-next', (req, res) => { + res.redirect(301, '/admin-next/') + }) + + // 使用 all 方法确保捕获所有 HTTP 方法 + this.app.all('/admin-next/', (req, res) => { + logger.info('🎯 HIT: /admin-next/ route handler triggered!') + logger.info(`Method: ${req.method}, Path: ${req.path}, URL: ${req.url}`) + + if (req.method !== 'GET' && req.method !== 'HEAD') { + return res.status(405).send('Method Not Allowed') + } + + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + res.sendFile(path.join(adminSpaPath, 'index.html')) + }) + + // 处理所有其他 /admin-next/* 路径(但排除根路径) + this.app.get('/admin-next/*', (req, res) => { + // 如果是根路径,跳过(应该由上面的路由处理) + if (req.path === '/admin-next/') { + logger.error('❌ ERROR: /admin-next/ should not reach here!') + return res.status(500).send('Route configuration error') + } + + const requestPath = req.path.replace('/admin-next/', '') + + // 安全检查 + if ( + requestPath.includes('..') || + requestPath.includes('//') || + requestPath.includes('\\') + ) { + return res.status(400).json({ error: 'Invalid path' }) + } + + // 检查是否为静态资源 + const filePath = path.join(adminSpaPath, requestPath) + + // 如果文件存在且是静态资源 + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + // 设置缓存头 + if (filePath.endsWith('.js') || filePath.endsWith('.css')) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + } else if (filePath.endsWith('.html')) { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + } + return res.sendFile(filePath) + } + + // 如果是静态资源但文件不存在 + if (requestPath.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf)$/i)) { + return res.status(404).send('Not found') + } + + // 其他所有路径返回 index.html(SPA 路由) + res.sendFile(path.join(adminSpaPath, 'index.html')) + }) + + logger.info('✅ Admin SPA (next) static files mounted at /admin-next/') + } else { + logger.warn('⚠️ Admin SPA dist directory not found, skipping /admin-next route') + } + + // 🛣️ 路由 + this.app.use('/api', apiRoutes) + this.app.use('/api', unifiedRoutes) // 统一智能路由(支持 /v1/chat/completions 等) + this.app.use('/claude', apiRoutes) // /claude 路由别名,与 /api 功能相同 + // Anthropic (Claude Code) 路由:按路径强制分流到 Gemini OAuth 账户 + // - /antigravity/api/v1/messages -> Antigravity OAuth + // - /gemini-cli/api/v1/messages -> Gemini CLI OAuth + this.app.use( + '/antigravity/api', + (req, res, next) => { + req._anthropicVendor = 'antigravity' + next() + }, + apiRoutes + ) + this.app.use( + '/gemini-cli/api', + (req, res, next) => { + req._anthropicVendor = 'gemini-cli' + next() + }, + apiRoutes + ) + this.app.use('/admin', adminRoutes) + this.app.use('/users', userRoutes) + // 使用 web 路由(包含 auth 和页面重定向) + this.app.use('/web', webRoutes) + this.app.use('/apiStats', apiStatsRoutes) + // Gemini 路由:同时支持标准格式和原有格式 + this.app.use('/gemini', standardGeminiRoutes) // 标准 Gemini API 格式路由 + this.app.use('/gemini', geminiRoutes) // 保留原有路径以保持向后兼容 + this.app.use('/openai/gemini', openaiGeminiRoutes) + this.app.use('/openai/claude', openaiClaudeRoutes) + this.app.use('/openai', unifiedRoutes) // 复用统一智能路由,支持 /openai/v1/chat/completions + this.app.use('/openai', openaiRoutes) // Codex API 路由(/openai/responses, /openai/v1/responses) + // Droid 路由:支持多种 Factory.ai 端点 + this.app.use('/droid', droidRoutes) // Droid (Factory.ai) API 转发 + this.app.use('/azure', azureOpenaiRoutes) + this.app.use('/admin/webhook', webhookRoutes) + + // 🏠 根路径重定向到新版管理界面 + this.app.get('/', (req, res) => { + res.redirect('/admin-next/api-stats') + }) + + // 🏥 增强的健康检查端点 + this.app.get('/health', async (req, res) => { + try { + const timer = logger.timer('health-check') + + // 检查各个组件健康状态 + const [redisHealth, loggerHealth] = await Promise.all([ + this.checkRedisHealth(), + this.checkLoggerHealth() + ]) + + const memory = process.memoryUsage() + + // 获取版本号:优先使用环境变量,其次VERSION文件,再次package.json,最后使用默认值 + let version = process.env.APP_VERSION || process.env.VERSION + if (!version) { + try { + const versionFile = path.join(__dirname, '..', 'VERSION') + if (fs.existsSync(versionFile)) { + version = fs.readFileSync(versionFile, 'utf8').trim() + } + } catch (error) { + // 忽略错误,继续尝试其他方式 + } + } + if (!version) { + try { + const { version: pkgVersion } = require('../package.json') + version = pkgVersion + } catch (error) { + version = '1.0.0' + } + } + + const health = { + status: 'healthy', + service: 'claude-relay-service', + version, + timestamp: new Date().toISOString(), + uptime: process.uptime(), + memory: { + used: `${Math.round(memory.heapUsed / 1024 / 1024)}MB`, + total: `${Math.round(memory.heapTotal / 1024 / 1024)}MB`, + external: `${Math.round(memory.external / 1024 / 1024)}MB` + }, + components: { + redis: redisHealth, + logger: loggerHealth + }, + stats: logger.getStats() + } + + timer.end('completed') + res.json(health) + } catch (error) { + logger.error('❌ Health check failed:', { error: error.message, stack: error.stack }) + res.status(503).json({ + status: 'unhealthy', + error: getSafeMessage(error), + timestamp: new Date().toISOString() + }) + } + }) + + // 📊 指标端点 + this.app.get('/metrics', async (req, res) => { + try { + const stats = await redis.getSystemStats() + const metrics = { + ...stats, + uptime: process.uptime(), + memory: process.memoryUsage(), + timestamp: new Date().toISOString() + } + + res.json(metrics) + } catch (error) { + logger.error('❌ Metrics collection failed:', error) + res.status(500).json({ error: 'Failed to collect metrics' }) + } + }) + + // 🚫 404 处理 + this.app.use('*', (req, res) => { + res.status(404).json({ + error: 'Not Found', + message: `Route ${req.originalUrl} not found`, + timestamp: new Date().toISOString() + }) + }) + + // 🚨 错误处理 + this.app.use(errorHandler) + + logger.success('Application initialized successfully') + } catch (error) { + logger.error('💥 Application initialization failed:', error) + throw error + } + } + + // 🔧 初始化管理员凭据(总是从 init.json 加载,确保数据一致性) + async initializeAdmin() { + try { + const initFilePath = path.join(__dirname, '..', 'data', 'init.json') + + if (!fs.existsSync(initFilePath)) { + logger.warn('⚠️ No admin credentials found. Please run npm run setup first.') + return + } + + // 从 init.json 读取管理员凭据(作为唯一真实数据源) + const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + + // 将明文密码哈希化 + const saltRounds = 10 + const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds) + + // 存储到Redis(每次启动都覆盖,确保与 init.json 同步) + const adminCredentials = { + username: initData.adminUsername, + passwordHash, + createdAt: initData.initializedAt || new Date().toISOString(), + lastLogin: null, + updatedAt: initData.updatedAt || null + } + + await redis.setSession('admin_credentials', adminCredentials) + + logger.success('Admin credentials loaded from init.json (single source of truth)') + logger.info(`📋 Admin username: ${adminCredentials.username}`) + } catch (error) { + logger.error('❌ Failed to initialize admin credentials:', { + error: error.message, + stack: error.stack + }) + throw error + } + } + + // 🔒 清理无效/伪造的管理员会话(安全启动检查) + async cleanupInvalidSessions() { + try { + const client = redis.getClient() + + // 获取所有 session:* 键 + const sessionKeys = await redis.scanKeys('session:*') + const dataList = await redis.batchHgetallChunked(sessionKeys) + + let validCount = 0 + let invalidCount = 0 + + for (let i = 0; i < sessionKeys.length; i++) { + const key = sessionKeys[i] + // 跳过 admin_credentials(系统凭据) + if (key === 'session:admin_credentials') { + continue + } + + const sessionData = dataList[i] + + // 检查会话完整性:必须有 username 和 loginTime + const hasUsername = !!sessionData?.username + const hasLoginTime = !!sessionData?.loginTime + + if (!hasUsername || !hasLoginTime) { + // 无效会话 - 可能是漏洞利用创建的伪造会话 + invalidCount++ + logger.security( + `🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})` + ) + await client.del(key) + } else { + validCount++ + } + } + + if (invalidCount > 0) { + logger.security(`Startup security check: Removed ${invalidCount} invalid sessions`) + } + + logger.success( + `Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed` + ) + } catch (error) { + // 清理失败不应阻止服务启动 + logger.error('❌ Failed to cleanup invalid sessions:', error.message) + } + } + + // 🔍 Redis健康检查 + async checkRedisHealth() { + try { + const start = Date.now() + await redis.getClient().ping() + const latency = Date.now() - start + + return { + status: 'healthy', + connected: redis.isConnected, + latency: `${latency}ms` + } + } catch (error) { + return { + status: 'unhealthy', + connected: false, + error: error.message + } + } + } + + // 📝 Logger健康检查 + async checkLoggerHealth() { + try { + const health = logger.healthCheck() + return { + status: health.healthy ? 'healthy' : 'unhealthy', + ...health + } + } catch (error) { + return { + status: 'unhealthy', + error: error.message + } + } + } + + async start() { + try { + await this.initialize() + + this.server = this.app.listen(config.server.port, config.server.host, () => { + logger.start(`Claude Relay Service started on ${config.server.host}:${config.server.port}`) + logger.info( + `🌐 Web interface: http://${config.server.host}:${config.server.port}/admin-next/api-stats` + ) + logger.info( + `🔗 API endpoint: http://${config.server.host}:${config.server.port}/api/v1/messages` + ) + logger.info(`⚙️ Admin API: http://${config.server.host}:${config.server.port}/admin`) + logger.info(`🏥 Health check: http://${config.server.host}:${config.server.port}/health`) + logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`) + }) + + const serverTimeout = 600000 // 默认10分钟 + this.server.timeout = serverTimeout + this.server.keepAliveTimeout = serverTimeout + 5000 // keepAlive 稍长一点 + logger.info(`⏱️ Server timeout set to ${serverTimeout}ms (${serverTimeout / 1000}s)`) + + // 🔄 定期清理任务 + this.startCleanupTasks() + + // 🛑 优雅关闭 + this.setupGracefulShutdown() + } catch (error) { + logger.error('💥 Failed to start server:', error) + process.exit(1) + } + } + + // 📊 初始化缓存监控 + async initializeCacheMonitoring() { + try { + logger.info('🔄 Initializing cache monitoring...') + + // 注册各个服务的缓存实例 + const services = [ + { name: 'claudeAccount', service: require('./services/account/claudeAccountService') }, + { + name: 'claudeConsole', + service: require('./services/account/claudeConsoleAccountService') + }, + { name: 'bedrockAccount', service: require('./services/account/bedrockAccountService') } + ] + + // 注册已加载的服务缓存 + for (const { name, service } of services) { + if (service && (service._decryptCache || service.decryptCache)) { + const cache = service._decryptCache || service.decryptCache + cacheMonitor.registerCache(`${name}_decrypt`, cache) + logger.info(`✅ Registered ${name} decrypt cache for monitoring`) + } + } + + // 初始化时打印一次统计 + setTimeout(() => { + const stats = cacheMonitor.getGlobalStats() + logger.info(`📊 Cache System - Registered: ${stats.cacheCount} caches`) + }, 5000) + + logger.success('Cache monitoring initialized') + } catch (error) { + logger.error('❌ Failed to initialize cache monitoring:', error) + // 不阻止应用启动 + } + } + + startCleanupTasks() { + // 🧹 每小时清理一次过期数据 + setInterval(async () => { + try { + logger.info('🧹 Starting scheduled cleanup...') + + const apiKeyService = require('./services/apiKeyService') + const claudeAccountService = require('./services/account/claudeAccountService') + + const [expiredKeys, errorAccounts] = await Promise.all([ + apiKeyService.cleanupExpiredKeys(), + claudeAccountService.cleanupErrorAccounts(), + claudeAccountService.cleanupTempErrorAccounts() // 新增:清理临时错误账户 + ]) + + await redis.cleanup() + + logger.success( + `🧹 Cleanup completed: ${expiredKeys} expired keys, ${errorAccounts} error accounts reset` + ) + } catch (error) { + logger.error('❌ Cleanup task failed:', error) + } + }, config.system.cleanupInterval) + + logger.info( + `🔄 Cleanup tasks scheduled every ${config.system.cleanupInterval / 1000 / 60} minutes` + ) + + // 🚨 启动限流状态自动清理服务 + // 每5分钟检查一次过期的限流状态,确保账号能及时恢复调度 + const rateLimitCleanupService = require('./services/rateLimitCleanupService') + const cleanupIntervalMinutes = config.system.rateLimitCleanupInterval || 5 // 默认5分钟 + rateLimitCleanupService.start(cleanupIntervalMinutes) + logger.info( + `🚨 Rate limit cleanup service started (checking every ${cleanupIntervalMinutes} minutes)` + ) + + // 🔢 启动并发计数自动清理任务(Phase 1 修复:解决并发泄漏问题) + // 每分钟主动清理所有过期的并发项,不依赖请求触发 + setInterval(async () => { + try { + const keys = await redis.scanKeys('concurrency:*') + if (keys.length === 0) { + return + } + + const now = Date.now() + let totalCleaned = 0 + let legacyCleaned = 0 + + // 使用 Lua 脚本批量清理所有过期项 + for (const key of keys) { + // 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑) + // - concurrency:queue:stats:* 是 Hash 类型 + // - concurrency:queue:wait_times:* 是 List 类型 + // - concurrency:queue:* (不含stats/wait_times) 是 String 类型 + if ( + key.startsWith('concurrency:queue:stats:') || + key.startsWith('concurrency:queue:wait_times:') || + (key.startsWith('concurrency:queue:') && + !key.includes(':stats:') && + !key.includes(':wait_times:')) + ) { + continue + } + + try { + // 使用原子 Lua 脚本:先检查类型,再执行清理 + // 返回值:0 = 正常清理无删除,1 = 清理后删除空键,-1 = 遗留键已删除 + const result = await redis.client.eval( + ` + local key = KEYS[1] + local now = tonumber(ARGV[1]) + + -- 先检查键类型,只对 Sorted Set 执行清理 + local keyType = redis.call('TYPE', key) + if keyType.ok ~= 'zset' then + -- 非 ZSET 类型的遗留键,直接删除 + redis.call('DEL', key) + return -1 + end + + -- 清理过期项 + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + + -- 获取剩余计数 + local count = redis.call('ZCARD', key) + + -- 如果计数为0,删除键 + if count <= 0 then + redis.call('DEL', key) + return 1 + end + + return 0 + `, + 1, + key, + now + ) + if (result === 1) { + totalCleaned++ + } else if (result === -1) { + legacyCleaned++ + } + } catch (error) { + logger.error(`❌ Failed to clean concurrency key ${key}:`, error) + } + } + + if (totalCleaned > 0) { + logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`) + } + if (legacyCleaned > 0) { + logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`) + } + } catch (error) { + logger.error('❌ Concurrency cleanup task failed:', error) + } + }, 60000) // 每分钟执行一次 + + logger.info('🔢 Concurrency cleanup task started (running every 1 minute)') + + // 📬 启动用户消息队列服务 + const userMessageQueueService = require('./services/userMessageQueueService') + // 先清理服务重启后残留的锁,防止旧锁阻塞新请求 + userMessageQueueService.cleanupStaleLocks().then(() => { + // 然后启动定时清理任务 + userMessageQueueService.startCleanupTask() + }) + + // 🚦 清理服务重启后残留的并发排队计数器 + // 多实例部署时建议关闭此开关,避免新实例启动时清空其他实例的队列计数 + // 可通过 DELETE /admin/concurrency/queue 接口手动清理 + const clearQueuesOnStartup = process.env.CLEAR_CONCURRENCY_QUEUES_ON_STARTUP !== 'false' + if (clearQueuesOnStartup) { + redis.clearAllConcurrencyQueues().catch((error) => { + logger.error('❌ Error clearing concurrency queues on startup:', error) + }) + } else { + logger.info( + '🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)' + ) + } + + // 🧪 启动账户定时测试调度器 + // 根据配置定期测试账户连通性并保存测试历史 + const accountTestSchedulerEnabled = + process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' && + config.accountTestScheduler?.enabled !== false + if (accountTestSchedulerEnabled) { + const accountTestSchedulerService = require('./services/accountTestSchedulerService') + accountTestSchedulerService.start() + logger.info('🧪 Account test scheduler service started') + } else { + logger.info('🧪 Account test scheduler service disabled') + } + } + + setupGracefulShutdown() { + const shutdown = async (signal) => { + logger.info(`🛑 Received ${signal}, starting graceful shutdown...`) + + if (this.server) { + this.server.close(async () => { + logger.info('🚪 HTTP server closed') + + // 清理 pricing service 的文件监听器 + try { + pricingService.cleanup() + logger.info('💰 Pricing service cleaned up') + } catch (error) { + logger.error('❌ Error cleaning up pricing service:', error) + } + + // 清理 model service 的文件监听器 + try { + const modelService = require('./services/modelService') + modelService.cleanup() + logger.info('📋 Model service cleaned up') + } catch (error) { + logger.error('❌ Error cleaning up model service:', error) + } + + // 停止限流清理服务 + try { + const rateLimitCleanupService = require('./services/rateLimitCleanupService') + rateLimitCleanupService.stop() + logger.info('🚨 Rate limit cleanup service stopped') + } catch (error) { + logger.error('❌ Error stopping rate limit cleanup service:', error) + } + + // 停止用户消息队列清理服务 + try { + const userMessageQueueService = require('./services/userMessageQueueService') + userMessageQueueService.stopCleanupTask() + logger.info('📬 User message queue service stopped') + } catch (error) { + logger.error('❌ Error stopping user message queue service:', error) + } + + // 停止费用排序索引服务 + try { + const costRankService = require('./services/costRankService') + costRankService.shutdown() + logger.info('📊 Cost rank service stopped') + } catch (error) { + logger.error('❌ Error stopping cost rank service:', error) + } + + // 停止账户定时测试调度器 + try { + const accountTestSchedulerService = require('./services/accountTestSchedulerService') + accountTestSchedulerService.stop() + logger.info('🧪 Account test scheduler service stopped') + } catch (error) { + logger.error('❌ Error stopping account test scheduler service:', error) + } + + // 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏) + try { + logger.info('🔢 Cleaning up all concurrency counters...') + const keys = await redis.scanKeys('concurrency:*') + if (keys.length > 0) { + await redis.batchDelChunked(keys) + logger.info(`✅ Cleaned ${keys.length} concurrency keys`) + } else { + logger.info('✅ No concurrency keys to clean') + } + } catch (error) { + logger.error('❌ Error cleaning up concurrency counters:', error) + // 不阻止退出流程 + } + + try { + await redis.disconnect() + logger.info('👋 Redis disconnected') + } catch (error) { + logger.error('❌ Error disconnecting Redis:', error) + } + + logger.success('Graceful shutdown completed') + process.exit(0) + }) + + // 强制关闭超时 + setTimeout(() => { + logger.warn('⚠️ Forced shutdown due to timeout') + process.exit(1) + }, 10000) + } else { + process.exit(0) + } + } + + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) + + // 处理未捕获异常 + process.on('uncaughtException', (error) => { + logger.error('💥 Uncaught exception:', error) + shutdown('uncaughtException') + }) + + process.on('unhandledRejection', (reason, promise) => { + logger.error('💥 Unhandled rejection at:', promise, 'reason:', reason) + shutdown('unhandledRejection') + }) + } +} + +// 启动应用 +if (require.main === module) { + const app = new Application() + app.start().catch((error) => { + logger.error('💥 Application startup failed:', error) + process.exit(1) + }) +} + +module.exports = Application diff --git a/src/cli/initCosts.js b/src/cli/initCosts.js new file mode 100644 index 0000000..35ab75d --- /dev/null +++ b/src/cli/initCosts.js @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +const costInitService = require('../services/costInitService') +const logger = require('../utils/logger') +const redis = require('../models/redis') + +async function main() { + try { + // 连接Redis + await redis.connect() + + console.log('💰 Starting cost data initialization...\n') + + // 执行初始化 + const result = await costInitService.initializeAllCosts() + + console.log('\n✅ Cost initialization completed!') + console.log(` Processed: ${result.processed} API Keys`) + console.log(` Errors: ${result.errors}`) + + // 断开连接 + await redis.disconnect() + throw new Error('INIT_COSTS_SUCCESS') + } catch (error) { + if (error.message === 'INIT_COSTS_SUCCESS') { + return + } + console.error('\n❌ Cost initialization failed:', error.message) + logger.error('Cost initialization failed:', error) + throw error + } +} + +// 运行主函数 +main() diff --git a/src/handlers/geminiHandlers.js b/src/handlers/geminiHandlers.js new file mode 100644 index 0000000..d0acf94 --- /dev/null +++ b/src/handlers/geminiHandlers.js @@ -0,0 +1,3053 @@ +/** + * Gemini API 处理函数模块 + * + * 该模块包含所有 Gemini API 的处理函数,供 geminiRoutes.js 和 standardGeminiRoutes.js 共享使用。 + * 这样可以避免代码重复,确保处理逻辑的一致性。 + */ + +const logger = require('../utils/logger') +const geminiAccountService = require('../services/account/geminiAccountService') +const geminiApiAccountService = require('../services/account/geminiApiAccountService') +const { sendGeminiRequest, getAvailableModels } = require('../services/relay/geminiRelayService') +const { sendAntigravityRequest } = require('../services/relay/antigravityRelayService') +const crypto = require('crypto') +const sessionHelper = require('../utils/sessionHelper') +const unifiedGeminiScheduler = require('../services/scheduler/unifiedGeminiScheduler') +const apiKeyService = require('../services/apiKeyService') +const redis = require('../models/redis') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const { parseSSELine } = require('../utils/sseParser') +const axios = require('axios') +const { getSafeMessage } = require('../utils/errorSanitizer') +const ProxyHelper = require('../utils/proxyHelper') +const upstreamErrorHelper = require('../utils/upstreamErrorHelper') +const { createRequestDetailMeta } = require('../utils/requestDetailHelper') + +// 处理 Gemini 上游错误,标记账户为临时不可用 +const handleGeminiUpstreamError = async ( + errorStatus, + accountId, + accountType, + sessionHash, + headers, + disableAutoProtection = false +) => { + if (!accountId || !errorStatus) { + return + } + const autoProtectionDisabled = disableAutoProtection === true || disableAutoProtection === 'true' + try { + if (errorStatus === 429) { + if (!autoProtectionDisabled) { + const ttl = upstreamErrorHelper.parseRetryAfter(headers) + await upstreamErrorHelper.markTempUnavailable(accountId, accountType || 'gemini', 429, ttl) + // 同时设置 rate-limit 状态,保持与 /messages handler 一致 + await unifiedGeminiScheduler + .markAccountRateLimited(accountId, accountType || 'gemini', sessionHash) + .catch((e) => logger.warn('Failed to mark account as rate limited:', e)) + } + if (sessionHash) { + await unifiedGeminiScheduler._deleteSessionMapping(sessionHash) + } + return + } + if (errorStatus >= 500 || errorStatus === 401 || errorStatus === 403) { + if (!autoProtectionDisabled) { + await upstreamErrorHelper.markTempUnavailable( + accountId, + accountType || 'gemini', + errorStatus + ) + } + } + if (sessionHash) { + await unifiedGeminiScheduler._deleteSessionMapping(sessionHash) + } + } catch (e) { + logger.warn('[UpstreamError] Failed to handle Gemini upstream error:', e) + } +} + +// ============================================================================ +// 工具函数 +// ============================================================================ + +/** + * 构建 Gemini API URL + * 兼容新旧 baseUrl 格式: + * - 新格式(以 /models 结尾): https://xxx.com/v1beta/models -> 直接拼接 /{model}:action + * - 旧格式(不以 /models 结尾): https://xxx.com -> 拼接 /v1beta/models/{model}:action + * + * @param {string} baseUrl - 账户配置的基础地址 + * @param {string} model - 模型名称 + * @param {string} action - API 动作 (generateContent, streamGenerateContent, countTokens) + * @param {string} apiKey - API Key + * @param {object} options - 额外选项 { stream: boolean, listModels: boolean } + * @returns {string} 完整的 API URL + */ +function buildGeminiApiUrl(baseUrl, model, action, apiKey, options = {}) { + const { stream = false, listModels = false } = options + + // 移除末尾的斜杠(如果有) + const normalizedBaseUrl = baseUrl.replace(/\/+$/, '') + + // 模式 3: URL 模板(包含 {model} 占位符) + const isTemplate = normalizedBaseUrl.includes('{model}') + // 模式 2: 以 /models 结尾 + const isModelsFormat = normalizedBaseUrl.endsWith('/models') + + // 模板校验: 有 {model} 但没有 {action} 且 {model} 后面没有 : 开头的固定 action + if (isTemplate && !listModels && !normalizedBaseUrl.includes('{action}')) { + const afterModel = normalizedBaseUrl.split('{model}')[1] || '' + if (!afterModel.startsWith(':')) { + const err = new Error( + `Gemini baseUrl 模板配置错误: 包含 {model} 但缺少 :{action} 或固定 action。` + + `当前: ${baseUrl},示例: https://proxy.com/v1beta/models/{model}:{action}` + ) + err.statusCode = 400 + throw err + } + } + + let url + if (listModels) { + if (isTemplate) { + // 模板模式: 分离 path 和 query,分别剔除含 {model}/{action} 的部分 + const [pathPart, queryPart] = normalizedBaseUrl.split('?') + let cleanPath = pathPart.split('{model}')[0].replace(/\/+$/, '') + let cleanQuery = '' + if (queryPart) { + cleanQuery = queryPart + .split('&') + .filter((p) => !p.includes('{model}') && !p.includes('{action}')) + .join('&') + } + // 如果 {model} 在 query 里(path 未变),path 可能缺少 /models + if (cleanPath === pathPart.replace(/\/+$/, '') && !cleanPath.endsWith('/models')) { + logger.warn( + 'Gemini 模板 {model} 在 query 中,listModels 路径可能不正确,自动追加 /v1beta/models', + { baseUrl } + ) + cleanPath += '/v1beta/models' + } + const base = cleanQuery ? `${cleanPath}?${cleanQuery}` : cleanPath + const separator = base.includes('?') ? '&' : '?' + url = `${base}${separator}key=${apiKey}` + } else if (isModelsFormat) { + url = `${normalizedBaseUrl}?key=${apiKey}` + } else { + url = `${normalizedBaseUrl}/v1beta/models?key=${apiKey}` + } + } else { + const streamParam = stream ? '&alt=sse' : '' + + if (isTemplate) { + // 模板模式: 直接替换占位符({action} 可选,用户可硬编码 action) + url = normalizedBaseUrl.replace('{model}', model).replace('{action}', action) + const separator = url.includes('?') ? '&' : '?' + url += `${separator}key=${apiKey}${streamParam}` + } else if (isModelsFormat) { + url = `${normalizedBaseUrl}/${model}:${action}?key=${apiKey}${streamParam}` + } else { + url = `${normalizedBaseUrl}/v1beta/models/${model}:${action}?key=${apiKey}${streamParam}` + } + } + + return url +} + +/** + * 生成会话哈希 + */ +function generateSessionHash(req) { + const apiKeyPrefix = + req.headers['x-api-key']?.substring(0, 10) || req.headers['x-goog-api-key']?.substring(0, 10) + + const sessionData = [req.headers['user-agent'], req.ip, apiKeyPrefix].filter(Boolean).join(':') + + return crypto.createHash('sha256').update(sessionData).digest('hex') +} + +/** + * 检查 API Key 权限 + */ +function checkPermissions(apiKeyData, requiredPermission = 'gemini') { + return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission) +} + +/** + * 确保请求具有 Gemini 访问权限 + */ +function ensureGeminiPermission(req, res) { + const apiKeyData = req.apiKey || {} + if (checkPermissions(apiKeyData, 'gemini')) { + return true + } + + logger.security( + `🚫 API Key ${apiKeyData.id || 'unknown'} 缺少 Gemini 权限,拒绝访问 ${req.originalUrl}` + ) + + res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied' + } + }) + return false +} + +/** + * 权限检查中间件 + */ +function ensureGeminiPermissionMiddleware(req, res, next) { + if (ensureGeminiPermission(req, res)) { + return next() + } + return undefined +} + +/** + * 应用速率限制跟踪 + */ +async function applyRateLimitTracking( + req, + usageSummary, + model, + context = '', + preCalculatedCost = null +) { + if (!req.rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + req.rateLimitInfo, + usageSummary, + model, + req.apiKey?.id, + 'gemini', + preCalculatedCost + ) + + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + } catch (error) { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + } +} + +/** + * 判断对象是否为可读流 + */ +function isReadableStream(value) { + return value && typeof value.on === 'function' && typeof value.pipe === 'function' +} + +/** + * 清理 contents 中 functionResponse 不被标准 Gemini API 支持的字段 + * 标准 Gemini API (generativelanguage.googleapis.com) 的 functionResponse 只支持 name 和 response 字段,不支持 id 字段 + * 注意:此函数仅用于 API Key 账户,OAuth 账户使用的 Cloud Code Assist API 可能支持额外字段 + */ +function sanitizeFunctionResponsesForApiKey(contents) { + if (!contents || !Array.isArray(contents)) { + return contents + } + + return contents.map((content) => { + if (!content.parts || !Array.isArray(content.parts)) { + return content + } + + const sanitizedParts = content.parts.map((part) => { + if (part.functionResponse) { + // 只保留标准 Gemini API 支持的字段:name 和 response + const { name, response } = part.functionResponse + return { + functionResponse: { + name, + response + } + } + } + return part + }) + + return { + ...content, + parts: sanitizedParts + } + }) +} + +/** + * 读取可读流内容为字符串 + */ +async function readStreamToString(stream) { + return new Promise((resolve, reject) => { + let result = '' + + try { + if (typeof stream.setEncoding === 'function') { + stream.setEncoding('utf8') + } + } catch (error) { + logger.warn('设置流编码失败:', error) + } + + stream.on('data', (chunk) => { + result += chunk + }) + + stream.on('end', () => { + resolve(result) + }) + + stream.on('error', (error) => { + reject(error) + }) + }) +} + +/** + * 规范化上游 Axios 错误信息 + */ +async function normalizeAxiosStreamError(error) { + const status = error.response?.status + const statusText = error.response?.statusText + const responseData = error.response?.data + let rawBody = null + let parsedBody = null + + if (responseData) { + try { + if (isReadableStream(responseData)) { + rawBody = await readStreamToString(responseData) + } else if (Buffer.isBuffer(responseData)) { + rawBody = responseData.toString('utf8') + } else if (typeof responseData === 'string') { + rawBody = responseData + } else { + rawBody = JSON.stringify(responseData) + } + } catch (streamError) { + logger.warn('读取 Gemini 上游错误流失败:', streamError) + } + } + + if (rawBody) { + if (typeof rawBody === 'string') { + try { + parsedBody = JSON.parse(rawBody) + } catch (parseError) { + parsedBody = rawBody + } + } else { + parsedBody = rawBody + } + } + + let finalMessage = error.message || 'Internal server error' + if (parsedBody && typeof parsedBody === 'object') { + finalMessage = parsedBody.error?.message || parsedBody.message || finalMessage + } else if (typeof parsedBody === 'string' && parsedBody.trim()) { + finalMessage = parsedBody.trim() + } + + return { + status, + statusText, + message: finalMessage, + parsedBody, + rawBody + } +} + +/** + * 解析账户代理配置 + */ +function parseProxyConfig(account) { + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + return proxyConfig +} + +// ============================================================================ +// 处理函数 - OpenAI 兼容格式(/messages 端点) +// ============================================================================ + +/** + * 处理 OpenAI 兼容格式的消息请求 + */ +async function handleMessages(req, res) { + const startTime = Date.now() + let abortController = null + let accountId + let accountType + let sessionHash + let account + + try { + const apiKeyData = req.apiKey + + // 检查权限 + if (!checkPermissions(apiKeyData, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied' + } + }) + } + + // 提取请求参数 + const { + messages, + model = 'gemini-2.5-flash', + temperature = 0.7, + max_tokens = 4096, + stream = false + } = req.body + + // 验证必需参数 + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ + error: { + message: 'Messages array is required', + type: 'invalid_request_error' + } + }) + } + + // 生成会话哈希用于粘性会话 + sessionHash = generateSessionHash(req) + + // 使用统一调度选择可用的 Gemini 账户(传递请求的模型) + try { + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + model, // 传递请求的模型进行过滤 + { allowApiAccounts: true } // 允许调度 API 账户 + ) + ;({ accountId, accountType } = schedulerResult) + } catch (error) { + logger.error('Failed to select Gemini account:', error) + return res.status(503).json({ + error: { + message: getSafeMessage(error) || 'No available Gemini accounts', + type: 'service_unavailable' + } + }) + } + + // 判断账户类型:根据 accountType 判断,而非 accountId 前缀 + const isApiAccount = accountType === 'gemini-api' + + // 获取账户详情 + if (isApiAccount) { + account = await geminiApiAccountService.getAccount(accountId) + if (!account) { + return res.status(503).json({ + error: { + message: 'Gemini API account not found', + type: 'service_unavailable' + } + }) + } + logger.info(`Using Gemini API account: ${account.id} for API key: ${apiKeyData.id}`) + // 标记 API 账户被使用 + await geminiApiAccountService.markAccountUsed(account.id) + } else { + account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(503).json({ + error: { + message: 'Gemini OAuth account not found', + type: 'service_unavailable' + } + }) + } + logger.info(`Using Gemini OAuth account: ${account.id} for API key: ${apiKeyData.id}`) + // 标记 OAuth 账户被使用 + await geminiAccountService.markAccountUsed(account.id) + } + + // 创建中止控制器 + abortController = new AbortController() + + // 处理客户端断开连接 + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info('Client disconnected, aborting Gemini request') + abortController.abort() + } + }) + + let geminiResponse + + if (isApiAccount) { + // API 账户:直接调用 Google Gemini API + // 转换 OpenAI 格式的 messages 为 Gemini 格式的 contents + const contents = messages.map((msg) => ({ + role: msg.role === 'assistant' ? 'model' : msg.role, + parts: [{ text: msg.content }] + })) + + const requestBody = { + contents, + generationConfig: { + temperature, + maxOutputTokens: max_tokens, + topP: 0.95, + topK: 40 + } + } + + // 解析代理配置 + const proxyConfig = parseProxyConfig(account) + + const apiUrl = buildGeminiApiUrl( + account.baseUrl, + model, + stream ? 'streamGenerateContent' : 'generateContent', + account.apiKey, + { stream } + ) + + const axiosConfig = { + method: 'POST', + url: apiUrl, + data: requestBody, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': account.apiKey, + 'x-goog-api-key': account.apiKey + }, + responseType: stream ? 'stream' : 'json', + signal: abortController.signal + } + + // 添加代理配置 + if (proxyConfig) { + axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig) + } + + try { + const apiResponse = await axios(axiosConfig) + if (stream) { + geminiResponse = apiResponse.data + } else { + // 转换为 OpenAI 兼容格式 + const geminiData = apiResponse.data + geminiResponse = { + id: crypto.randomUUID(), + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: + geminiData.candidates?.[0]?.content?.parts?.[0]?.text || 'No response generated' + }, + finish_reason: 'stop' + } + ], + usage: { + prompt_tokens: geminiData.usageMetadata?.promptTokenCount || 0, + completion_tokens: geminiData.usageMetadata?.candidatesTokenCount || 0, + total_tokens: geminiData.usageMetadata?.totalTokenCount || 0 + } + } + + // 记录使用统计 + if (geminiData.usageMetadata) { + await apiKeyService.recordUsage( + apiKeyData.id, + geminiData.usageMetadata.promptTokenCount || 0, + geminiData.usageMetadata.candidatesTokenCount || 0, + 0, + 0, + model, + accountId, + 'gemini', + null, + createRequestDetailMeta(req, { + requestBody: req.body, + stream, + statusCode: res.statusCode || 200 + }) + ) + } + } + } catch (error) { + logger.error('Gemini API request failed:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data + }) + throw error + } + } else { + // OAuth 账户:使用现有的 sendGeminiRequest + // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId + const effectiveProjectId = account.projectId || account.tempProjectId || null + const oauthProvider = account.oauthProvider || 'gemini-cli' + + if (oauthProvider === 'antigravity') { + geminiResponse = await sendAntigravityRequest({ + messages, + model, + temperature, + maxTokens: max_tokens, + stream, + accessToken: account.accessToken, + proxy: account.proxy, + apiKeyId: apiKeyData.id, + signal: abortController.signal, + projectId: effectiveProjectId, + accountId: account.id, + requestMeta: createRequestDetailMeta(req, { + requestBody: req.body, + stream + }) + }) + } else { + geminiResponse = await sendGeminiRequest({ + messages, + model, + temperature, + maxTokens: max_tokens, + stream, + accessToken: account.accessToken, + proxy: account.proxy, + apiKeyId: apiKeyData.id, + signal: abortController.signal, + projectId: effectiveProjectId, + accountId: account.id, + requestMeta: createRequestDetailMeta(req, { + requestBody: req.body, + stream + }) + }) + } + } + + if (stream) { + // 设置流式响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + if (isApiAccount) { + // API 账户:处理 SSE 流并记录使用统计 + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + let streamBuffer = '' + + geminiResponse.on('data', (chunk) => { + try { + const chunkStr = chunk.toString() + res.write(chunkStr) + + // 尝试从 SSE 流中提取 usage 数据 + streamBuffer += chunkStr + + // 如果 buffer 过大,进行保护性清理(防止内存泄漏) + if (streamBuffer.length > 1024 * 1024) { + // 1MB + streamBuffer = streamBuffer.slice(-1024 * 64) // 只保留最后 64KB + } + + const lines = streamBuffer.split('\n') + // 保留最后一行(可能不完整) + streamBuffer = lines.pop() || '' + + for (const line of lines) { + if (line.startsWith('data:')) { + const data = line.substring(5).trim() + if (data && data !== '[DONE]') { + try { + const parsed = JSON.parse(data) + if (parsed.usageMetadata || parsed.response?.usageMetadata) { + totalUsage = parsed.usageMetadata || parsed.response.usageMetadata + } + } catch (e) { + // 解析失败,忽略 + } + } + } + } + } catch (error) { + logger.error('Error processing stream chunk:', error) + } + }) + + geminiResponse.on('end', () => { + res.end() + + // 异步记录使用统计 + if (totalUsage.totalTokenCount > 0) { + apiKeyService + .recordUsage( + apiKeyData.id, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, + 0, + model, + accountId, + 'gemini', + null, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: true, + statusCode: res.statusCode + }) + ) + .then(() => { + logger.info( + `📊 Recorded Gemini API stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}` + ) + }) + .catch((error) => { + logger.error('Failed to record Gemini API usage:', error) + }) + } + }) + + geminiResponse.on('error', (error) => { + logger.error('Stream error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: getSafeMessage(error) || 'Stream error', + type: 'api_error' + } + }) + } else { + res.end() + } + }) + } else { + // OAuth 账户:使用原有的流式传输逻辑 + for await (const chunk of geminiResponse) { + if (abortController.signal.aborted) { + break + } + res.write(chunk) + } + res.end() + } + } else { + // 非流式响应 + res.json(geminiResponse) + } + + const duration = Date.now() - startTime + logger.info(`Gemini request completed in ${duration}ms`) + } catch (error) { + logger.error('Gemini request error:', error) + + // 处理速率限制 + const errorStatus = error.response?.status || error.status + if (errorStatus === 429 && accountId) { + try { + const rateLimitAccountType = accountType || 'gemini' + await unifiedGeminiScheduler.markAccountRateLimited( + accountId, + rateLimitAccountType, + sessionHash + ) + logger.warn(`⚠️ Gemini account ${accountId} rate limited (/messages), marking as limited`) + } catch (limitError) { + logger.warn('Failed to mark account as rate limited:', limitError) + } + } + + // 处理其他上游错误(5xx/401/403) + await handleGeminiUpstreamError( + errorStatus, + accountId, + accountType, + sessionHash, + error.response?.headers, + account?.disableAutoProtection + ) + + // 返回错误响应 + const status = errorStatus || 500 + const errorResponse = { + error: error.error || { + message: getSafeMessage(error) || 'Internal server error', + type: 'api_error' + } + } + + res.status(status).json(errorResponse) + } finally { + // 清理资源 + if (abortController) { + abortController = null + } + } + return undefined +} + +// ============================================================================ +// 处理函数 - 模型列表和详情 +// ============================================================================ + +/** + * 获取可用模型列表 + */ +async function handleModels(req, res) { + try { + const apiKeyData = req.apiKey + + // 检查权限 + if (!checkPermissions(apiKeyData, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied' + } + }) + } + + // 选择账户获取模型列表(允许 API 账户) + let account = null + let isApiAccount = false + try { + const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + null, + null, + { allowApiAccounts: true } + ) + isApiAccount = accountSelection.accountType === 'gemini-api' + if (isApiAccount) { + account = await geminiApiAccountService.getAccount(accountSelection.accountId) + } else { + account = await geminiAccountService.getAccount(accountSelection.accountId) + } + } catch (error) { + logger.warn('Failed to select Gemini account for models endpoint:', error) + } + + if (!account) { + // 返回默认模型列表 + return res.json({ + object: 'list', + data: [ + { + id: 'gemini-2.5-flash', + object: 'model', + created: Date.now() / 1000, + owned_by: 'google' + } + ] + }) + } + + // 获取模型列表 + let models + if (isApiAccount) { + // API Key 账户:使用 API Key 获取模型列表 + const proxyConfig = parseProxyConfig(account) + try { + const apiUrl = buildGeminiApiUrl(account.baseUrl, null, null, account.apiKey, { + listModels: true + }) + const axiosConfig = { + method: 'GET', + url: apiUrl, + headers: { 'Content-Type': 'application/json' } + } + if (proxyConfig) { + axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig) + } + const response = await axios(axiosConfig) + models = (response.data.models || []).map((m) => ({ + id: m.name?.replace('models/', '') || m.name, + object: 'model', + created: Date.now() / 1000, + owned_by: 'google' + })) + } catch (error) { + logger.warn('Failed to fetch models from Gemini API:', error.message) + // 返回默认模型列表 + models = [ + { + id: 'gemini-2.5-flash', + object: 'model', + created: Date.now() / 1000, + owned_by: 'google' + } + ] + } + } else { + // OAuth 账户:根据 OAuth provider 选择上游 + const oauthProvider = account.oauthProvider || 'gemini-cli' + models = + oauthProvider === 'antigravity' + ? await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + account.proxy, + account.refreshToken + ) + : await getAvailableModels(account.accessToken, account.proxy) + } + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('Failed to get Gemini models:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve models', + type: 'api_error' + } + }) + } + return undefined +} + +/** + * 获取模型详情(标准 Gemini API 格式) + */ +function handleModelDetails(req, res) { + const { modelName } = req.params + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1' + logger.info(`Standard Gemini API model details request (${version}): ${modelName}`) + + res.json({ + name: `models/${modelName}`, + version: '001', + displayName: modelName, + description: `Gemini model: ${modelName}`, + inputTokenLimit: 1048576, + outputTokenLimit: 8192, + supportedGenerationMethods: ['generateContent', 'streamGenerateContent', 'countTokens'], + temperature: 1.0, + topP: 0.95, + topK: 40 + }) +} + +// ============================================================================ +// 处理函数 - 使用统计和 API Key 信息 +// ============================================================================ + +/** + * 获取使用情况统计 + */ +async function handleUsage(req, res) { + try { + const keyData = req.apiKey + // 按需查询 usage 数据 + const usage = await redis.getUsageStats(keyData.id) + + res.json({ + object: 'usage', + total_tokens: usage?.total?.tokens || 0, + total_requests: usage?.total?.requests || 0, + daily_tokens: usage?.daily?.tokens || 0, + daily_requests: usage?.daily?.requests || 0, + monthly_tokens: usage?.monthly?.tokens || 0, + monthly_requests: usage?.monthly?.requests || 0 + }) + } catch (error) { + logger.error('Failed to get usage stats:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve usage statistics', + type: 'api_error' + } + }) + } +} + +/** + * 获取 API Key 信息 + */ +async function handleKeyInfo(req, res) { + try { + const keyData = req.apiKey + // 按需查询 usage 数据(仅 key-info 端点需要) + const usage = await redis.getUsageStats(keyData.id) + const tokensUsed = usage?.total?.tokens || 0 + + res.json({ + id: keyData.id, + name: keyData.name, + permissions: keyData.permissions, + token_limit: keyData.tokenLimit, + tokens_used: tokensUsed, + tokens_remaining: + keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null, + rate_limit: { + window: keyData.rateLimitWindow, + requests: keyData.rateLimitRequests + }, + concurrency_limit: keyData.concurrencyLimit, + model_restrictions: { + enabled: keyData.enableModelRestriction, + models: keyData.restrictedModels + } + }) + } catch (error) { + logger.error('Failed to get key info:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve API key information', + type: 'api_error' + } + }) + } +} + +// ============================================================================ +// 处理函数 - v1internal 格式(Gemini CLI 内部格式) +// ============================================================================ + +/** + * 简单端点处理函数工厂(用于直接转发的端点) + */ +function handleSimpleEndpoint(apiMethod) { + return async (req, res) => { + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 从路径参数或请求体中获取模型名 + const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + const { accountId, accountType } = schedulerResult + + // v1internal 路由只支持 OAuth 账户,不支持 API Key 账户 + if (accountType === 'gemini-api') { + logger.error( + `❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}` + ) + return res.status(400).json({ + error: { + message: + 'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.', + type: 'invalid_account_type' + } + }) + } + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ + error: { + message: 'Gemini account not found', + type: 'account_not_found' + } + }) + } + const { accessToken, refreshToken } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`${apiMethod} request (${version})`, { + apiKeyId: req.apiKey?.id || 'unknown', + requestBody: req.body + }) + + // 解析账户的代理配置 + const proxyConfig = parseProxyConfig(account) + + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) + + // 直接转发请求体,不做特殊处理 + const response = await geminiAccountService.forwardToCodeAssist( + client, + apiMethod, + req.body, + proxyConfig + ) + + res.json(response) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in ${apiMethod} endpoint (${version})`, { error: error.message }) + res.status(500).json({ + error: 'Internal server error', + message: error.message + }) + } + } +} + +/** + * 处理 loadCodeAssist 请求 + */ +async function handleLoadCodeAssist(req, res) { + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 从路径参数或请求体中获取模型名 + const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + const { accountId, accountType } = schedulerResult + + // v1internal 路由只支持 OAuth 账户,不支持 API Key 账户 + if (accountType === 'gemini-api') { + logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`) + return res.status(400).json({ + error: { + message: + 'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.', + type: 'invalid_account_type' + } + }) + } + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ + error: { + message: 'Gemini account not found', + type: 'account_not_found' + } + }) + } + const { accessToken, refreshToken, projectId } = account + + const { metadata, cloudaicompanionProject } = req.body + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`LoadCodeAssist request (${version})`, { + metadata: metadata || {}, + requestedProject: cloudaicompanionProject || null, + accountProject: projectId || null, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 解析账户的代理配置 + const proxyConfig = parseProxyConfig(account) + + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) + + // 智能处理项目ID + const effectiveProjectId = projectId || cloudaicompanionProject || null + + logger.info('📋 loadCodeAssist项目ID处理逻辑', { + accountProjectId: projectId, + requestProjectId: cloudaicompanionProject, + effectiveProjectId, + decision: projectId + ? '使用账户配置' + : cloudaicompanionProject + ? '使用请求参数' + : '不使用项目ID' + }) + + const response = await geminiAccountService.loadCodeAssist( + client, + effectiveProjectId, + proxyConfig + ) + + // 如果响应中包含 cloudaicompanionProject,保存到账户作为临时项目 ID + if (response.cloudaicompanionProject && !account.projectId) { + await geminiAccountService.updateTempProjectId(accountId, response.cloudaicompanionProject) + logger.info( + `📋 Cached temporary projectId from loadCodeAssist: ${response.cloudaicompanionProject}` + ) + } + + res.json(response) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in loadCodeAssist endpoint (${version})`, { error: error.message }) + res.status(500).json({ + error: 'Internal server error', + message: error.message + }) + } +} + +/** + * 处理 onboardUser 请求 + */ +async function handleOnboardUser(req, res) { + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + // 提取请求参数 + const { tierId, cloudaicompanionProject, metadata } = req.body + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 从路径参数或请求体中获取模型名 + const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + const { accountId, accountType } = schedulerResult + + // v1internal 路由只支持 OAuth 账户,不支持 API Key 账户 + if (accountType === 'gemini-api') { + logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`) + return res.status(400).json({ + error: { + message: + 'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.', + type: 'invalid_account_type' + } + }) + } + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ + error: { + message: 'Gemini account not found', + type: 'account_not_found' + } + }) + } + const { accessToken, refreshToken, projectId } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`OnboardUser request (${version})`, { + tierId: tierId || 'not provided', + requestedProject: cloudaicompanionProject || null, + accountProject: projectId || null, + metadata: metadata || {}, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 解析账户的代理配置 + const proxyConfig = parseProxyConfig(account) + + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) + + // 智能处理项目ID + const effectiveProjectId = projectId || cloudaicompanionProject || null + + logger.info('📋 onboardUser项目ID处理逻辑', { + accountProjectId: projectId, + requestProjectId: cloudaicompanionProject, + effectiveProjectId, + decision: projectId + ? '使用账户配置' + : cloudaicompanionProject + ? '使用请求参数' + : '不使用项目ID' + }) + + // 如果提供了 tierId,直接调用 onboardUser + if (tierId) { + const response = await geminiAccountService.onboardUser( + client, + tierId, + effectiveProjectId, + metadata, + proxyConfig + ) + + res.json(response) + } else { + // 否则执行完整的 setupUser 流程 + const response = await geminiAccountService.setupUser( + client, + effectiveProjectId, + metadata, + proxyConfig + ) + + res.json(response) + } + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in onboardUser endpoint (${version})`, { error: error.message }) + res.status(500).json({ + error: 'Internal server error', + message: error.message + }) + } +} + +/** + * 处理 retrieveUserQuota 请求 + * POST /v1internal:retrieveUserQuota + * + * 功能:查询用户在各个Gemini模型上的配额使用情况 + * 请求体:{ "project": "项目ID" } + * 响应:{ "buckets": [...] } + */ +async function handleRetrieveUserQuota(req, res) { + try { + // 1. 权限检查 + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + // 2. 会话哈希 + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 3. 账户选择 + const requestedModel = req.body.model || req.params.modelName || 'gemini-2.5-flash' + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + const { accountId, accountType } = schedulerResult + + // 4. 账户类型验证 - v1internal 路由只支持 OAuth 账户 + if (accountType === 'gemini-api') { + logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`) + return res.status(400).json({ + error: { + message: + 'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.', + type: 'invalid_account_type' + } + }) + } + + // 5. 获取账户 + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ + error: { + message: 'Gemini account not found', + type: 'account_not_found' + } + }) + } + const { accessToken, refreshToken, projectId } = account + + // 6. 从请求体提取项目字段(注意:字段名是 "project",不是 "cloudaicompanionProject") + const requestProject = req.body.project + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`RetrieveUserQuota request (${version})`, { + requestedProject: requestProject || null, + accountProject: projectId || null, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 7. 解析账户的代理配置 + const proxyConfig = parseProxyConfig(account) + + // 8. 获取OAuth客户端 + const client = await geminiAccountService.getOauthClient(accessToken, refreshToken, proxyConfig) + + // 9. 智能处理项目ID(与其他 v1internal 接口保持一致) + const effectiveProject = projectId || requestProject || null + + logger.info('📋 retrieveUserQuota项目ID处理逻辑', { + accountProjectId: projectId, + requestProject, + effectiveProject, + decision: projectId ? '使用账户配置' : requestProject ? '使用请求参数' : '不使用项目ID' + }) + + // 10. 构建请求体(注入 effectiveProject) + const requestBody = { ...req.body } + if (effectiveProject) { + requestBody.project = effectiveProject + } + + // 11. 调用底层服务转发请求 + const response = await geminiAccountService.forwardToCodeAssist( + client, + 'retrieveUserQuota', + requestBody, + proxyConfig + ) + + res.json(response) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in retrieveUserQuota endpoint (${version})`, { + error: error.message + }) + res.status(500).json({ + error: 'Internal server error', + message: error.message + }) + } +} + +/** + * 处理 countTokens 请求 + */ +async function handleCountTokens(req, res) { + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + // 处理请求体结构,支持直接 contents 或 request.contents + const requestData = req.body.request || req.body + const { contents } = requestData + // 从路径参数或请求体中获取模型名 + const model = requestData.model || req.params.modelName || 'gemini-2.5-flash' + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 验证必需参数 + if (!contents || !Array.isArray(contents)) { + return res.status(400).json({ + error: { + message: 'Contents array is required', + type: 'invalid_request_error' + } + }) + } + + // 使用统一调度选择账号(允许 API 账户) + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model, + { allowApiAccounts: true } + ) + const { accountId, accountType } = schedulerResult + const isApiAccount = accountType === 'gemini-api' + + let account + if (isApiAccount) { + account = await geminiApiAccountService.getAccount(accountId) + } else { + account = await geminiAccountService.getAccount(accountId) + } + + if (!account) { + return res.status(404).json({ + error: { + message: `${isApiAccount ? 'Gemini API' : 'Gemini'} account not found`, + type: 'account_not_found' + } + }) + } + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1' + logger.info( + `CountTokens request (${version}) - ${isApiAccount ? 'API Key' : 'OAuth'} Account`, + { + model, + contentsLength: contents.length, + accountId, + apiKeyId: req.apiKey?.id || 'unknown' + } + ) + + // 解析账户的代理配置 + const proxyConfig = parseProxyConfig(account) + + let response + if (isApiAccount) { + // API Key 账户:直接使用 API Key 请求 + const modelName = model.startsWith('models/') ? model.replace('models/', '') : model + const apiUrl = buildGeminiApiUrl(account.baseUrl, modelName, 'countTokens', account.apiKey) + + const axiosConfig = { + method: 'POST', + url: apiUrl, + data: { contents }, + headers: { 'Content-Type': 'application/json' } + } + + if (proxyConfig) { + axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig) + } + + try { + const apiResponse = await axios(axiosConfig) + response = { + totalTokens: apiResponse.data.totalTokens || 0, + totalBillableCharacters: apiResponse.data.totalBillableCharacters || 0, + ...apiResponse.data + } + } catch (error) { + logger.error('Gemini API countTokens request failed:', { + status: error.response?.status, + data: error.response?.data + }) + throw error + } + } else { + // OAuth 账户 + const { accessToken, refreshToken } = account + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) + response = await geminiAccountService.countTokens(client, contents, model, proxyConfig) + } + + res.json(response) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1' + logger.error(`Error in countTokens endpoint (${version})`, { error: error.message }) + res.status(500).json({ + error: { + message: getSafeMessage(error) || 'Internal server error', + type: 'api_error' + } + }) + } + return undefined +} + +/** + * 处理 generateContent 请求(v1internal 格式) + */ +async function handleGenerateContent(req, res) { + let accountId = null + let accountType = null + let sessionHash = null + let account = null + + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + const { project, user_prompt_id, request: requestData } = req.body + // 从路径参数或请求体中获取模型名 + const model = req.body.model || req.params.modelName || 'gemini-2.5-flash' + sessionHash = sessionHelper.generateSessionHash(req.body) + + // 处理不同格式的请求 + let actualRequestData = requestData + if (!requestData) { + if (req.body.messages) { + // 这是 OpenAI 格式的请求,构建 Gemini 格式的 request 对象 + actualRequestData = { + contents: req.body.messages.map((msg) => ({ + role: msg.role === 'assistant' ? 'model' : msg.role, + parts: [{ text: msg.content }] + })), + generationConfig: { + temperature: req.body.temperature !== undefined ? req.body.temperature : 0.7, + maxOutputTokens: req.body.max_tokens !== undefined ? req.body.max_tokens : 4096, + topP: req.body.top_p !== undefined ? req.body.top_p : 0.95, + topK: req.body.top_k !== undefined ? req.body.top_k : 40 + } + } + } else if (req.body.contents) { + // 直接的 Gemini 格式请求(没有 request 包装) + actualRequestData = req.body + } + } + + // 验证必需参数 + if (!actualRequestData || !actualRequestData.contents) { + return res.status(400).json({ + error: { + message: 'Request contents are required', + type: 'invalid_request_error' + } + }) + } + + // 使用统一调度选择账号(v1internal 不允许 API 账户) + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model + ) + ;({ accountId, accountType } = schedulerResult) + + // v1internal 路由只支持 OAuth 账户,不支持 API Key 账户 + if (accountType === 'gemini-api') { + logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`) + return res.status(400).json({ + error: { + message: + 'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.', + type: 'invalid_account_type' + } + }) + } + + account = await geminiAccountService.getAccount(accountId) + if (!account) { + logger.error(`❌ Gemini account not found: ${accountId}`) + return res.status(404).json({ + error: { + message: 'Gemini account not found', + type: 'account_not_found' + } + }) + } + + const { accessToken, refreshToken } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`GenerateContent request (${version})`, { + model, + userPromptId: user_prompt_id, + projectId: project || account.projectId, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 解析账户的代理配置 + const proxyConfig = parseProxyConfig(account) + + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) + + // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId + let effectiveProjectId = account.projectId || account.tempProjectId || null + + const oauthProvider = account.oauthProvider || 'gemini-cli' + + // 如果没有任何项目ID,尝试调用 loadCodeAssist 获取 + if (!effectiveProjectId && oauthProvider !== 'antigravity') { + try { + logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') + const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) + + if (loadResponse.cloudaicompanionProject) { + effectiveProjectId = loadResponse.cloudaicompanionProject + // 保存临时项目ID + await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId) + logger.info(`📋 Fetched and cached temporary projectId: ${effectiveProjectId}`) + } + } catch (loadError) { + logger.warn('Failed to fetch projectId from loadCodeAssist:', loadError.message) + } + } + + if (!effectiveProjectId && oauthProvider === 'antigravity') { + // Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存 + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId) + } + + // 如果还是没有项目ID,返回错误 + if (!effectiveProjectId) { + return res.status(403).json({ + error: { + message: + 'This account requires a project ID to be configured. Please configure a project ID in the account settings.', + type: 'configuration_required' + } + }) + } + + logger.info('📋 项目ID处理逻辑', { + accountProjectId: account.projectId, + accountTempProjectId: account.tempProjectId, + effectiveProjectId, + decision: account.projectId + ? '使用账户配置' + : account.tempProjectId + ? '使用临时项目ID' + : '从loadCodeAssist获取' + }) + + const response = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentAntigravity( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + : await geminiAccountService.generateContent( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + + // 记录使用统计 + if (response?.response?.usageMetadata) { + try { + const usage = response.response.usageMetadata + const geminiNonStreamCosts = await apiKeyService.recordUsage( + req.apiKey.id, + usage.promptTokenCount || 0, + usage.candidatesTokenCount || 0, + 0, + 0, + model, + account.id, + 'gemini', + null, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: false, + statusCode: res.statusCode || 200 + }) + ) + logger.info( + `📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}` + ) + + await applyRateLimitTracking( + req, + { + inputTokens: usage.promptTokenCount || 0, + outputTokens: usage.candidatesTokenCount || 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }, + model, + 'gemini-non-stream', + geminiNonStreamCosts + ) + } catch (error) { + logger.error('Failed to record Gemini usage:', error) + } + } + + res.json(version === 'v1beta' ? response.response : response) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in generateContent endpoint (${version})`, { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + requestUrl: error.config?.url, + requestMethod: error.config?.method, + stack: error.stack + }) + await handleGeminiUpstreamError( + error.response?.status, + accountId, + accountType, + sessionHash, + error.response?.headers, + account?.disableAutoProtection + ) + res.status(500).json({ + error: { + message: getSafeMessage(error) || 'Internal server error', + type: 'api_error' + } + }) + } + return undefined +} + +/** + * 处理 streamGenerateContent 请求(v1internal 格式) + */ +async function handleStreamGenerateContent(req, res) { + let abortController = null + let accountId = null + let accountType = null + let sessionHash = null + let account = null + + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + const { project, user_prompt_id, request: requestData } = req.body + // 从路径参数或请求体中获取模型名 + const model = req.body.model || req.params.modelName || 'gemini-2.5-flash' + sessionHash = sessionHelper.generateSessionHash(req.body) + + // 处理不同格式的请求 + let actualRequestData = requestData + if (!requestData) { + if (req.body.messages) { + // 这是 OpenAI 格式的请求,构建 Gemini 格式的 request 对象 + actualRequestData = { + contents: req.body.messages.map((msg) => ({ + role: msg.role === 'assistant' ? 'model' : msg.role, + parts: [{ text: msg.content }] + })), + generationConfig: { + temperature: req.body.temperature !== undefined ? req.body.temperature : 0.7, + maxOutputTokens: req.body.max_tokens !== undefined ? req.body.max_tokens : 4096, + topP: req.body.top_p !== undefined ? req.body.top_p : 0.95, + topK: req.body.top_k !== undefined ? req.body.top_k : 40 + } + } + } else if (req.body.contents) { + // 直接的 Gemini 格式请求(没有 request 包装) + actualRequestData = req.body + } + } + + // 验证必需参数 + if (!actualRequestData || !actualRequestData.contents) { + return res.status(400).json({ + error: { + message: 'Request contents are required', + type: 'invalid_request_error' + } + }) + } + + // 使用统一调度选择账号(v1internal 不允许 API 账户) + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model + ) + ;({ accountId, accountType } = schedulerResult) + + // v1internal 路由只支持 OAuth 账户,不支持 API Key 账户 + if (accountType === 'gemini-api') { + logger.error(`❌ v1internal routes do not support Gemini API accounts. Account: ${accountId}`) + return res.status(400).json({ + error: { + message: + 'This endpoint only supports Gemini OAuth accounts. Gemini API Key accounts are not compatible with v1internal format.', + type: 'invalid_account_type' + } + }) + } + + account = await geminiAccountService.getAccount(accountId) + if (!account) { + logger.error(`❌ Gemini account not found: ${accountId}`) + return res.status(404).json({ + error: { + message: 'Gemini account not found', + type: 'account_not_found' + } + }) + } + + const { accessToken, refreshToken } = account + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.info(`StreamGenerateContent request (${version})`, { + model, + userPromptId: user_prompt_id, + projectId: project || account.projectId, + apiKeyId: req.apiKey?.id || 'unknown' + }) + + // 创建中止控制器 + abortController = new AbortController() + + // 处理客户端断开连接 + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info('Client disconnected, aborting stream request') + abortController.abort() + } + }) + + // 解析账户的代理配置 + const proxyConfig = parseProxyConfig(account) + + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) + + // 智能处理项目ID:优先使用配置的 projectId,降级到临时 tempProjectId + let effectiveProjectId = account.projectId || account.tempProjectId || null + + const oauthProvider = account.oauthProvider || 'gemini-cli' + + // 如果没有任何项目ID,尝试调用 loadCodeAssist 获取 + if (!effectiveProjectId && oauthProvider !== 'antigravity') { + try { + logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') + const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) + + if (loadResponse.cloudaicompanionProject) { + effectiveProjectId = loadResponse.cloudaicompanionProject + // 保存临时项目ID + await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId) + logger.info(`📋 Fetched and cached temporary projectId: ${effectiveProjectId}`) + } + } catch (loadError) { + logger.warn('Failed to fetch projectId from loadCodeAssist:', loadError.message) + } + } + + if (!effectiveProjectId && oauthProvider === 'antigravity') { + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(accountId, effectiveProjectId) + } + + // 如果还是没有项目ID,返回错误 + if (!effectiveProjectId) { + return res.status(403).json({ + error: { + message: + 'This account requires a project ID to be configured. Please configure a project ID in the account settings.', + type: 'configuration_required' + } + }) + } + + logger.info('📋 流式请求项目ID处理逻辑', { + accountProjectId: account.projectId, + accountTempProjectId: account.tempProjectId, + effectiveProjectId, + decision: account.projectId + ? '使用账户配置' + : account.tempProjectId + ? '使用临时项目ID' + : '从loadCodeAssist获取' + }) + + const streamResponse = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentStreamAntigravity( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + : await geminiAccountService.generateContentStream( + client, + { model, request: actualRequestData }, + user_prompt_id, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + // 处理流式响应并捕获usage数据 + let streamBuffer = '' // 移动到 data 事件处理器外部,保持状态 + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + let usageReported = false + + // SSE 心跳机制 + let heartbeatTimer = null + let lastDataTime = Date.now() + const HEARTBEAT_INTERVAL = 15000 + + const sendHeartbeat = () => { + const timeSinceLastData = Date.now() - lastDataTime + if (timeSinceLastData >= HEARTBEAT_INTERVAL && !res.destroyed) { + res.write('\n') + logger.info(`💓 Sent SSE keepalive (gap: ${(timeSinceLastData / 1000).toFixed(1)}s)`) + } + } + + heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL) + + streamResponse.on('data', (chunk) => { + try { + lastDataTime = Date.now() + + // 立即转发原始数据 + if (!res.destroyed) { + res.write(chunk) + } + + // 提取 usage 数据 + try { + const chunkStr = chunk.toString() + streamBuffer += chunkStr + + // 如果 buffer 过大,进行保护性清理(防止内存泄漏) + if (streamBuffer.length > 1024 * 1024) { + // 1MB + streamBuffer = streamBuffer.slice(-1024 * 64) // 只保留最后 64KB + } + + const lines = streamBuffer.split('\n') + // 保留最后一行(可能不完整) + streamBuffer = lines.pop() || '' + + for (const line of lines) { + // 只处理可能包含数据的行 + if (!line.trim() || !line.startsWith('data:')) { + continue + } + + try { + // ��试解析 SSE 行 + const parsed = parseSSELine(line) + + // 检查各种可能的 usage 位置 + let extractedUsage = null + + if (parsed.type === 'data') { + if (parsed.data.response?.usageMetadata) { + extractedUsage = parsed.data.response.usageMetadata + } else if (parsed.data.usageMetadata) { + extractedUsage = parsed.data.usageMetadata + } + } + + if (extractedUsage) { + totalUsage = extractedUsage + logger.debug('📊 Captured Gemini usage data:', totalUsage) + } + } catch (parseError) { + // 解析失败忽略,可能是非 JSON 数据 + } + } + } catch (error) { + logger.warn('⚠️ Error extracting usage data:', error.message) + } + } catch (error) { + logger.error('Error processing stream chunk:', error) + } + }) + + streamResponse.on('end', () => { + logger.info('Stream completed successfully') + + if (heartbeatTimer) { + clearInterval(heartbeatTimer) + heartbeatTimer = null + } + + res.end() + + // 异步记录使用统计 + if (!usageReported && totalUsage.totalTokenCount > 0) { + apiKeyService + .recordUsage( + req.apiKey.id, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, + 0, + model, + account.id, + 'gemini', + null, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: true, + statusCode: res.statusCode + }) + ) + .then((costs) => + applyRateLimitTracking( + req, + { + inputTokens: totalUsage.promptTokenCount || 0, + outputTokens: totalUsage.candidatesTokenCount || 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }, + model, + 'gemini-stream', + costs + ) + ) + .then(() => { + logger.info( + `📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` + ) + usageReported = true + }) + .catch((error) => { + logger.error('Failed to record Gemini usage:', error) + }) + } + }) + + streamResponse.on('error', (error) => { + logger.error('Stream error:', error) + + if (heartbeatTimer) { + clearInterval(heartbeatTimer) + heartbeatTimer = null + } + + if (!res.headersSent) { + res.status(500).json({ + error: { + message: getSafeMessage(error) || 'Stream error', + type: 'api_error' + } + }) + } else { + if (!res.destroyed) { + try { + res.write( + `data: ${JSON.stringify({ + error: { + message: getSafeMessage(error) || 'Stream error', + type: 'stream_error', + code: error.code + } + })}\n\n` + ) + res.write('data: [DONE]\n\n') + } catch (writeError) { + logger.error('Error sending error event:', writeError) + } + } + res.end() + } + }) + } catch (error) { + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1internal' + logger.error(`Error in streamGenerateContent endpoint (${version})`, { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + requestUrl: error.config?.url, + requestMethod: error.config?.method, + stack: error.stack + }) + await handleGeminiUpstreamError( + error.response?.status, + accountId, + accountType, + sessionHash, + error.response?.headers, + account?.disableAutoProtection + ) + + if (!res.headersSent) { + res.status(500).json({ + error: { + message: getSafeMessage(error) || 'Internal server error', + type: 'api_error' + } + }) + } + } finally { + if (abortController) { + abortController = null + } + } + return undefined +} + +// ============================================================================ +// 处理函数 - 标准 Gemini API 格式(/v1beta/models/:model:generateContent 等) +// ============================================================================ + +/** + * 处理标准 Gemini API 格式的 generateContent(支持 OAuth 和 API 账户) + */ +async function handleStandardGenerateContent(req, res) { + let account = null + let sessionHash = null + let accountId = null + let accountType = null + let isApiAccount = false + + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + // 从路径参数中获取模型名 + const model = req.params.modelName || 'gemini-2.0-flash-exp' + sessionHash = sessionHelper.generateSessionHash(req.body) + + // 标准 Gemini API 请求体直接包含 contents 等字段 + const { contents, generationConfig, safetySettings, systemInstruction, tools, toolConfig } = + req.body + + // 验证必需参数 + if (!contents || !Array.isArray(contents) || contents.length === 0) { + return res.status(400).json({ + error: { + message: 'Contents array is required', + type: 'invalid_request_error' + } + }) + } + + // 构建内部 API 需要的请求格式 + const actualRequestData = { + contents, + generationConfig: generationConfig || { + temperature: 0.7, + maxOutputTokens: 4096, + topP: 0.95, + topK: 40 + } + } + + // 只有在 safetySettings 存在且非空时才添加 + if (safetySettings && safetySettings.length > 0) { + actualRequestData.safetySettings = safetySettings + } + + // 添加工具配置 + if (tools) { + actualRequestData.tools = tools + } + + if (toolConfig) { + actualRequestData.toolConfig = toolConfig + } + + // 处理 system instruction + if (systemInstruction) { + if (typeof systemInstruction === 'string' && systemInstruction.trim()) { + actualRequestData.systemInstruction = { + role: 'user', + parts: [{ text: systemInstruction }] + } + } else if (systemInstruction.parts && systemInstruction.parts.length > 0) { + const hasContent = systemInstruction.parts.some( + (part) => part.text && part.text.trim() !== '' + ) + if (hasContent) { + actualRequestData.systemInstruction = { + role: 'user', + parts: systemInstruction.parts + } + } + } + } + + // 使用统一调度选择账号 + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model, + { allowApiAccounts: true } + ) + ;({ accountId, accountType } = schedulerResult) + + isApiAccount = accountType === 'gemini-api' + const actualAccountId = accountId + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1' + + if (isApiAccount) { + account = await geminiApiAccountService.getAccount(actualAccountId) + if (!account) { + return res.status(404).json({ + error: { + message: 'Gemini API account not found', + type: 'account_not_found' + } + }) + } + + // API Key 账户:清理 functionResponse 中标准 Gemini API 不支持的字段(如 id) + actualRequestData.contents = sanitizeFunctionResponsesForApiKey(actualRequestData.contents) + + logger.info(`Standard Gemini API generateContent request (${version}) - API Key Account`, { + model, + accountId: actualAccountId, + apiKeyId: req.apiKey?.id || 'unknown' + }) + } else { + account = await geminiAccountService.getAccount(actualAccountId) + + logger.info(`Standard Gemini API generateContent request (${version}) - OAuth Account`, { + model, + projectId: account.projectId, + apiKeyId: req.apiKey?.id || 'unknown' + }) + } + + // 解析账户的代理配置 + const proxyConfig = parseProxyConfig(account) + + let response + + if (isApiAccount) { + // Gemini API 账户:直接使用 API Key 请求 + const apiUrl = buildGeminiApiUrl(account.baseUrl, model, 'generateContent', account.apiKey) + + logger.info('📤 Gemini upstream request', { + targetUrl: apiUrl.replace(/key=[^&]+/, 'key=***'), + model, + accountId: account.id + }) + + const axiosConfig = { + method: 'POST', + url: apiUrl, + data: actualRequestData, + headers: { + 'Content-Type': 'application/json' + } + } + + if (proxyConfig) { + axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig) + } + + try { + const apiResponse = await axios(axiosConfig) + response = { response: apiResponse.data } + } catch (error) { + logger.error('Gemini API request failed:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data + }) + throw error + } + } else { + // OAuth 账户 + const { accessToken, refreshToken } = account + const oauthProvider = account.oauthProvider || 'gemini-cli' + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + oauthProvider + ) + + let effectiveProjectId = account.projectId || account.tempProjectId || null + + if (oauthProvider === 'antigravity') { + if (!effectiveProjectId) { + // Antigravity 账号允许没有 projectId:生成一个稳定的临时 projectId 并缓存 + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId) + } + } else if (!effectiveProjectId) { + try { + logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') + const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) + + if (loadResponse.cloudaicompanionProject) { + effectiveProjectId = loadResponse.cloudaicompanionProject + await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId) + logger.info(`📋 Fetched and cached temporary projectId: ${effectiveProjectId}`) + } + } catch (loadError) { + logger.warn('Failed to fetch projectId from loadCodeAssist:', loadError.message) + } + } + + if (!effectiveProjectId) { + return res.status(403).json({ + error: { + message: + 'This account requires a project ID to be configured. Please configure a project ID in the account settings.', + type: 'configuration_required' + } + }) + } + + logger.info('📋 Standard API 项目ID处理逻辑', { + accountProjectId: account.projectId, + tempProjectId: account.tempProjectId, + effectiveProjectId, + decision: account.projectId + ? '使用账户配置' + : account.tempProjectId + ? '使用临时项目ID' + : '从loadCodeAssist获取' + }) + + const userPromptId = `${crypto.randomUUID()}########0` + + if (oauthProvider === 'antigravity') { + response = await geminiAccountService.generateContentAntigravity( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + } else { + response = await geminiAccountService.generateContent( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + proxyConfig + ) + } + } + + // 记录使用统计 + if (response?.response?.usageMetadata) { + try { + const usage = response.response.usageMetadata + await apiKeyService.recordUsage( + req.apiKey.id, + usage.promptTokenCount || 0, + usage.candidatesTokenCount || 0, + 0, + 0, + model, + accountId, + 'gemini', + null, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: false, + statusCode: res.statusCode || 200 + }) + ) + logger.info( + `📊 Recorded Gemini usage - Input: ${usage.promptTokenCount}, Output: ${usage.candidatesTokenCount}, Total: ${usage.totalTokenCount}` + ) + } catch (error) { + logger.error('Failed to record Gemini usage:', error) + } + } + + res.json(response.response || response) + } catch (error) { + logger.error(`Error in standard generateContent endpoint`, { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + stack: error.stack + }) + await handleGeminiUpstreamError( + error.response?.status, + accountId, + accountType, + sessionHash, + error.response?.headers, + account?.disableAutoProtection + ) + + res.status(500).json({ + error: { + message: getSafeMessage(error) || 'Internal server error', + type: 'api_error' + } + }) + } +} + +/** + * 处理标准 Gemini API 格式的 streamGenerateContent(支持 OAuth 和 API 账户) + */ +async function handleStandardStreamGenerateContent(req, res) { + let abortController = null + let account = null + let sessionHash = null + let accountId = null + let accountType = null + let isApiAccount = false + + try { + if (!ensureGeminiPermission(req, res)) { + return undefined + } + + // 从路径参数中获取模型名 + const model = req.params.modelName || 'gemini-2.0-flash-exp' + sessionHash = sessionHelper.generateSessionHash(req.body) + + // 标准 Gemini API 请求体直接包含 contents 等字段 + const { contents, generationConfig, safetySettings, systemInstruction, tools, toolConfig } = + req.body + + // 验证必需参数 + if (!contents || !Array.isArray(contents) || contents.length === 0) { + return res.status(400).json({ + error: { + message: 'Contents array is required', + type: 'invalid_request_error' + } + }) + } + + // 构建内部 API 需要的请求格式 + const actualRequestData = { + contents, + generationConfig: generationConfig || { + temperature: 0.7, + maxOutputTokens: 4096, + topP: 0.95, + topK: 40 + } + } + + if (safetySettings && safetySettings.length > 0) { + actualRequestData.safetySettings = safetySettings + } + + if (tools) { + actualRequestData.tools = tools + } + + if (toolConfig) { + actualRequestData.toolConfig = toolConfig + } + + // 处理 system instruction + if (systemInstruction) { + if (typeof systemInstruction === 'string' && systemInstruction.trim()) { + actualRequestData.systemInstruction = { + role: 'user', + parts: [{ text: systemInstruction }] + } + } else if (systemInstruction.parts && systemInstruction.parts.length > 0) { + const hasContent = systemInstruction.parts.some( + (part) => part.text && part.text.trim() !== '' + ) + if (hasContent) { + actualRequestData.systemInstruction = { + role: 'user', + parts: systemInstruction.parts + } + } + } + } + + // 使用统一调度选择账号 + const schedulerResult = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model, + { allowApiAccounts: true } + ) + ;({ accountId, accountType } = schedulerResult) + + isApiAccount = accountType === 'gemini-api' + const actualAccountId = accountId + + const version = req.path.includes('v1beta') ? 'v1beta' : 'v1' + + if (isApiAccount) { + account = await geminiApiAccountService.getAccount(actualAccountId) + if (!account) { + return res.status(404).json({ + error: { + message: 'Gemini API account not found', + type: 'account_not_found' + } + }) + } + + // API Key 账户:清理 functionResponse 中标准 Gemini API 不支持的字段(如 id) + actualRequestData.contents = sanitizeFunctionResponsesForApiKey(actualRequestData.contents) + + logger.info( + `Standard Gemini API streamGenerateContent request (${version}) - API Key Account`, + { + model, + accountId: actualAccountId, + apiKeyId: req.apiKey?.id || 'unknown' + } + ) + } else { + account = await geminiAccountService.getAccount(actualAccountId) + + logger.info( + `Standard Gemini API streamGenerateContent request (${version}) - OAuth Account`, + { + model, + projectId: account.projectId, + apiKeyId: req.apiKey?.id || 'unknown' + } + ) + } + + // 创建中止控制器 + abortController = new AbortController() + + // 处理客户端断开连接 + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info('Client disconnected, aborting stream request') + abortController.abort() + } + }) + + // 解析账户的代理配置 + const proxyConfig = parseProxyConfig(account) + + let streamResponse + + if (isApiAccount) { + // Gemini API 账户:直接使用 API Key 请求流式接口 + const apiUrl = buildGeminiApiUrl( + account.baseUrl, + model, + 'streamGenerateContent', + account.apiKey, + { + stream: true + } + ) + + logger.info('📤 Gemini upstream request', { + targetUrl: apiUrl.replace(/key=[^&]+/, 'key=***'), + model, + accountId: actualAccountId + }) + + const axiosConfig = { + method: 'POST', + url: apiUrl, + data: actualRequestData, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': account.apiKey, + 'x-goog-api-key': account.apiKey + }, + responseType: 'stream', + signal: abortController.signal + } + + if (proxyConfig) { + axiosConfig.httpsAgent = ProxyHelper.createProxyAgent(proxyConfig) + axiosConfig.httpAgent = ProxyHelper.createProxyAgent(proxyConfig) + } + + try { + const apiResponse = await axios(axiosConfig) + streamResponse = apiResponse.data + } catch (error) { + logger.error('Gemini API stream request failed:', { + status: error.response?.status, + statusText: error.response?.statusText, + data: error.response?.data + }) + throw error + } + } else { + // OAuth 账户 + const { accessToken, refreshToken } = account + const client = await geminiAccountService.getOauthClient( + accessToken, + refreshToken, + proxyConfig, + account.oauthProvider + ) + + let effectiveProjectId = account.projectId || account.tempProjectId || null + + const oauthProvider = account.oauthProvider || 'gemini-cli' + + if (oauthProvider === 'antigravity') { + if (!effectiveProjectId) { + effectiveProjectId = `ag-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}` + await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId) + } + } else if (!effectiveProjectId) { + try { + logger.info('📋 No projectId available, attempting to fetch from loadCodeAssist...') + const loadResponse = await geminiAccountService.loadCodeAssist(client, null, proxyConfig) + + if (loadResponse.cloudaicompanionProject) { + effectiveProjectId = loadResponse.cloudaicompanionProject + await geminiAccountService.updateTempProjectId(actualAccountId, effectiveProjectId) + logger.info(`📋 Fetched and cached temporary projectId: ${effectiveProjectId}`) + } + } catch (loadError) { + logger.warn('Failed to fetch projectId from loadCodeAssist:', loadError.message) + } + } + + if (!effectiveProjectId) { + return res.status(403).json({ + error: { + message: + 'This account requires a project ID to be configured. Please configure a project ID in the account settings.', + type: 'configuration_required' + } + }) + } + + logger.info('📋 Standard API 流式项目ID处理逻辑', { + accountProjectId: account.projectId, + tempProjectId: account.tempProjectId, + effectiveProjectId, + decision: account.projectId + ? '使用账户配置' + : account.tempProjectId + ? '使用临时项目ID' + : '从loadCodeAssist获取' + }) + + const userPromptId = `${crypto.randomUUID()}########0` + + if (oauthProvider === 'antigravity') { + streamResponse = await geminiAccountService.generateContentStreamAntigravity( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + } else { + streamResponse = await geminiAccountService.generateContentStream( + client, + { model, request: actualRequestData }, + userPromptId, + effectiveProjectId, + req.apiKey?.id, + abortController.signal, + proxyConfig + ) + } + } + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + // 处理流式响应 + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + + let heartbeatTimer = null + let lastDataTime = Date.now() + const HEARTBEAT_INTERVAL = 15000 + + const sendHeartbeat = () => { + const timeSinceLastData = Date.now() - lastDataTime + if (timeSinceLastData >= HEARTBEAT_INTERVAL && !res.destroyed) { + res.write('\n') + logger.info(`💓 Sent SSE keepalive (gap: ${(timeSinceLastData / 1000).toFixed(1)}s)`) + } + } + + heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL) + + let sseBuffer = '' + + const handleEventBlock = (evt) => { + if (!evt.trim()) { + return + } + + const dataLines = evt.split(/\r?\n/).filter((line) => line.startsWith('data:')) + if (dataLines.length === 0) { + if (!res.destroyed) { + res.write(`${evt}\n\n`) + } + return + } + + const dataPayload = dataLines.map((line) => line.replace(/^data:\s?/, '')).join('\n') + + let processedPayload = null + let parsed = null + + if (dataPayload === '[DONE]') { + processedPayload = '[DONE]' + } else { + try { + parsed = JSON.parse(dataPayload) + + if (parsed.usageMetadata) { + totalUsage = parsed.usageMetadata + } else if (parsed.response?.usageMetadata) { + totalUsage = parsed.response.usageMetadata + } + + processedPayload = JSON.stringify(parsed.response || parsed) + } catch (e) { + // 解析失败,直接转发原始 data + } + } + + const outputChunk = processedPayload === null ? `${evt}\n\n` : `data: ${processedPayload}\n\n` + + if (!res.destroyed) { + res.write(outputChunk) + } + + try { + const usageSource = + processedPayload && processedPayload !== '[DONE]' ? processedPayload : dataPayload + + if (!usageSource || !usageSource.includes('usageMetadata')) { + return + } + + const usageObj = JSON.parse(usageSource) + const usage = usageObj.usageMetadata || usageObj.response?.usageMetadata || usageObj.usage + + if (usage && typeof usage === 'object') { + totalUsage = usage + logger.debug('📊 Captured Gemini usage data (async):', totalUsage) + } + } catch (error) { + // 提取用量失败时忽略 + } + } + + streamResponse.on('data', (chunk) => { + try { + lastDataTime = Date.now() + + sseBuffer += chunk.toString() + const events = sseBuffer.split(/\r?\n\r?\n/) + sseBuffer = events.pop() || '' + + for (const evt of events) { + handleEventBlock(evt) + } + } catch (error) { + logger.error('Error processing stream chunk:', error) + } + }) + + streamResponse.on('end', () => { + logger.info('Stream completed successfully') + + if (sseBuffer.trim()) { + try { + handleEventBlock(sseBuffer) + } catch (flushError) { + // 忽略 flush 期间的异常 + } + sseBuffer = '' + } + + if (heartbeatTimer) { + clearInterval(heartbeatTimer) + heartbeatTimer = null + } + + res.end() + + if (totalUsage.totalTokenCount > 0) { + apiKeyService + .recordUsage( + req.apiKey.id, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, + 0, + model, + accountId, + 'gemini', + null, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: true, + statusCode: res.statusCode + }) + ) + .then(() => { + logger.info( + `📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` + ) + }) + .catch((error) => { + logger.error('Failed to record Gemini usage:', error) + }) + } else { + logger.warn( + `⚠️ Stream completed without usage data - totalTokenCount: ${totalUsage.totalTokenCount}` + ) + } + }) + + streamResponse.on('error', (error) => { + logger.error('Stream error:', error) + + if (heartbeatTimer) { + clearInterval(heartbeatTimer) + heartbeatTimer = null + } + + if (!res.headersSent) { + res.status(500).json({ + error: { + message: getSafeMessage(error) || 'Stream error', + type: 'api_error' + } + }) + } else { + if (!res.destroyed) { + try { + res.write( + `data: ${JSON.stringify({ + error: { + message: getSafeMessage(error) || 'Stream error', + type: 'stream_error', + code: error.code + } + })}\n\n` + ) + res.write('data: [DONE]\n\n') + } catch (writeError) { + logger.error('Error sending error event:', writeError) + } + } + res.end() + } + }) + } catch (error) { + const normalizedError = await normalizeAxiosStreamError(error) + + logger.error(`Error in standard streamGenerateContent endpoint`, { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: normalizedError.parsedBody || normalizedError.rawBody, + stack: error.stack + }) + await handleGeminiUpstreamError( + normalizedError.status || error.response?.status, + accountId, + accountType, + sessionHash, + error.response?.headers, + account?.disableAutoProtection + ) + + if (!res.headersSent) { + const statusCode = error.statusCode || normalizedError.status || 500 + const responseBody = { + error: { + message: normalizedError.message, + type: 'api_error' + } + } + + if (normalizedError.status) { + responseBody.error.upstreamStatus = normalizedError.status + } + if (normalizedError.statusText) { + responseBody.error.upstreamStatusText = normalizedError.statusText + } + if (normalizedError.parsedBody && typeof normalizedError.parsedBody === 'object') { + responseBody.error.upstreamResponse = normalizedError.parsedBody + } else if (normalizedError.rawBody) { + responseBody.error.upstreamRaw = normalizedError.rawBody + } + + return res.status(statusCode).json(responseBody) + } + } finally { + if (abortController) { + abortController = null + } + } +} + +// ============================================================================ +// 导出 +// ============================================================================ + +module.exports = { + // 工具函数 + buildGeminiApiUrl, + generateSessionHash, + checkPermissions, + ensureGeminiPermission, + ensureGeminiPermissionMiddleware, + applyRateLimitTracking, + parseProxyConfig, + normalizeAxiosStreamError, + + // OpenAI 兼容格式处理函数 + handleMessages, + + // 模型相关处理函数 + handleModels, + handleModelDetails, + + // 使用统计和 API Key 信息 + handleUsage, + handleKeyInfo, + + // v1internal 格式处理函数 + handleSimpleEndpoint, + handleLoadCodeAssist, + handleOnboardUser, + handleRetrieveUserQuota, + handleCountTokens, + handleGenerateContent, + handleStreamGenerateContent, + + // 标准 Gemini API 格式处理函数 + handleStandardGenerateContent, + handleStandardStreamGenerateContent +} diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..e8e0cd2 --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,2109 @@ +const { v4: uuidv4 } = require('uuid') +const config = require('../../config/config') +const apiKeyService = require('../services/apiKeyService') +const userService = require('../services/userService') +const logger = require('../utils/logger') +const redis = require('../models/redis') +// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用 +const ClientValidator = require('../validators/clientValidator') +const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator') +const claudeRelayConfigService = require('../services/claudeRelayConfigService') +const { calculateWaitTimeStats } = require('../utils/statsHelper') +const { isClaudeFamilyModel } = require('../utils/modelHelper') + +// 工具函数 +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * 检查排队是否过载,决定是否应该快速失败 + * 详见 design.md Decision 7: 排队健康检查与快速失败 + * + * @param {string} apiKeyId - API Key ID + * @param {number} timeoutMs - 排队超时时间(毫秒) + * @param {Object} queueConfig - 队列配置 + * @param {number} maxQueueSize - 最大排队数 + * @returns {Promise} { reject: boolean, reason?: string, estimatedWaitMs?: number, timeoutMs?: number } + */ +async function shouldRejectDueToOverload(apiKeyId, timeoutMs, queueConfig, maxQueueSize) { + try { + // 如果健康检查被禁用,直接返回不拒绝 + if (!queueConfig.concurrentRequestQueueHealthCheckEnabled) { + return { reject: false, reason: 'health_check_disabled' } + } + + // 🔑 先检查当前队列长度 + const currentQueueCount = await redis.getConcurrencyQueueCount(apiKeyId).catch(() => 0) + + // 队列为空,说明系统已恢复,跳过健康检查 + if (currentQueueCount === 0) { + return { reject: false, reason: 'queue_empty', currentQueueCount: 0 } + } + + // 🔑 关键改进:只有当队列接近满载时才进行健康检查 + // 队列长度 <= maxQueueSize * 0.5 时,认为系统有足够余量,跳过健康检查 + // 这避免了在队列较短时过于保守地拒绝请求 + // 使用 ceil 确保小队列(如 maxQueueSize=3)时阈值为 2,即队列 <=1 时跳过 + const queueLoadThreshold = Math.ceil(maxQueueSize * 0.5) + if (currentQueueCount <= queueLoadThreshold) { + return { + reject: false, + reason: 'queue_not_loaded', + currentQueueCount, + queueLoadThreshold, + maxQueueSize + } + } + + // 获取该 API Key 的等待时间样本 + const waitTimes = await redis.getQueueWaitTimes(apiKeyId) + const stats = calculateWaitTimeStats(waitTimes) + + // 样本不足(< 10),跳过健康检查,避免冷启动误判 + if (!stats || stats.sampleCount < 10) { + return { reject: false, reason: 'insufficient_samples', sampleCount: stats?.sampleCount || 0 } + } + + // P90 不可靠时也跳过(虽然 sampleCount >= 10 时 p90Unreliable 应该是 false) + if (stats.p90Unreliable) { + return { reject: false, reason: 'p90_unreliable', sampleCount: stats.sampleCount } + } + + // 计算健康阈值:P90 >= 超时时间 × 阈值 时拒绝 + const threshold = queueConfig.concurrentRequestQueueHealthThreshold || 0.8 + const maxAllowedP90 = timeoutMs * threshold + + if (stats.p90 >= maxAllowedP90) { + return { + reject: true, + reason: 'queue_overloaded', + estimatedWaitMs: stats.p90, + timeoutMs, + threshold, + sampleCount: stats.sampleCount, + currentQueueCount, + maxQueueSize + } + } + + return { reject: false, p90: stats.p90, sampleCount: stats.sampleCount, currentQueueCount } + } catch (error) { + // 健康检查出错时不阻塞请求,记录警告并继续 + logger.warn(`Health check failed for ${apiKeyId}:`, error.message) + return { reject: false, reason: 'health_check_error', error: error.message } + } +} + +// 排队轮询配置常量(可通过配置文件覆盖) +// 性能权衡:初始间隔越短响应越快,但 Redis QPS 越高 +// 当前配置:100 个等待者时约 250-300 QPS(指数退避后) +const QUEUE_POLLING_CONFIG = { + pollIntervalMs: 200, // 初始轮询间隔(毫秒)- 平衡响应速度和 Redis 压力 + maxPollIntervalMs: 2000, // 最大轮询间隔(毫秒)- 长时间等待时降低 Redis 压力 + backoffFactor: 1.5, // 指数退避系数 + jitterRatio: 0.2, // 抖动比例(±20%)- 防止惊群效应 + maxRedisFailCount: 5 // 连续 Redis 失败阈值(从 3 提高到 5,提高网络抖动容忍度) +} + +const FALLBACK_CONCURRENCY_CONFIG = { + leaseSeconds: 300, + renewIntervalSeconds: 30, + cleanupGraceSeconds: 30 +} + +const resolveConcurrencyConfig = () => { + if (typeof redis._getConcurrencyConfig === 'function') { + return redis._getConcurrencyConfig() + } + + const raw = { + ...FALLBACK_CONCURRENCY_CONFIG, + ...(config.concurrency || {}) + } + + const toNumber = (value, fallback) => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return fallback + } + return parsed + } + + const leaseSeconds = Math.max( + toNumber(raw.leaseSeconds, FALLBACK_CONCURRENCY_CONFIG.leaseSeconds), + 30 + ) + + let renewIntervalSeconds + if (raw.renewIntervalSeconds === 0 || raw.renewIntervalSeconds === '0') { + renewIntervalSeconds = 0 + } else { + renewIntervalSeconds = Math.max( + toNumber(raw.renewIntervalSeconds, FALLBACK_CONCURRENCY_CONFIG.renewIntervalSeconds), + 0 + ) + } + + const cleanupGraceSeconds = Math.max( + toNumber(raw.cleanupGraceSeconds, FALLBACK_CONCURRENCY_CONFIG.cleanupGraceSeconds), + 0 + ) + + return { + leaseSeconds, + renewIntervalSeconds, + cleanupGraceSeconds + } +} + +const TOKEN_COUNT_PATHS = new Set([ + '/v1/messages/count_tokens', + '/api/v1/messages/count_tokens', + '/claude/v1/messages/count_tokens' +]) + +function extractApiKey(req) { + const candidates = [ + req.headers['x-api-key'], + req.headers['x-goog-api-key'], + req.headers['authorization'], + req.headers['api-key'], + req.query?.key + ] + + for (const candidate of candidates) { + let value = candidate + + if (Array.isArray(value)) { + value = value.find((item) => typeof item === 'string' && item.trim()) + } + + if (typeof value !== 'string') { + continue + } + + let trimmed = value.trim() + if (!trimmed) { + continue + } + + if (/^Bearer\s+/i.test(trimmed)) { + trimmed = trimmed.replace(/^Bearer\s+/i, '').trim() + if (!trimmed) { + continue + } + } + + return trimmed + } + + return '' +} + +function normalizeRequestPath(value) { + if (!value) { + return '/' + } + const lower = value.split('?')[0].toLowerCase() + const collapsed = lower.replace(/\/{2,}/g, '/') + if (collapsed.length > 1 && collapsed.endsWith('/')) { + return collapsed.slice(0, -1) + } + return collapsed || '/' +} + +function isTokenCountRequest(req) { + const combined = normalizeRequestPath(`${req.baseUrl || ''}${req.path || ''}`) + if (TOKEN_COUNT_PATHS.has(combined)) { + return true + } + const original = normalizeRequestPath(req.originalUrl || '') + if (TOKEN_COUNT_PATHS.has(original)) { + return true + } + return false +} + +/** + * 等待并发槽位(排队机制核心) + * + * 采用「先占后检查」模式避免竞态条件: + * - 每次轮询时尝试 incrConcurrency 占位 + * - 如果超限则 decrConcurrency 释放并继续等待 + * - 成功获取槽位后返回,调用方无需再次 incrConcurrency + * + * ⚠️ 重要清理责任说明: + * - 排队计数:此函数的 finally 块负责调用 decrConcurrencyQueue 清理 + * - 并发槽位:当返回 acquired=true 时,槽位已被占用(通过 incrConcurrency) + * 调用方必须在请求结束时调用 decrConcurrency 释放槽位 + * (已在 authenticateApiKey 的 finally 块中处理) + * + * @param {Object} req - Express 请求对象 + * @param {Object} res - Express 响应对象 + * @param {string} apiKeyId - API Key ID + * @param {Object} queueOptions - 配置参数 + * @returns {Promise} { acquired: boolean, reason?: string, waitTimeMs: number } + */ +async function waitForConcurrencySlot(req, res, apiKeyId, queueOptions) { + const { + concurrencyLimit, + requestId, + leaseSeconds, + timeoutMs, + pollIntervalMs, + maxPollIntervalMs, + backoffFactor, + jitterRatio, + maxRedisFailCount: configMaxRedisFailCount + } = queueOptions + + let clientDisconnected = false + // 追踪轮询过程中是否临时占用了槽位(用于异常时清理) + // 工作流程: + // 1. incrConcurrency 成功且 count <= limit 时,设置 internalSlotAcquired = true + // 2. 统计记录完成后,设置 internalSlotAcquired = false 并返回(所有权转移给调用方) + // 3. 如果在步骤 1-2 之间发生异常,finally 块会检测到 internalSlotAcquired = true 并释放槽位 + let internalSlotAcquired = false + + // 监听客户端断开事件 + // ⚠️ 重要:必须监听 socket 的事件,而不是 req 的事件! + // 原因:对于 POST 请求,当 body-parser 读取完请求体后,req(IncomingMessage 可读流) + // 的 'close' 事件会立即触发,但这不代表客户端断开连接!客户端仍在等待响应。 + // socket 的 'close' 事件才是真正的连接关闭信号。 + const { socket } = req + const onSocketClose = () => { + clientDisconnected = true + logger.debug( + `🔌 [Queue] Socket closed during queue wait for API key ${apiKeyId}, requestId: ${requestId}` + ) + } + + if (socket) { + socket.once('close', onSocketClose) + } + + // 检查 socket 是否在监听器注册前已被销毁(边界情况) + if (socket?.destroyed) { + clientDisconnected = true + } + + const startTime = Date.now() + let pollInterval = pollIntervalMs + let redisFailCount = 0 + // 优先使用配置中的值,否则使用默认值 + const maxRedisFailCount = configMaxRedisFailCount || QUEUE_POLLING_CONFIG.maxRedisFailCount + + try { + while (Date.now() - startTime < timeoutMs) { + // 检测客户端是否断开(双重检查:事件标记 + socket 状态) + // socket.destroyed 是同步检查,确保即使事件处理有延迟也能及时检测 + if (clientDisconnected || socket?.destroyed) { + redis + .incrConcurrencyQueueStats(apiKeyId, 'cancelled') + .catch((e) => logger.warn('Failed to record cancelled stat:', e)) + return { + acquired: false, + reason: 'client_disconnected', + waitTimeMs: Date.now() - startTime + } + } + + // 尝试获取槽位(先占后检查) + try { + const count = await redis.incrConcurrency(apiKeyId, requestId, leaseSeconds) + redisFailCount = 0 // 重置失败计数 + + if (count <= concurrencyLimit) { + // 成功获取槽位! + const waitTimeMs = Date.now() - startTime + + // 槽位所有权转移说明: + // 1. 此时槽位已通过 incrConcurrency 获取 + // 2. 先标记 internalSlotAcquired = true,确保异常时 finally 块能清理 + // 3. 统计操作完成后,清除标记并返回,所有权转移给调用方 + // 4. 调用方(authenticateApiKey)负责在请求结束时释放槽位 + + // 标记槽位已获取(用于异常时 finally 块清理) + internalSlotAcquired = true + + // 记录统计(非阻塞,fire-and-forget 模式) + // ⚠️ 设计说明: + // - 故意不 await 这些 Promise,因为统计记录不应阻塞请求处理 + // - 每个 Promise 都有独立的 .catch(),确保单个失败不影响其他 + // - 外层 .catch() 是防御性措施,处理 Promise.all 本身的异常 + // - 即使统计记录在函数返回后才完成/失败,也是安全的(仅日志记录) + // - 统计数据丢失可接受,不影响核心业务逻辑 + Promise.all([ + redis + .recordQueueWaitTime(apiKeyId, waitTimeMs) + .catch((e) => logger.warn('Failed to record queue wait time:', e)), + redis + .recordGlobalQueueWaitTime(waitTimeMs) + .catch((e) => logger.warn('Failed to record global wait time:', e)), + redis + .incrConcurrencyQueueStats(apiKeyId, 'success') + .catch((e) => logger.warn('Failed to increment success stats:', e)) + ]).catch((e) => logger.warn('Failed to record queue stats batch:', e)) + + // 成功返回前清除标记(所有权转移给调用方,由其负责释放) + internalSlotAcquired = false + return { acquired: true, waitTimeMs } + } + + // 超限,释放槽位继续等待 + try { + await redis.decrConcurrency(apiKeyId, requestId) + } catch (decrError) { + // 释放失败时记录警告但继续轮询 + // 下次 incrConcurrency 会自然覆盖同一 requestId 的条目 + logger.warn( + `Failed to release slot during polling for ${apiKeyId}, will retry:`, + decrError + ) + } + } catch (redisError) { + redisFailCount++ + logger.error( + `Redis error in queue polling (${redisFailCount}/${maxRedisFailCount}):`, + redisError + ) + + if (redisFailCount >= maxRedisFailCount) { + // 连续 Redis 失败,放弃排队 + return { + acquired: false, + reason: 'redis_error', + waitTimeMs: Date.now() - startTime + } + } + } + + // 指数退避等待 + await sleep(pollInterval) + + // 计算下一次轮询间隔(指数退避 + 抖动) + // 1. 先应用指数退避 + let nextInterval = pollInterval * backoffFactor + // 2. 添加抖动防止惊群效应(±jitterRatio 范围内的随机偏移) + // 抖动范围:[-jitterRatio, +jitterRatio],例如 jitterRatio=0.2 时为 ±20% + // 这是预期行为:负抖动可使间隔略微缩短,正抖动可使间隔略微延长 + // 目的是分散多个等待者的轮询时间点,避免同时请求 Redis + const jitter = nextInterval * jitterRatio * (Math.random() * 2 - 1) + nextInterval = nextInterval + jitter + // 3. 确保在合理范围内:最小 1ms,最大 maxPollIntervalMs + // Math.max(1, ...) 保证即使负抖动也不会产生 ≤0 的间隔 + pollInterval = Math.max(1, Math.min(nextInterval, maxPollIntervalMs)) + } + + // 超时 + redis + .incrConcurrencyQueueStats(apiKeyId, 'timeout') + .catch((e) => logger.warn('Failed to record timeout stat:', e)) + return { acquired: false, reason: 'timeout', waitTimeMs: Date.now() - startTime } + } finally { + // 确保清理: + // 1. 减少排队计数(排队计数在调用方已增加,这里负责减少) + try { + await redis.decrConcurrencyQueue(apiKeyId) + } catch (cleanupError) { + // 清理失败记录错误(可能导致计数泄漏,但有 TTL 保护) + logger.error( + `Failed to decrement queue count in finally block for ${apiKeyId}:`, + cleanupError + ) + } + + // 2. 如果内部获取了槽位但未正常返回(异常路径),释放槽位 + if (internalSlotAcquired) { + try { + await redis.decrConcurrency(apiKeyId, requestId) + logger.warn( + `⚠️ Released orphaned concurrency slot in finally block for ${apiKeyId}, requestId: ${requestId}` + ) + } catch (slotCleanupError) { + logger.error( + `Failed to release orphaned concurrency slot for ${apiKeyId}:`, + slotCleanupError + ) + } + } + + // 清理 socket 事件监听器 + if (socket) { + socket.removeListener('close', onSocketClose) + } + } +} + +// 🔑 API Key验证中间件(优化版) +const authenticateApiKey = async (req, res, next) => { + const startTime = Date.now() + let authErrored = false + let concurrencyCleanup = null + let hasConcurrencySlot = false + + try { + // 安全提取API Key,支持多种格式(包括Gemini CLI支持) + const apiKey = extractApiKey(req) + + if (apiKey) { + req.headers['x-api-key'] = apiKey + } + + if (!apiKey) { + logger.security(`Missing API key attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Missing API key', + message: + 'Please provide an API key in the x-api-key, x-goog-api-key, or Authorization header' + }) + } + + // 基本API Key格式验证 + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + logger.security(`Invalid API key format from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + // 验证API Key(带缓存优化) + const validation = await apiKeyService.validateApiKey(apiKey) + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security(`Invalid API key attempt: ${validation.error} from ${clientIP}`) + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + const skipKeyRestrictions = isTokenCountRequest(req) + + // 🔒 检查客户端限制(使用新的验证器) + if ( + !skipKeyRestrictions && + validation.keyData.enableClientRestriction && + validation.keyData.allowedClients?.length > 0 + ) { + // 使用新的 ClientValidator 进行验证 + const validationResult = ClientValidator.validateRequest( + validation.keyData.allowedClients, + req + ) + + if (!validationResult.allowed) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security( + `🚫 Client restriction failed for key: ${validation.keyData.id} (${validation.keyData.name}) from ${clientIP}` + ) + return res.status(403).json({ + error: 'Client not allowed', + message: 'Your client is not authorized to use this API key', + allowedClients: validation.keyData.allowedClients, + userAgent: validationResult.userAgent + }) + } + + // 验证通过 + logger.api( + `✅ Client validated: ${validationResult.clientName} (${validationResult.matchedClient}) for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + } + + // 🔒 检查全局 Claude Code 限制(与 API Key 级别是 OR 逻辑) + // 仅对 Claude 服务端点生效 (/api/v1/messages 和 /claude/v1/messages) + if (!skipKeyRestrictions) { + const normalizedPath = (req.originalUrl || req.path || '').toLowerCase() + const isClaudeMessagesEndpoint = + normalizedPath.includes('/v1/messages') && + (normalizedPath.startsWith('/api') || normalizedPath.startsWith('/claude')) + + if (isClaudeMessagesEndpoint) { + try { + const globalClaudeCodeOnly = await claudeRelayConfigService.isClaudeCodeOnlyEnabled() + + // API Key 级别的 Claude Code 限制 + const keyClaudeCodeOnly = + validation.keyData.enableClientRestriction && + Array.isArray(validation.keyData.allowedClients) && + validation.keyData.allowedClients.length === 1 && + validation.keyData.allowedClients.includes('claude_code') + + // OR 逻辑:全局开启 或 API Key 级别限制为仅 claude_code + if (globalClaudeCodeOnly || keyClaudeCodeOnly) { + const isClaudeCode = ClaudeCodeValidator.validate(req) + + if (!isClaudeCode) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.api( + `❌ Claude Code client validation failed (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly}) from ${clientIP}` + ) + return res.status(403).json({ + error: { + type: 'client_validation_error', + message: 'This endpoint only accepts requests from Claude Code CLI' + } + }) + } + + logger.api( + `✅ Claude Code client validated (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly})` + ) + } + } catch (error) { + logger.error('❌ Error checking Claude Code restriction:', error) + // 配置服务出错时不阻断请求 + } + } + } + + // 检查并发限制 + const concurrencyLimit = validation.keyData.concurrencyLimit || 0 + if (!skipKeyRestrictions && concurrencyLimit > 0) { + const { leaseSeconds: configLeaseSeconds, renewIntervalSeconds: configRenewIntervalSeconds } = + resolveConcurrencyConfig() + const leaseSeconds = Math.max(Number(configLeaseSeconds) || 300, 30) + let renewIntervalSeconds = configRenewIntervalSeconds + if (renewIntervalSeconds > 0) { + const maxSafeRenew = Math.max(leaseSeconds - 5, 15) + renewIntervalSeconds = Math.min(Math.max(renewIntervalSeconds, 15), maxSafeRenew) + } else { + renewIntervalSeconds = 0 + } + const requestId = uuidv4() + + // ⚠️ 优化后的 Connection: close 设置策略 + // 问题背景:HTTP Keep-Alive 使多个请求共用同一个 TCP 连接 + // 当第一个请求正在处理,第二个请求进入排队时,它们共用同一个 socket + // 如果客户端超时关闭连接,两个请求都会受影响 + // 优化方案:只有在请求实际进入排队时才设置 Connection: close + // 未排队的请求保持 Keep-Alive,避免不必要的 TCP 握手开销 + // 详见 design.md Decision 2: Connection: close 设置时机 + // 注意:Connection: close 将在下方代码实际进入排队时设置(第 637 行左右) + + // ============================================================ + // 🔒 并发槽位状态管理说明 + // ============================================================ + // 此函数中有两个关键状态变量: + // - hasConcurrencySlot: 当前是否持有并发槽位 + // - concurrencyCleanup: 错误时调用的清理函数 + // + // 状态转换流程: + // 1. incrConcurrency 成功 → hasConcurrencySlot=true, 设置临时清理函数 + // 2. 若超限 → 释放槽位,hasConcurrencySlot=false, concurrencyCleanup=null + // 3. 若排队成功 → hasConcurrencySlot=true, 升级为完整清理函数(含 interval 清理) + // 4. 请求结束(res.close/req.close)→ 调用 decrementConcurrency 释放 + // 5. 认证错误 → finally 块调用 concurrencyCleanup 释放 + // + // 为什么需要两种清理函数? + // - 临时清理:在排队/认证过程中出错时使用,只释放槽位 + // - 完整清理:请求正常开始后使用,还需清理 leaseRenewInterval + // ============================================================ + const setTemporaryConcurrencyCleanup = () => { + concurrencyCleanup = async () => { + if (!hasConcurrencySlot) { + return + } + hasConcurrencySlot = false + try { + await redis.decrConcurrency(validation.keyData.id, requestId) + } catch (cleanupError) { + logger.error( + `Failed to decrement concurrency after auth error for key ${validation.keyData.id}:`, + cleanupError + ) + } + } + } + + const currentConcurrency = await redis.incrConcurrency( + validation.keyData.id, + requestId, + leaseSeconds + ) + hasConcurrencySlot = true + setTemporaryConcurrencyCleanup() + logger.api( + `📈 Incremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency}, limit: ${concurrencyLimit}` + ) + + if (currentConcurrency > concurrencyLimit) { + // 1. 先释放刚占用的槽位 + try { + await redis.decrConcurrency(validation.keyData.id, requestId) + } catch (error) { + logger.error( + `Failed to decrement concurrency after limit exceeded for key ${validation.keyData.id}:`, + error + ) + } + hasConcurrencySlot = false + concurrencyCleanup = null + + // 2. 获取排队配置 + const queueConfig = await claudeRelayConfigService.getConfig() + + // 3. 排队功能未启用,直接返回 429(保持现有行为) + if (!queueConfig.concurrentRequestQueueEnabled) { + logger.security( + `🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}` + ) + // 建议客户端在短暂延迟后重试(并发场景下通常很快会有槽位释放) + res.set('Retry-After', '1') + return res.status(429).json({ + error: 'Concurrency limit exceeded', + message: `Too many concurrent requests. Limit: ${concurrencyLimit} concurrent requests`, + currentConcurrency: currentConcurrency - 1, + concurrencyLimit + }) + } + + // 4. 计算最大排队数 + const maxQueueSize = Math.max( + concurrencyLimit * queueConfig.concurrentRequestQueueMaxSizeMultiplier, + queueConfig.concurrentRequestQueueMaxSize + ) + + // 4.5 排队健康检查:过载时快速失败 + // 详见 design.md Decision 7: 排队健康检查与快速失败 + const overloadCheck = await shouldRejectDueToOverload( + validation.keyData.id, + queueConfig.concurrentRequestQueueTimeoutMs, + queueConfig, + maxQueueSize + ) + if (overloadCheck.reject) { + // 使用健康检查返回的当前排队数,避免重复调用 Redis + const currentQueueCount = overloadCheck.currentQueueCount || 0 + logger.api( + `🚨 Queue overloaded for key: ${validation.keyData.id} (${validation.keyData.name}), ` + + `P90=${overloadCheck.estimatedWaitMs}ms, timeout=${overloadCheck.timeoutMs}ms, ` + + `threshold=${overloadCheck.threshold}, samples=${overloadCheck.sampleCount}, ` + + `concurrency=${concurrencyLimit}, queue=${currentQueueCount}/${maxQueueSize}` + ) + // 记录被拒绝的过载统计 + redis + .incrConcurrencyQueueStats(validation.keyData.id, 'rejected_overload') + .catch((e) => logger.warn('Failed to record rejected_overload stat:', e)) + // 返回 429 + Retry-After,让客户端稍后重试 + const retryAfterSeconds = 30 + res.set('Retry-After', String(retryAfterSeconds)) + return res.status(429).json({ + error: 'Queue overloaded', + message: `Queue is overloaded. Estimated wait time (${overloadCheck.estimatedWaitMs}ms) exceeds threshold. Limit: ${concurrencyLimit} concurrent requests, queue: ${currentQueueCount}/${maxQueueSize}. Please retry later.`, + currentConcurrency: concurrencyLimit, + concurrencyLimit, + queueCount: currentQueueCount, + maxQueueSize, + estimatedWaitMs: overloadCheck.estimatedWaitMs, + timeoutMs: overloadCheck.timeoutMs, + queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs, + retryAfterSeconds + }) + } + + // 5. 尝试进入排队(原子操作:先增加再检查,避免竞态条件) + let queueIncremented = false + try { + const newQueueCount = await redis.incrConcurrencyQueue( + validation.keyData.id, + queueConfig.concurrentRequestQueueTimeoutMs + ) + queueIncremented = true + + if (newQueueCount > maxQueueSize) { + // 超过最大排队数,立即释放并返回 429 + await redis.decrConcurrencyQueue(validation.keyData.id) + queueIncremented = false + logger.api( + `🚦 Concurrency queue full for key: ${validation.keyData.id} (${validation.keyData.name}), ` + + `queue: ${newQueueCount - 1}, maxQueue: ${maxQueueSize}` + ) + // 队列已满,建议客户端在排队超时时间后重试 + const retryAfterSeconds = Math.ceil(queueConfig.concurrentRequestQueueTimeoutMs / 1000) + res.set('Retry-After', String(retryAfterSeconds)) + return res.status(429).json({ + error: 'Concurrency queue full', + message: `Too many requests waiting in queue. Limit: ${concurrencyLimit} concurrent requests, queue: ${newQueueCount - 1}/${maxQueueSize}, timeout: ${retryAfterSeconds}s`, + currentConcurrency: concurrencyLimit, + concurrencyLimit, + queueCount: newQueueCount - 1, + maxQueueSize, + queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs, + retryAfterSeconds + }) + } + + // 6. 已成功进入排队,记录统计并开始等待槽位 + logger.api( + `⏳ Request entering queue for key: ${validation.keyData.id} (${validation.keyData.name}), ` + + `queue position: ${newQueueCount}` + ) + redis + .incrConcurrencyQueueStats(validation.keyData.id, 'entered') + .catch((e) => logger.warn('Failed to record entered stat:', e)) + + // ⚠️ 仅在请求实际进入排队时设置 Connection: close + // 详见 design.md Decision 2: Connection: close 设置时机 + // 未排队的请求保持 Keep-Alive,避免不必要的 TCP 握手开销 + if (!res.headersSent) { + res.setHeader('Connection', 'close') + logger.api( + `🔌 [Queue] Set Connection: close for queued request, key: ${validation.keyData.id}` + ) + } + + // ⚠️ 记录排队开始时的 socket 标识,用于排队完成后验证 + // 问题背景:HTTP Keep-Alive 连接复用时,长时间排队可能导致 socket 被其他请求使用 + // 验证方法:使用 UUID token + socket 对象引用双重验证 + // 详见 design.md Decision 1: Socket 身份验证机制 + req._crService = req._crService || {} + req._crService.queueToken = uuidv4() + req._crService.originalSocket = req.socket + req._crService.startTime = Date.now() + const savedToken = req._crService.queueToken + const savedSocket = req._crService.originalSocket + + // ⚠️ 重要:在调用前将 queueIncremented 设为 false + // 因为 waitForConcurrencySlot 的 finally 块会负责清理排队计数 + // 如果在调用后设置,当 waitForConcurrencySlot 抛出异常时 + // 外层 catch 块会重复减少计数(finally 已经减过一次) + queueIncremented = false + + const slot = await waitForConcurrencySlot(req, res, validation.keyData.id, { + concurrencyLimit, + requestId, + leaseSeconds, + timeoutMs: queueConfig.concurrentRequestQueueTimeoutMs, + pollIntervalMs: QUEUE_POLLING_CONFIG.pollIntervalMs, + maxPollIntervalMs: QUEUE_POLLING_CONFIG.maxPollIntervalMs, + backoffFactor: QUEUE_POLLING_CONFIG.backoffFactor, + jitterRatio: QUEUE_POLLING_CONFIG.jitterRatio, + maxRedisFailCount: queueConfig.concurrentRequestQueueMaxRedisFailCount + }) + + // 7. 处理排队结果 + if (!slot.acquired) { + if (slot.reason === 'client_disconnected') { + // 客户端已断开,不返回响应(连接已关闭) + logger.api( + `🔌 Client disconnected while queuing for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + return + } + + if (slot.reason === 'redis_error') { + // Redis 连续失败,返回 503 + logger.error( + `❌ Redis error during queue wait for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + return res.status(503).json({ + error: 'Service temporarily unavailable', + message: 'Failed to acquire concurrency slot due to internal error' + }) + } + // 排队超时(使用 api 级别,与其他排队日志保持一致) + logger.api( + `⏰ Queue timeout for key: ${validation.keyData.id} (${validation.keyData.name}), waited: ${slot.waitTimeMs}ms` + ) + // 已等待超时,建议客户端稍后重试 + // ⚠️ Retry-After 策略优化: + // - 请求已经等了完整的 timeout 时间,说明系统负载较高 + // - 过早重试(如固定 5 秒)会加剧拥塞,导致更多超时 + // - 合理策略:使用 timeout 时间的一半作为重试间隔 + // - 最小值 5 秒,最大值 30 秒,避免极端情况 + const timeoutSeconds = Math.ceil(queueConfig.concurrentRequestQueueTimeoutMs / 1000) + const retryAfterSeconds = Math.max(5, Math.min(30, Math.ceil(timeoutSeconds / 2))) + res.set('Retry-After', String(retryAfterSeconds)) + return res.status(429).json({ + error: 'Queue timeout', + message: `Request timed out waiting for concurrency slot. Limit: ${concurrencyLimit} concurrent requests, maxQueue: ${maxQueueSize}, Queue timeout: ${timeoutSeconds}s, waited: ${slot.waitTimeMs}ms`, + currentConcurrency: concurrencyLimit, + concurrencyLimit, + maxQueueSize, + queueTimeoutMs: queueConfig.concurrentRequestQueueTimeoutMs, + waitTimeMs: slot.waitTimeMs, + retryAfterSeconds + }) + } + + // 8. 排队成功,slot.acquired 表示已在 waitForConcurrencySlot 中获取到槽位 + logger.api( + `✅ Queue wait completed for key: ${validation.keyData.id} (${validation.keyData.name}), ` + + `waited: ${slot.waitTimeMs}ms` + ) + hasConcurrencySlot = true + setTemporaryConcurrencyCleanup() + + // 9. ⚠️ 关键检查:排队等待结束后,验证客户端是否还在等待响应 + // 长时间排队后,客户端可能在应用层已放弃(如 Claude Code 的超时机制), + // 但 TCP 连接仍然存活。此时继续处理请求是浪费资源。 + // 注意:如果发送了心跳,headersSent 会是 true,但这是正常的 + const postQueueSocket = req.socket + // 只检查连接是否真正断开(destroyed/writableEnded/socketDestroyed) + // headersSent 在心跳场景下是正常的,不应该作为放弃的依据 + if (res.destroyed || res.writableEnded || postQueueSocket?.destroyed) { + logger.warn( + `⚠️ Client no longer waiting after queue for key: ${validation.keyData.id} (${validation.keyData.name}), ` + + `waited: ${slot.waitTimeMs}ms | destroyed: ${res.destroyed}, ` + + `writableEnded: ${res.writableEnded}, socketDestroyed: ${postQueueSocket?.destroyed}` + ) + // 释放刚获取的槽位 + hasConcurrencySlot = false + await redis + .decrConcurrency(validation.keyData.id, requestId) + .catch((e) => logger.error('Failed to release slot after client abandoned:', e)) + // 不返回响应(客户端已不在等待) + return + } + + // 10. ⚠️ 关键检查:验证 socket 身份是否改变 + // HTTP Keep-Alive 连接复用可能导致排队期间 socket 被其他请求使用 + // 验证方法:UUID token + socket 对象引用双重验证 + // 详见 design.md Decision 1: Socket 身份验证机制 + const queueData = req._crService + const socketIdentityChanged = + !queueData || + queueData.queueToken !== savedToken || + queueData.originalSocket !== savedSocket + + if (socketIdentityChanged) { + logger.error( + `❌ [Queue] Socket identity changed during queue wait! ` + + `key: ${validation.keyData.id} (${validation.keyData.name}), ` + + `waited: ${slot.waitTimeMs}ms | ` + + `tokenMatch: ${queueData?.queueToken === savedToken}, ` + + `socketMatch: ${queueData?.originalSocket === savedSocket}` + ) + // 释放刚获取的槽位 + hasConcurrencySlot = false + await redis + .decrConcurrency(validation.keyData.id, requestId) + .catch((e) => logger.error('Failed to release slot after socket identity change:', e)) + // 记录 socket_changed 统计 + redis + .incrConcurrencyQueueStats(validation.keyData.id, 'socket_changed') + .catch((e) => logger.warn('Failed to record socket_changed stat:', e)) + // 不返回响应(socket 已被其他请求使用) + return + } + } catch (queueError) { + // 异常时清理资源,防止泄漏 + // 1. 清理排队计数(如果还没被 waitForConcurrencySlot 的 finally 清理) + if (queueIncremented) { + await redis + .decrConcurrencyQueue(validation.keyData.id) + .catch((e) => logger.error('Failed to cleanup queue count after error:', e)) + } + + // 2. 防御性清理:如果 waitForConcurrencySlot 内部获取了槽位但在返回前异常 + // 虽然这种情况极少发生(统计记录的异常会被内部捕获),但为了安全起见 + // 尝试释放可能已获取的槽位。decrConcurrency 使用 ZREM,即使成员不存在也安全 + if (hasConcurrencySlot) { + hasConcurrencySlot = false + await redis + .decrConcurrency(validation.keyData.id, requestId) + .catch((e) => + logger.error('Failed to cleanup concurrency slot after queue error:', e) + ) + } + + throw queueError + } + } + + const renewIntervalMs = + renewIntervalSeconds > 0 ? Math.max(renewIntervalSeconds * 1000, 15000) : 0 + + // 使用标志位确保只减少一次 + let concurrencyDecremented = false + let leaseRenewInterval = null + + if (renewIntervalMs > 0) { + // 🔴 关键修复:添加最大刷新次数限制,防止租约永不过期 + // 默认最大生存时间为 10 分钟,可通过环境变量配置 + const maxLifetimeMinutes = parseInt(process.env.CONCURRENCY_MAX_LIFETIME_MINUTES) || 10 + const maxRefreshCount = Math.ceil((maxLifetimeMinutes * 60 * 1000) / renewIntervalMs) + let refreshCount = 0 + + leaseRenewInterval = setInterval(() => { + refreshCount++ + + // 超过最大刷新次数,强制停止并清理 + if (refreshCount > maxRefreshCount) { + logger.warn( + `⚠️ Lease refresh exceeded max count (${maxRefreshCount}) for key ${validation.keyData.id} (${validation.keyData.name}), forcing cleanup after ${maxLifetimeMinutes} minutes` + ) + // 清理定时器 + if (leaseRenewInterval) { + clearInterval(leaseRenewInterval) + leaseRenewInterval = null + } + // 强制减少并发计数(如果还没减少) + if (!concurrencyDecremented) { + concurrencyDecremented = true + redis.decrConcurrency(validation.keyData.id, requestId).catch((error) => { + logger.error( + `Failed to decrement concurrency after max refresh for key ${validation.keyData.id}:`, + error + ) + }) + } + return + } + + redis + .refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds) + .catch((error) => { + logger.error( + `Failed to refresh concurrency lease for key ${validation.keyData.id}:`, + error + ) + }) + }, renewIntervalMs) + + if (typeof leaseRenewInterval.unref === 'function') { + leaseRenewInterval.unref() + } + } + + const decrementConcurrency = async () => { + if (!concurrencyDecremented) { + concurrencyDecremented = true + hasConcurrencySlot = false + if (leaseRenewInterval) { + clearInterval(leaseRenewInterval) + leaseRenewInterval = null + } + try { + const newCount = await redis.decrConcurrency(validation.keyData.id, requestId) + logger.api( + `📉 Decremented concurrency for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}` + ) + } catch (error) { + logger.error(`Failed to decrement concurrency for key ${validation.keyData.id}:`, error) + } + } + } + // 升级为完整清理函数(包含 leaseRenewInterval 清理逻辑) + // 此时请求已通过认证,后续由 res.close/req.close 事件触发清理 + if (hasConcurrencySlot) { + concurrencyCleanup = decrementConcurrency + } + + // 监听最可靠的事件(避免重复监听) + // res.on('close') 是最可靠的,会在连接关闭时触发 + res.once('close', () => { + logger.api( + `🔌 Response closed for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + + // req.on('close') 作为备用,处理请求端断开 + req.once('close', () => { + logger.api( + `🔌 Request closed for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + + req.once('aborted', () => { + logger.warn( + `⚠️ Request aborted for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + + req.once('error', (error) => { + logger.error( + `❌ Request error for key ${validation.keyData.id} (${validation.keyData.name}):`, + error + ) + decrementConcurrency() + }) + + res.once('error', (error) => { + logger.error( + `❌ Response error for key ${validation.keyData.id} (${validation.keyData.name}):`, + error + ) + decrementConcurrency() + }) + + // res.on('finish') 处理正常完成的情况 + res.once('finish', () => { + logger.api( + `✅ Response finished for key: ${validation.keyData.id} (${validation.keyData.name})` + ) + decrementConcurrency() + }) + + // 存储并发信息到请求对象,便于后续处理 + req.concurrencyInfo = { + apiKeyId: validation.keyData.id, + apiKeyName: validation.keyData.name, + requestId, + decrementConcurrency + } + } + + // 检查时间窗口限流 + const rateLimitWindow = validation.keyData.rateLimitWindow || 0 + const rateLimitRequests = validation.keyData.rateLimitRequests || 0 + const rateLimitCost = validation.keyData.rateLimitCost || 0 // 新增:费用限制 + + // 兼容性检查:如果tokenLimit仍有值,使用tokenLimit;否则使用rateLimitCost + const hasRateLimits = + rateLimitWindow > 0 && + (rateLimitRequests > 0 || validation.keyData.tokenLimit > 0 || rateLimitCost > 0) + + if (hasRateLimits) { + const windowStartKey = `rate_limit:window_start:${validation.keyData.id}` + const requestCountKey = `rate_limit:requests:${validation.keyData.id}` + const tokenCountKey = `rate_limit:tokens:${validation.keyData.id}` + const costCountKey = `rate_limit:cost:${validation.keyData.id}` // 新增:费用计数器 + + const now = Date.now() + const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒 + + // 获取窗口开始时间 + let windowStart = await redis.getClient().get(windowStartKey) + + if (!windowStart) { + // 第一次请求,设置窗口开始时间 + await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) + await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用 + windowStart = now + } else { + windowStart = parseInt(windowStart) + + // 检查窗口是否已过期 + if (now - windowStart >= windowDuration) { + // 窗口已过期,重置 + await redis.getClient().set(windowStartKey, now, 'PX', windowDuration) + await redis.getClient().set(requestCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(tokenCountKey, 0, 'PX', windowDuration) + await redis.getClient().set(costCountKey, 0, 'PX', windowDuration) // 新增:重置费用 + windowStart = now + } + } + + // 获取当前计数 + const currentRequests = parseInt((await redis.getClient().get(requestCountKey)) || '0') + const currentTokens = parseInt((await redis.getClient().get(tokenCountKey)) || '0') + const currentCost = parseFloat((await redis.getClient().get(costCountKey)) || '0') // 新增:当前费用 + + // 检查请求次数限制 + if (rateLimitRequests > 0 && currentRequests >= rateLimitRequests) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) + + logger.security( + `🚦 Rate limit exceeded (requests) for key: ${validation.keyData.id} (${validation.keyData.name}), requests: ${currentRequests}/${rateLimitRequests}` + ) + + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到请求次数限制 (${rateLimitRequests} 次),将在 ${remainingMinutes} 分钟后重置`, + currentRequests, + requestLimit: rateLimitRequests, + resetAt: resetTime.toISOString(), + remainingMinutes + }) + } + + // 兼容性检查:优先使用Token限制(历史数据),否则使用费用限制 + const tokenLimit = parseInt(validation.keyData.tokenLimit) + if (tokenLimit > 0) { + // 使用Token限制(向后兼容) + if (currentTokens >= tokenLimit) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) + + logger.security( + `🚦 Rate limit exceeded (tokens) for key: ${validation.keyData.id} (${validation.keyData.name}), tokens: ${currentTokens}/${tokenLimit}` + ) + + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到 Token 使用限制 (${tokenLimit} tokens),将在 ${remainingMinutes} 分钟后重置`, + currentTokens, + tokenLimit, + resetAt: resetTime.toISOString(), + remainingMinutes + }) + } + } else if (rateLimitCost > 0) { + // 使用费用限制(新功能) + if (currentCost >= rateLimitCost) { + const resetTime = new Date(windowStart + windowDuration) + const remainingMinutes = Math.ceil((resetTime - now) / 60000) + + logger.security( + `💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}` + ) + + return res.status(429).json({ + error: 'Rate limit exceeded', + message: `已达到费用限制 ($${rateLimitCost}),将在 ${remainingMinutes} 分钟后重置`, + currentCost, + costLimit: rateLimitCost, + resetAt: resetTime.toISOString(), + remainingMinutes + }) + } + } + + // 增加请求计数 + await redis.getClient().incr(requestCountKey) + + // 存储限流信息到请求对象 + req.rateLimitInfo = { + windowStart, + windowDuration, + requestCountKey, + tokenCountKey, + costCountKey, // 新增:费用计数器 + currentRequests: currentRequests + 1, + currentTokens, + currentCost, // 新增:当前费用 + rateLimitRequests, + tokenLimit, + rateLimitCost // 新增:费用限制 + } + } + + // 检查每日费用限制 + const dailyCostLimit = validation.keyData.dailyCostLimit || 0 + if (dailyCostLimit > 0) { + const dailyCost = validation.keyData.dailyCost || 0 + + if (dailyCost >= dailyCostLimit) { + logger.security( + `💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` + ) + + // 使用 402 Payment Required 而非 429,避免客户端自动重试 + return res.status(402).json({ + error: { + type: 'insufficient_quota', + message: `已达到每日费用限制 ($${dailyCostLimit})`, + code: 'daily_cost_limit_exceeded' + }, + currentCost: dailyCost, + costLimit: dailyCostLimit, + resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() + }) + } + + // 记录当前费用使用情况 + logger.api( + `💰 Cost usage for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` + ) + } + + // 检查总费用限制 + const totalCostLimit = validation.keyData.totalCostLimit || 0 + if (totalCostLimit > 0) { + const totalCost = validation.keyData.totalCost || 0 + + if (totalCost >= totalCostLimit) { + logger.security( + `💰 Total cost limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}` + ) + + // 使用 402 Payment Required 而非 429,避免客户端自动重试 + return res.status(402).json({ + error: { + type: 'insufficient_quota', + message: `已达到总费用限制 ($${totalCostLimit})`, + code: 'total_cost_limit_exceeded' + }, + currentCost: totalCost, + costLimit: totalCostLimit + }) + } + + logger.api( + `💰 Total cost usage for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: $${totalCost.toFixed(2)}/$${totalCostLimit}` + ) + } + + // 检查 Claude 周费用限制 + const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0 + if (weeklyOpusCostLimit > 0) { + // 从请求中获取模型信息 + const requestBody = req.body || {} + const model = requestBody.model || '' + + // 判断是否为 Claude 模型 + if (isClaudeFamilyModel(model)) { + const weeklyOpusCost = validation.keyData.weeklyOpusCost || 0 + + if (weeklyOpusCost >= weeklyOpusCostLimit) { + logger.security( + `💰 Weekly Claude cost limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` + ) + + // 计算下次重置时间(基于 API Key 配置的重置日/时) + const resetDay = validation.keyData.weeklyResetDay || 1 + const resetHour = validation.keyData.weeklyResetHour || 0 + const resetDate = redis.getNextResetTime(resetDay, resetHour) + + // 使用 402 Payment Required 而非 429,避免客户端自动重试 + return res.status(402).json({ + error: { + type: 'insufficient_quota', + message: `已达到 Claude 模型周费用限制 ($${weeklyOpusCostLimit})`, + code: 'weekly_opus_cost_limit_exceeded' + }, + currentCost: weeklyOpusCost, + costLimit: weeklyOpusCostLimit, + resetAt: resetDate.toISOString() + }) + } + + // 记录当前 Claude 费用使用情况 + logger.api( + `💰 Claude weekly cost usage for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` + ) + } + } + + // 将验证信息添加到请求对象(只包含必要信息) + req.apiKey = { + id: validation.keyData.id, + name: validation.keyData.name, + tokenLimit: validation.keyData.tokenLimit, + claudeAccountId: validation.keyData.claudeAccountId, + claudeConsoleAccountId: validation.keyData.claudeConsoleAccountId, // 添加 Claude Console 账号ID + geminiAccountId: validation.keyData.geminiAccountId, + openaiAccountId: validation.keyData.openaiAccountId, // 添加 OpenAI 账号ID + bedrockAccountId: validation.keyData.bedrockAccountId, // 添加 Bedrock 账号ID + droidAccountId: validation.keyData.droidAccountId, + permissions: validation.keyData.permissions, + concurrencyLimit: validation.keyData.concurrencyLimit, + rateLimitWindow: validation.keyData.rateLimitWindow, + rateLimitRequests: validation.keyData.rateLimitRequests, + rateLimitCost: validation.keyData.rateLimitCost, // 新增:费用限制 + enableModelRestriction: validation.keyData.enableModelRestriction, + restrictedModels: validation.keyData.restrictedModels, + enableClientRestriction: validation.keyData.enableClientRestriction, + allowedClients: validation.keyData.allowedClients, + dailyCostLimit: validation.keyData.dailyCostLimit, + dailyCost: validation.keyData.dailyCost, + totalCostLimit: validation.keyData.totalCostLimit, + totalCost: validation.keyData.totalCost, + enableOpenAIResponsesCodexAdaptation: validation.keyData.enableOpenAIResponsesCodexAdaptation, + enableOpenAIResponsesPayloadRules: validation.keyData.enableOpenAIResponsesPayloadRules, + openaiResponsesPayloadRules: validation.keyData.openaiResponsesPayloadRules + } + + const authDuration = Date.now() - startTime + const userAgent = req.headers['user-agent'] || 'No User-Agent' + logger.api( + `🔓 Authenticated request from key: ${validation.keyData.name} (${validation.keyData.id}) in ${authDuration}ms` + ) + logger.api(` User-Agent: "${userAgent}"`) + + return next() + } catch (error) { + authErrored = true + const authDuration = Date.now() - startTime + logger.error(`❌ Authentication middleware error (${authDuration}ms):`, { + error: error.message, + stack: error.stack, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during authentication' + }) + } finally { + if (authErrored && typeof concurrencyCleanup === 'function') { + try { + await concurrencyCleanup() + } catch (cleanupError) { + logger.error('Failed to cleanup concurrency after auth error:', cleanupError) + } + } + } +} + +// 🛡️ 管理员验证中间件(优化版) +const authenticateAdmin = async (req, res, next) => { + const startTime = Date.now() + + try { + // 安全提取token,支持多种方式 + const token = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.adminToken || + req.headers['x-admin-token'] + + if (!token) { + logger.security(`Missing admin token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Missing admin token', + message: 'Please provide an admin token' + }) + } + + // 基本token格式验证 + if (typeof token !== 'string' || token.length < 32 || token.length > 512) { + logger.security(`Invalid admin token format from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid admin token format', + message: 'Admin token format is invalid' + }) + } + + // 获取管理员会话(带超时处理) + const adminSession = await Promise.race([ + redis.getSession(token), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Session lookup timeout')), 5000) + ) + ]) + + if (!adminSession || Object.keys(adminSession).length === 0) { + logger.security(`Invalid admin token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid admin token', + message: 'Invalid or expired admin session' + }) + } + + // 🔒 安全修复:验证会话必须字段(防止伪造会话绕过认证) + if (!adminSession.username || !adminSession.loginTime) { + logger.security( + `🔒 Corrupted admin session from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})` + ) + await redis.deleteSession(token) // 清理无效/伪造的会话 + return res.status(401).json({ + error: 'Invalid session', + message: 'Session data corrupted or incomplete' + }) + } + + // 检查会话活跃性(可选:检查最后活动时间) + const now = new Date() + const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime) + const inactiveDuration = now - lastActivity + const maxInactivity = 24 * 60 * 60 * 1000 // 24小时 + + if (inactiveDuration > maxInactivity) { + logger.security( + `🔒 Expired admin session for ${adminSession.username} from ${req.ip || 'unknown'}` + ) + await redis.deleteSession(token) // 清理过期会话 + return res.status(401).json({ + error: 'Session expired', + message: 'Admin session has expired due to inactivity' + }) + } + + // 更新最后活动时间(异步,不阻塞请求) + redis + .setSession( + token, + { + ...adminSession, + lastActivity: now.toISOString() + }, + 86400 + ) + .catch((error) => { + logger.error('Failed to update admin session activity:', error) + }) + + // 设置管理员信息(只包含必要信息) + req.admin = { + username: adminSession.username, + sessionId: token, + loginTime: adminSession.loginTime + } + + const authDuration = Date.now() - startTime + req._authInfo = `${adminSession.username} ${authDuration}ms` + logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`) + + return next() + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ Admin authentication error (${authDuration}ms):`, { + error: error.message, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during admin authentication' + }) + } +} + +// 👤 用户验证中间件 +const authenticateUser = async (req, res, next) => { + const startTime = Date.now() + + try { + // 安全提取用户session token,支持多种方式 + const sessionToken = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.userToken || + req.headers['x-user-token'] + + if (!sessionToken) { + logger.security(`Missing user session token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Missing user session token', + message: 'Please login to access this resource' + }) + } + + // 基本token格式验证 + if (typeof sessionToken !== 'string' || sessionToken.length < 32 || sessionToken.length > 128) { + logger.security(`Invalid user session token format from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid session token format', + message: 'Session token format is invalid' + }) + } + + // 验证用户会话 + const sessionValidation = await userService.validateUserSession(sessionToken) + + if (!sessionValidation) { + logger.security(`Invalid user session token attempt from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Invalid session token', + message: 'Invalid or expired user session' + }) + } + + const { session, user } = sessionValidation + + // 检查用户是否被禁用 + if (!user.isActive) { + logger.security( + `🔒 Disabled user login attempt: ${user.username} from ${req.ip || 'unknown'}` + ) + return res.status(403).json({ + error: 'Account disabled', + message: 'Your account has been disabled. Please contact administrator.' + }) + } + + // 设置用户信息(只包含必要信息) + req.user = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + sessionToken, + sessionCreatedAt: session.createdAt + } + + const authDuration = Date.now() - startTime + logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) + + return next() + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ User authentication error (${authDuration}ms):`, { + error: error.message, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during user authentication' + }) + } +} + +// 👤 用户或管理员验证中间件(支持两种身份) +const authenticateUserOrAdmin = async (req, res, next) => { + const startTime = Date.now() + + try { + // 检查是否有管理员token + const adminToken = + req.headers['authorization']?.replace(/^Bearer\s+/i, '') || + req.cookies?.adminToken || + req.headers['x-admin-token'] + + // 检查是否有用户session token + const userToken = + req.headers['x-user-token'] || + req.cookies?.userToken || + (!adminToken ? req.headers['authorization']?.replace(/^Bearer\s+/i, '') : null) + + // 优先尝试管理员认证 + if (adminToken) { + try { + const adminSession = await redis.getSession(adminToken) + if (adminSession && Object.keys(adminSession).length > 0) { + // 🔒 安全修复:验证会话必须字段(与 authenticateAdmin 保持一致) + if (!adminSession.username || !adminSession.loginTime) { + logger.security( + `🔒 Corrupted admin session in authenticateUserOrAdmin from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})` + ) + await redis.deleteSession(adminToken) // 清理无效/伪造的会话 + // 不返回 401,继续尝试用户认证 + } else { + req.admin = { + username: adminSession.username, + sessionId: adminToken, + loginTime: adminSession.loginTime + } + req.userType = 'admin' + + const authDuration = Date.now() - startTime + req._authInfo = `${adminSession.username} ${authDuration}ms` + logger.security(`Admin authenticated: ${adminSession.username} in ${authDuration}ms`) + return next() + } + } + } catch (error) { + logger.debug('Admin authentication failed, trying user authentication:', error.message) + } + } + + // 尝试用户认证 + if (userToken) { + try { + const sessionValidation = await userService.validateUserSession(userToken) + if (sessionValidation) { + const { session, user } = sessionValidation + + if (user.isActive) { + req.user = { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + sessionToken: userToken, + sessionCreatedAt: session.createdAt + } + req.userType = 'user' + + const authDuration = Date.now() - startTime + logger.info(`👤 User authenticated: ${user.username} (${user.id}) in ${authDuration}ms`) + return next() + } + } + } catch (error) { + logger.debug('User authentication failed:', error.message) + } + } + + // 如果都失败了,返回未授权 + logger.security(`Authentication failed from ${req.ip || 'unknown'}`) + return res.status(401).json({ + error: 'Authentication required', + message: 'Please login as user or admin to access this resource' + }) + } catch (error) { + const authDuration = Date.now() - startTime + logger.error(`❌ User/Admin authentication error (${authDuration}ms):`, { + error: error.message, + ip: req.ip, + userAgent: req.get('User-Agent'), + url: req.originalUrl + }) + + return res.status(500).json({ + error: 'Authentication error', + message: 'Internal server error during authentication' + }) + } +} + +// 🛡️ 权限检查中间件 +const requireRole = (allowedRoles) => (req, res, next) => { + // 管理员始终有权限 + if (req.admin) { + return next() + } + + // 检查用户角色 + if (req.user) { + const userRole = req.user.role + const allowed = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles] + + if (allowed.includes(userRole)) { + return next() + } else { + logger.security( + `🚫 Access denied for user ${req.user.username} (role: ${userRole}) to ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'Insufficient permissions', + message: `This resource requires one of the following roles: ${allowed.join(', ')}` + }) + } + } + + return res.status(401).json({ + error: 'Authentication required', + message: 'Please login to access this resource' + }) +} + +// 🔒 管理员权限检查中间件 +const requireAdmin = (req, res, next) => { + if (req.admin) { + return next() + } + + // 检查是否是admin角色的用户 + if (req.user && req.user.role === 'admin') { + return next() + } + + logger.security( + `🚫 Admin access denied for ${req.user?.username || 'unknown'} from ${req.ip || 'unknown'}` + ) + return res.status(403).json({ + error: 'Admin access required', + message: 'This resource requires administrator privileges' + }) +} + +// 注意:使用统计现在直接在/api/v1/messages路由中处理, +// 以便从Claude API响应中提取真实的usage数据 + +// 🚦 CORS中间件(优化版,支持Chrome插件) +const corsMiddleware = (req, res, next) => { + const { origin } = req.headers + + // 允许的源(可以从配置文件读取) + const allowedOrigins = [ + 'http://localhost:3000', + 'https://localhost:3000', + 'http://127.0.0.1:3000', + 'https://127.0.0.1:3000' + ] + + // 🆕 检查是否为Chrome插件请求 + const isChromeExtension = origin && origin.startsWith('chrome-extension://') + + // 设置CORS头 + if (allowedOrigins.includes(origin) || !origin || isChromeExtension) { + res.header('Access-Control-Allow-Origin', origin || '*') + } + + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + res.header( + 'Access-Control-Allow-Headers', + [ + 'Origin', + 'X-Requested-With', + 'Content-Type', + 'Accept', + 'Authorization', + 'x-api-key', + 'x-goog-api-key', + 'api-key', + 'x-admin-token', + 'anthropic-version', + 'anthropic-dangerous-direct-browser-access' + ].join(', ') + ) + + res.header('Access-Control-Expose-Headers', ['X-Request-ID', 'Content-Type'].join(', ')) + + res.header('Access-Control-Max-Age', '86400') // 24小时预检缓存 + res.header('Access-Control-Allow-Credentials', 'true') + + if (req.method === 'OPTIONS') { + res.status(204).end() + } else { + next() + } +} + +// 📝 请求日志中间件(优化版) +const requestLogger = (req, res, next) => { + const start = Date.now() + const requestId = Math.random().toString(36).substring(2, 15) + + // 添加请求ID到请求对象 + req.requestId = requestId + req.requestStartedAt = start + res.setHeader('X-Request-ID', requestId) + + // 获取客户端信息 + const clientIP = req.ip || req.connection?.remoteAddress || req.socket?.remoteAddress || 'unknown' + const userAgent = req.get('User-Agent') || 'unknown' + const referer = req.get('Referer') || 'none' + + // 请求开始 → debug 级别(减少正常请求的日志量) + const isDebugRoute = req.originalUrl.includes('event_logging') + if (req.originalUrl !== '/health') { + logger.debug(`▶ [${requestId}] ${req.method} ${req.originalUrl}`, { + ip: clientIP, + body: req.body && Object.keys(req.body).length > 0 ? req.body : undefined + }) + } + + // 拦截 res.json() 捕获响应体 + const originalJson = res.json.bind(res) + res.json = (body) => { + res._responseBody = body + return originalJson(body) + } + + res.on('finish', () => { + if (req.originalUrl === '/health') { + return + } + const duration = Date.now() - start + const contentLength = res.get('Content-Length') || '0' + const status = res.statusCode + + // 状态 emoji + const emoji = status >= 500 ? '❌' : status >= 400 ? '⚠️ ' : '🟢' + const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info' + + // 主消息行 + const msg = `${emoji} ${status} ${req.method} ${req.originalUrl} ${duration}ms ${contentLength}B` + + // 构建树形 metadata + const meta = { requestId } + + // 请求体(非 GET 且有内容时显示) + if (req.method !== 'GET' && req.body && Object.keys(req.body).length > 0) { + meta.req = req.body + } + + // 查询参数(GET 请求且有查询参数时单独显示) + const queryIdx = req.originalUrl.indexOf('?') + if (queryIdx > -1) { + meta.query = req.originalUrl.substring(queryIdx + 1) + } + + // 响应体 + if (res._responseBody) { + meta.res = res._responseBody + } + + // API Key 信息(合并到同一条日志) + if (req.apiKey) { + meta.key = `${req.apiKey.name} (${req.apiKey.id})` + } + + // 认证信息 + if (req._authInfo) { + meta.auth = req._authInfo + } + + // 完整信息写入文件 + meta.ip = clientIP + meta.ua = userAgent + meta.referer = referer + + if (isDebugRoute) { + logger.debug(msg, meta) + } else { + logger[level](msg, meta) + } + + // 慢请求警告 + if (duration > 5000) { + logger.warn(`🐌 Slow request: ${duration}ms ${req.method} ${req.originalUrl}`) + } + }) + + res.on('error', (error) => { + const duration = Date.now() - start + logger.error(`💥 [${requestId}] Response error after ${duration}ms:`, error) + }) + + next() +} + +// 🛡️ 安全中间件(增强版) +const securityMiddleware = (req, res, next) => { + // 设置基础安全头 + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader('X-XSS-Protection', '1; mode=block') + res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin') + + // 添加更多安全头 + res.setHeader('X-DNS-Prefetch-Control', 'off') + res.setHeader('X-Download-Options', 'noopen') + res.setHeader('X-Permitted-Cross-Domain-Policies', 'none') + + // Cross-Origin-Opener-Policy (仅对可信来源设置) + const host = req.get('host') || '' + const isLocalhost = + host.includes('localhost') || host.includes('127.0.0.1') || host.includes('0.0.0.0') + const isHttps = req.secure || req.headers['x-forwarded-proto'] === 'https' + + if (isLocalhost || isHttps) { + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') + res.setHeader('Cross-Origin-Resource-Policy', 'same-origin') + res.setHeader('Origin-Agent-Cluster', '?1') + } + + // Content Security Policy (适用于web界面) + if (req.path.startsWith('/web') || req.path === '/') { + res.setHeader( + 'Content-Security-Policy', + [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://unpkg.com https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://cdn.bootcdn.net", + "style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://cdnjs.cloudflare.com https://cdn.bootcdn.net", + "font-src 'self' https://cdnjs.cloudflare.com https://cdn.bootcdn.net", + "img-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'" + ].join('; ') + ) + } + + // Strict Transport Security (HTTPS) + if (req.secure || req.headers['x-forwarded-proto'] === 'https') { + res.setHeader('Strict-Transport-Security', 'max-age=15552000; includeSubDomains') + } + + // 移除泄露服务器信息的头 + res.removeHeader('X-Powered-By') + res.removeHeader('Server') + + // 防止信息泄露 + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate') + res.setHeader('Pragma', 'no-cache') + res.setHeader('Expires', '0') + + next() +} + +// 🚨 错误处理中间件(增强版) +const errorHandler = (error, req, res, _next) => { + const requestId = req.requestId || 'unknown' + const isDevelopment = process.env.NODE_ENV === 'development' + + // 记录详细错误信息 + logger.error(`💥 [${requestId}] Unhandled error:`, { + error: error.message, + stack: error.stack, + url: req.originalUrl, + method: req.method, + ip: req.ip || 'unknown', + userAgent: req.get('User-Agent') || 'unknown', + apiKey: req.apiKey ? req.apiKey.id : 'none', + admin: req.admin ? req.admin.username : 'none' + }) + + // 确定HTTP状态码 + let statusCode = 500 + let errorMessage = 'Internal Server Error' + let userMessage = 'Something went wrong' + + if (error.status && error.status >= 400 && error.status < 600) { + statusCode = error.status + } + + // 根据错误类型提供友好的错误消息 + switch (error.name) { + case 'ValidationError': + statusCode = 400 + errorMessage = 'Validation Error' + userMessage = 'Invalid input data' + break + case 'CastError': + statusCode = 400 + errorMessage = 'Cast Error' + userMessage = 'Invalid data format' + break + case 'MongoError': + case 'RedisError': + statusCode = 503 + errorMessage = 'Database Error' + userMessage = 'Database temporarily unavailable' + break + case 'TimeoutError': + statusCode = 408 + errorMessage = 'Request Timeout' + userMessage = 'Request took too long to process' + break + default: + if (error.message && !isDevelopment) { + // 在生产环境中,只显示安全的错误消息 + if (error.message.includes('ECONNREFUSED')) { + userMessage = 'Service temporarily unavailable' + } else if (error.message.includes('timeout')) { + userMessage = 'Request timeout' + } + } + } + + // 设置响应头 + res.setHeader('X-Request-ID', requestId) + + // 构建错误响应 + const errorResponse = { + error: errorMessage, + message: isDevelopment ? error.message : userMessage, + requestId, + timestamp: new Date().toISOString() + } + + // 在开发环境中包含更多调试信息 + if (isDevelopment) { + errorResponse.stack = error.stack + errorResponse.url = req.originalUrl + errorResponse.method = req.method + } + + res.status(statusCode).json(errorResponse) +} + +// 🌐 全局速率限制中间件(延迟初始化) +// const rateLimiter = null // 暂时未使用 + +// 暂时注释掉未使用的函数 +// const getRateLimiter = () => { +// if (!rateLimiter) { +// try { +// const client = redis.getClient() +// if (!client) { +// logger.warn('⚠️ Redis client not available for rate limiter') +// return null +// } +// +// rateLimiter = new RateLimiterRedis({ +// storeClient: client, +// keyPrefix: 'global_rate_limit', +// points: 1000, // 请求数量 +// duration: 900, // 15分钟 (900秒) +// blockDuration: 900 // 阻塞时间15分钟 +// }) +// +// logger.info('✅ Rate limiter initialized successfully') +// } catch (error) { +// logger.warn('⚠️ Rate limiter initialization failed, using fallback', { error: error.message }) +// return null +// } +// } +// return rateLimiter +// } + +const globalRateLimit = async (req, res, next) => + // 已禁用全局IP限流 - 直接跳过所有请求 + next() + +// 以下代码已被禁用 +/* + // 跳过健康检查和内部请求 + if (req.path === '/health' || req.path === '/api/health') { + return next() + } + + const limiter = getRateLimiter() + if (!limiter) { + // 如果Redis不可用,直接跳过速率限制 + return next() + } + + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + + try { + await limiter.consume(clientIP) + return next() + } catch (rejRes) { + const remainingPoints = rejRes.remainingPoints || 0 + const msBeforeNext = rejRes.msBeforeNext || 900000 + + logger.security(`🚦 Global rate limit exceeded for IP: ${clientIP}`) + + res.set({ + 'Retry-After': Math.round(msBeforeNext / 1000) || 900, + 'X-RateLimit-Limit': 1000, + 'X-RateLimit-Remaining': remainingPoints, + 'X-RateLimit-Reset': new Date(Date.now() + msBeforeNext).toISOString() + }) + + return res.status(429).json({ + error: 'Too Many Requests', + message: 'Too many requests from this IP, please try again later.', + retryAfter: Math.round(msBeforeNext / 1000) + }) + } + */ + +// 📊 请求大小限制中间件 +const requestSizeLimit = (req, res, next) => { + const MAX_SIZE_MB = parseInt(process.env.REQUEST_MAX_SIZE_MB || '100', 10) + const maxSize = MAX_SIZE_MB * 1024 * 1024 + const contentLength = parseInt(req.headers['content-length'] || '0') + + if (contentLength > maxSize) { + logger.security(`🚨 Request too large: ${contentLength} bytes from ${req.ip}`) + return res.status(413).json({ + error: 'Payload Too Large', + message: 'Request body size exceeds limit', + limit: `${MAX_SIZE_MB}MB` + }) + } + + return next() +} + +module.exports = { + authenticateApiKey, + authenticateAdmin, + authenticateUser, + authenticateUserOrAdmin, + requireRole, + requireAdmin, + corsMiddleware, + requestLogger, + securityMiddleware, + errorHandler, + globalRateLimit, + requestSizeLimit +} diff --git a/src/middleware/browserFallback.js b/src/middleware/browserFallback.js new file mode 100644 index 0000000..ed82532 --- /dev/null +++ b/src/middleware/browserFallback.js @@ -0,0 +1,78 @@ +const logger = require('../utils/logger') + +/** + * 浏览器/Chrome插件兜底中间件 + * 专门处理第三方插件的兼容性问题 + */ +const browserFallbackMiddleware = (req, res, next) => { + const userAgent = req.headers['user-agent'] || '' + const origin = req.headers['origin'] || '' + + const extractHeader = (value) => { + let candidate = value + + if (Array.isArray(candidate)) { + candidate = candidate.find((item) => typeof item === 'string' && item.trim()) + } + + if (typeof candidate !== 'string') { + return '' + } + + let trimmed = candidate.trim() + if (!trimmed) { + return '' + } + + if (/^Bearer\s+/i.test(trimmed)) { + trimmed = trimmed.replace(/^Bearer\s+/i, '').trim() + } + + return trimmed + } + + const apiKeyHeader = + extractHeader(req.headers['x-api-key']) || extractHeader(req.headers['x-goog-api-key']) + const normalizedKey = extractHeader(req.headers['authorization']) || apiKeyHeader + + // 检查是否为Chrome插件或浏览器请求 + const isChromeExtension = origin.startsWith('chrome-extension://') + const isBrowserRequest = userAgent.includes('Mozilla/') && userAgent.includes('Chrome/') + const hasApiKey = normalizedKey.startsWith('cr_') // 我们的API Key格式 + + if ((isChromeExtension || isBrowserRequest) && hasApiKey) { + // 为Chrome插件请求添加特殊标记 + req.isBrowserFallback = true + req.originalUserAgent = userAgent + + // 🆕 关键修改:伪装成claude-cli请求以绕过客户端限制 + req.headers['user-agent'] = 'claude-cli/1.0.110 (external, cli, browser-fallback)' + + // 确保设置正确的认证头 + if (!req.headers['authorization'] && apiKeyHeader) { + req.headers['authorization'] = `Bearer ${apiKeyHeader}` + } + + // 添加必要的Anthropic头 + if (!req.headers['anthropic-version']) { + req.headers['anthropic-version'] = '2023-06-01' + } + + if (!req.headers['anthropic-dangerous-direct-browser-access']) { + req.headers['anthropic-dangerous-direct-browser-access'] = 'true' + } + + logger.api( + `🔧 Browser fallback activated for ${isChromeExtension ? 'Chrome extension' : 'browser'} request` + ) + logger.api(` Original User-Agent: "${req.originalUserAgent}"`) + logger.api(` Origin: "${origin}"`) + logger.api(` Modified User-Agent: "${req.headers['user-agent']}"`) + } + + next() +} + +module.exports = { + browserFallbackMiddleware +} diff --git a/src/models/redis.js b/src/models/redis.js new file mode 100644 index 0000000..9a408fc --- /dev/null +++ b/src/models/redis.js @@ -0,0 +1,5310 @@ +const Redis = require('ioredis') +const config = require('../../config/config') +const logger = require('../utils/logger') + +// 时区辅助函数 +// 注意:这个函数的目的是获取某个时间点在目标时区的"本地"表示 +// 例如:UTC时间 2025-07-30 01:00:00 在 UTC+8 时区表示为 2025-07-30 09:00:00 +function getDateInTimezone(date = new Date()) { + const offset = config.system.timezoneOffset || 8 // 默认UTC+8 + + // 方法:创建一个偏移后的Date对象,使其getUTCXXX方法返回目标时区的值 + // 这样我们可以用getUTCFullYear()等方法获取目标时区的年月日时分秒 + const offsetMs = offset * 3600000 // 时区偏移的毫秒数 + const adjustedTime = new Date(date.getTime() + offsetMs) + + return adjustedTime +} + +// 获取配置时区的日期字符串 (YYYY-MM-DD) +function getDateStringInTimezone(date = new Date()) { + const tzDate = getDateInTimezone(date) + // 使用UTC方法获取偏移后的日期部分 + return `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String( + tzDate.getUTCDate() + ).padStart(2, '0')}` +} + +// 获取配置时区的小时 (0-23) +function getHourInTimezone(date = new Date()) { + const tzDate = getDateInTimezone(date) + return tzDate.getUTCHours() +} + +// 获取配置时区的 ISO 周(YYYY-Wxx 格式,周一到周日) +function getWeekStringInTimezone(date = new Date()) { + const tzDate = getDateInTimezone(date) + + // 获取年份 + const year = tzDate.getUTCFullYear() + + // 计算 ISO 周数(周一为第一天) + const dateObj = new Date(tzDate) + const dayOfWeek = dateObj.getUTCDay() || 7 // 将周日(0)转换为7 + const firstThursday = new Date(dateObj) + firstThursday.setUTCDate(dateObj.getUTCDate() + 4 - dayOfWeek) // 找到这周的周四 + + const yearStart = new Date(firstThursday.getUTCFullYear(), 0, 1) + const weekNumber = Math.ceil(((firstThursday - yearStart) / 86400000 + 1) / 7) + + return `${year}-W${String(weekNumber).padStart(2, '0')}` +} + +// 获取基于自定义重置日/时的周期标识符 (YYYY-MM-DDThh 格式) +// resetDay: 1-7 (周一到周日),默认 1 (周一) +// resetHour: 0-23,默认 0 (00:00) +function getPeriodString(resetDay = 1, resetHour = 0, date = new Date()) { + const tzDate = getDateInTimezone(date) + + // 当前时区时间的 ISO 星期几 (1=周一 ... 7=周日) + const currentDay = tzDate.getUTCDay() || 7 + const currentHour = tzDate.getUTCHours() + + // 计算距上次重置已过的天数 + let daysSinceReset = (currentDay - resetDay + 7) % 7 + // 如果同一天但还没到重置时间,视为上一个周期 + if (daysSinceReset === 0 && currentHour < resetHour) { + daysSinceReset = 7 + } + + // 回退到周期起始日 + const periodStart = new Date(tzDate) + periodStart.setUTCDate(tzDate.getUTCDate() - daysSinceReset) + periodStart.setUTCHours(resetHour, 0, 0, 0) + + const y = periodStart.getUTCFullYear() + const m = String(periodStart.getUTCMonth() + 1).padStart(2, '0') + const d = String(periodStart.getUTCDate()).padStart(2, '0') + const h = String(periodStart.getUTCHours()).padStart(2, '0') + + return `${y}-${m}-${d}T${h}` +} + +// 获取下次重置的真实 UTC 时间(用于 402 响应中的 resetAt) +// resetDay: 1-7 (周一到周日),默认 1 (周一) +// resetHour: 0-23,默认 0 (00:00) +function getNextResetTime(resetDay = 1, resetHour = 0) { + const offset = config.system.timezoneOffset || 8 + const tzDate = getDateInTimezone(new Date()) + + const currentDay = tzDate.getUTCDay() || 7 + const currentHour = tzDate.getUTCHours() + + let daysUntilReset = (resetDay - currentDay + 7) % 7 + // 如果同一天但已过重置时间,等到下周 + if (daysUntilReset === 0 && currentHour >= resetHour) { + daysUntilReset = 7 + } + + // 构造时区下的重置时间 + const resetTz = new Date(tzDate) + resetTz.setUTCDate(tzDate.getUTCDate() + daysUntilReset) + resetTz.setUTCHours(resetHour, 0, 0, 0) + + // 转换回真实 UTC:减去时区偏移 + const resetUtc = new Date(resetTz.getTime() - offset * 3600000) + return resetUtc +} + +// 获取周期起始日期的 Date 对象(时区下),用于回填时判断日期是否在当前周期内 +// 返回 getDateInTimezone 风格的 Date,可用 getUTC* 获取时区本地值 +function getPeriodStartDate(resetDay = 1, resetHour = 0, date = new Date()) { + const tzDate = getDateInTimezone(date) + + const currentDay = tzDate.getUTCDay() || 7 + const currentHour = tzDate.getUTCHours() + + let daysSinceReset = (currentDay - resetDay + 7) % 7 + if (daysSinceReset === 0 && currentHour < resetHour) { + daysSinceReset = 7 + } + + const periodStart = new Date(tzDate) + periodStart.setUTCDate(tzDate.getUTCDate() - daysSinceReset) + periodStart.setUTCHours(resetHour, 0, 0, 0) + + return periodStart +} + +// 并发队列相关常量 +const QUEUE_STATS_TTL_SECONDS = 86400 * 7 // 统计计数保留 7 天 +const WAIT_TIME_TTL_SECONDS = 86400 // 等待时间样本保留 1 天(滚动窗口,无需长期保留) +// 等待时间样本数配置(提高统计置信度) +// - 每 API Key 从 100 提高到 500:提供更稳定的 P99 估计 +// - 全局从 500 提高到 2000:支持更高精度的 P99.9 分析 +// - 内存开销约 12-20KB(Redis quicklist 每元素 1-10 字节),可接受 +// 详见 design.md Decision 5: 等待时间统计样本数 +const WAIT_TIME_SAMPLES_PER_KEY = 500 // 每个 API Key 保留的等待时间样本数 +const WAIT_TIME_SAMPLES_GLOBAL = 2000 // 全局保留的等待时间样本数 +const QUEUE_TTL_BUFFER_SECONDS = 30 // 排队计数器TTL缓冲时间 + +class RedisClient { + constructor() { + this.client = null + this.isConnected = false + } + + async connect() { + try { + this.client = new Redis({ + host: config.redis.host, + port: config.redis.port, + password: config.redis.password, + db: config.redis.db, + retryDelayOnFailover: config.redis.retryDelayOnFailover, + maxRetriesPerRequest: config.redis.maxRetriesPerRequest, + lazyConnect: config.redis.lazyConnect, + tls: config.redis.enableTLS ? {} : false + }) + + this.client.on('connect', () => { + this.isConnected = true + logger.info('🔗 Redis connected successfully') + }) + + this.client.on('error', (err) => { + this.isConnected = false + logger.error('❌ Redis connection error:', err) + }) + + this.client.on('close', () => { + this.isConnected = false + logger.warn('⚠️ Redis connection closed') + }) + + // 只有在 lazyConnect 模式下才需要手动调用 connect() + // 如果 Redis 已经连接或正在连接中,则跳过 + if ( + this.client.status !== 'connecting' && + this.client.status !== 'connect' && + this.client.status !== 'ready' + ) { + await this.client.connect() + } else { + // 等待 ready 状态 + await new Promise((resolve, reject) => { + if (this.client.status === 'ready') { + resolve() + } else { + this.client.once('ready', resolve) + this.client.once('error', reject) + } + }) + } + return this.client + } catch (error) { + logger.error('💥 Failed to connect to Redis:', error) + throw error + } + } + + // 🔄 自动迁移 usage 索引(启动时调用) + async migrateUsageIndex() { + const migrationKey = 'system:migration:usage_index_v2' // v2: 添加 keymodel 迁移 + const migrated = await this.client.get(migrationKey) + if (migrated) { + logger.debug('📊 Usage index migration already completed') + return + } + + logger.info('📊 Starting usage index migration...') + const stats = { daily: 0, hourly: 0, modelDaily: 0, modelHourly: 0 } + + try { + // 迁移 usage:daily + let cursor = '0' + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'usage:daily:*', + 'COUNT', + 500 + ) + cursor = newCursor + const pipeline = this.client.pipeline() + for (const key of keys) { + const match = key.match(/^usage:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + pipeline.sadd(`usage:daily:index:${match[2]}`, match[1]) + pipeline.expire(`usage:daily:index:${match[2]}`, 86400 * 32) + stats.daily++ + } + } + if (keys.length > 0) { + await pipeline.exec() + } + } while (cursor !== '0') + + // 迁移 usage:hourly + cursor = '0' + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'usage:hourly:*', + 'COUNT', + 500 + ) + cursor = newCursor + const pipeline = this.client.pipeline() + for (const key of keys) { + const match = key.match(/^usage:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/) + if (match) { + pipeline.sadd(`usage:hourly:index:${match[2]}`, match[1]) + pipeline.expire(`usage:hourly:index:${match[2]}`, 86400 * 7) + stats.hourly++ + } + } + if (keys.length > 0) { + await pipeline.exec() + } + } while (cursor !== '0') + + // 迁移 usage:model:daily + cursor = '0' + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'usage:model:daily:*', + 'COUNT', + 500 + ) + cursor = newCursor + const pipeline = this.client.pipeline() + for (const key of keys) { + const match = key.match(/^usage:model:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + pipeline.sadd(`usage:model:daily:index:${match[2]}`, match[1]) + pipeline.expire(`usage:model:daily:index:${match[2]}`, 86400 * 32) + stats.modelDaily++ + } + } + if (keys.length > 0) { + await pipeline.exec() + } + } while (cursor !== '0') + + // 迁移 usage:model:hourly + cursor = '0' + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'usage:model:hourly:*', + 'COUNT', + 500 + ) + cursor = newCursor + const pipeline = this.client.pipeline() + for (const key of keys) { + const match = key.match(/^usage:model:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/) + if (match) { + pipeline.sadd(`usage:model:hourly:index:${match[2]}`, match[1]) + pipeline.expire(`usage:model:hourly:index:${match[2]}`, 86400 * 7) + stats.modelHourly++ + } + } + if (keys.length > 0) { + await pipeline.exec() + } + } while (cursor !== '0') + + // 迁移 usage:keymodel:daily (usage:{keyId}:model:daily:{model}:{date}) + cursor = '0' + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'usage:*:model:daily:*', + 'COUNT', + 500 + ) + cursor = newCursor + const pipeline = this.client.pipeline() + for (const key of keys) { + // usage:{keyId}:model:daily:{model}:{date} + const match = key.match(/^usage:([^:]+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + const [, keyId, model, date] = match + pipeline.sadd(`usage:keymodel:daily:index:${date}`, `${keyId}:${model}`) + pipeline.expire(`usage:keymodel:daily:index:${date}`, 86400 * 32) + stats.keymodelDaily = (stats.keymodelDaily || 0) + 1 + } + } + if (keys.length > 0) { + await pipeline.exec() + } + } while (cursor !== '0') + + // 迁移 usage:keymodel:hourly (usage:{keyId}:model:hourly:{model}:{hour}) + cursor = '0' + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'usage:*:model:hourly:*', + 'COUNT', + 500 + ) + cursor = newCursor + const pipeline = this.client.pipeline() + for (const key of keys) { + // usage:{keyId}:model:hourly:{model}:{hour} + const match = key.match(/^usage:([^:]+):model:hourly:(.+):(\d{4}-\d{2}-\d{2}:\d{2})$/) + if (match) { + const [, keyId, model, hour] = match + pipeline.sadd(`usage:keymodel:hourly:index:${hour}`, `${keyId}:${model}`) + pipeline.expire(`usage:keymodel:hourly:index:${hour}`, 86400 * 7) + stats.keymodelHourly = (stats.keymodelHourly || 0) + 1 + } + } + if (keys.length > 0) { + await pipeline.exec() + } + } while (cursor !== '0') + + // 标记迁移完成 + await this.client.set(migrationKey, Date.now().toString()) + logger.info( + `📊 Usage index migration completed: daily=${stats.daily}, hourly=${stats.hourly}, modelDaily=${stats.modelDaily}, modelHourly=${stats.modelHourly}, keymodelDaily=${stats.keymodelDaily || 0}, keymodelHourly=${stats.keymodelHourly || 0}` + ) + } catch (error) { + logger.error('📊 Usage index migration failed:', error) + } + } + + // 🔄 自动迁移 alltime 模型统计(启动时调用) + async migrateAlltimeModelStats() { + const migrationKey = 'system:migration:alltime_model_stats_v1' + const migrated = await this.client.get(migrationKey) + if (migrated) { + logger.debug('📊 Alltime model stats migration already completed') + return + } + + logger.info('📊 Starting alltime model stats migration...') + const stats = { keys: 0, models: 0 } + + try { + // 扫描所有月度模型统计数据并聚合到 alltime + // 格式: usage:{keyId}:model:monthly:{model}:{month} + let cursor = '0' + const aggregatedData = new Map() // keyId:model -> {inputTokens, outputTokens, ...} + + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'usage:*:model:monthly:*:*', + 'COUNT', + 500 + ) + cursor = newCursor + + for (const key of keys) { + // usage:{keyId}:model:monthly:{model}:{month} + const match = key.match(/^usage:([^:]+):model:monthly:(.+):(\d{4}-\d{2})$/) + if (match) { + const [, keyId, model] = match + const aggregateKey = `${keyId}:${model}` + + // 获取该月的数据 + const data = await this.client.hgetall(key) + if (data && Object.keys(data).length > 0) { + if (!aggregatedData.has(aggregateKey)) { + aggregatedData.set(aggregateKey, { + keyId, + model, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + requests: 0 + }) + } + + const agg = aggregatedData.get(aggregateKey) + agg.inputTokens += parseInt(data.inputTokens) || 0 + agg.outputTokens += parseInt(data.outputTokens) || 0 + agg.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + agg.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + agg.requests += parseInt(data.requests) || 0 + stats.keys++ + } + } + } + } while (cursor !== '0') + + // 写入聚合后的 alltime 数据 + const pipeline = this.client.pipeline() + for (const [, agg] of aggregatedData) { + const alltimeKey = `usage:${agg.keyId}:model:alltime:${agg.model}` + pipeline.hset(alltimeKey, { + inputTokens: agg.inputTokens.toString(), + outputTokens: agg.outputTokens.toString(), + cacheCreateTokens: agg.cacheCreateTokens.toString(), + cacheReadTokens: agg.cacheReadTokens.toString(), + requests: agg.requests.toString() + }) + stats.models++ + } + + if (stats.models > 0) { + await pipeline.exec() + } + + // 标记迁移完成 + await this.client.set(migrationKey, Date.now().toString()) + logger.info( + `📊 Alltime model stats migration completed: scanned ${stats.keys} monthly keys, created ${stats.models} alltime keys` + ) + } catch (error) { + logger.error('📊 Alltime model stats migration failed:', error) + } + } + + async disconnect() { + if (this.client) { + await this.client.quit() + this.isConnected = false + logger.info('👋 Redis disconnected') + } + } + + getClient() { + if (!this.client || !this.isConnected) { + logger.warn('⚠️ Redis client is not connected') + return null + } + return this.client + } + + // 安全获取客户端(用于关键操作) + getClientSafe() { + if (!this.client || !this.isConnected) { + throw new Error('Redis client is not connected') + } + return this.client + } + + // 🔑 API Key 相关操作 + async setApiKey(keyId, keyData, hashedKey = null) { + const key = `apikey:${keyId}` + const client = this.getClientSafe() + + // 维护哈希映射表(用于快速查找) + // hashedKey参数是实际的哈希值,用于建立映射 + if (hashedKey) { + await client.hset('apikey:hash_map', hashedKey, keyId) + } + + await client.hset(key, keyData) + await client.expire(key, 86400 * 365) // 1年过期 + } + + async getApiKey(keyId) { + const key = `apikey:${keyId}` + return await this.client.hgetall(key) + } + + async deleteApiKey(keyId) { + const key = `apikey:${keyId}` + + // 获取要删除的API Key哈希值,以便从映射表中移除 + const keyData = await this.client.hgetall(key) + if (keyData && keyData.apiKey) { + // keyData.apiKey现在存储的是哈希值,直接从映射表删除 + await this.client.hdel('apikey:hash_map', keyData.apiKey) + } + + return await this.client.del(key) + } + + async getAllApiKeys() { + const keys = await this.scanKeys('apikey:*') + const apiKeys = [] + const dataList = await this.batchHgetallChunked(keys) + + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + // 过滤掉hash_map,它不是真正的API Key + if (key === 'apikey:hash_map') { + continue + } + + const keyData = dataList[i] + if (keyData && Object.keys(keyData).length > 0) { + apiKeys.push({ id: key.replace('apikey:', ''), ...keyData }) + } + } + return apiKeys + } + + /** + * 使用 SCAN 获取所有 API Key ID(避免 KEYS 命令阻塞) + * @returns {Promise} API Key ID 列表(已去重) + */ + async scanApiKeyIds() { + const keyIds = new Set() + let cursor = '0' + // 排除索引 key 的前缀 + const excludePrefixes = [ + 'apikey:hash_map', + 'apikey:idx:', + 'apikey:set:', + 'apikey:tags:', + 'apikey:index:' + ] + + do { + const [newCursor, keys] = await this.client.scan(cursor, 'MATCH', 'apikey:*', 'COUNT', 100) + cursor = newCursor + + for (const key of keys) { + // 只接受 apikey: 形态,排除索引 key + if (excludePrefixes.some((prefix) => key.startsWith(prefix))) { + continue + } + // 确保是 apikey: 格式(只有一个冒号) + if (key.split(':').length !== 2) { + continue + } + keyIds.add(key.replace('apikey:', '')) + } + } while (cursor !== '0') + + return [...keyIds] + } + + // 添加标签到全局标签集合 + async addTag(tagName) { + await this.client.sadd('apikey:tags:all', tagName) + } + + // 从全局标签集合删除标签 + async removeTag(tagName) { + await this.client.srem('apikey:tags:all', tagName) + } + + // 获取全局标签集合 + async getGlobalTags() { + return await this.client.smembers('apikey:tags:all') + } + + /** + * 使用索引获取所有 API Key 的标签(优化版本) + * 优先级:索引就绪时用 apikey:tags:all > apikey:idx:all + pipeline > SCAN + * @returns {Promise} 去重排序后的标签列表 + */ + async scanAllApiKeyTags() { + // 检查索引是否就绪(非重建中且版本号正确) + const isIndexReady = await this._checkIndexReady() + + if (isIndexReady) { + // 方案1:直接读取索引服务维护的标签集合 + const cachedTags = await this.client.smembers('apikey:tags:all') + if (cachedTags && cachedTags.length > 0) { + // 保持 trim 一致性 + return cachedTags + .map((t) => (t ? t.trim() : '')) + .filter((t) => t) + .sort() + } + + // 方案2:使用索引的 key ID 列表 + pipeline + const indexedKeyIds = await this.client.smembers('apikey:idx:all') + if (indexedKeyIds && indexedKeyIds.length > 0) { + return this._extractTagsFromKeyIds(indexedKeyIds) + } + } + + // 方案3:回退到 SCAN(索引未就绪或重建中) + return this._scanTagsFallback() + } + + /** + * 检查索引是否就绪 + */ + async _checkIndexReady() { + try { + const version = await this.client.get('apikey:index:version') + // 版本号 >= 2 表示索引就绪 + return parseInt(version) >= 2 + } catch { + return false + } + } + + async _extractTagsFromKeyIds(keyIds) { + const tagSet = new Set() + const pipeline = this.client.pipeline() + for (const keyId of keyIds) { + pipeline.hmget(`apikey:${keyId}`, 'tags', 'isDeleted') + } + + const results = await pipeline.exec() + if (!results) { + return [] + } + + for (const result of results) { + if (!result) { + continue + } + const [err, values] = result + if (err || !values) { + continue + } + const [tags, isDeleted] = values + if (isDeleted === 'true' || !tags) { + continue + } + + try { + const parsed = JSON.parse(tags) + if (Array.isArray(parsed)) { + for (const tag of parsed) { + if (tag && typeof tag === 'string' && tag.trim()) { + tagSet.add(tag.trim()) + } + } + } + } catch { + // 忽略解析错误 + } + } + return Array.from(tagSet).sort() + } + + async _scanTagsFallback() { + const tagSet = new Set() + let cursor = '0' + + do { + const [newCursor, keys] = await this.client.scan(cursor, 'MATCH', 'apikey:*', 'COUNT', 100) + cursor = newCursor + + const validKeys = keys.filter((k) => k !== 'apikey:hash_map' && k.split(':').length === 2) + if (validKeys.length === 0) { + continue + } + + const pipeline = this.client.pipeline() + for (const key of validKeys) { + pipeline.hmget(key, 'tags', 'isDeleted') + } + + const results = await pipeline.exec() + if (!results) { + continue + } + + for (const result of results) { + if (!result) { + continue + } + const [err, values] = result + if (err || !values) { + continue + } + const [tags, isDeleted] = values + if (isDeleted === 'true' || !tags) { + continue + } + + try { + const parsed = JSON.parse(tags) + if (Array.isArray(parsed)) { + for (const tag of parsed) { + if (tag && typeof tag === 'string' && tag.trim()) { + tagSet.add(tag.trim()) + } + } + } + } catch { + // 忽略解析错误 + } + } + } while (cursor !== '0') + + return Array.from(tagSet).sort() + } + + /** + * 批量获取 API Key 数据(使用 Pipeline 优化) + * @param {string[]} keyIds - API Key ID 列表 + * @returns {Promise} API Key 数据列表 + */ + async batchGetApiKeys(keyIds) { + if (!keyIds || keyIds.length === 0) { + return [] + } + + const pipeline = this.client.pipeline() + for (const keyId of keyIds) { + pipeline.hgetall(`apikey:${keyId}`) + } + + const results = await pipeline.exec() + const apiKeys = [] + + for (let i = 0; i < results.length; i++) { + const [err, data] = results[i] + if (!err && data && Object.keys(data).length > 0) { + apiKeys.push({ id: keyIds[i], ...this._parseApiKeyData(data) }) + } + } + + return apiKeys + } + + /** + * 解析 API Key 数据,将字符串转换为正确的类型 + * @param {Object} data - 原始数据 + * @returns {Object} 解析后的数据 + */ + _parseApiKeyData(data) { + if (!data) { + return data + } + + const parsed = { ...data } + + // 布尔字段 + const boolFields = [ + 'isActive', + 'enableModelRestriction', + 'enableClientRestriction', + 'enableOpenAIResponsesCodexAdaptation', + 'enableOpenAIResponsesPayloadRules', + 'isDeleted' + ] + for (const field of boolFields) { + if (parsed[field] !== undefined) { + parsed[field] = parsed[field] === 'true' + } + } + + if (parsed.enableOpenAIResponsesCodexAdaptation === undefined) { + parsed.enableOpenAIResponsesCodexAdaptation = true + } + if (parsed.enableOpenAIResponsesPayloadRules === undefined) { + parsed.enableOpenAIResponsesPayloadRules = false + } + + // 数字字段 + const numFields = [ + 'tokenLimit', + 'dailyCostLimit', + 'totalCostLimit', + 'rateLimitRequests', + 'rateLimitTokens', + 'rateLimitWindow', + 'rateLimitCost', + 'maxConcurrency', + 'activationDuration' + ] + for (const field of numFields) { + if (parsed[field] !== undefined && parsed[field] !== '') { + parsed[field] = parseFloat(parsed[field]) || 0 + } + } + + // 数组字段(JSON 解析) + const arrayFields = [ + 'tags', + 'restrictedModels', + 'allowedClients', + 'openaiResponsesPayloadRules' + ] + for (const field of arrayFields) { + if (parsed[field]) { + try { + parsed[field] = JSON.parse(parsed[field]) + } catch (e) { + parsed[field] = [] + } + } + } + + if (!Array.isArray(parsed.openaiResponsesPayloadRules)) { + parsed.openaiResponsesPayloadRules = [] + } + + // 对象字段(JSON 解析) + const objectFields = ['serviceRates'] + for (const field of objectFields) { + if (parsed[field]) { + try { + parsed[field] = JSON.parse(parsed[field]) + } catch (e) { + parsed[field] = {} + } + } + } + + return parsed + } + + /** + * 获取 API Keys 分页数据(不含费用,用于优化列表加载) + * @param {Object} options - 分页和筛选选项 + * @returns {Promise<{items: Object[], pagination: Object, availableTags: string[]}>} + */ + async getApiKeysPaginated(options = {}) { + const { + page = 1, + pageSize = 20, + searchMode = 'apiKey', + search = '', + tag = '', + isActive = '', + sortBy = 'createdAt', + sortOrder = 'desc', + excludeDeleted = true, // 默认排除已删除的 API Keys + modelFilter = [] + } = options + + // 尝试使用索引查询(性能优化) + const apiKeyIndexService = require('../services/apiKeyIndexService') + const indexReady = await apiKeyIndexService.isIndexReady() + + // 索引路径支持的条件: + // - 无模型筛选(需要查询使用记录) + // - 非 bindingAccount 搜索模式(索引不支持) + // - 非 status/expiresAt 排序(索引不支持) + // - 无搜索关键词(索引只搜 name,旧逻辑搜 name+owner,不一致) + const canUseIndex = + indexReady && + modelFilter.length === 0 && + searchMode !== 'bindingAccount' && + !['status', 'expiresAt'].includes(sortBy) && + !search + + if (canUseIndex) { + // 使用索引查询 + try { + return await apiKeyIndexService.queryWithIndex({ + page, + pageSize, + sortBy, + sortOrder, + isActive: isActive === '' ? undefined : isActive === 'true' || isActive === true, + tag, + excludeDeleted + }) + } catch (error) { + logger.warn('⚠️ 索引查询失败,降级到全量扫描:', error.message) + } + } + + // 降级:使用 SCAN 获取所有 apikey:* 的 ID 列表(避免阻塞) + const keyIds = await this.scanApiKeyIds() + + // 2. 使用 Pipeline 批量获取基础数据 + const apiKeys = await this.batchGetApiKeys(keyIds) + + // 3. 应用筛选条件 + let filteredKeys = apiKeys + + // 排除已删除的 API Keys(默认行为) + if (excludeDeleted) { + filteredKeys = filteredKeys.filter((k) => !k.isDeleted) + } + + // 状态筛选 + if (isActive !== '' && isActive !== undefined && isActive !== null) { + const activeValue = isActive === 'true' || isActive === true + filteredKeys = filteredKeys.filter((k) => k.isActive === activeValue) + } + + // 标签筛选 + if (tag) { + filteredKeys = filteredKeys.filter((k) => { + const tags = Array.isArray(k.tags) ? k.tags : [] + return tags.includes(tag) + }) + } + + // 搜索 + if (search) { + const lowerSearch = search.toLowerCase().trim() + if (searchMode === 'apiKey') { + // apiKey 模式:搜索名称和拥有者 + filteredKeys = filteredKeys.filter( + (k) => + (k.name && k.name.toLowerCase().includes(lowerSearch)) || + (k.ownerDisplayName && k.ownerDisplayName.toLowerCase().includes(lowerSearch)) + ) + } else if (searchMode === 'bindingAccount') { + // bindingAccount 模式:直接在Redis层处理,避免路由层加载10000条 + const accountNameCacheService = require('../services/accountNameCacheService') + filteredKeys = accountNameCacheService.searchByBindingAccount(filteredKeys, lowerSearch) + } + } + + // 模型筛选 + if (modelFilter.length > 0) { + const keyIdsWithModels = await this.getKeyIdsWithModels( + filteredKeys.map((k) => k.id), + modelFilter + ) + filteredKeys = filteredKeys.filter((k) => keyIdsWithModels.has(k.id)) + } + + // 4. 排序 + filteredKeys.sort((a, b) => { + // status 排序实际上使用 isActive 字段(API Key 没有 status 字段) + const effectiveSortBy = sortBy === 'status' ? 'isActive' : sortBy + let aVal = a[effectiveSortBy] + let bVal = b[effectiveSortBy] + + // 日期字段转时间戳 + if (['createdAt', 'expiresAt', 'lastUsedAt'].includes(effectiveSortBy)) { + aVal = aVal ? new Date(aVal).getTime() : 0 + bVal = bVal ? new Date(bVal).getTime() : 0 + } + + // 布尔字段转数字 + if (effectiveSortBy === 'isActive') { + aVal = aVal ? 1 : 0 + bVal = bVal ? 1 : 0 + } + + // 字符串字段 + if (sortBy === 'name') { + aVal = (aVal || '').toLowerCase() + bVal = (bVal || '').toLowerCase() + } + + if (aVal < bVal) { + return sortOrder === 'asc' ? -1 : 1 + } + if (aVal > bVal) { + return sortOrder === 'asc' ? 1 : -1 + } + return 0 + }) + + // 5. 收集所有可用标签(在分页之前) + const allTags = new Set() + for (const key of apiKeys) { + const tags = Array.isArray(key.tags) ? key.tags : [] + tags.forEach((t) => allTags.add(t)) + } + const availableTags = [...allTags].sort() + + // 6. 分页 + const total = filteredKeys.length + const totalPages = Math.ceil(total / pageSize) || 1 + const validPage = Math.min(Math.max(1, page), totalPages) + const start = (validPage - 1) * pageSize + const items = filteredKeys.slice(start, start + pageSize) + + return { + items, + pagination: { + page: validPage, + pageSize, + total, + totalPages + }, + availableTags + } + } + + // 🔍 通过哈希值查找API Key(性能优化) + async findApiKeyByHash(hashedKey) { + // 使用反向映射表:hash -> keyId + let keyId = await this.client.hget('apikey:hash_map', hashedKey) + + // 回退:查旧结构 apikey_hash:*(启动回填未完成时兼容) + if (!keyId) { + const oldData = await this.client.hgetall(`apikey_hash:${hashedKey}`) + if (oldData && oldData.id) { + keyId = oldData.id + // 回填到 hash_map + await this.client.hset('apikey:hash_map', hashedKey, keyId) + } + } + + if (!keyId) { + return null + } + + const keyData = await this.client.hgetall(`apikey:${keyId}`) + if (keyData && Object.keys(keyData).length > 0) { + return { id: keyId, ...keyData } + } + + // 如果数据不存在,清理映射表 + await this.client.hdel('apikey:hash_map', hashedKey) + return null + } + + // 📊 使用统计相关操作(支持缓存token统计和模型信息) + // 标准化模型名称,用于统计聚合 + _normalizeModelName(model) { + if (!model || model === 'unknown') { + return model + } + + // 对于Bedrock模型,去掉区域前缀进行统一 + if (model.includes('.anthropic.') || model.includes('.claude')) { + // 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name + // 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等 + let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用) + normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀 + normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等) + return normalized + } + + // 对于其他模型,去掉常见的版本后缀 + return model.replace(/-v\d+:\d+$|:latest$/, '') + } + + async incrementTokenUsage( + keyId, + tokens, + inputTokens = 0, + outputTokens = 0, + cacheCreateTokens = 0, + cacheReadTokens = 0, + model = 'unknown', + ephemeral5mTokens = 0, // 新增:5分钟缓存 tokens + ephemeral1hTokens = 0, // 新增:1小时缓存 tokens + isLongContextRequest = false, // 新增:是否为 1M 上下文请求(超过200k) + realCost = 0, // 真实费用(官方API费用) + ratedCost = 0 // 计费费用(应用倍率后) + ) { + const key = `usage:${keyId}` + const now = new Date() + const today = getDateStringInTimezone(now) + const tzDate = getDateInTimezone(now) + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}` // 新增小时级别 + + const daily = `usage:daily:${keyId}:${today}` + const monthly = `usage:monthly:${keyId}:${currentMonth}` + const hourly = `usage:hourly:${keyId}:${currentHour}` // 新增小时级别key + + // 标准化模型名用于统计聚合 + const normalizedModel = this._normalizeModelName(model) + + // 按模型统计的键 + const modelDaily = `usage:model:daily:${normalizedModel}:${today}` + const modelMonthly = `usage:model:monthly:${normalizedModel}:${currentMonth}` + const modelHourly = `usage:model:hourly:${normalizedModel}:${currentHour}` // 新增模型小时级别 + + // API Key级别的模型统计 + const keyModelDaily = `usage:${keyId}:model:daily:${normalizedModel}:${today}` + const keyModelMonthly = `usage:${keyId}:model:monthly:${normalizedModel}:${currentMonth}` + const keyModelHourly = `usage:${keyId}:model:hourly:${normalizedModel}:${currentHour}` // 新增API Key模型小时级别 + + // 新增:系统级分钟统计 + const minuteTimestamp = Math.floor(now.getTime() / 60000) + const systemMinuteKey = `system:metrics:minute:${minuteTimestamp}` + + // 智能处理输入输出token分配 + const finalInputTokens = inputTokens || 0 + const finalOutputTokens = outputTokens || (finalInputTokens > 0 ? 0 : tokens) + const finalCacheCreateTokens = cacheCreateTokens || 0 + const finalCacheReadTokens = cacheReadTokens || 0 + + // 重新计算真实的总token数(包括缓存token) + const totalTokens = + finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens + // 核心token(不包括缓存)- 用于与历史数据兼容 + const coreTokens = finalInputTokens + finalOutputTokens + + // 使用Pipeline优化性能 + const pipeline = this.client.pipeline() + + // 现有的统计保持不变 + // 核心token统计(保持向后兼容) + pipeline.hincrby(key, 'totalTokens', coreTokens) + pipeline.hincrby(key, 'totalInputTokens', finalInputTokens) + pipeline.hincrby(key, 'totalOutputTokens', finalOutputTokens) + // 缓存token统计(新增) + pipeline.hincrby(key, 'totalCacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(key, 'totalCacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(key, 'totalAllTokens', totalTokens) // 包含所有类型的总token + // 详细缓存类型统计(新增) + pipeline.hincrby(key, 'totalEphemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(key, 'totalEphemeral1hTokens', ephemeral1hTokens) + // 1M 上下文请求统计(新增) + if (isLongContextRequest) { + pipeline.hincrby(key, 'totalLongContextInputTokens', finalInputTokens) + pipeline.hincrby(key, 'totalLongContextOutputTokens', finalOutputTokens) + pipeline.hincrby(key, 'totalLongContextRequests', 1) + } + // 请求计数 + pipeline.hincrby(key, 'totalRequests', 1) + + // 每日统计 + pipeline.hincrby(daily, 'tokens', coreTokens) + pipeline.hincrby(daily, 'inputTokens', finalInputTokens) + pipeline.hincrby(daily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(daily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(daily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(daily, 'allTokens', totalTokens) + pipeline.hincrby(daily, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(daily, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(daily, 'ephemeral1hTokens', ephemeral1hTokens) + // 1M 上下文请求统计 + if (isLongContextRequest) { + pipeline.hincrby(daily, 'longContextInputTokens', finalInputTokens) + pipeline.hincrby(daily, 'longContextOutputTokens', finalOutputTokens) + pipeline.hincrby(daily, 'longContextRequests', 1) + } + + // 每月统计 + pipeline.hincrby(monthly, 'tokens', coreTokens) + pipeline.hincrby(monthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(monthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(monthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(monthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(monthly, 'allTokens', totalTokens) + pipeline.hincrby(monthly, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(monthly, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(monthly, 'ephemeral1hTokens', ephemeral1hTokens) + + // 按模型统计 - 每日 + pipeline.hincrby(modelDaily, 'inputTokens', finalInputTokens) + pipeline.hincrby(modelDaily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(modelDaily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(modelDaily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(modelDaily, 'allTokens', totalTokens) + pipeline.hincrby(modelDaily, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(modelDaily, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(modelDaily, 'ephemeral1hTokens', ephemeral1hTokens) + + // 按模型统计 - 每月 + pipeline.hincrby(modelMonthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(modelMonthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(modelMonthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(modelMonthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(modelMonthly, 'allTokens', totalTokens) + pipeline.hincrby(modelMonthly, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(modelMonthly, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(modelMonthly, 'ephemeral1hTokens', ephemeral1hTokens) + + // API Key级别的模型统计 - 每日 + pipeline.hincrby(keyModelDaily, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelDaily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelDaily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelDaily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelDaily, 'allTokens', totalTokens) + pipeline.hincrby(keyModelDaily, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(keyModelDaily, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(keyModelDaily, 'ephemeral1hTokens', ephemeral1hTokens) + // 费用统计(使用整数存储,单位:微美元,1美元=1000000微美元) + if (realCost > 0) { + pipeline.hincrby(keyModelDaily, 'realCostMicro', Math.round(realCost * 1000000)) + } + if (ratedCost > 0) { + pipeline.hincrby(keyModelDaily, 'ratedCostMicro', Math.round(ratedCost * 1000000)) + } + + // API Key级别的模型统计 - 每月 + pipeline.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelMonthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelMonthly, 'allTokens', totalTokens) + pipeline.hincrby(keyModelMonthly, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(keyModelMonthly, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(keyModelMonthly, 'ephemeral1hTokens', ephemeral1hTokens) + // 费用统计 + if (realCost > 0) { + pipeline.hincrby(keyModelMonthly, 'realCostMicro', Math.round(realCost * 1000000)) + } + if (ratedCost > 0) { + pipeline.hincrby(keyModelMonthly, 'ratedCostMicro', Math.round(ratedCost * 1000000)) + } + + // API Key级别的模型统计 - 所有时间(无 TTL) + const keyModelAlltime = `usage:${keyId}:model:alltime:${normalizedModel}` + pipeline.hincrby(keyModelAlltime, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelAlltime, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelAlltime, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelAlltime, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelAlltime, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(keyModelAlltime, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(keyModelAlltime, 'ephemeral1hTokens', ephemeral1hTokens) + // 费用统计 + if (realCost > 0) { + pipeline.hincrby(keyModelAlltime, 'realCostMicro', Math.round(realCost * 1000000)) + } + if (ratedCost > 0) { + pipeline.hincrby(keyModelAlltime, 'ratedCostMicro', Math.round(ratedCost * 1000000)) + } + + // 小时级别统计 + pipeline.hincrby(hourly, 'tokens', coreTokens) + pipeline.hincrby(hourly, 'inputTokens', finalInputTokens) + pipeline.hincrby(hourly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(hourly, 'allTokens', totalTokens) + pipeline.hincrby(hourly, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(hourly, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(hourly, 'ephemeral1hTokens', ephemeral1hTokens) + + // 按模型统计 - 每小时 + pipeline.hincrby(modelHourly, 'inputTokens', finalInputTokens) + pipeline.hincrby(modelHourly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(modelHourly, 'allTokens', totalTokens) + pipeline.hincrby(modelHourly, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(modelHourly, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(modelHourly, 'ephemeral1hTokens', ephemeral1hTokens) + + // API Key级别的模型统计 - 每小时 + pipeline.hincrby(keyModelHourly, 'inputTokens', finalInputTokens) + pipeline.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(keyModelHourly, 'allTokens', totalTokens) + pipeline.hincrby(keyModelHourly, 'requests', 1) + // 详细缓存类型统计 + pipeline.hincrby(keyModelHourly, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(keyModelHourly, 'ephemeral1hTokens', ephemeral1hTokens) + // 费用统计 + if (realCost > 0) { + pipeline.hincrby(keyModelHourly, 'realCostMicro', Math.round(realCost * 1000000)) + } + if (ratedCost > 0) { + pipeline.hincrby(keyModelHourly, 'ratedCostMicro', Math.round(ratedCost * 1000000)) + } + + // 新增:系统级分钟统计 + pipeline.hincrby(systemMinuteKey, 'requests', 1) + pipeline.hincrby(systemMinuteKey, 'totalTokens', totalTokens) + pipeline.hincrby(systemMinuteKey, 'inputTokens', finalInputTokens) + pipeline.hincrby(systemMinuteKey, 'outputTokens', finalOutputTokens) + pipeline.hincrby(systemMinuteKey, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(systemMinuteKey, 'cacheReadTokens', finalCacheReadTokens) + + // 设置过期时间 + pipeline.expire(daily, 86400 * 32) // 32天过期 + pipeline.expire(monthly, 86400 * 365) // 1年过期 + pipeline.expire(hourly, 86400 * 7) // 小时统计7天过期 + pipeline.expire(modelDaily, 86400 * 32) // 模型每日统计32天过期 + pipeline.expire(modelMonthly, 86400 * 365) // 模型每月统计1年过期 + pipeline.expire(modelHourly, 86400 * 7) // 模型小时统计7天过期 + pipeline.expire(keyModelDaily, 86400 * 32) // API Key模型每日统计32天过期 + pipeline.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期 + pipeline.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期 + + // 系统级分钟统计的过期时间(窗口时间的2倍,默认5分钟) + const configLocal = require('../../config/config') + const metricsWindow = configLocal.system?.metricsWindow || 5 + pipeline.expire(systemMinuteKey, metricsWindow * 60 * 2) + + // 添加索引(用于快速查询,避免 SCAN) + pipeline.sadd(`usage:daily:index:${today}`, keyId) + pipeline.sadd(`usage:hourly:index:${currentHour}`, keyId) + pipeline.sadd(`usage:model:daily:index:${today}`, normalizedModel) + pipeline.sadd(`usage:model:hourly:index:${currentHour}`, normalizedModel) + pipeline.sadd(`usage:model:monthly:index:${currentMonth}`, normalizedModel) + pipeline.sadd('usage:model:monthly:months', currentMonth) // 全局月份索引 + pipeline.sadd(`usage:keymodel:daily:index:${today}`, `${keyId}:${normalizedModel}`) + pipeline.sadd(`usage:keymodel:hourly:index:${currentHour}`, `${keyId}:${normalizedModel}`) + // 清理空标记(有新数据时) + pipeline.del(`usage:daily:index:${today}:empty`) + pipeline.del(`usage:hourly:index:${currentHour}:empty`) + pipeline.del(`usage:model:daily:index:${today}:empty`) + pipeline.del(`usage:model:hourly:index:${currentHour}:empty`) + pipeline.del(`usage:model:monthly:index:${currentMonth}:empty`) + pipeline.del(`usage:keymodel:daily:index:${today}:empty`) + pipeline.del(`usage:keymodel:hourly:index:${currentHour}:empty`) + // 索引过期时间 + pipeline.expire(`usage:daily:index:${today}`, 86400 * 32) + pipeline.expire(`usage:hourly:index:${currentHour}`, 86400 * 7) + pipeline.expire(`usage:model:daily:index:${today}`, 86400 * 32) + pipeline.expire(`usage:model:hourly:index:${currentHour}`, 86400 * 7) + pipeline.expire(`usage:model:monthly:index:${currentMonth}`, 86400 * 365) + pipeline.expire(`usage:keymodel:daily:index:${today}`, 86400 * 32) + pipeline.expire(`usage:keymodel:hourly:index:${currentHour}`, 86400 * 7) + + // 全局预聚合统计 + const globalDaily = `usage:global:daily:${today}` + const globalMonthly = `usage:global:monthly:${currentMonth}` + pipeline.hincrby('usage:global:total', 'requests', 1) + pipeline.hincrby('usage:global:total', 'inputTokens', finalInputTokens) + pipeline.hincrby('usage:global:total', 'outputTokens', finalOutputTokens) + pipeline.hincrby('usage:global:total', 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby('usage:global:total', 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby('usage:global:total', 'allTokens', totalTokens) + pipeline.hincrby('usage:global:total', 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby('usage:global:total', 'ephemeral1hTokens', ephemeral1hTokens) + pipeline.hincrby(globalDaily, 'requests', 1) + pipeline.hincrby(globalDaily, 'inputTokens', finalInputTokens) + pipeline.hincrby(globalDaily, 'outputTokens', finalOutputTokens) + pipeline.hincrby(globalDaily, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(globalDaily, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(globalDaily, 'allTokens', totalTokens) + pipeline.hincrby(globalDaily, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(globalDaily, 'ephemeral1hTokens', ephemeral1hTokens) + pipeline.hincrby(globalMonthly, 'requests', 1) + pipeline.hincrby(globalMonthly, 'inputTokens', finalInputTokens) + pipeline.hincrby(globalMonthly, 'outputTokens', finalOutputTokens) + pipeline.hincrby(globalMonthly, 'cacheCreateTokens', finalCacheCreateTokens) + pipeline.hincrby(globalMonthly, 'cacheReadTokens', finalCacheReadTokens) + pipeline.hincrby(globalMonthly, 'allTokens', totalTokens) + pipeline.hincrby(globalMonthly, 'ephemeral5mTokens', ephemeral5mTokens) + pipeline.hincrby(globalMonthly, 'ephemeral1hTokens', ephemeral1hTokens) + pipeline.expire(globalDaily, 86400 * 32) + pipeline.expire(globalMonthly, 86400 * 365) + + // 执行Pipeline + await pipeline.exec() + } + + // 📊 记录账户级别的使用统计 + async incrementAccountUsage( + accountId, + totalTokens, + inputTokens = 0, + outputTokens = 0, + cacheCreateTokens = 0, + cacheReadTokens = 0, + ephemeral5mTokens = 0, + ephemeral1hTokens = 0, + model = 'unknown', + isLongContextRequest = false + ) { + const now = new Date() + const today = getDateStringInTimezone(now) + const tzDate = getDateInTimezone(now) + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}` + + // 账户级别统计的键 + const accountKey = `account_usage:${accountId}` + const accountDaily = `account_usage:daily:${accountId}:${today}` + const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}` + const accountHourly = `account_usage:hourly:${accountId}:${currentHour}` + + // 标准化模型名用于统计聚合 + const normalizedModel = this._normalizeModelName(model) + + // 账户按模型统计的键 + const accountModelDaily = `account_usage:model:daily:${accountId}:${normalizedModel}:${today}` + const accountModelMonthly = `account_usage:model:monthly:${accountId}:${normalizedModel}:${currentMonth}` + const accountModelHourly = `account_usage:model:hourly:${accountId}:${normalizedModel}:${currentHour}` + + // 处理token分配 + const finalInputTokens = inputTokens || 0 + const finalOutputTokens = outputTokens || 0 + const finalCacheCreateTokens = cacheCreateTokens || 0 + const finalCacheReadTokens = cacheReadTokens || 0 + const finalEphemeral5mTokens = ephemeral5mTokens || 0 + const finalEphemeral1hTokens = ephemeral1hTokens || 0 + const actualTotalTokens = + finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens + const coreTokens = finalInputTokens + finalOutputTokens + + // 构建统计操作数组 + const operations = [ + // 账户总体统计 + this.client.hincrby(accountKey, 'totalTokens', coreTokens), + this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens), + this.client.hincrby(accountKey, 'totalOutputTokens', finalOutputTokens), + this.client.hincrby(accountKey, 'totalCacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountKey, 'totalCacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountKey, 'totalEphemeral5mTokens', finalEphemeral5mTokens), + this.client.hincrby(accountKey, 'totalEphemeral1hTokens', finalEphemeral1hTokens), + this.client.hincrby(accountKey, 'totalAllTokens', actualTotalTokens), + this.client.hincrby(accountKey, 'totalRequests', 1), + + // 账户每日统计 + this.client.hincrby(accountDaily, 'tokens', coreTokens), + this.client.hincrby(accountDaily, 'inputTokens', finalInputTokens), + this.client.hincrby(accountDaily, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountDaily, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountDaily, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountDaily, 'ephemeral5mTokens', finalEphemeral5mTokens), + this.client.hincrby(accountDaily, 'ephemeral1hTokens', finalEphemeral1hTokens), + this.client.hincrby(accountDaily, 'allTokens', actualTotalTokens), + this.client.hincrby(accountDaily, 'requests', 1), + + // 账户每月统计 + this.client.hincrby(accountMonthly, 'tokens', coreTokens), + this.client.hincrby(accountMonthly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountMonthly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountMonthly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountMonthly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountMonthly, 'ephemeral5mTokens', finalEphemeral5mTokens), + this.client.hincrby(accountMonthly, 'ephemeral1hTokens', finalEphemeral1hTokens), + this.client.hincrby(accountMonthly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountMonthly, 'requests', 1), + + // 账户每小时统计 + this.client.hincrby(accountHourly, 'tokens', coreTokens), + this.client.hincrby(accountHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountHourly, 'ephemeral5mTokens', finalEphemeral5mTokens), + this.client.hincrby(accountHourly, 'ephemeral1hTokens', finalEphemeral1hTokens), + this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountHourly, 'requests', 1), + + // 添加模型级别的数据到hourly键中,以支持会话窗口的统计 + this.client.hincrby(accountHourly, `model:${normalizedModel}:inputTokens`, finalInputTokens), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:outputTokens`, + finalOutputTokens + ), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:cacheCreateTokens`, + finalCacheCreateTokens + ), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:cacheReadTokens`, + finalCacheReadTokens + ), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:ephemeral5mTokens`, + finalEphemeral5mTokens + ), + this.client.hincrby( + accountHourly, + `model:${normalizedModel}:ephemeral1hTokens`, + finalEphemeral1hTokens + ), + this.client.hincrby(accountHourly, `model:${normalizedModel}:allTokens`, actualTotalTokens), + this.client.hincrby(accountHourly, `model:${normalizedModel}:requests`, 1), + + // 账户按模型统计 - 每日 + this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelDaily, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelDaily, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelDaily, 'ephemeral5mTokens', finalEphemeral5mTokens), + this.client.hincrby(accountModelDaily, 'ephemeral1hTokens', finalEphemeral1hTokens), + this.client.hincrby(accountModelDaily, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelDaily, 'requests', 1), + + // 账户按模型统计 - 每月 + this.client.hincrby(accountModelMonthly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelMonthly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelMonthly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelMonthly, 'ephemeral5mTokens', finalEphemeral5mTokens), + this.client.hincrby(accountModelMonthly, 'ephemeral1hTokens', finalEphemeral1hTokens), + this.client.hincrby(accountModelMonthly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelMonthly, 'requests', 1), + + // 账户按模型统计 - 每小时 + this.client.hincrby(accountModelHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelHourly, 'ephemeral5mTokens', finalEphemeral5mTokens), + this.client.hincrby(accountModelHourly, 'ephemeral1hTokens', finalEphemeral1hTokens), + this.client.hincrby(accountModelHourly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelHourly, 'requests', 1), + + // 设置过期时间 + this.client.expire(accountDaily, 86400 * 32), // 32天过期 + this.client.expire(accountMonthly, 86400 * 365), // 1年过期 + this.client.expire(accountHourly, 86400 * 7), // 7天过期 + this.client.expire(accountModelDaily, 86400 * 32), // 32天过期 + this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期 + this.client.expire(accountModelHourly, 86400 * 7), // 7天过期 + + // 添加索引 + this.client.sadd(`account_usage:hourly:index:${currentHour}`, accountId), + this.client.sadd( + `account_usage:model:hourly:index:${currentHour}`, + `${accountId}:${normalizedModel}` + ), + this.client.expire(`account_usage:hourly:index:${currentHour}`, 86400 * 7), + this.client.expire(`account_usage:model:hourly:index:${currentHour}`, 86400 * 7), + // daily 索引 + this.client.sadd(`account_usage:daily:index:${today}`, accountId), + this.client.sadd( + `account_usage:model:daily:index:${today}`, + `${accountId}:${normalizedModel}` + ), + this.client.expire(`account_usage:daily:index:${today}`, 86400 * 32), + this.client.expire(`account_usage:model:daily:index:${today}`, 86400 * 32), + // 清理空标记 + this.client.del(`account_usage:hourly:index:${currentHour}:empty`), + this.client.del(`account_usage:model:hourly:index:${currentHour}:empty`), + this.client.del(`account_usage:daily:index:${today}:empty`), + this.client.del(`account_usage:model:daily:index:${today}:empty`) + ] + + // 如果是 1M 上下文请求,添加额外的统计 + if (isLongContextRequest) { + operations.push( + this.client.hincrby(accountKey, 'totalLongContextInputTokens', finalInputTokens), + this.client.hincrby(accountKey, 'totalLongContextOutputTokens', finalOutputTokens), + this.client.hincrby(accountKey, 'totalLongContextRequests', 1), + this.client.hincrby(accountDaily, 'longContextInputTokens', finalInputTokens), + this.client.hincrby(accountDaily, 'longContextOutputTokens', finalOutputTokens), + this.client.hincrby(accountDaily, 'longContextRequests', 1) + ) + } + + await Promise.all(operations) + } + + /** + * 获取使用了指定模型的 Key IDs(OR 逻辑) + * 使用 EXISTS + pipeline 批量检查 alltime 键,避免 KEYS 全量扫描 + * 支持分批处理和 fallback 到 SCAN 模式 + */ + async getKeyIdsWithModels(keyIds, models) { + if (!keyIds.length || !models.length) { + return new Set() + } + + const client = this.getClientSafe() + const result = new Set() + const BATCH_SIZE = 1000 + + // 构建所有需要检查的 key + const checkKeys = [] + const keyIdMap = new Map() + + for (const keyId of keyIds) { + for (const model of models) { + const key = `usage:${keyId}:model:alltime:${model}` + checkKeys.push(key) + keyIdMap.set(key, keyId) + } + } + + // 分批 EXISTS 检查(避免单个 pipeline 过大) + for (let i = 0; i < checkKeys.length; i += BATCH_SIZE) { + const batch = checkKeys.slice(i, i + BATCH_SIZE) + const pipeline = client.pipeline() + for (const key of batch) { + pipeline.exists(key) + } + const results = await pipeline.exec() + + for (let j = 0; j < batch.length; j++) { + const [err, exists] = results[j] + if (!err && exists) { + result.add(keyIdMap.get(batch[j])) + } + } + } + + // Fallback: 如果 alltime 键全部不存在,回退到 SCAN 模式 + if (result.size === 0 && keyIds.length > 0) { + // 多抽样检查:抽取最多 3 个 keyId 检查是否有 alltime 数据 + const sampleIndices = new Set() + sampleIndices.add(0) // 始终包含第一个 + if (keyIds.length > 1) { + sampleIndices.add(keyIds.length - 1) + } // 包含最后一个 + if (keyIds.length > 2) { + sampleIndices.add(Math.floor(keyIds.length / 2)) + } // 包含中间一个 + + let hasAnyAlltimeData = false + for (const idx of sampleIndices) { + const samplePattern = `usage:${keyIds[idx]}:model:alltime:*` + const sampleKeys = await this.scanKeys(samplePattern) + if (sampleKeys.length > 0) { + hasAnyAlltimeData = true + break + } + } + + if (!hasAnyAlltimeData) { + // alltime 数据不存在,回退到旧扫描逻辑 + logger.warn('⚠️ alltime 模型数据不存在,回退到 SCAN 模式(建议运行迁移脚本)') + for (const keyId of keyIds) { + for (const model of models) { + const pattern = `usage:${keyId}:model:*:${model}:*` + const keys = await this.scanKeys(pattern) + if (keys.length > 0) { + result.add(keyId) + break + } + } + } + } + } + + return result + } + + /** + * 获取所有被使用过的模型列表 + */ + async getAllUsedModels() { + const client = this.getClientSafe() + const models = new Set() + + // 扫描所有模型使用记录 + const pattern = 'usage:*:model:daily:*' + let cursor = '0' + do { + const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000) + cursor = nextCursor + for (const key of keys) { + // 从 key 中提取模型名: usage:{keyId}:model:daily:{model}:{date} + const match = key.match(/usage:[^:]+:model:daily:([^:]+):/) + if (match) { + models.add(match[1]) + } + } + } while (cursor !== '0') + + return [...models].sort() + } + + async getUsageStats(keyId) { + const totalKey = `usage:${keyId}` + const today = getDateStringInTimezone() + const dailyKey = `usage:daily:${keyId}:${today}` + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const monthlyKey = `usage:monthly:${keyId}:${currentMonth}` + + const [total, daily, monthly] = await Promise.all([ + this.client.hgetall(totalKey), + this.client.hgetall(dailyKey), + this.client.hgetall(monthlyKey) + ]) + + // 获取API Key的创建时间来计算平均值 + const keyData = await this.client.hgetall(`apikey:${keyId}`) + const createdAt = keyData.createdAt ? new Date(keyData.createdAt) : new Date() + const now = new Date() + const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))) + + const totalTokens = parseInt(total.totalTokens) || 0 + const totalRequests = parseInt(total.totalRequests) || 0 + + // 计算平均RPM (requests per minute) 和 TPM (tokens per minute) + const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60) + const avgRPM = totalRequests / totalMinutes + const avgTPM = totalTokens / totalMinutes + + // 处理旧数据兼容性(支持缓存token) + const handleLegacyData = (data) => { + // 优先使用total*字段(存储时使用的字段) + const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0 + const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0 + + // 新增缓存token字段 + const cacheCreateTokens = + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0 + + const totalFromSeparate = inputTokens + outputTokens + // 计算实际的总tokens(包含所有类型) + const actualAllTokens = + allTokens || inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (totalFromSeparate === 0 && tokens > 0) { + // 旧数据:没有输入输出分离 + return { + tokens, // 保持兼容性,但统一使用allTokens + inputTokens: Math.round(tokens * 0.3), // 假设30%为输入 + outputTokens: Math.round(tokens * 0.7), // 假设70%为输出 + cacheCreateTokens: 0, // 旧数据没有缓存token + cacheReadTokens: 0, + allTokens: tokens, // 对于旧数据,allTokens等于tokens + requests + } + } else { + // 新数据或无数据 - 统一使用allTokens作为tokens的值 + return { + tokens: actualAllTokens, // 统一使用allTokens作为总数 + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + allTokens: actualAllTokens, + requests + } + } + } + + const totalData = handleLegacyData(total) + const dailyData = handleLegacyData(daily) + const monthlyData = handleLegacyData(monthly) + + return { + total: totalData, + daily: dailyData, + monthly: monthlyData, + averages: { + rpm: Math.round(avgRPM * 100) / 100, // 保留2位小数 + tpm: Math.round(avgTPM * 100) / 100, + dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100, + dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100 + } + } + } + + async addUsageRecord(keyId, record, maxRecords = 200) { + const listKey = `usage:records:${keyId}` + const client = this.getClientSafe() + + try { + await client + .multi() + .lpush(listKey, JSON.stringify(record)) + .ltrim(listKey, 0, Math.max(0, maxRecords - 1)) + .expire(listKey, 86400 * 90) // 默认保留90天 + .exec() + } catch (error) { + logger.error(`❌ Failed to append usage record for key ${keyId}:`, error) + } + } + + async getUsageRecords(keyId, limit = 50) { + const listKey = `usage:records:${keyId}` + const client = this.getClient() + + if (!client) { + return [] + } + + try { + const rawRecords = await client.lrange(listKey, 0, Math.max(0, limit - 1)) + return rawRecords + .map((entry) => { + try { + return JSON.parse(entry) + } catch (error) { + logger.warn('⚠️ Failed to parse usage record entry:', error) + return null + } + }) + .filter(Boolean) + } catch (error) { + logger.error(`❌ Failed to load usage records for key ${keyId}:`, error) + return [] + } + } + + // 💰 获取当日费用 + async getDailyCost(keyId) { + const today = getDateStringInTimezone() + const costKey = `usage:cost:daily:${keyId}:${today}` + const cost = await this.client.get(costKey) + const result = parseFloat(cost || 0) + logger.debug( + `💰 Getting daily cost for ${keyId}, date: ${today}, key: ${costKey}, value: ${cost}, result: ${result}` + ) + return result + } + + // 💰 增加当日费用(支持倍率成本和真实成本分开记录) + // amount: 倍率后的成本(用于限额校验) + // realAmount: 真实成本(用于对账),如果不传则等于 amount + async incrementDailyCost(keyId, amount, realAmount = null) { + const today = getDateStringInTimezone() + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}` + + const dailyKey = `usage:cost:daily:${keyId}:${today}` + const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}` + const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}` + const totalKey = `usage:cost:total:${keyId}` // 总费用键 - 永不过期,持续累加 + + // 真实成本键(用于对账) + const realTotalKey = `usage:cost:real:total:${keyId}` + const realDailyKey = `usage:cost:real:daily:${keyId}:${today}` + const actualRealAmount = realAmount !== null ? realAmount : amount + + logger.debug( + `💰 Incrementing cost for ${keyId}, rated: $${amount}, real: $${actualRealAmount}, date: ${today}` + ) + + const results = await Promise.all([ + this.client.incrbyfloat(dailyKey, amount), + this.client.incrbyfloat(monthlyKey, amount), + this.client.incrbyfloat(hourlyKey, amount), + this.client.incrbyfloat(totalKey, amount), // 倍率后总费用(用于限额) + this.client.incrbyfloat(realTotalKey, actualRealAmount), // 真实总费用(用于对账) + this.client.incrbyfloat(realDailyKey, actualRealAmount), // 真实每日费用 + // 设置过期时间(注意:totalKey 和 realTotalKey 不设置过期时间,保持永久累计) + this.client.expire(dailyKey, 86400 * 30), // 30天 + this.client.expire(monthlyKey, 86400 * 90), // 90天 + this.client.expire(hourlyKey, 86400 * 7), // 7天 + this.client.expire(realDailyKey, 86400 * 30) // 30天 + ]) + + logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`) + } + + // 💰 获取费用统计(包含倍率成本和真实成本) + async getCostStats(keyId) { + const today = getDateStringInTimezone() + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}` + + const [daily, monthly, hourly, total, realTotal, realDaily] = await Promise.all([ + this.client.get(`usage:cost:daily:${keyId}:${today}`), + this.client.get(`usage:cost:monthly:${keyId}:${currentMonth}`), + this.client.get(`usage:cost:hourly:${keyId}:${currentHour}`), + this.client.get(`usage:cost:total:${keyId}`), + this.client.get(`usage:cost:real:total:${keyId}`), + this.client.get(`usage:cost:real:daily:${keyId}:${today}`) + ]) + + return { + daily: parseFloat(daily || 0), + monthly: parseFloat(monthly || 0), + hourly: parseFloat(hourly || 0), + total: parseFloat(total || 0), + realTotal: parseFloat(realTotal || 0), + realDaily: parseFloat(realDaily || 0) + } + } + + // 💰 获取本周 Opus 费用(支持自定义重置周期) + async getWeeklyOpusCost(keyId, resetDay = 1, resetHour = 0) { + const periodStr = getPeriodString(resetDay, resetHour) + const costKey = `usage:opus:weekly:${keyId}:${periodStr}` + const cost = await this.client.get(costKey) + const result = parseFloat(cost || 0) + logger.debug( + `💰 Getting weekly Opus cost for ${keyId}, period: ${periodStr}, key: ${costKey}, value: ${cost}, result: ${result}` + ) + return result + } + + // 💰 增加本周 Opus 费用(支持倍率成本和真实成本,支持自定义重置周期) + // amount: 倍率后的成本(用于限额校验) + // realAmount: 真实成本(用于对账),如果不传则等于 amount + async incrementWeeklyOpusCost(keyId, amount, realAmount = null, resetDay = 1, resetHour = 0) { + const periodStr = getPeriodString(resetDay, resetHour) + const weeklyKey = `usage:opus:weekly:${keyId}:${periodStr}` + const totalKey = `usage:opus:total:${keyId}` + const realWeeklyKey = `usage:opus:real:weekly:${keyId}:${periodStr}` + const realTotalKey = `usage:opus:real:total:${keyId}` + const actualRealAmount = realAmount !== null ? realAmount : amount + + logger.debug( + `💰 Incrementing weekly Opus cost for ${keyId}, period: ${periodStr}, rated: $${amount}, real: $${actualRealAmount}` + ) + + // 使用 pipeline 批量执行,提高性能 + const pipeline = this.client.pipeline() + pipeline.incrbyfloat(weeklyKey, amount) + pipeline.incrbyfloat(totalKey, amount) + pipeline.incrbyfloat(realWeeklyKey, actualRealAmount) + pipeline.incrbyfloat(realTotalKey, actualRealAmount) + // 设置周费用键的过期时间为 2 周 + pipeline.expire(weeklyKey, 14 * 24 * 3600) + pipeline.expire(realWeeklyKey, 14 * 24 * 3600) + + const results = await pipeline.exec() + logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`) + } + + // 💰 覆盖设置本周 Opus 费用(用于启动回填/迁移,支持自定义周期标识) + async setWeeklyOpusCost(keyId, amount, periodString = null, resetDay = 1, resetHour = 0) { + const currentPeriod = periodString || getPeriodString(resetDay, resetHour) + const weeklyKey = `usage:opus:weekly:${keyId}:${currentPeriod}` + + await this.client.set(weeklyKey, String(amount || 0)) + // 保留 2 周,足够覆盖"当前周期 + 上周期"查看/回填 + await this.client.expire(weeklyKey, 14 * 24 * 3600) + } + + // 💰 计算账户的每日费用(基于模型使用,使用索引集合替代 KEYS) + async getAccountDailyCost(accountId) { + const CostCalculator = require('../utils/costCalculator') + const today = getDateStringInTimezone() + + // 使用索引集合替代 KEYS 命令 + const indexKey = `account_usage:model:daily:index:${today}` + const allEntries = await this.client.smembers(indexKey) + + // 过滤出当前账户的条目(格式:accountId:model) + const accountPrefix = `${accountId}:` + const accountModels = allEntries + .filter((entry) => entry.startsWith(accountPrefix)) + .map((entry) => entry.substring(accountPrefix.length)) + + if (accountModels.length === 0) { + return 0 + } + + // Pipeline 批量获取所有模型数据 + const pipeline = this.client.pipeline() + for (const model of accountModels) { + pipeline.hgetall(`account_usage:model:daily:${accountId}:${model}:${today}`) + } + const results = await pipeline.exec() + + let totalCost = 0 + for (let i = 0; i < accountModels.length; i++) { + const model = accountModels[i] + const [err, modelUsage] = results[i] + + if (!err && modelUsage && (modelUsage.inputTokens || modelUsage.outputTokens)) { + const usage = { + input_tokens: parseInt(modelUsage.inputTokens || 0), + output_tokens: parseInt(modelUsage.outputTokens || 0), + cache_creation_input_tokens: parseInt(modelUsage.cacheCreateTokens || 0), + cache_read_input_tokens: parseInt(modelUsage.cacheReadTokens || 0) + } + + // 添加 cache_creation 子对象以支持精确 ephemeral 定价 + const eph5m = parseInt(modelUsage.ephemeral5mTokens) || 0 + const eph1h = parseInt(modelUsage.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, model) + totalCost += costResult.costs.total + + logger.debug( + `💰 Account ${accountId} daily cost for model ${model}: $${costResult.costs.total}` + ) + } + } + + logger.debug(`💰 Account ${accountId} total daily cost: $${totalCost}`) + return totalCost + } + + // 💰 批量计算多个账户的每日费用 + async batchGetAccountDailyCost(accountIds) { + if (!accountIds || accountIds.length === 0) { + return new Map() + } + + const CostCalculator = require('../utils/costCalculator') + const today = getDateStringInTimezone() + + // 一次获取索引 + const indexKey = `account_usage:model:daily:index:${today}` + const allEntries = await this.client.smembers(indexKey) + + // 按 accountId 分组 + const accountIdSet = new Set(accountIds) + const entriesByAccount = new Map() + for (const entry of allEntries) { + const colonIndex = entry.indexOf(':') + if (colonIndex === -1) { + continue + } + const accountId = entry.substring(0, colonIndex) + const model = entry.substring(colonIndex + 1) + if (accountIdSet.has(accountId)) { + if (!entriesByAccount.has(accountId)) { + entriesByAccount.set(accountId, []) + } + entriesByAccount.get(accountId).push(model) + } + } + + const costMap = new Map(accountIds.map((id) => [id, 0])) + + // 如果索引为空,回退到 KEYS 命令(兼容旧数据) + if (allEntries.length === 0) { + logger.debug('💰 Daily cost index empty, falling back to KEYS for batch cost calculation') + for (const accountId of accountIds) { + try { + const cost = await this.getAccountDailyCostFallback(accountId, today, CostCalculator) + costMap.set(accountId, cost) + } catch { + // 忽略单个账户的错误 + } + } + return costMap + } + + // Pipeline 批量获取所有模型数据 + const pipeline = this.client.pipeline() + const queryOrder = [] + for (const [accountId, models] of entriesByAccount) { + for (const model of models) { + pipeline.hgetall(`account_usage:model:daily:${accountId}:${model}:${today}`) + queryOrder.push({ accountId, model }) + } + } + + if (queryOrder.length === 0) { + return costMap + } + + const results = await pipeline.exec() + + for (let i = 0; i < queryOrder.length; i++) { + const { accountId, model } = queryOrder[i] + const [err, modelUsage] = results[i] + + if (!err && modelUsage && (modelUsage.inputTokens || modelUsage.outputTokens)) { + const usage = { + input_tokens: parseInt(modelUsage.inputTokens || 0), + output_tokens: parseInt(modelUsage.outputTokens || 0), + cache_creation_input_tokens: parseInt(modelUsage.cacheCreateTokens || 0), + cache_read_input_tokens: parseInt(modelUsage.cacheReadTokens || 0) + } + + // 添加 cache_creation 子对象以支持精确 ephemeral 定价 + const eph5m = parseInt(modelUsage.ephemeral5mTokens) || 0 + const eph1h = parseInt(modelUsage.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, model) + costMap.set(accountId, costMap.get(accountId) + costResult.costs.total) + } + } + + return costMap + } + + // 💰 回退方法:计算单个账户的每日费用(使用 scanKeys 替代 keys) + async getAccountDailyCostFallback(accountId, today, CostCalculator) { + const pattern = `account_usage:model:daily:${accountId}:*:${today}` + const modelKeys = await this.scanKeys(pattern) + + if (!modelKeys || modelKeys.length === 0) { + return 0 + } + + let totalCost = 0 + const pipeline = this.client.pipeline() + for (const key of modelKeys) { + pipeline.hgetall(key) + } + const results = await pipeline.exec() + + for (let i = 0; i < modelKeys.length; i++) { + const key = modelKeys[i] + const [err, modelUsage] = results[i] + if (err || !modelUsage) { + continue + } + + const parts = key.split(':') + const model = parts[4] + + if (modelUsage.inputTokens || modelUsage.outputTokens) { + const usage = { + input_tokens: parseInt(modelUsage.inputTokens || 0), + output_tokens: parseInt(modelUsage.outputTokens || 0), + cache_creation_input_tokens: parseInt(modelUsage.cacheCreateTokens || 0), + cache_read_input_tokens: parseInt(modelUsage.cacheReadTokens || 0) + } + + // 添加 cache_creation 子对象以支持精确 ephemeral 定价 + const eph5m = parseInt(modelUsage.ephemeral5mTokens) || 0 + const eph1h = parseInt(modelUsage.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, model) + totalCost += costResult.costs.total + } + } + + return totalCost + } + + // 📊 获取账户使用统计 + async getAccountUsageStats(accountId, accountType = null) { + const accountKey = `account_usage:${accountId}` + const today = getDateStringInTimezone() + const accountDailyKey = `account_usage:daily:${accountId}:${today}` + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}` + + const [total, daily, monthly] = await Promise.all([ + this.client.hgetall(accountKey), + this.client.hgetall(accountDailyKey), + this.client.hgetall(accountMonthlyKey) + ]) + + // 获取账户创建时间来计算平均值 - 支持不同类型的账号 + let accountData = {} + if (accountType === 'droid') { + accountData = await this.client.hgetall(`droid:account:${accountId}`) + } else if (accountType === 'openai') { + accountData = await this.client.hgetall(`openai:account:${accountId}`) + } else if (accountType === 'openai-responses') { + accountData = await this.client.hgetall(`openai_responses_account:${accountId}`) + } else { + // 尝试多个前缀(优先 claude:account:) + accountData = await this.client.hgetall(`claude:account:${accountId}`) + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`claude_account:${accountId}`) + } + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`openai:account:${accountId}`) + } + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`openai_responses_account:${accountId}`) + } + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`openai_account:${accountId}`) + } + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`droid:account:${accountId}`) + } + } + const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date() + const now = new Date() + const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))) + + const totalTokens = parseInt(total.totalTokens) || 0 + const totalRequests = parseInt(total.totalRequests) || 0 + + // 计算平均RPM和TPM + const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60) + const avgRPM = totalRequests / totalMinutes + const avgTPM = totalTokens / totalMinutes + + // 处理账户统计数据 + const handleAccountData = (data) => { + const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0 + const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0 + const cacheCreateTokens = + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0 + + const actualAllTokens = + allTokens || inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + return { + tokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + allTokens: actualAllTokens, + requests + } + } + + const totalData = handleAccountData(total) + const dailyData = handleAccountData(daily) + const monthlyData = handleAccountData(monthly) + + // 获取每日费用(基于模型使用) + const dailyCost = await this.getAccountDailyCost(accountId) + + return { + accountId, + total: totalData, + daily: { + ...dailyData, + cost: dailyCost + }, + monthly: monthlyData, + averages: { + rpm: Math.round(avgRPM * 100) / 100, + tpm: Math.round(avgTPM * 100) / 100, + dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100, + dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100 + } + } + } + + // 📈 获取所有账户的使用统计 + async getAllAccountsUsageStats() { + try { + // 使用 getAllIdsByIndex 获取账户 ID(自动处理索引/SCAN 回退) + const accountIds = await this.getAllIdsByIndex( + 'claude:account:index', + 'claude:account:*', + /^claude:account:(.+)$/ + ) + + if (accountIds.length === 0) { + return [] + } + + const accountStats = [] + + for (const accountId of accountIds) { + const accountKey = `claude:account:${accountId}` + const accountData = await this.client.hgetall(accountKey) + + if (accountData && accountData.name) { + const stats = await this.getAccountUsageStats(accountId) + accountStats.push({ + id: accountId, + name: accountData.name, + email: accountData.email || '', + status: accountData.status || 'unknown', + isActive: accountData.isActive === 'true', + ...stats + }) + } + } + + // 按当日token使用量排序 + accountStats.sort((a, b) => (b.daily.allTokens || 0) - (a.daily.allTokens || 0)) + + return accountStats + } catch (error) { + logger.error('❌ Failed to get all accounts usage stats:', error) + return [] + } + } + + // 🧹 清空所有API Key的使用统计数据(使用 scanKeys + batchDelChunked 优化) + async resetAllUsageStats() { + const client = this.getClientSafe() + const stats = { + deletedKeys: 0, + deletedDailyKeys: 0, + deletedMonthlyKeys: 0, + resetApiKeys: 0 + } + + try { + // 1. 获取所有 API Key ID(使用 scanKeys) + const apiKeyKeys = await this.scanKeys('apikey:*') + const apiKeyIds = apiKeyKeys + .filter((k) => k !== 'apikey:hash_map' && k.split(':').length === 2) + .map((k) => k.replace('apikey:', '')) + + // 2. 批量删除总体使用统计 + const usageKeys = apiKeyIds.map((id) => `usage:${id}`) + stats.deletedKeys = await this.batchDelChunked(usageKeys) + + // 3. 使用 scanKeys 获取并批量删除 daily 统计 + const dailyKeys = await this.scanKeys('usage:daily:*') + stats.deletedDailyKeys = await this.batchDelChunked(dailyKeys) + + // 4. 使用 scanKeys 获取并批量删除 monthly 统计 + const monthlyKeys = await this.scanKeys('usage:monthly:*') + stats.deletedMonthlyKeys = await this.batchDelChunked(monthlyKeys) + + // 5. 批量重置 lastUsedAt(仅对存在的 key 操作,避免重建空 hash) + const BATCH_SIZE = 500 + for (let i = 0; i < apiKeyIds.length; i += BATCH_SIZE) { + const batch = apiKeyIds.slice(i, i + BATCH_SIZE) + const existsPipeline = client.pipeline() + for (const keyId of batch) { + existsPipeline.exists(`apikey:${keyId}`) + } + const existsResults = await existsPipeline.exec() + + const updatePipeline = client.pipeline() + let updateCount = 0 + for (let j = 0; j < batch.length; j++) { + const [err, exists] = existsResults[j] + if (!err && exists) { + updatePipeline.hset(`apikey:${batch[j]}`, 'lastUsedAt', '') + updateCount++ + } + } + if (updateCount > 0) { + await updatePipeline.exec() + stats.resetApiKeys += updateCount + } + } + + // 6. 清理所有 usage 相关键(使用 scanKeys + batchDelChunked) + const allUsageKeys = await this.scanKeys('usage:*') + const additionalDeleted = await this.batchDelChunked(allUsageKeys) + stats.deletedKeys += additionalDeleted + + return stats + } catch (error) { + throw new Error(`Failed to reset usage stats: ${error.message}`) + } + } + + // 🏢 Claude 账户管理 + async setClaudeAccount(accountId, accountData) { + const key = `claude:account:${accountId}` + await this.client.hset(key, accountData) + await this.client.sadd('claude:account:index', accountId) + await this.client.del('claude:account:index:empty') + } + + async getClaudeAccount(accountId) { + const key = `claude:account:${accountId}` + return await this.client.hgetall(key) + } + + async getAllClaudeAccounts() { + const accountIds = await this.getAllIdsByIndex( + 'claude:account:index', + 'claude:account:*', + /^claude:account:(.+)$/ + ) + if (accountIds.length === 0) { + return [] + } + + const keys = accountIds.map((id) => `claude:account:${id}`) + const pipeline = this.client.pipeline() + keys.forEach((key) => pipeline.hgetall(key)) + const results = await pipeline.exec() + + const accounts = [] + results.forEach(([err, accountData], index) => { + if (!err && accountData && Object.keys(accountData).length > 0) { + accounts.push({ id: accountIds[index], ...accountData }) + } + }) + return accounts + } + + async deleteClaudeAccount(accountId) { + const key = `claude:account:${accountId}` + await this.client.srem('claude:account:index', accountId) + return await this.client.del(key) + } + + // 🤖 Droid 账户相关操作 + async setDroidAccount(accountId, accountData) { + const key = `droid:account:${accountId}` + await this.client.hset(key, accountData) + await this.client.sadd('droid:account:index', accountId) + await this.client.del('droid:account:index:empty') + } + + async getDroidAccount(accountId) { + const key = `droid:account:${accountId}` + return await this.client.hgetall(key) + } + + async getAllDroidAccounts() { + const accountIds = await this.getAllIdsByIndex( + 'droid:account:index', + 'droid:account:*', + /^droid:account:(.+)$/ + ) + if (accountIds.length === 0) { + return [] + } + + const keys = accountIds.map((id) => `droid:account:${id}`) + const pipeline = this.client.pipeline() + keys.forEach((key) => pipeline.hgetall(key)) + const results = await pipeline.exec() + + const accounts = [] + results.forEach(([err, accountData], index) => { + if (!err && accountData && Object.keys(accountData).length > 0) { + accounts.push({ id: accountIds[index], ...accountData }) + } + }) + return accounts + } + + async deleteDroidAccount(accountId) { + const key = `droid:account:${accountId}` + // 从索引中移除 + await this.client.srem('droid:account:index', accountId) + return await this.client.del(key) + } + + async setOpenAiAccount(accountId, accountData) { + const key = `openai:account:${accountId}` + await this.client.hset(key, accountData) + await this.client.sadd('openai:account:index', accountId) + await this.client.del('openai:account:index:empty') + } + async getOpenAiAccount(accountId) { + const key = `openai:account:${accountId}` + return await this.client.hgetall(key) + } + async deleteOpenAiAccount(accountId) { + const key = `openai:account:${accountId}` + await this.client.srem('openai:account:index', accountId) + return await this.client.del(key) + } + + async getAllOpenAIAccounts() { + const accountIds = await this.getAllIdsByIndex( + 'openai:account:index', + 'openai:account:*', + /^openai:account:(.+)$/ + ) + if (accountIds.length === 0) { + return [] + } + + const keys = accountIds.map((id) => `openai:account:${id}`) + const pipeline = this.client.pipeline() + keys.forEach((key) => pipeline.hgetall(key)) + const results = await pipeline.exec() + + const accounts = [] + results.forEach(([err, accountData], index) => { + if (!err && accountData && Object.keys(accountData).length > 0) { + accounts.push({ id: accountIds[index], ...accountData }) + } + }) + return accounts + } + + // 🔐 会话管理(用于管理员登录等) + async setSession(sessionId, sessionData, ttl = 86400) { + const key = `session:${sessionId}` + await this.client.hset(key, sessionData) + await this.client.expire(key, ttl) + } + + async getSession(sessionId) { + const key = `session:${sessionId}` + return await this.client.hgetall(key) + } + + async deleteSession(sessionId) { + const key = `session:${sessionId}` + return await this.client.del(key) + } + + // 🗝️ API Key哈希索引管理(兼容旧结构 apikey_hash:* 和新结构 apikey:hash_map) + async setApiKeyHash(hashedKey, keyData, ttl = 0) { + // 写入旧结构(兼容) + const key = `apikey_hash:${hashedKey}` + await this.client.hset(key, keyData) + if (ttl > 0) { + await this.client.expire(key, ttl) + } + // 同时写入新结构 hash_map(认证使用此结构) + if (keyData.id) { + await this.client.hset('apikey:hash_map', hashedKey, keyData.id) + } + } + + async getApiKeyHash(hashedKey) { + const key = `apikey_hash:${hashedKey}` + return await this.client.hgetall(key) + } + + async deleteApiKeyHash(hashedKey) { + // 同时清理旧结构和新结构,确保 Key 轮换/删除后旧 Key 失效 + const oldKey = `apikey_hash:${hashedKey}` + await this.client.del(oldKey) + // 从新的 hash_map 中移除(认证使用此结构) + await this.client.hdel('apikey:hash_map', hashedKey) + } + + // 🔗 OAuth会话管理 + async setOAuthSession(sessionId, sessionData, ttl = 600) { + // 10分钟过期 + const key = `oauth:${sessionId}` + + // 序列化复杂对象,特别是 proxy 配置 + const serializedData = {} + for (const [dataKey, value] of Object.entries(sessionData)) { + if (typeof value === 'object' && value !== null) { + serializedData[dataKey] = JSON.stringify(value) + } else { + serializedData[dataKey] = value + } + } + + await this.client.hset(key, serializedData) + await this.client.expire(key, ttl) + } + + async getOAuthSession(sessionId) { + const key = `oauth:${sessionId}` + const data = await this.client.hgetall(key) + + // hgetall 在 key 不存在或已过期时返回空对象 {},调用方的 if (!session) 检查无法识别 + // 这里显式返回 null,避免过期会话绕过检查后在后续逻辑中崩溃 + if (!data || Object.keys(data).length === 0) { + return null + } + + // 反序列化 proxy 字段 + if (data.proxy) { + try { + data.proxy = JSON.parse(data.proxy) + } catch (error) { + // 如果解析失败,设置为 null + data.proxy = null + } + } + + return data + } + + async deleteOAuthSession(sessionId) { + const key = `oauth:${sessionId}` + return await this.client.del(key) + } + + // 💰 账户余额缓存(API 查询结果) + async setAccountBalance(platform, accountId, balanceData, ttl = 3600) { + const key = `account_balance:${platform}:${accountId}` + + const payload = { + balance: + balanceData && balanceData.balance !== null && balanceData.balance !== undefined + ? String(balanceData.balance) + : '', + currency: balanceData?.currency || 'USD', + lastRefreshAt: balanceData?.lastRefreshAt || new Date().toISOString(), + queryMethod: balanceData?.queryMethod || 'api', + status: balanceData?.status || 'success', + errorMessage: balanceData?.errorMessage || balanceData?.error || '', + rawData: balanceData?.rawData ? JSON.stringify(balanceData.rawData) : '', + quota: balanceData?.quota ? JSON.stringify(balanceData.quota) : '' + } + + await this.client.hset(key, payload) + await this.client.expire(key, ttl) + } + + async getAccountBalance(platform, accountId) { + const key = `account_balance:${platform}:${accountId}` + const [data, ttlSeconds] = await Promise.all([this.client.hgetall(key), this.client.ttl(key)]) + + if (!data || Object.keys(data).length === 0) { + return null + } + + let rawData = null + if (data.rawData) { + try { + rawData = JSON.parse(data.rawData) + } catch (error) { + rawData = null + } + } + + let quota = null + if (data.quota) { + try { + quota = JSON.parse(data.quota) + } catch (error) { + quota = null + } + } + + return { + balance: data.balance ? parseFloat(data.balance) : null, + currency: data.currency || 'USD', + lastRefreshAt: data.lastRefreshAt || null, + queryMethod: data.queryMethod || null, + status: data.status || null, + errorMessage: data.errorMessage || '', + rawData, + quota, + ttlSeconds: Number.isFinite(ttlSeconds) ? ttlSeconds : null + } + } + + // 📊 账户余额缓存(本地统计) + async setLocalBalance(platform, accountId, statisticsData, ttl = 300) { + const key = `account_balance_local:${platform}:${accountId}` + + await this.client.hset(key, { + estimatedBalance: JSON.stringify(statisticsData || {}), + lastCalculated: new Date().toISOString() + }) + await this.client.expire(key, ttl) + } + + async getLocalBalance(platform, accountId) { + const key = `account_balance_local:${platform}:${accountId}` + const data = await this.client.hgetall(key) + + if (!data || !data.estimatedBalance) { + return null + } + + try { + return JSON.parse(data.estimatedBalance) + } catch (error) { + return null + } + } + + async deleteAccountBalance(platform, accountId) { + const key = `account_balance:${platform}:${accountId}` + const localKey = `account_balance_local:${platform}:${accountId}` + await this.client.del(key, localKey) + } + + // 🧩 账户余额脚本配置 + async setBalanceScriptConfig(platform, accountId, scriptConfig) { + const key = `account_balance_script:${platform}:${accountId}` + await this.client.set(key, JSON.stringify(scriptConfig || {})) + } + + async getBalanceScriptConfig(platform, accountId) { + const key = `account_balance_script:${platform}:${accountId}` + const raw = await this.client.get(key) + if (!raw) { + return null + } + try { + return JSON.parse(raw) + } catch (error) { + return null + } + } + + async deleteBalanceScriptConfig(platform, accountId) { + const key = `account_balance_script:${platform}:${accountId}` + return await this.client.del(key) + } + + // 📈 系统统计(使用 scanKeys 替代 keys) + async getSystemStats() { + const keys = await Promise.all([ + this.scanKeys('apikey:*'), + this.scanKeys('claude:account:*'), + this.scanKeys('usage:*') + ]) + + // 过滤 apikey 索引键,只统计实际的 apikey + const apiKeyCount = keys[0].filter( + (k) => k !== 'apikey:hash_map' && k.split(':').length === 2 + ).length + + return { + totalApiKeys: apiKeyCount, + totalClaudeAccounts: keys[1].length, + totalUsageRecords: keys[2].length + } + } + + // 🔍 通过索引获取 key 列表(替代 SCAN) + async getKeysByIndex(indexKey, keyPattern) { + const members = await this.client.smembers(indexKey) + if (!members || members.length === 0) { + return [] + } + return members.map((id) => keyPattern.replace('{id}', id)) + } + + // 🔍 批量通过索引获取数据 + async getDataByIndex(indexKey, keyPattern) { + const keys = await this.getKeysByIndex(indexKey, keyPattern) + if (keys.length === 0) { + return [] + } + return await this.batchHgetallChunked(keys) + } + + // 📊 获取今日系统统计 + async getTodayStats() { + try { + const today = getDateStringInTimezone() + // 优先使用索引查询,回退到 SCAN + let dailyKeys = [] + const indexKey = `usage:daily:index:${today}` + const indexMembers = await this.client.smembers(indexKey) + if (indexMembers && indexMembers.length > 0) { + dailyKeys = indexMembers.map((keyId) => `usage:daily:${keyId}:${today}`) + } else { + // 回退到 SCAN(兼容历史数据) + dailyKeys = await this.scanKeys(`usage:daily:*:${today}`) + } + + let totalRequestsToday = 0 + let totalTokensToday = 0 + let totalInputTokensToday = 0 + let totalOutputTokensToday = 0 + let totalCacheCreateTokensToday = 0 + let totalCacheReadTokensToday = 0 + + // 批量获取所有今日数据,提高性能 + if (dailyKeys.length > 0) { + const results = await this.batchHgetallChunked(dailyKeys) + + for (const dailyData of results) { + if (!dailyData) { + continue + } + + totalRequestsToday += parseInt(dailyData.requests) || 0 + const currentDayTokens = parseInt(dailyData.tokens) || 0 + totalTokensToday += currentDayTokens + + // 处理旧数据兼容性:如果有总token但没有输入输出分离,则使用总token作为输出token + const inputTokens = parseInt(dailyData.inputTokens) || 0 + const outputTokens = parseInt(dailyData.outputTokens) || 0 + const cacheCreateTokens = parseInt(dailyData.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(dailyData.cacheReadTokens) || 0 + const totalTokensFromSeparate = inputTokens + outputTokens + + if (totalTokensFromSeparate === 0 && currentDayTokens > 0) { + // 旧数据:没有输入输出分离,假设70%为输出,30%为输入(基于一般对话比例) + totalOutputTokensToday += Math.round(currentDayTokens * 0.7) + totalInputTokensToday += Math.round(currentDayTokens * 0.3) + } else { + // 新数据:使用实际的输入输出分离 + totalInputTokensToday += inputTokens + totalOutputTokensToday += outputTokens + } + + // 添加cache token统计 + totalCacheCreateTokensToday += cacheCreateTokens + totalCacheReadTokensToday += cacheReadTokens + } + } + + // 获取今日创建的API Key数量(批量优化) + const allApiKeys = await this.scanKeys('apikey:*') + let apiKeysCreatedToday = 0 + + if (allApiKeys.length > 0) { + const pipeline = this.client.pipeline() + allApiKeys.forEach((key) => pipeline.hget(key, 'createdAt')) + const results = await pipeline.exec() + + for (const [error, createdAt] of results) { + if (!error && createdAt && createdAt.startsWith(today)) { + apiKeysCreatedToday++ + } + } + } + + return { + requestsToday: totalRequestsToday, + tokensToday: totalTokensToday, + inputTokensToday: totalInputTokensToday, + outputTokensToday: totalOutputTokensToday, + cacheCreateTokensToday: totalCacheCreateTokensToday, + cacheReadTokensToday: totalCacheReadTokensToday, + apiKeysCreatedToday + } + } catch (error) { + console.error('Error getting today stats:', error) + return { + requestsToday: 0, + tokensToday: 0, + inputTokensToday: 0, + outputTokensToday: 0, + cacheCreateTokensToday: 0, + cacheReadTokensToday: 0, + apiKeysCreatedToday: 0 + } + } + } + + // 📈 获取系统总的平均RPM和TPM + async getSystemAverages() { + try { + const allApiKeys = await this.scanKeys('apikey:*') + let totalRequests = 0 + let totalTokens = 0 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let oldestCreatedAt = new Date() + + // 批量获取所有usage数据和key数据,提高性能 + const usageKeys = allApiKeys.map((key) => `usage:${key.replace('apikey:', '')}`) + const pipeline = this.client.pipeline() + + // 添加所有usage查询 + usageKeys.forEach((key) => pipeline.hgetall(key)) + // 添加所有key数据查询 + allApiKeys.forEach((key) => pipeline.hgetall(key)) + + const results = await pipeline.exec() + const usageResults = results.slice(0, usageKeys.length) + const keyResults = results.slice(usageKeys.length) + + for (let i = 0; i < allApiKeys.length; i++) { + const totalData = usageResults[i][1] || {} + const keyData = keyResults[i][1] || {} + + totalRequests += parseInt(totalData.totalRequests) || 0 + totalTokens += parseInt(totalData.totalTokens) || 0 + totalInputTokens += parseInt(totalData.totalInputTokens) || 0 + totalOutputTokens += parseInt(totalData.totalOutputTokens) || 0 + + const createdAt = keyData.createdAt ? new Date(keyData.createdAt) : new Date() + if (createdAt < oldestCreatedAt) { + oldestCreatedAt = createdAt + } + } + + const now = new Date() + // 保持与个人API Key计算一致的算法:按天计算然后转换为分钟 + const daysSinceOldest = Math.max( + 1, + Math.ceil((now - oldestCreatedAt) / (1000 * 60 * 60 * 24)) + ) + const totalMinutes = daysSinceOldest * 24 * 60 + + return { + systemRPM: Math.round((totalRequests / totalMinutes) * 100) / 100, + systemTPM: Math.round((totalTokens / totalMinutes) * 100) / 100, + totalInputTokens, + totalOutputTokens, + totalTokens + } + } catch (error) { + console.error('Error getting system averages:', error) + return { + systemRPM: 0, + systemTPM: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalTokens: 0 + } + } + } + + // 📊 获取实时系统指标(基于滑动窗口) + async getRealtimeSystemMetrics() { + try { + const configLocal = require('../../config/config') + const windowMinutes = configLocal.system.metricsWindow || 5 + + const now = new Date() + const currentMinute = Math.floor(now.getTime() / 60000) + + // 调试:打印当前时间和分钟时间戳 + logger.debug( + `🔍 Realtime metrics - Current time: ${now.toISOString()}, Minute timestamp: ${currentMinute}` + ) + + // 使用Pipeline批量获取窗口内的所有分钟数据 + const pipeline = this.client.pipeline() + const minuteKeys = [] + for (let i = 0; i < windowMinutes; i++) { + const minuteKey = `system:metrics:minute:${currentMinute - i}` + minuteKeys.push(minuteKey) + pipeline.hgetall(minuteKey) + } + + logger.debug(`🔍 Realtime metrics - Checking keys: ${minuteKeys.join(', ')}`) + + const results = await pipeline.exec() + + // 聚合计算 + let totalRequests = 0 + let totalTokens = 0 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let totalCacheCreateTokens = 0 + let totalCacheReadTokens = 0 + let validDataCount = 0 + + results.forEach(([err, data], index) => { + if (!err && data && Object.keys(data).length > 0) { + validDataCount++ + totalRequests += parseInt(data.requests || 0) + totalTokens += parseInt(data.totalTokens || 0) + totalInputTokens += parseInt(data.inputTokens || 0) + totalOutputTokens += parseInt(data.outputTokens || 0) + totalCacheCreateTokens += parseInt(data.cacheCreateTokens || 0) + totalCacheReadTokens += parseInt(data.cacheReadTokens || 0) + + logger.debug(`🔍 Realtime metrics - Key ${minuteKeys[index]} data:`, { + requests: data.requests, + totalTokens: data.totalTokens + }) + } + }) + + logger.debug( + `🔍 Realtime metrics - Valid data count: ${validDataCount}/${windowMinutes}, Total requests: ${totalRequests}, Total tokens: ${totalTokens}` + ) + + // 计算平均值(每分钟) + const realtimeRPM = + windowMinutes > 0 ? Math.round((totalRequests / windowMinutes) * 100) / 100 : 0 + const realtimeTPM = + windowMinutes > 0 ? Math.round((totalTokens / windowMinutes) * 100) / 100 : 0 + + const result = { + realtimeRPM, + realtimeTPM, + windowMinutes, + totalRequests, + totalTokens, + totalInputTokens, + totalOutputTokens, + totalCacheCreateTokens, + totalCacheReadTokens + } + + logger.debug('🔍 Realtime metrics - Final result:', result) + + return result + } catch (error) { + console.error('Error getting realtime system metrics:', error) + // 如果出错,返回历史平均值作为降级方案 + const historicalMetrics = await this.getSystemAverages() + return { + realtimeRPM: historicalMetrics.systemRPM, + realtimeTPM: historicalMetrics.systemTPM, + windowMinutes: 0, // 标识使用了历史数据 + totalRequests: 0, + totalTokens: historicalMetrics.totalTokens, + totalInputTokens: historicalMetrics.totalInputTokens, + totalOutputTokens: historicalMetrics.totalOutputTokens, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0 + } + } + } + + // 🔗 会话sticky映射管理 + async setSessionAccountMapping(sessionHash, accountId, ttl = null) { + const appConfig = require('../../config/config') + // 从配置读取TTL(小时),转换为秒,默认1小时 + const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlHours || 1) * 60 * 60 + const key = `sticky_session:${sessionHash}` + await this.client.set(key, accountId, 'EX', defaultTTL) + } + + async getSessionAccountMapping(sessionHash) { + const key = `sticky_session:${sessionHash}` + return await this.client.get(key) + } + + // 🚀 智能会话TTL续期:剩余时间少于阈值时自动续期 + async extendSessionAccountMappingTTL(sessionHash) { + const appConfig = require('../../config/config') + const key = `sticky_session:${sessionHash}` + + // 📊 从配置获取参数 + const ttlHours = appConfig.session?.stickyTtlHours || 1 // 小时,默认1小时 + const thresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 // 分钟,默认0(不续期) + + // 如果阈值为0,不执行续期 + if (thresholdMinutes === 0) { + return true + } + + const fullTTL = ttlHours * 60 * 60 // 转换为秒 + const renewalThreshold = thresholdMinutes * 60 // 转换为秒 + + try { + // 获取当前剩余TTL(秒) + const remainingTTL = await this.client.ttl(key) + + // 键不存在或已过期 + if (remainingTTL === -2) { + return false + } + + // 键存在但没有TTL(永不过期,不需要处理) + if (remainingTTL === -1) { + return true + } + + // 🎯 智能续期策略:仅在剩余时间少于阈值时才续期 + if (remainingTTL < renewalThreshold) { + await this.client.expire(key, fullTTL) + logger.debug( + `🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round( + remainingTTL / 60 + )}min, renewed to ${ttlHours}h)` + ) + return true + } + + // 剩余时间充足,无需续期 + logger.debug( + `✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round( + remainingTTL / 60 + )}min)` + ) + return true + } catch (error) { + logger.error('❌ Failed to extend session TTL:', error) + return false + } + } + + async deleteSessionAccountMapping(sessionHash) { + const key = `sticky_session:${sessionHash}` + return await this.client.del(key) + } + + // 🧹 清理过期数据(使用 scanKeys 替代 keys) + async cleanup() { + try { + const patterns = ['usage:daily:*', 'ratelimit:*', 'session:*', 'sticky_session:*', 'oauth:*'] + + for (const pattern of patterns) { + const keys = await this.scanKeys(pattern) + const pipeline = this.client.pipeline() + + for (const key of keys) { + const ttl = await this.client.ttl(key) + if (ttl === -1) { + // 没有设置过期时间的键 + if (key.startsWith('oauth:')) { + pipeline.expire(key, 600) // OAuth会话设置10分钟过期 + } else { + pipeline.expire(key, 86400) // 其他设置1天过期 + } + } + } + + await pipeline.exec() + } + + logger.info('🧹 Redis cleanup completed') + } catch (error) { + logger.error('❌ Redis cleanup failed:', error) + } + } + + // 获取并发配置 + _getConcurrencyConfig() { + const defaults = { + leaseSeconds: 300, + renewIntervalSeconds: 30, + cleanupGraceSeconds: 30 + } + + const configValues = { + ...defaults, + ...(config.concurrency || {}) + } + + const normalizeNumber = (value, fallback, options = {}) => { + const parsed = Number(value) + if (!Number.isFinite(parsed)) { + return fallback + } + + if (options.allowZero && parsed === 0) { + return 0 + } + + if (options.min !== undefined && parsed < options.min) { + return options.min + } + + return parsed + } + + return { + leaseSeconds: normalizeNumber(configValues.leaseSeconds, defaults.leaseSeconds, { + min: 30 + }), + renewIntervalSeconds: normalizeNumber( + configValues.renewIntervalSeconds, + defaults.renewIntervalSeconds, + { + allowZero: true, + min: 0 + } + ), + cleanupGraceSeconds: normalizeNumber( + configValues.cleanupGraceSeconds, + defaults.cleanupGraceSeconds, + { + min: 0 + } + ) + } + } + + // 增加并发计数(基于租约的有序集合) + async incrConcurrency(apiKeyId, requestId, leaseSeconds = null) { + if (!requestId) { + throw new Error('Request ID is required for concurrency tracking') + } + + try { + const { leaseSeconds: defaultLeaseSeconds, cleanupGraceSeconds } = + this._getConcurrencyConfig() + const lease = leaseSeconds || defaultLeaseSeconds + const key = `concurrency:${apiKeyId}` + const now = Date.now() + const expireAt = now + lease * 1000 + const ttl = Math.max((lease + cleanupGraceSeconds) * 1000, 60000) + + const luaScript = ` + local key = KEYS[1] + local member = ARGV[1] + local expireAt = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local ttl = tonumber(ARGV[4]) + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + redis.call('ZADD', key, expireAt, member) + + if ttl > 0 then + redis.call('PEXPIRE', key, ttl) + end + + local count = redis.call('ZCARD', key) + return count + ` + + const count = await this.client.eval(luaScript, 1, key, requestId, expireAt, now, ttl) + logger.database( + `🔢 Incremented concurrency for key ${apiKeyId}: ${count} (request ${requestId})` + ) + return count + } catch (error) { + logger.error('❌ Failed to increment concurrency:', error) + throw error + } + } + + // 刷新并发租约,防止长连接提前过期 + async refreshConcurrencyLease(apiKeyId, requestId, leaseSeconds = null) { + if (!requestId) { + return 0 + } + + try { + const { leaseSeconds: defaultLeaseSeconds, cleanupGraceSeconds } = + this._getConcurrencyConfig() + const lease = leaseSeconds || defaultLeaseSeconds + const key = `concurrency:${apiKeyId}` + const now = Date.now() + const expireAt = now + lease * 1000 + const ttl = Math.max((lease + cleanupGraceSeconds) * 1000, 60000) + + const luaScript = ` + local key = KEYS[1] + local member = ARGV[1] + local expireAt = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local ttl = tonumber(ARGV[4]) + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + + local exists = redis.call('ZSCORE', key, member) + + if exists then + redis.call('ZADD', key, expireAt, member) + if ttl > 0 then + redis.call('PEXPIRE', key, ttl) + end + return 1 + end + + return 0 + ` + + const refreshed = await this.client.eval(luaScript, 1, key, requestId, expireAt, now, ttl) + if (refreshed === 1) { + logger.debug(`🔄 Refreshed concurrency lease for key ${apiKeyId} (request ${requestId})`) + } + return refreshed + } catch (error) { + logger.error('❌ Failed to refresh concurrency lease:', error) + return 0 + } + } + + // 减少并发计数 + async decrConcurrency(apiKeyId, requestId) { + try { + const key = `concurrency:${apiKeyId}` + const now = Date.now() + + const luaScript = ` + local key = KEYS[1] + local member = ARGV[1] + local now = tonumber(ARGV[2]) + + if member then + redis.call('ZREM', key, member) + end + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + + local count = redis.call('ZCARD', key) + if count <= 0 then + redis.call('DEL', key) + return 0 + end + + return count + ` + + const count = await this.client.eval(luaScript, 1, key, requestId || '', now) + logger.database( + `🔢 Decremented concurrency for key ${apiKeyId}: ${count} (request ${requestId || 'n/a'})` + ) + return count + } catch (error) { + logger.error('❌ Failed to decrement concurrency:', error) + throw error + } + } + + // 获取当前并发数 + async getConcurrency(apiKeyId) { + try { + const key = `concurrency:${apiKeyId}` + const now = Date.now() + + const luaScript = ` + local key = KEYS[1] + local now = tonumber(ARGV[1]) + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now) + return redis.call('ZCARD', key) + ` + + const count = await this.client.eval(luaScript, 1, key, now) + return parseInt(count || 0) + } catch (error) { + logger.error('❌ Failed to get concurrency:', error) + return 0 + } + } + + // 🏢 Claude Console 账户并发控制(复用现有并发机制) + // 增加 Console 账户并发计数 + async incrConsoleAccountConcurrency(accountId, requestId, leaseSeconds = null) { + if (!requestId) { + throw new Error('Request ID is required for console account concurrency tracking') + } + // 使用特殊的 key 前缀区分 Console 账户并发 + const compositeKey = `console_account:${accountId}` + return await this.incrConcurrency(compositeKey, requestId, leaseSeconds) + } + + // 刷新 Console 账户并发租约 + async refreshConsoleAccountConcurrencyLease(accountId, requestId, leaseSeconds = null) { + if (!requestId) { + return 0 + } + const compositeKey = `console_account:${accountId}` + return await this.refreshConcurrencyLease(compositeKey, requestId, leaseSeconds) + } + + // 减少 Console 账户并发计数 + async decrConsoleAccountConcurrency(accountId, requestId) { + const compositeKey = `console_account:${accountId}` + return await this.decrConcurrency(compositeKey, requestId) + } + + // 获取 Console 账户当前并发数 + async getConsoleAccountConcurrency(accountId) { + const compositeKey = `console_account:${accountId}` + return await this.getConcurrency(compositeKey) + } + + // 🔧 并发管理方法(用于管理员手动清理) + + /** + * 获取所有并发状态(使用 scanKeys 替代 keys) + * @returns {Promise} 并发状态列表 + */ + async getAllConcurrencyStatus() { + try { + const client = this.getClientSafe() + const keys = await this.scanKeys('concurrency:*') + const now = Date.now() + const results = [] + + for (const key of keys) { + // 跳过已知非 Sorted Set 类型的键 + // - concurrency:queue:stats:* 是 Hash 类型 + // - concurrency:queue:wait_times:* 是 List 类型 + // - concurrency:queue:* (不含stats/wait_times) 是 String 类型 + if ( + key.startsWith('concurrency:queue:stats:') || + key.startsWith('concurrency:queue:wait_times:') || + (key.startsWith('concurrency:queue:') && + !key.includes(':stats:') && + !key.includes(':wait_times:')) + ) { + continue + } + + // 检查键类型,只处理 Sorted Set + const keyType = await client.type(key) + if (keyType !== 'zset') { + logger.debug(`🔢 getAllConcurrencyStatus skipped non-zset key: ${key} (type: ${keyType})`) + continue + } + + // 提取 apiKeyId(去掉 concurrency: 前缀) + const apiKeyId = key.replace('concurrency:', '') + + // 获取所有成员和分数(过期时间) + const members = await client.zrangebyscore(key, now, '+inf', 'WITHSCORES') + + // 解析成员和过期时间 + const activeRequests = [] + for (let i = 0; i < members.length; i += 2) { + const requestId = members[i] + const expireAt = parseInt(members[i + 1]) + const remainingSeconds = Math.max(0, Math.round((expireAt - now) / 1000)) + activeRequests.push({ + requestId, + expireAt: new Date(expireAt).toISOString(), + remainingSeconds + }) + } + + // 获取过期的成员数量 + const expiredCount = await client.zcount(key, '-inf', now) + + results.push({ + apiKeyId, + key, + activeCount: activeRequests.length, + expiredCount, + activeRequests + }) + } + + return results + } catch (error) { + logger.error('❌ Failed to get all concurrency status:', error) + throw error + } + } + + /** + * 获取特定 API Key 的并发状态详情 + * @param {string} apiKeyId - API Key ID + * @returns {Promise} 并发状态详情 + */ + async getConcurrencyStatus(apiKeyId) { + try { + const client = this.getClientSafe() + const key = `concurrency:${apiKeyId}` + const now = Date.now() + + // 检查 key 是否存在 + const exists = await client.exists(key) + if (!exists) { + return { + apiKeyId, + key, + activeCount: 0, + expiredCount: 0, + activeRequests: [], + exists: false + } + } + + // 检查键类型,只处理 Sorted Set + const keyType = await client.type(key) + if (keyType !== 'zset') { + logger.warn( + `⚠️ getConcurrencyStatus: key ${key} has unexpected type: ${keyType}, expected zset` + ) + return { + apiKeyId, + key, + activeCount: 0, + expiredCount: 0, + activeRequests: [], + exists: true, + invalidType: keyType + } + } + + // 获取所有成员和分数 + const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES') + + const activeRequests = [] + const expiredRequests = [] + + for (let i = 0; i < allMembers.length; i += 2) { + const requestId = allMembers[i] + const expireAt = parseInt(allMembers[i + 1]) + const remainingSeconds = Math.round((expireAt - now) / 1000) + + const requestInfo = { + requestId, + expireAt: new Date(expireAt).toISOString(), + remainingSeconds + } + + if (expireAt > now) { + activeRequests.push(requestInfo) + } else { + expiredRequests.push(requestInfo) + } + } + + return { + apiKeyId, + key, + activeCount: activeRequests.length, + expiredCount: expiredRequests.length, + activeRequests, + expiredRequests, + exists: true + } + } catch (error) { + logger.error(`❌ Failed to get concurrency status for ${apiKeyId}:`, error) + throw error + } + } + + /** + * 强制清理特定 API Key 的并发计数(忽略租约) + * @param {string} apiKeyId - API Key ID + * @returns {Promise} 清理结果 + */ + async forceClearConcurrency(apiKeyId) { + try { + const client = this.getClientSafe() + const key = `concurrency:${apiKeyId}` + + // 检查键类型 + const keyType = await client.type(key) + + let beforeCount = 0 + let isLegacy = false + + if (keyType === 'zset') { + // 正常的 zset 键,获取条目数 + beforeCount = await client.zcard(key) + } else if (keyType !== 'none') { + // 非 zset 且非空的遗留键 + isLegacy = true + logger.warn( + `⚠️ forceClearConcurrency: key ${key} has unexpected type: ${keyType}, will be deleted` + ) + } + + // 删除键(无论什么类型) + await client.del(key) + + logger.warn( + `🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries${isLegacy ? ' (legacy key)' : ''}` + ) + + return { + apiKeyId, + key, + clearedCount: beforeCount, + type: keyType, + legacy: isLegacy, + success: true + } + } catch (error) { + logger.error(`❌ Failed to force clear concurrency for ${apiKeyId}:`, error) + throw error + } + } + + /** + * 强制清理所有并发计数(使用 scanKeys 替代 keys) + * @returns {Promise} 清理结果 + */ + async forceClearAllConcurrency() { + try { + const client = this.getClientSafe() + const keys = await this.scanKeys('concurrency:*') + + let totalCleared = 0 + let legacyCleared = 0 + const clearedKeys = [] + + for (const key of keys) { + // 跳过 queue 相关的键(它们有各自的清理逻辑) + if (key.startsWith('concurrency:queue:')) { + continue + } + + // 检查键类型 + const keyType = await client.type(key) + if (keyType === 'zset') { + const count = await client.zcard(key) + await client.del(key) + totalCleared += count + clearedKeys.push({ + key, + clearedCount: count, + type: 'zset' + }) + } else { + // 非 zset 类型的遗留键,直接删除 + await client.del(key) + legacyCleared++ + clearedKeys.push({ + key, + clearedCount: 0, + type: keyType, + legacy: true + }) + } + } + + logger.warn( + `🧹 Force cleared all concurrency: ${clearedKeys.length} keys, ${totalCleared} entries, ${legacyCleared} legacy keys` + ) + + return { + keysCleared: clearedKeys.length, + totalEntriesCleared: totalCleared, + legacyKeysCleared: legacyCleared, + clearedKeys, + success: true + } + } catch (error) { + logger.error('❌ Failed to force clear all concurrency:', error) + throw error + } + } + + /** + * 清理过期的并发条目(不影响活跃请求,使用 scanKeys 替代 keys) + * @param {string} apiKeyId - API Key ID(可选,不传则清理所有) + * @returns {Promise} 清理结果 + */ + async cleanupExpiredConcurrency(apiKeyId = null) { + try { + const client = this.getClientSafe() + const now = Date.now() + let keys + + if (apiKeyId) { + keys = [`concurrency:${apiKeyId}`] + } else { + keys = await this.scanKeys('concurrency:*') + } + + let totalCleaned = 0 + let legacyCleaned = 0 + const cleanedKeys = [] + + for (const key of keys) { + // 跳过 queue 相关的键(它们有各自的清理逻辑) + if (key.startsWith('concurrency:queue:')) { + continue + } + + // 检查键类型 + const keyType = await client.type(key) + if (keyType !== 'zset') { + // 非 zset 类型的遗留键,直接删除 + await client.del(key) + legacyCleaned++ + cleanedKeys.push({ + key, + cleanedCount: 0, + type: keyType, + legacy: true + }) + continue + } + + // 只清理过期的条目 + const cleaned = await client.zremrangebyscore(key, '-inf', now) + if (cleaned > 0) { + totalCleaned += cleaned + cleanedKeys.push({ + key, + cleanedCount: cleaned + }) + } + + // 如果 key 为空,删除它 + const remaining = await client.zcard(key) + if (remaining === 0) { + await client.del(key) + } + } + + logger.info( + `🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys, ${legacyCleaned} legacy keys removed` + ) + + return { + keysProcessed: keys.length, + keysCleaned: cleanedKeys.length, + totalEntriesCleaned: totalCleaned, + legacyKeysRemoved: legacyCleaned, + cleanedKeys, + success: true + } + } catch (error) { + logger.error('❌ Failed to cleanup expired concurrency:', error) + throw error + } + } + + // 🔧 Basic Redis operations wrapper methods for convenience + async get(key) { + const client = this.getClientSafe() + return await client.get(key) + } + + async set(key, value, ...args) { + const client = this.getClientSafe() + return await client.set(key, value, ...args) + } + + async setex(key, ttl, value) { + const client = this.getClientSafe() + return await client.setex(key, ttl, value) + } + + async del(...keys) { + const client = this.getClientSafe() + return await client.del(...keys) + } + + async keys(pattern) { + const client = this.getClientSafe() + return await client.keys(pattern) + } + + // 📊 获取账户会话窗口内的使用统计(包含模型细分) + async getAccountSessionWindowUsage(accountId, windowStart, windowEnd) { + try { + if (!windowStart || !windowEnd) { + return { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0, + totalAllTokens: 0, + totalRequests: 0, + modelUsage: {} + } + } + + const startDate = new Date(windowStart) + const endDate = new Date(windowEnd) + + // 添加日志以调试时间窗口 + logger.debug(`📊 Getting session window usage for account ${accountId}`) + logger.debug(` Window: ${windowStart} to ${windowEnd}`) + logger.debug(` Start UTC: ${startDate.toISOString()}, End UTC: ${endDate.toISOString()}`) + + // 获取窗口内所有可能的小时键 + // 重要:需要使用配置的时区来构建键名,因为数据存储时使用的是配置时区 + const hourlyKeys = [] + const currentHour = new Date(startDate) + currentHour.setMinutes(0) + currentHour.setSeconds(0) + currentHour.setMilliseconds(0) + + while (currentHour <= endDate) { + // 使用时区转换函数来获取正确的日期和小时 + const tzDateStr = getDateStringInTimezone(currentHour) + const tzHour = String(getHourInTimezone(currentHour)).padStart(2, '0') + const key = `account_usage:hourly:${accountId}:${tzDateStr}:${tzHour}` + + logger.debug(` Adding hourly key: ${key}`) + hourlyKeys.push(key) + currentHour.setHours(currentHour.getHours() + 1) + } + + // 批量获取所有小时的数据 + const pipeline = this.client.pipeline() + for (const key of hourlyKeys) { + pipeline.hgetall(key) + } + const results = await pipeline.exec() + + // 聚合所有数据 + let totalInputTokens = 0 + let totalOutputTokens = 0 + let totalCacheCreateTokens = 0 + let totalCacheReadTokens = 0 + let totalAllTokens = 0 + let totalRequests = 0 + const modelUsage = {} + + logger.debug(` Processing ${results.length} hourly results`) + + for (const [error, data] of results) { + if (error || !data || Object.keys(data).length === 0) { + continue + } + + // 处理总计数据 + const hourInputTokens = parseInt(data.inputTokens || 0) + const hourOutputTokens = parseInt(data.outputTokens || 0) + const hourCacheCreateTokens = parseInt(data.cacheCreateTokens || 0) + const hourCacheReadTokens = parseInt(data.cacheReadTokens || 0) + const hourAllTokens = parseInt(data.allTokens || 0) + const hourRequests = parseInt(data.requests || 0) + + totalInputTokens += hourInputTokens + totalOutputTokens += hourOutputTokens + totalCacheCreateTokens += hourCacheCreateTokens + totalCacheReadTokens += hourCacheReadTokens + totalAllTokens += hourAllTokens + totalRequests += hourRequests + + if (hourAllTokens > 0) { + logger.debug(` Hour data: allTokens=${hourAllTokens}, requests=${hourRequests}`) + } + + // 处理每个模型的数据 + for (const [key, value] of Object.entries(data)) { + // 查找模型相关的键(格式: model:{modelName}:{metric}) + if (key.startsWith('model:')) { + const parts = key.split(':') + if (parts.length >= 3) { + const modelName = parts[1] + const metric = parts.slice(2).join(':') + + if (!modelUsage[modelName]) { + modelUsage[modelName] = { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + ephemeral5mTokens: 0, + ephemeral1hTokens: 0, + allTokens: 0, + requests: 0 + } + } + + if (metric === 'inputTokens') { + modelUsage[modelName].inputTokens += parseInt(value || 0) + } else if (metric === 'outputTokens') { + modelUsage[modelName].outputTokens += parseInt(value || 0) + } else if (metric === 'cacheCreateTokens') { + modelUsage[modelName].cacheCreateTokens += parseInt(value || 0) + } else if (metric === 'cacheReadTokens') { + modelUsage[modelName].cacheReadTokens += parseInt(value || 0) + } else if (metric === 'ephemeral5mTokens') { + modelUsage[modelName].ephemeral5mTokens += parseInt(value || 0) + } else if (metric === 'ephemeral1hTokens') { + modelUsage[modelName].ephemeral1hTokens += parseInt(value || 0) + } else if (metric === 'allTokens') { + modelUsage[modelName].allTokens += parseInt(value || 0) + } else if (metric === 'requests') { + modelUsage[modelName].requests += parseInt(value || 0) + } + } + } + } + } + + logger.debug(`📊 Session window usage summary:`) + logger.debug(` Total allTokens: ${totalAllTokens}`) + logger.debug(` Total requests: ${totalRequests}`) + logger.debug(` Input: ${totalInputTokens}, Output: ${totalOutputTokens}`) + logger.debug( + ` Cache Create: ${totalCacheCreateTokens}, Cache Read: ${totalCacheReadTokens}` + ) + + return { + totalInputTokens, + totalOutputTokens, + totalCacheCreateTokens, + totalCacheReadTokens, + totalAllTokens, + totalRequests, + modelUsage + } + } catch (error) { + logger.error(`❌ Failed to get session window usage for account ${accountId}:`, error) + return { + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0, + totalAllTokens: 0, + totalRequests: 0, + modelUsage: {} + } + } + } +} + +const redisClient = new RedisClient() + +// 分布式锁相关方法 +redisClient.setAccountLock = async function (lockKey, lockValue, ttlMs) { + try { + // 使用SET NX PX实现原子性的锁获取 + // ioredis语法: set(key, value, 'PX', milliseconds, 'NX') + const result = await this.client.set(lockKey, lockValue, 'PX', ttlMs, 'NX') + return result === 'OK' + } catch (error) { + logger.error(`Failed to acquire lock ${lockKey}:`, error) + return false + } +} + +redisClient.releaseAccountLock = async function (lockKey, lockValue) { + try { + // 使用Lua脚本确保只有持有锁的进程才能释放锁 + const script = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + ` + // ioredis语法: eval(script, numberOfKeys, key1, key2, ..., arg1, arg2, ...) + const result = await this.client.eval(script, 1, lockKey, lockValue) + return result === 1 + } catch (error) { + logger.error(`Failed to release lock ${lockKey}:`, error) + return false + } +} + +// 导出时区辅助函数 +redisClient.getDateInTimezone = getDateInTimezone +redisClient.getDateStringInTimezone = getDateStringInTimezone +redisClient.getHourInTimezone = getHourInTimezone +redisClient.getWeekStringInTimezone = getWeekStringInTimezone +redisClient.getPeriodString = getPeriodString +redisClient.getNextResetTime = getNextResetTime +redisClient.getPeriodStartDate = getPeriodStartDate + +// ============== 用户消息队列相关方法 ============== + +/** + * 尝试获取用户消息队列锁 + * 使用 Lua 脚本保证原子性 + * @param {string} accountId - 账户ID + * @param {string} requestId - 请求ID + * @param {number} lockTtlMs - 锁 TTL(毫秒) + * @param {number} delayMs - 请求间隔(毫秒) + * @returns {Promise<{acquired: boolean, waitMs: number}>} + * - acquired: 是否成功获取锁 + * - waitMs: 需要等待的毫秒数(-1表示被占用需等待,>=0表示需要延迟的毫秒数) + */ +redisClient.acquireUserMessageLock = async function (accountId, requestId, lockTtlMs, delayMs) { + const lockKey = `user_msg_queue_lock:${accountId}` + const lastTimeKey = `user_msg_queue_last:${accountId}` + + const script = ` + local lockKey = KEYS[1] + local lastTimeKey = KEYS[2] + local requestId = ARGV[1] + local lockTtl = tonumber(ARGV[2]) + local delayMs = tonumber(ARGV[3]) + + -- 检查锁是否空闲 + local currentLock = redis.call('GET', lockKey) + if currentLock == false then + -- 检查是否需要延迟 + local lastTime = redis.call('GET', lastTimeKey) + local now = redis.call('TIME') + local nowMs = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000) + + if lastTime then + local elapsed = nowMs - tonumber(lastTime) + if elapsed < delayMs then + -- 需要等待的毫秒数 + return {0, delayMs - elapsed} + end + end + + -- 获取锁 + redis.call('SET', lockKey, requestId, 'PX', lockTtl) + return {1, 0} + end + + -- 锁被占用,返回等待 + return {0, -1} + ` + + try { + const result = await this.client.eval( + script, + 2, + lockKey, + lastTimeKey, + requestId, + lockTtlMs, + delayMs + ) + return { + acquired: result[0] === 1, + waitMs: result[1] + } + } catch (error) { + logger.error(`Failed to acquire user message lock for account ${accountId}:`, error) + // 返回 redisError 标记,让上层能区分 Redis 故障和正常锁占用 + return { acquired: false, waitMs: -1, redisError: true, errorMessage: error.message } + } +} + +/** + * 释放用户消息队列锁并记录完成时间 + * @param {string} accountId - 账户ID + * @param {string} requestId - 请求ID + * @returns {Promise} 是否成功释放 + */ +redisClient.releaseUserMessageLock = async function (accountId, requestId) { + const lockKey = `user_msg_queue_lock:${accountId}` + const lastTimeKey = `user_msg_queue_last:${accountId}` + + const script = ` + local lockKey = KEYS[1] + local lastTimeKey = KEYS[2] + local requestId = ARGV[1] + + -- 验证锁持有者 + local currentLock = redis.call('GET', lockKey) + if currentLock == requestId then + -- 记录完成时间 + local now = redis.call('TIME') + local nowMs = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000) + redis.call('SET', lastTimeKey, nowMs, 'EX', 60) -- 60秒后过期 + + -- 删除锁 + redis.call('DEL', lockKey) + return 1 + end + return 0 + ` + + try { + const result = await this.client.eval(script, 2, lockKey, lastTimeKey, requestId) + return result === 1 + } catch (error) { + logger.error(`Failed to release user message lock for account ${accountId}:`, error) + return false + } +} + +/** + * 强制释放用户消息队列锁(用于清理孤儿锁) + * @param {string} accountId - 账户ID + * @returns {Promise} 是否成功释放 + */ +redisClient.forceReleaseUserMessageLock = async function (accountId) { + const lockKey = `user_msg_queue_lock:${accountId}` + + try { + await this.client.del(lockKey) + return true + } catch (error) { + logger.error(`Failed to force release user message lock for account ${accountId}:`, error) + return false + } +} + +/** + * 获取用户消息队列统计信息(用于调试) + * @param {string} accountId - 账户ID + * @returns {Promise} 队列统计 + */ +redisClient.getUserMessageQueueStats = async function (accountId) { + const lockKey = `user_msg_queue_lock:${accountId}` + const lastTimeKey = `user_msg_queue_last:${accountId}` + + try { + const [lockHolder, lastTime, lockTtl] = await Promise.all([ + this.client.get(lockKey), + this.client.get(lastTimeKey), + this.client.pttl(lockKey) + ]) + + return { + accountId, + isLocked: !!lockHolder, + lockHolder, + lockTtlMs: lockTtl > 0 ? lockTtl : 0, + lockTtlRaw: lockTtl, // 原始 PTTL 值:>0 有TTL,-1 无过期时间,-2 键不存在 + lastCompletedAt: lastTime ? new Date(parseInt(lastTime)).toISOString() : null + } + } catch (error) { + logger.error(`Failed to get user message queue stats for account ${accountId}:`, error) + return { + accountId, + isLocked: false, + lockHolder: null, + lockTtlMs: 0, + lockTtlRaw: -2, + lastCompletedAt: null + } + } +} + +/** + * 扫描所有用户消息队列锁(用于清理任务) + * @returns {Promise} 账户ID列表 + */ +redisClient.scanUserMessageQueueLocks = async function () { + const accountIds = [] + let cursor = '0' + let iterations = 0 + const MAX_ITERATIONS = 1000 // 防止无限循环 + + try { + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'user_msg_queue_lock:*', + 'COUNT', + 100 + ) + cursor = newCursor + iterations++ + + for (const key of keys) { + const accountId = key.replace('user_msg_queue_lock:', '') + accountIds.push(accountId) + } + + // 防止无限循环 + if (iterations >= MAX_ITERATIONS) { + logger.warn( + `📬 User message queue: SCAN reached max iterations (${MAX_ITERATIONS}), stopping early`, + { foundLocks: accountIds.length } + ) + break + } + } while (cursor !== '0') + + if (accountIds.length > 0) { + logger.debug( + `📬 User message queue: scanned ${accountIds.length} lock(s) in ${iterations} iteration(s)` + ) + } + + return accountIds + } catch (error) { + logger.error('Failed to scan user message queue locks:', error) + return [] + } +} + +// ============================================ +// 🚦 API Key 并发请求排队方法 +// ============================================ + +/** + * 增加排队计数(使用 Lua 脚本确保原子性) + * @param {string} apiKeyId - API Key ID + * @param {number} [timeoutMs=60000] - 排队超时时间(毫秒),用于计算 TTL + * @returns {Promise} 增加后的排队数量 + */ +redisClient.incrConcurrencyQueue = async function (apiKeyId, timeoutMs = 60000) { + const key = `concurrency:queue:${apiKeyId}` + try { + // 使用 Lua 脚本确保 INCR 和 EXPIRE 原子执行,防止进程崩溃导致计数器泄漏 + // TTL = 超时时间 + 缓冲时间(确保键不会在请求还在等待时过期) + const ttlSeconds = Math.ceil(timeoutMs / 1000) + QUEUE_TTL_BUFFER_SECONDS + const script = ` + local count = redis.call('INCR', KEYS[1]) + redis.call('EXPIRE', KEYS[1], ARGV[1]) + return count + ` + const count = await this.client.eval(script, 1, key, String(ttlSeconds)) + logger.database( + `🚦 Incremented queue count for key ${apiKeyId}: ${count} (TTL: ${ttlSeconds}s)` + ) + return parseInt(count) + } catch (error) { + logger.error(`Failed to increment concurrency queue for ${apiKeyId}:`, error) + throw error + } +} + +/** + * 减少排队计数(使用 Lua 脚本确保原子性) + * @param {string} apiKeyId - API Key ID + * @returns {Promise} 减少后的排队数量 + */ +redisClient.decrConcurrencyQueue = async function (apiKeyId) { + const key = `concurrency:queue:${apiKeyId}` + try { + // 使用 Lua 脚本确保 DECR 和 DEL 原子执行,防止进程崩溃导致计数器残留 + const script = ` + local count = redis.call('DECR', KEYS[1]) + if count <= 0 then + redis.call('DEL', KEYS[1]) + return 0 + end + return count + ` + const count = await this.client.eval(script, 1, key) + const result = parseInt(count) + if (result === 0) { + logger.database(`🚦 Queue count for key ${apiKeyId} is 0, removed key`) + } else { + logger.database(`🚦 Decremented queue count for key ${apiKeyId}: ${result}`) + } + return result + } catch (error) { + logger.error(`Failed to decrement concurrency queue for ${apiKeyId}:`, error) + throw error + } +} + +/** + * 获取排队计数 + * @param {string} apiKeyId - API Key ID + * @returns {Promise} 当前排队数量 + */ +redisClient.getConcurrencyQueueCount = async function (apiKeyId) { + const key = `concurrency:queue:${apiKeyId}` + try { + const count = await this.client.get(key) + return parseInt(count || 0) + } catch (error) { + logger.error(`Failed to get concurrency queue count for ${apiKeyId}:`, error) + return 0 + } +} + +/** + * 清空排队计数 + * @param {string} apiKeyId - API Key ID + * @returns {Promise} 是否成功清空 + */ +redisClient.clearConcurrencyQueue = async function (apiKeyId) { + const key = `concurrency:queue:${apiKeyId}` + try { + await this.client.del(key) + logger.database(`🚦 Cleared queue count for key ${apiKeyId}`) + return true + } catch (error) { + logger.error(`Failed to clear concurrency queue for ${apiKeyId}:`, error) + return false + } +} + +/** + * 扫描所有排队计数器 + * @returns {Promise} API Key ID 列表 + */ +redisClient.scanConcurrencyQueueKeys = async function () { + const apiKeyIds = [] + let cursor = '0' + let iterations = 0 + const MAX_ITERATIONS = 1000 + + try { + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'concurrency:queue:*', + 'COUNT', + 100 + ) + cursor = newCursor + iterations++ + + for (const key of keys) { + // 排除统计和等待时间相关的键 + if ( + key.startsWith('concurrency:queue:stats:') || + key.startsWith('concurrency:queue:wait_times:') + ) { + continue + } + const apiKeyId = key.replace('concurrency:queue:', '') + apiKeyIds.push(apiKeyId) + } + + if (iterations >= MAX_ITERATIONS) { + logger.warn( + `🚦 Concurrency queue: SCAN reached max iterations (${MAX_ITERATIONS}), stopping early`, + { foundQueues: apiKeyIds.length } + ) + break + } + } while (cursor !== '0') + + return apiKeyIds + } catch (error) { + logger.error('Failed to scan concurrency queue keys:', error) + return [] + } +} + +/** + * 清理所有排队计数器(用于服务重启) + * @returns {Promise} 清理的计数器数量 + */ +redisClient.clearAllConcurrencyQueues = async function () { + let cleared = 0 + let cursor = '0' + let iterations = 0 + const MAX_ITERATIONS = 1000 + + try { + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'concurrency:queue:*', + 'COUNT', + 100 + ) + cursor = newCursor + iterations++ + + // 只删除排队计数器,保留统计数据 + const queueKeys = keys.filter( + (key) => + !key.startsWith('concurrency:queue:stats:') && + !key.startsWith('concurrency:queue:wait_times:') + ) + + if (queueKeys.length > 0) { + await this.client.del(...queueKeys) + cleared += queueKeys.length + } + + if (iterations >= MAX_ITERATIONS) { + break + } + } while (cursor !== '0') + + if (cleared > 0) { + logger.info(`🚦 Cleared ${cleared} concurrency queue counter(s) on startup`) + } + return cleared + } catch (error) { + logger.error('Failed to clear all concurrency queues:', error) + return 0 + } +} + +/** + * 增加排队统计计数(使用 Lua 脚本确保原子性) + * @param {string} apiKeyId - API Key ID + * @param {string} field - 统计字段 (entered/success/timeout/cancelled) + * @returns {Promise} 增加后的计数 + */ +redisClient.incrConcurrencyQueueStats = async function (apiKeyId, field) { + const key = `concurrency:queue:stats:${apiKeyId}` + try { + // 使用 Lua 脚本确保 HINCRBY 和 EXPIRE 原子执行 + // 防止在两者之间崩溃导致统计键没有 TTL(内存泄漏) + const script = ` + local count = redis.call('HINCRBY', KEYS[1], ARGV[1], 1) + redis.call('EXPIRE', KEYS[1], ARGV[2]) + return count + ` + const count = await this.client.eval(script, 1, key, field, String(QUEUE_STATS_TTL_SECONDS)) + return parseInt(count) + } catch (error) { + logger.error(`Failed to increment queue stats ${field} for ${apiKeyId}:`, error) + return 0 + } +} + +/** + * 获取排队统计 + * @param {string} apiKeyId - API Key ID + * @returns {Promise} 统计数据 + */ +redisClient.getConcurrencyQueueStats = async function (apiKeyId) { + const key = `concurrency:queue:stats:${apiKeyId}` + try { + const stats = await this.client.hgetall(key) + return { + entered: parseInt(stats?.entered || 0), + success: parseInt(stats?.success || 0), + timeout: parseInt(stats?.timeout || 0), + cancelled: parseInt(stats?.cancelled || 0), + socket_changed: parseInt(stats?.socket_changed || 0), + rejected_overload: parseInt(stats?.rejected_overload || 0) + } + } catch (error) { + logger.error(`Failed to get queue stats for ${apiKeyId}:`, error) + return { + entered: 0, + success: 0, + timeout: 0, + cancelled: 0, + socket_changed: 0, + rejected_overload: 0 + } + } +} + +/** + * 记录排队等待时间(按 API Key 分开存储) + * @param {string} apiKeyId - API Key ID + * @param {number} waitTimeMs - 等待时间(毫秒) + * @returns {Promise} + */ +redisClient.recordQueueWaitTime = async function (apiKeyId, waitTimeMs) { + const key = `concurrency:queue:wait_times:${apiKeyId}` + try { + // 使用 Lua 脚本确保原子性,同时设置 TTL 防止内存泄漏 + const script = ` + redis.call('LPUSH', KEYS[1], ARGV[1]) + redis.call('LTRIM', KEYS[1], 0, ARGV[2]) + redis.call('EXPIRE', KEYS[1], ARGV[3]) + return 1 + ` + await this.client.eval( + script, + 1, + key, + waitTimeMs, + WAIT_TIME_SAMPLES_PER_KEY - 1, + WAIT_TIME_TTL_SECONDS + ) + } catch (error) { + logger.error(`Failed to record queue wait time for ${apiKeyId}:`, error) + } +} + +/** + * 记录全局排队等待时间 + * @param {number} waitTimeMs - 等待时间(毫秒) + * @returns {Promise} + */ +redisClient.recordGlobalQueueWaitTime = async function (waitTimeMs) { + const key = 'concurrency:queue:wait_times:global' + try { + // 使用 Lua 脚本确保原子性,同时设置 TTL 防止内存泄漏 + const script = ` + redis.call('LPUSH', KEYS[1], ARGV[1]) + redis.call('LTRIM', KEYS[1], 0, ARGV[2]) + redis.call('EXPIRE', KEYS[1], ARGV[3]) + return 1 + ` + await this.client.eval( + script, + 1, + key, + waitTimeMs, + WAIT_TIME_SAMPLES_GLOBAL - 1, + WAIT_TIME_TTL_SECONDS + ) + } catch (error) { + logger.error('Failed to record global queue wait time:', error) + } +} + +/** + * 获取全局等待时间列表 + * @returns {Promise} 等待时间列表 + */ +redisClient.getGlobalQueueWaitTimes = async function () { + const key = 'concurrency:queue:wait_times:global' + try { + const samples = await this.client.lrange(key, 0, -1) + return samples.map(Number) + } catch (error) { + logger.error('Failed to get global queue wait times:', error) + return [] + } +} + +/** + * 获取指定 API Key 的等待时间列表 + * @param {string} apiKeyId - API Key ID + * @returns {Promise} 等待时间列表 + */ +redisClient.getQueueWaitTimes = async function (apiKeyId) { + const key = `concurrency:queue:wait_times:${apiKeyId}` + try { + const samples = await this.client.lrange(key, 0, -1) + return samples.map(Number) + } catch (error) { + logger.error(`Failed to get queue wait times for ${apiKeyId}:`, error) + return [] + } +} + +/** + * 扫描所有排队统计键 + * @returns {Promise} API Key ID 列表 + */ +redisClient.scanConcurrencyQueueStatsKeys = async function () { + const apiKeyIds = [] + let cursor = '0' + let iterations = 0 + const MAX_ITERATIONS = 1000 + + try { + do { + const [newCursor, keys] = await this.client.scan( + cursor, + 'MATCH', + 'concurrency:queue:stats:*', + 'COUNT', + 100 + ) + cursor = newCursor + iterations++ + + for (const key of keys) { + const apiKeyId = key.replace('concurrency:queue:stats:', '') + apiKeyIds.push(apiKeyId) + } + + if (iterations >= MAX_ITERATIONS) { + break + } + } while (cursor !== '0') + + return apiKeyIds + } catch (error) { + logger.error('Failed to scan concurrency queue stats keys:', error) + return [] + } +} + +// ============================================================================ +// 账户测试历史相关操作 +// ============================================================================ + +const ACCOUNT_TEST_HISTORY_MAX = 5 // 保留最近5次测试记录 +const ACCOUNT_TEST_HISTORY_TTL = 86400 * 30 // 30天过期 +const ACCOUNT_TEST_CONFIG_TTL = 86400 * 365 // 测试配置保留1年(用户通常长期使用) + +/** + * 保存账户测试结果 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 (claude/gemini/openai等) + * @param {Object} testResult - 测试结果对象 + * @param {boolean} testResult.success - 是否成功 + * @param {string} testResult.message - 测试消息/响应 + * @param {number} testResult.latencyMs - 延迟毫秒数 + * @param {string} testResult.error - 错误信息(如有) + * @param {string} testResult.timestamp - 测试时间戳 + */ +redisClient.saveAccountTestResult = async function (accountId, platform, testResult) { + const key = `account:test_history:${platform}:${accountId}` + try { + const record = JSON.stringify({ + ...testResult, + timestamp: testResult.timestamp || new Date().toISOString() + }) + + // 使用 LPUSH + LTRIM 保持最近5条记录 + const client = this.getClientSafe() + await client.lpush(key, record) + await client.ltrim(key, 0, ACCOUNT_TEST_HISTORY_MAX - 1) + await client.expire(key, ACCOUNT_TEST_HISTORY_TTL) + + logger.debug(`📝 Saved test result for ${platform} account ${accountId}`) + } catch (error) { + logger.error(`Failed to save test result for ${accountId}:`, error) + } +} + +/** + * 获取账户测试历史 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} 测试历史记录数组(最新在前) + */ +redisClient.getAccountTestHistory = async function (accountId, platform) { + const key = `account:test_history:${platform}:${accountId}` + try { + const client = this.getClientSafe() + const records = await client.lrange(key, 0, -1) + return records.map((r) => JSON.parse(r)) + } catch (error) { + logger.error(`Failed to get test history for ${accountId}:`, error) + return [] + } +} + +/** + * 获取账户最新测试结果 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} 最新测试结果 + */ +redisClient.getAccountLatestTestResult = async function (accountId, platform) { + const key = `account:test_history:${platform}:${accountId}` + try { + const client = this.getClientSafe() + const record = await client.lindex(key, 0) + return record ? JSON.parse(record) : null + } catch (error) { + logger.error(`Failed to get latest test result for ${accountId}:`, error) + return null + } +} + +/** + * 批量获取多个账户的测试历史 + * @param {Array<{accountId: string, platform: string}>} accounts - 账户列表 + * @returns {Promise} 以 accountId 为 key 的测试历史映射 + */ +redisClient.getAccountsTestHistory = async function (accounts) { + const result = {} + try { + const client = this.getClientSafe() + const pipeline = client.pipeline() + + for (const { accountId, platform } of accounts) { + const key = `account:test_history:${platform}:${accountId}` + pipeline.lrange(key, 0, -1) + } + + const responses = await pipeline.exec() + + accounts.forEach(({ accountId }, index) => { + const [err, records] = responses[index] + if (!err && records) { + result[accountId] = records.map((r) => JSON.parse(r)) + } else { + result[accountId] = [] + } + }) + } catch (error) { + logger.error('Failed to get batch test history:', error) + } + return result +} + +/** + * 保存定时测试配置 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @param {Object} config - 配置对象 + * @param {boolean} config.enabled - 是否启用定时测试 + * @param {string} config.cronExpression - Cron 表达式 (如 "0 8 * * *" 表示每天8点) + * @param {string} config.model - 测试使用的模型 + */ +redisClient.saveAccountTestConfig = async function (accountId, platform, testConfig) { + const key = `account:test_config:${platform}:${accountId}` + try { + const client = this.getClientSafe() + await client.hset(key, { + enabled: testConfig.enabled ? 'true' : 'false', + cronExpression: testConfig.cronExpression || '0 8 * * *', // 默认每天早上8点 + model: testConfig.model || 'claude-sonnet-4-5-20250929', // 默认模型 + updatedAt: new Date().toISOString() + }) + // 设置过期时间(1年) + await client.expire(key, ACCOUNT_TEST_CONFIG_TTL) + } catch (error) { + logger.error(`Failed to save test config for ${accountId}:`, error) + } +} + +/** + * 获取定时测试配置 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} 配置对象 + */ +redisClient.getAccountTestConfig = async function (accountId, platform) { + const key = `account:test_config:${platform}:${accountId}` + try { + const client = this.getClientSafe() + const testConfig = await client.hgetall(key) + if (!testConfig || Object.keys(testConfig).length === 0) { + return null + } + // 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式 + let { cronExpression } = testConfig + if (!cronExpression && testConfig.testHour) { + const hour = parseInt(testConfig.testHour, 10) + cronExpression = `0 ${hour} * * *` + } + return { + enabled: testConfig.enabled === 'true', + cronExpression: cronExpression || '0 8 * * *', + model: testConfig.model || 'claude-sonnet-4-5-20250929', + updatedAt: testConfig.updatedAt + } + } catch (error) { + logger.error(`Failed to get test config for ${accountId}:`, error) + return null + } +} + +/** + * 获取所有启用定时测试的账户 + * @param {string} platform - 平台类型 + * @returns {Promise} 账户ID列表及 cron 配置 + */ +redisClient.getEnabledTestAccounts = async function (platform) { + const accountIds = [] + let cursor = '0' + + try { + const client = this.getClientSafe() + do { + const [newCursor, keys] = await client.scan( + cursor, + 'MATCH', + `account:test_config:${platform}:*`, + 'COUNT', + 100 + ) + cursor = newCursor + + for (const key of keys) { + const testConfig = await client.hgetall(key) + if (testConfig && testConfig.enabled === 'true') { + const accountId = key.replace(`account:test_config:${platform}:`, '') + // 向后兼容:如果存在旧的 testHour 字段,转换为 cron 表达式 + let { cronExpression } = testConfig + if (!cronExpression && testConfig.testHour) { + const hour = parseInt(testConfig.testHour, 10) + cronExpression = `0 ${hour} * * *` + } + accountIds.push({ + accountId, + cronExpression: cronExpression || '0 8 * * *', + model: testConfig.model || 'claude-sonnet-4-5-20250929' + }) + } + } + } while (cursor !== '0') + + return accountIds + } catch (error) { + logger.error(`Failed to get enabled test accounts for ${platform}:`, error) + return [] + } +} + +/** + * 保存账户上次测试时间(用于调度器判断是否需要测试) + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + */ +redisClient.setAccountLastTestTime = async function (accountId, platform) { + const key = `account:last_test:${platform}:${accountId}` + try { + const client = this.getClientSafe() + await client.set(key, Date.now().toString(), 'EX', 86400 * 7) // 7天过期 + } catch (error) { + logger.error(`Failed to set last test time for ${accountId}:`, error) + } +} + +/** + * 获取账户上次测试时间 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} 上次测试时间戳 + */ +redisClient.getAccountLastTestTime = async function (accountId, platform) { + const key = `account:last_test:${platform}:${accountId}` + try { + const client = this.getClientSafe() + const timestamp = await client.get(key) + return timestamp ? parseInt(timestamp, 10) : null + } catch (error) { + logger.error(`Failed to get last test time for ${accountId}:`, error) + return null + } +} + +/** + * 使用 SCAN 获取匹配模式的所有 keys(避免 KEYS 命令阻塞 Redis) + * @param {string} pattern - 匹配模式,如 'usage:model:daily:*:2025-01-01' + * @param {number} batchSize - 每次 SCAN 的数量,默认 200 + * @returns {Promise} 匹配的 key 列表 + */ +redisClient.scanKeys = async function (pattern, batchSize = 200) { + const keys = [] + let cursor = '0' + const client = this.getClientSafe() + + do { + const [newCursor, batch] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', batchSize) + cursor = newCursor + keys.push(...batch) + } while (cursor !== '0') + + // 去重(SCAN 可能返回重复 key) + return [...new Set(keys)] +} + +/** + * 批量 HGETALL(使用 Pipeline 减少网络往返) + * @param {string[]} keys - 要获取的 key 列表 + * @returns {Promise} 每个 key 对应的数据,失败的返回 null + */ +redisClient.batchHgetall = async function (keys) { + if (!keys || keys.length === 0) { + return [] + } + + const client = this.getClientSafe() + const pipeline = client.pipeline() + keys.forEach((k) => pipeline.hgetall(k)) + const results = await pipeline.exec() + + return results.map(([err, data]) => (err ? null : data)) +} + +/** + * 使用 SCAN + Pipeline 获取匹配模式的所有数据 + * @param {string} pattern - 匹配模式 + * @param {number} batchSize - SCAN 批次大小 + * @returns {Promise<{key: string, data: Object}[]>} key 和数据的数组 + */ +redisClient.scanAndGetAll = async function (pattern, batchSize = 200) { + const keys = await this.scanKeys(pattern, batchSize) + if (keys.length === 0) { + return [] + } + + const dataList = await this.batchHgetall(keys) + return keys.map((key, i) => ({ key, data: dataList[i] })).filter((item) => item.data !== null) +} + +/** + * 批量获取多个 API Key 的使用统计、费用、并发等数据 + * @param {string[]} keyIds - API Key ID 列表 + * @returns {Promise>} keyId -> 统计数据的映射 + */ +redisClient.batchGetApiKeyStats = async function (keyIds) { + if (!keyIds || keyIds.length === 0) { + return new Map() + } + + const client = this.getClientSafe() + const today = getDateStringInTimezone() + const tzDate = getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentWeek = getWeekStringInTimezone() + const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}` + + const pipeline = client.pipeline() + + // 为每个 keyId 添加所有需要的查询 + for (const keyId of keyIds) { + // usage stats (3 hgetall) + pipeline.hgetall(`usage:${keyId}`) + pipeline.hgetall(`usage:daily:${keyId}:${today}`) + pipeline.hgetall(`usage:monthly:${keyId}:${currentMonth}`) + // cost stats (4 get) + pipeline.get(`usage:cost:daily:${keyId}:${today}`) + pipeline.get(`usage:cost:monthly:${keyId}:${currentMonth}`) + pipeline.get(`usage:cost:hourly:${keyId}:${currentHour}`) + pipeline.get(`usage:cost:total:${keyId}`) + // concurrency (1 zcard) + pipeline.zcard(`concurrency:${keyId}`) + // weekly opus cost (1 get) + pipeline.get(`usage:opus:weekly:${keyId}:${currentWeek}`) + // rate limit (4 get) + pipeline.get(`rate_limit:requests:${keyId}`) + pipeline.get(`rate_limit:tokens:${keyId}`) + pipeline.get(`rate_limit:cost:${keyId}`) + pipeline.get(`rate_limit:window_start:${keyId}`) + // apikey data for createdAt (1 hgetall) + pipeline.hgetall(`apikey:${keyId}`) + } + + const results = await pipeline.exec() + const statsMap = new Map() + const FIELDS_PER_KEY = 14 + + for (let i = 0; i < keyIds.length; i++) { + const keyId = keyIds[i] + const offset = i * FIELDS_PER_KEY + + const [ + [, usageTotal], + [, usageDaily], + [, usageMonthly], + [, costDaily], + [, costMonthly], + [, costHourly], + [, costTotal], + [, concurrency], + [, weeklyOpusCost], + [, rateLimitRequests], + [, rateLimitTokens], + [, rateLimitCost], + [, rateLimitWindowStart], + [, keyData] + ] = results.slice(offset, offset + FIELDS_PER_KEY) + + statsMap.set(keyId, { + usageTotal: usageTotal || {}, + usageDaily: usageDaily || {}, + usageMonthly: usageMonthly || {}, + costStats: { + daily: parseFloat(costDaily || 0), + monthly: parseFloat(costMonthly || 0), + hourly: parseFloat(costHourly || 0), + total: parseFloat(costTotal || 0) + }, + concurrency: concurrency || 0, + dailyCost: parseFloat(costDaily || 0), + weeklyOpusCost: parseFloat(weeklyOpusCost || 0), + rateLimit: { + requests: parseInt(rateLimitRequests || 0), + tokens: parseInt(rateLimitTokens || 0), + cost: parseFloat(rateLimitCost || 0), + windowStart: rateLimitWindowStart ? parseInt(rateLimitWindowStart) : null + }, + createdAt: keyData?.createdAt || null + }) + } + + return statsMap +} + +/** + * 分批 HGETALL(避免单次 pipeline 体积过大导致内存峰值) + * @param {string[]} keys - 要获取的 key 列表 + * @param {number} chunkSize - 每批大小,默认 500 + * @returns {Promise} 每个 key 对应的数据,失败的返回 null + */ +redisClient.batchHgetallChunked = async function (keys, chunkSize = 500) { + if (!keys || keys.length === 0) { + return [] + } + if (keys.length <= chunkSize) { + return this.batchHgetall(keys) + } + + const results = [] + for (let i = 0; i < keys.length; i += chunkSize) { + const chunk = keys.slice(i, i + chunkSize) + const chunkResults = await this.batchHgetall(chunk) + results.push(...chunkResults) + } + return results +} + +/** + * 分批 GET(避免单次 pipeline 体积过大) + * @param {string[]} keys - 要获取的 key 列表 + * @param {number} chunkSize - 每批大小,默认 500 + * @returns {Promise<(string|null)[]>} 每个 key 对应的值 + */ +redisClient.batchGetChunked = async function (keys, chunkSize = 500) { + if (!keys || keys.length === 0) { + return [] + } + + const client = this.getClientSafe() + if (keys.length <= chunkSize) { + const pipeline = client.pipeline() + keys.forEach((k) => pipeline.get(k)) + const results = await pipeline.exec() + return results.map(([err, val]) => (err ? null : val)) + } + + const results = [] + for (let i = 0; i < keys.length; i += chunkSize) { + const chunk = keys.slice(i, i + chunkSize) + const pipeline = client.pipeline() + chunk.forEach((k) => pipeline.get(k)) + const chunkResults = await pipeline.exec() + results.push(...chunkResults.map(([err, val]) => (err ? null : val))) + } + return results +} + +/** + * SCAN + 分批处理(边扫描边处理,避免全量 keys 堆内存) + * @param {string} pattern - 匹配模式 + * @param {Function} processor - 处理函数 (keys: string[], dataList: Object[]) => void + * @param {Object} options - 配置选项 + * @param {number} options.scanBatchSize - SCAN 每次返回数量,默认 200 + * @param {number} options.processBatchSize - 处理批次大小,默认 500 + * @param {string} options.fetchType - 获取类型:'hgetall' | 'get' | 'none',默认 'hgetall' + */ +redisClient.scanAndProcess = async function (pattern, processor, options = {}) { + const { scanBatchSize = 200, processBatchSize = 500, fetchType = 'hgetall' } = options + const client = this.getClientSafe() + + let cursor = '0' + let pendingKeys = [] + const processedKeys = new Set() // 全程去重 + + const processBatch = async (keys) => { + if (keys.length === 0) { + return + } + + // 过滤已处理的 key + const uniqueKeys = keys.filter((k) => !processedKeys.has(k)) + if (uniqueKeys.length === 0) { + return + } + + uniqueKeys.forEach((k) => processedKeys.add(k)) + + let dataList = [] + if (fetchType === 'hgetall') { + dataList = await this.batchHgetall(uniqueKeys) + } else if (fetchType === 'get') { + const pipeline = client.pipeline() + uniqueKeys.forEach((k) => pipeline.get(k)) + const results = await pipeline.exec() + dataList = results.map(([err, val]) => (err ? null : val)) + } else { + dataList = uniqueKeys.map(() => null) // fetchType === 'none' + } + + await processor(uniqueKeys, dataList) + } + + do { + const [newCursor, batch] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanBatchSize) + cursor = newCursor + pendingKeys.push(...batch) + + // 达到处理批次大小时处理 + while (pendingKeys.length >= processBatchSize) { + const toProcess = pendingKeys.slice(0, processBatchSize) + pendingKeys = pendingKeys.slice(processBatchSize) + await processBatch(toProcess) + } + } while (cursor !== '0') + + // 处理剩余的 keys + if (pendingKeys.length > 0) { + await processBatch(pendingKeys) + } +} + +/** + * SCAN + 分批获取所有数据(返回结果,适合需要聚合的场景) + * @param {string} pattern - 匹配模式 + * @param {Object} options - 配置选项 + * @returns {Promise<{key: string, data: Object}[]>} key 和数据的数组 + */ +redisClient.scanAndGetAllChunked = async function (pattern, options = {}) { + const results = [] + await this.scanAndProcess( + pattern, + (keys, dataList) => { + keys.forEach((key, i) => { + if (dataList[i] !== null) { + results.push({ key, data: dataList[i] }) + } + }) + }, + { ...options, fetchType: 'hgetall' } + ) + return results +} + +/** + * 分批删除 keys(避免大量 DEL 阻塞) + * @param {string[]} keys - 要删除的 key 列表 + * @param {number} chunkSize - 每批大小,默认 500 + * @returns {Promise} 删除的 key 数量 + */ +redisClient.batchDelChunked = async function (keys, chunkSize = 500) { + if (!keys || keys.length === 0) { + return 0 + } + + const client = this.getClientSafe() + let deleted = 0 + + for (let i = 0; i < keys.length; i += chunkSize) { + const chunk = keys.slice(i, i + chunkSize) + const pipeline = client.pipeline() + chunk.forEach((k) => pipeline.del(k)) + const results = await pipeline.exec() + deleted += results.filter(([err, val]) => !err && val > 0).length + } + + return deleted +} + +/** + * 通用索引辅助函数:获取所有 ID(优先索引,回退 SCAN) + * @param {string} indexKey - 索引 Set 的 key + * @param {string} scanPattern - SCAN 的 pattern + * @param {RegExp} extractRegex - 从 key 中提取 ID 的正则 + * @returns {Promise} ID 列表 + */ +redisClient.getAllIdsByIndex = async function (indexKey, scanPattern, extractRegex) { + const client = this.getClientSafe() + // 检查是否已标记为空(避免重复 SCAN) + const emptyMarker = await client.get(`${indexKey}:empty`) + if (emptyMarker === '1') { + return [] + } + let ids = await client.smembers(indexKey) + if (ids && ids.length > 0) { + return ids + } + // 回退到 SCAN(仅首次) + const keys = await this.scanKeys(scanPattern) + if (keys.length === 0) { + // 标记为空,避免重复 SCAN(1小时过期,允许新数据写入后重新检测) + await client.setex(`${indexKey}:empty`, 3600, '1') + return [] + } + ids = keys + .map((k) => { + const match = k.match(extractRegex) + return match ? match[1] : null + }) + .filter(Boolean) + // 建立索引 + if (ids.length > 0) { + await client.sadd(indexKey, ...ids) + } + return ids +} + +/** + * 添加到索引 + */ +redisClient.addToIndex = async function (indexKey, id) { + const client = this.getClientSafe() + await client.sadd(indexKey, id) + // 清除空标记(如果存在) + await client.del(`${indexKey}:empty`) +} + +/** + * 从索引移除 + */ +redisClient.removeFromIndex = async function (indexKey, id) { + const client = this.getClientSafe() + await client.srem(indexKey, id) +} + +// ============================================ +// 数据迁移相关 +// ============================================ + +// 迁移全局统计数据(从 API Key 数据聚合) +redisClient.migrateGlobalStats = async function () { + logger.info('🔄 开始迁移全局统计数据...') + + const keyIds = await this.scanApiKeyIds() + if (!keyIds || keyIds.length === 0) { + logger.info('📊 没有 API Key 数据需要迁移') + return { success: true, migrated: 0 } + } + + const total = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0 + } + + // 批量获取所有 usage 数据 + const pipeline = this.client.pipeline() + keyIds.forEach((id) => pipeline.hgetall(`usage:${id}`)) + const results = await pipeline.exec() + + results.forEach(([err, usage]) => { + if (err || !usage) { + return + } + // 兼容新旧字段格式(带 total 前缀和不带的) + total.requests += parseInt(usage.totalRequests || usage.requests) || 0 + total.inputTokens += parseInt(usage.totalInputTokens || usage.inputTokens) || 0 + total.outputTokens += parseInt(usage.totalOutputTokens || usage.outputTokens) || 0 + total.cacheCreateTokens += + parseInt(usage.totalCacheCreateTokens || usage.cacheCreateTokens) || 0 + total.cacheReadTokens += parseInt(usage.totalCacheReadTokens || usage.cacheReadTokens) || 0 + total.allTokens += parseInt(usage.totalAllTokens || usage.allTokens || usage.totalTokens) || 0 + }) + + // 写入全局统计 + await this.client.hset('usage:global:total', total) + + // 迁移月份索引(从现有的 usage:model:monthly:* key 中提取月份) + const monthlyKeys = await this.client.keys('usage:model:monthly:*') + const months = new Set() + for (const key of monthlyKeys) { + const match = key.match(/:(\d{4}-\d{2})$/) + if (match) { + months.add(match[1]) + } + } + if (months.size > 0) { + await this.client.sadd('usage:model:monthly:months', ...months) + logger.info(`📅 迁移月份索引: ${months.size} 个月份 (${[...months].sort().join(', ')})`) + } + + logger.success( + `✅ 迁移完成: ${keyIds.length} 个 API Key, ${total.requests} 请求, ${total.allTokens} tokens` + ) + return { success: true, migrated: keyIds.length, total } +} + +// 确保月份索引完整(后台检查,补充缺失的月份) +redisClient.ensureMonthlyMonthsIndex = async function () { + // 扫描所有月份 key + const monthlyKeys = await this.client.keys('usage:model:monthly:*') + const allMonths = new Set() + for (const key of monthlyKeys) { + const match = key.match(/:(\d{4}-\d{2})$/) + if (match) { + allMonths.add(match[1]) + } + } + + if (allMonths.size === 0) { + return // 没有月份数据 + } + + // 获取索引中已有的月份 + const existingMonths = await this.client.smembers('usage:model:monthly:months') + const existingSet = new Set(existingMonths) + + // 找出缺失的月份 + const missingMonths = [...allMonths].filter((m) => !existingSet.has(m)) + + if (missingMonths.length > 0) { + await this.client.sadd('usage:model:monthly:months', ...missingMonths) + logger.info( + `📅 补充月份索引: ${missingMonths.length} 个月份 (${missingMonths.sort().join(', ')})` + ) + } +} + +// 检查是否需要迁移 +redisClient.needsGlobalStatsMigration = async function () { + const exists = await this.client.exists('usage:global:total') + return exists === 0 +} + +// 获取已迁移版本 +redisClient.getMigratedVersion = async function () { + return (await this.client.get('system:migrated:version')) || '0.0.0' +} + +// 设置已迁移版本 +redisClient.setMigratedVersion = async function (version) { + await this.client.set('system:migrated:version', version) +} + +// 获取全局统计(用于 dashboard 快速查询) +redisClient.getGlobalStats = async function () { + const stats = await this.client.hgetall('usage:global:total') + if (!stats || !stats.requests) { + return null + } + return { + requests: parseInt(stats.requests) || 0, + inputTokens: parseInt(stats.inputTokens) || 0, + outputTokens: parseInt(stats.outputTokens) || 0, + cacheCreateTokens: parseInt(stats.cacheCreateTokens) || 0, + cacheReadTokens: parseInt(stats.cacheReadTokens) || 0, + allTokens: parseInt(stats.allTokens) || 0 + } +} + +// 快速获取 API Key 计数(不拉全量数据) +redisClient.getApiKeyCount = async function () { + const keyIds = await this.scanApiKeyIds() + if (!keyIds || keyIds.length === 0) { + return { total: 0, active: 0 } + } + + // 批量获取 isActive 字段 + const pipeline = this.client.pipeline() + keyIds.forEach((id) => pipeline.hget(`apikey:${id}`, 'isActive')) + const results = await pipeline.exec() + + let active = 0 + results.forEach(([err, val]) => { + if (!err && (val === 'true' || val === true)) { + active++ + } + }) + return { total: keyIds.length, active } +} + +// 清理过期的系统分钟统计数据(启动时调用) +redisClient.cleanupSystemMetrics = async function () { + logger.info('🧹 清理过期的系统分钟统计数据...') + + const keys = await this.scanKeys('system:metrics:minute:*') + if (!keys || keys.length === 0) { + logger.info('📊 没有需要清理的系统分钟统计数据') + return { cleaned: 0 } + } + + // 计算当前分钟时间戳和保留窗口 + const metricsWindow = config.system?.metricsWindow || 5 + const currentMinute = Math.floor(Date.now() / 60000) + const keepAfter = currentMinute - metricsWindow * 2 // 保留窗口的2倍 + + // 筛选需要删除的 key + const toDelete = keys.filter((key) => { + const match = key.match(/system:metrics:minute:(\d+)/) + if (!match) { + return false + } + const minute = parseInt(match[1]) + return minute < keepAfter + }) + + if (toDelete.length === 0) { + logger.info('📊 没有过期的系统分钟统计数据') + return { cleaned: 0 } + } + + // 分批删除 + const batchSize = 1000 + for (let i = 0; i < toDelete.length; i += batchSize) { + const batch = toDelete.slice(i, i + batchSize) + await this.client.del(...batch) + } + + logger.success(`✅ 清理完成: 删除 ${toDelete.length} 个过期的系统分钟统计 key`) + return { cleaned: toDelete.length } +} + +module.exports = redisClient diff --git a/src/routes/admin/accountBalance.js b/src/routes/admin/accountBalance.js new file mode 100644 index 0000000..b23c9a7 --- /dev/null +++ b/src/routes/admin/accountBalance.js @@ -0,0 +1,214 @@ +const express = require('express') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const accountBalanceService = require('../../services/account/accountBalanceService') +const balanceScriptService = require('../../services/balanceScriptService') +const { isBalanceScriptEnabled } = require('../../utils/featureFlags') + +const router = express.Router() + +const ensureValidPlatform = (rawPlatform) => { + const normalized = accountBalanceService.normalizePlatform(rawPlatform) + if (!normalized) { + return { ok: false, status: 400, error: '缺少 platform 参数' } + } + + const supported = accountBalanceService.getSupportedPlatforms() + if (!supported.includes(normalized)) { + return { ok: false, status: 400, error: `不支持的平台: ${normalized}` } + } + + return { ok: true, platform: normalized } +} + +// 1) 获取账户余额(默认本地统计优先,可选触发 Provider) +// GET /admin/accounts/:accountId/balance?platform=xxx&queryApi=false +router.get('/accounts/:accountId/balance', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform, queryApi } = req.query + + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const balance = await accountBalanceService.getAccountBalance(accountId, valid.platform, { + queryApi + }) + + if (!balance) { + return res.status(404).json({ success: false, error: 'Account not found' }) + } + + return res.json(balance) + } catch (error) { + logger.error('获取账户余额失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +// 2) 强制刷新账户余额(强制触发查询:优先脚本;Provider 仅为降级) +// POST /admin/accounts/:accountId/balance/refresh +// Body: { platform: 'xxx' } +router.post('/accounts/:accountId/balance/refresh', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.body || {} + + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + logger.info(`手动刷新余额: ${valid.platform}:${accountId}`) + + const balance = await accountBalanceService.refreshAccountBalance(accountId, valid.platform) + if (!balance) { + return res.status(404).json({ success: false, error: 'Account not found' }) + } + + return res.json(balance) + } catch (error) { + logger.error('刷新账户余额失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +// 3) 批量获取平台所有账户余额 +// GET /admin/accounts/balance/platform/:platform?queryApi=false +router.get('/accounts/balance/platform/:platform', authenticateAdmin, async (req, res) => { + try { + const { platform } = req.params + const { queryApi } = req.query + + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const balances = await accountBalanceService.getAllAccountsBalance(valid.platform, { queryApi }) + + return res.json({ success: true, data: balances }) + } catch (error) { + logger.error('批量获取余额失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +// 4) 获取余额汇总(Dashboard 用) +// GET /admin/accounts/balance/summary +router.get('/accounts/balance/summary', authenticateAdmin, async (req, res) => { + try { + const summary = await accountBalanceService.getBalanceSummary() + return res.json({ success: true, data: summary }) + } catch (error) { + logger.error('获取余额汇总失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +// 5) 清除缓存 +// DELETE /admin/accounts/:accountId/balance/cache?platform=xxx +router.delete('/accounts/:accountId/balance/cache', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.query + + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + await accountBalanceService.clearCache(accountId, valid.platform) + + return res.json({ success: true, message: '缓存已清除' }) + } catch (error) { + logger.error('清除缓存失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +// 6) 获取/保存/测试余额脚本配置(单账户) +router.get('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.query + + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const config = await accountBalanceService.redis.getBalanceScriptConfig( + valid.platform, + accountId + ) + return res.json({ success: true, data: config || null }) + } catch (error) { + logger.error('获取余额脚本配置失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +router.put('/accounts/:accountId/balance/script', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.query + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + const payload = req.body || {} + await accountBalanceService.redis.setBalanceScriptConfig(valid.platform, accountId, payload) + return res.json({ success: true, data: payload }) + } catch (error) { + logger.error('保存余额脚本配置失败', error) + return res.status(500).json({ success: false, error: error.message }) + } +}) + +router.post('/accounts/:accountId/balance/script/test', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform } = req.query + const valid = ensureValidPlatform(platform) + if (!valid.ok) { + return res.status(valid.status).json({ success: false, error: valid.error }) + } + + if (!isBalanceScriptEnabled()) { + return res.status(403).json({ + success: false, + error: '余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)' + }) + } + + const payload = req.body || {} + const { scriptBody } = payload + if (!scriptBody) { + return res.status(400).json({ success: false, error: '脚本内容不能为空' }) + } + + const result = await balanceScriptService.execute({ + scriptBody, + timeoutSeconds: payload.timeoutSeconds || 10, + variables: { + baseUrl: payload.baseUrl || '', + apiKey: payload.apiKey || '', + token: payload.token || '', + accountId, + platform: valid.platform, + extra: payload.extra || '' + } + }) + + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('测试余额脚本失败', error) + return res.status(400).json({ success: false, error: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/accountGroups.js b/src/routes/admin/accountGroups.js new file mode 100644 index 0000000..74616bb --- /dev/null +++ b/src/routes/admin/accountGroups.js @@ -0,0 +1,153 @@ +const express = require('express') +const accountGroupService = require('../../services/accountGroupService') +const claudeAccountService = require('../../services/account/claudeAccountService') +const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService') +const geminiAccountService = require('../../services/account/geminiAccountService') +const openaiAccountService = require('../../services/account/openaiAccountService') +const droidAccountService = require('../../services/account/droidAccountService') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') + +const router = express.Router() + +// 👥 账户分组管理 + +// 创建账户分组 +router.post('/', authenticateAdmin, async (req, res) => { + try { + const { name, platform, description } = req.body + + const group = await accountGroupService.createGroup({ + name, + platform, + description + }) + + return res.json({ success: true, data: group }) + } catch (error) { + logger.error('❌ Failed to create account group:', error) + return res.status(400).json({ error: error.message }) + } +}) + +// 获取所有分组 +router.get('/', authenticateAdmin, async (req, res) => { + try { + const { platform } = req.query + const groups = await accountGroupService.getAllGroups(platform) + return res.json({ success: true, data: groups }) + } catch (error) { + logger.error('❌ Failed to get account groups:', error) + return res.status(500).json({ error: error.message }) + } +}) + +// 获取分组详情 +router.get('/:groupId', authenticateAdmin, async (req, res) => { + try { + const { groupId } = req.params + const group = await accountGroupService.getGroup(groupId) + + if (!group) { + return res.status(404).json({ error: '分组不存在' }) + } + + return res.json({ success: true, data: group }) + } catch (error) { + logger.error('❌ Failed to get account group:', error) + return res.status(500).json({ error: error.message }) + } +}) + +// 更新分组 +router.put('/:groupId', authenticateAdmin, async (req, res) => { + try { + const { groupId } = req.params + const updates = req.body + + const updatedGroup = await accountGroupService.updateGroup(groupId, updates) + return res.json({ success: true, data: updatedGroup }) + } catch (error) { + logger.error('❌ Failed to update account group:', error) + return res.status(400).json({ error: error.message }) + } +}) + +// 删除分组 +router.delete('/:groupId', authenticateAdmin, async (req, res) => { + try { + const { groupId } = req.params + await accountGroupService.deleteGroup(groupId) + return res.json({ success: true, message: '分组删除成功' }) + } catch (error) { + logger.error('❌ Failed to delete account group:', error) + return res.status(400).json({ error: error.message }) + } +}) + +// 获取分组成员 +router.get('/:groupId/members', authenticateAdmin, async (req, res) => { + try { + const { groupId } = req.params + const group = await accountGroupService.getGroup(groupId) + + if (!group) { + return res.status(404).json({ error: '分组不存在' }) + } + + const memberIds = await accountGroupService.getGroupMembers(groupId) + + // 获取成员详细信息 + const members = [] + for (const memberId of memberIds) { + // 根据分组平台优先查找对应账户 + let account = null + switch (group.platform) { + case 'droid': + account = await droidAccountService.getAccount(memberId) + break + case 'gemini': + account = await geminiAccountService.getAccount(memberId) + break + case 'openai': + account = await openaiAccountService.getAccount(memberId) + break + case 'claude': + default: + account = await claudeAccountService.getAccount(memberId) + if (!account) { + account = await claudeConsoleAccountService.getAccount(memberId) + } + break + } + + // 兼容旧数据:若按平台未找到,则继续尝试其他平台 + if (!account) { + account = await claudeAccountService.getAccount(memberId) + } + if (!account) { + account = await claudeConsoleAccountService.getAccount(memberId) + } + if (!account) { + account = await geminiAccountService.getAccount(memberId) + } + if (!account) { + account = await openaiAccountService.getAccount(memberId) + } + if (!account && group.platform !== 'droid') { + account = await droidAccountService.getAccount(memberId) + } + + if (account) { + members.push(account) + } + } + + return res.json({ success: true, data: members }) + } catch (error) { + logger.error('❌ Failed to get group members:', error) + return res.status(500).json({ error: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/apiKeys.js b/src/routes/admin/apiKeys.js new file mode 100644 index 0000000..3dd4c45 --- /dev/null +++ b/src/routes/admin/apiKeys.js @@ -0,0 +1,2781 @@ +const express = require('express') +const apiKeyService = require('../../services/apiKeyService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const CostCalculator = require('../../utils/costCalculator') +const config = require('../../../config/config') +const requestBodyRuleService = require('../../services/requestBodyRuleService') + +const router = express.Router() + +// 有效的权限值列表 +const VALID_PERMISSIONS = ['claude', 'gemini', 'openai', 'droid'] + +/** + * 验证权限数组格式 + * @param {any} permissions - 权限值(可以是数组或其他) + * @returns {string|null} - 返回错误消息,null 表示验证通过 + */ +function validatePermissions(permissions) { + // 空值或未定义表示全部服务 + if (permissions === undefined || permissions === null || permissions === '') { + return null + } + // 兼容旧格式字符串 + if (typeof permissions === 'string') { + if (permissions === 'all' || VALID_PERMISSIONS.includes(permissions)) { + return null + } + return `Invalid permissions value. Must be an array of: ${VALID_PERMISSIONS.join(', ')}` + } + // 新格式数组 + if (Array.isArray(permissions)) { + // 空数组表示全部服务 + if (permissions.length === 0) { + return null + } + // 验证数组中的每个值 + for (const perm of permissions) { + if (!VALID_PERMISSIONS.includes(perm)) { + return `Invalid permission value "${perm}". Valid values are: ${VALID_PERMISSIONS.join(', ')}` + } + } + return null + } + return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}` +} + +/** + * 验证 serviceRates 格式 + * @param {any} serviceRates - 服务倍率对象 + * @returns {string|null} - 返回错误消息,null 表示验证通过 + */ +function validateServiceRates(serviceRates) { + if (serviceRates === undefined || serviceRates === null) { + return null + } + if (typeof serviceRates !== 'object' || Array.isArray(serviceRates)) { + return 'Service rates must be an object' + } + for (const [service, rate] of Object.entries(serviceRates)) { + const numRate = Number(rate) + if (!Number.isFinite(numRate) || numRate < 0) { + return `Invalid rate for service "${service}": must be a non-negative number` + } + } + return null +} + +// 👥 用户管理 (用于API Key分配) + +// 获取所有用户列表(用于API Key分配) +router.get('/users', authenticateAdmin, async (req, res) => { + try { + const userService = require('../../services/userService') + + // Extract query parameters for filtering + const { role, isActive } = req.query + const options = { limit: 1000 } + + // Apply role filter if provided + if (role) { + options.role = role + } + + // Apply isActive filter if provided, otherwise default to active users only + if (isActive !== undefined) { + options.isActive = isActive === 'true' + } else { + options.isActive = true // Default to active users for backwards compatibility + } + + const result = await userService.getAllUsers(options) + + // Extract users array from the paginated result + const allUsers = result.users || [] + + // Map to the format needed for the dropdown + const activeUsers = allUsers.map((user) => ({ + id: user.id, + username: user.username, + displayName: user.displayName || user.username, + email: user.email, + role: user.role + })) + + // 添加Admin选项作为第一个 + const usersWithAdmin = [ + { + id: 'admin', + username: 'admin', + displayName: 'Admin', + email: '', + role: 'admin' + }, + ...activeUsers + ] + + return res.json({ + success: true, + data: usersWithAdmin + }) + } catch (error) { + logger.error('❌ Failed to get users list:', error) + return res.status(500).json({ + error: 'Failed to get users list', + message: error.message + }) + } +}) + +// 🔑 API Keys 管理 + +// 调试:获取API Key费用详情 +router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const costStats = await redis.getCostStats(keyId) + const dailyCost = await redis.getDailyCost(keyId) + const today = redis.getDateStringInTimezone() + + // 获取所有相关的Redis键 + const costKeys = await redis.scanKeys(`usage:cost:*:${keyId}:*`) + const costValues = await redis.batchGetChunked(costKeys) + const keyValues = {} + + for (let i = 0; i < costKeys.length; i++) { + keyValues[costKeys[i]] = costValues[i] + } + + return res.json({ + keyId, + today, + dailyCost, + costStats, + redisKeys: keyValues, + timezone: config.system.timezoneOffset || 8 + }) + } catch (error) { + logger.error('❌ Failed to get cost debug info:', error) + return res.status(500).json({ error: 'Failed to get cost debug info', message: error.message }) + } +}) + +// 获取所有被使用过的模型列表 +router.get('/api-keys/used-models', authenticateAdmin, async (req, res) => { + try { + const models = await redis.getAllUsedModels() + return res.json({ success: true, data: models }) + } catch (error) { + logger.error('❌ Failed to get used models:', error) + return res.status(500).json({ error: 'Failed to get used models', message: error.message }) + } +}) + +// 获取所有API Keys +router.get('/api-keys', authenticateAdmin, async (req, res) => { + try { + const { + // 分页参数 + page = 1, + pageSize = 20, + // 搜索参数 + searchMode = 'apiKey', + search = '', + // 筛选参数 + tag = '', + isActive = '', + models = '', // 模型筛选(逗号分隔) + // 排序参数 + sortBy = 'createdAt', + sortOrder = 'desc', + // 费用排序参数 + costTimeRange = '7days', // 费用排序的时间范围 + costStartDate = '', // custom 时间范围的开始日期 + costEndDate = '', // custom 时间范围的结束日期 + // 兼容旧参数(不再用于费用计算,仅标记) + timeRange = 'all' + } = req.query + + // 解析模型筛选参数 + const modelFilter = models ? models.split(',').filter((m) => m.trim()) : [] + + // 验证分页参数 + const pageNum = Math.max(1, parseInt(page) || 1) + const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20 + + // 验证排序参数(新增 cost 排序) + const validSortFields = [ + 'name', + 'createdAt', + 'expiresAt', + 'lastUsedAt', + 'isActive', + 'status', + 'cost' + ] + const validSortBy = validSortFields.includes(sortBy) ? sortBy : 'createdAt' + const validSortOrder = ['asc', 'desc'].includes(sortOrder) ? sortOrder : 'desc' + + // 获取用户服务来补充owner信息 + const userService = require('../../services/userService') + + // 如果是绑定账号搜索模式,先刷新账户名称缓存 + if (searchMode === 'bindingAccount' && search) { + const accountNameCacheService = require('../../services/accountNameCacheService') + await accountNameCacheService.refreshIfNeeded() + } + + let result + let costSortStatus = null + + // 如果是费用排序 + if (validSortBy === 'cost') { + const costRankService = require('../../services/costRankService') + + // 验证费用排序的时间范围 + const validCostTimeRanges = ['today', '7days', '30days', 'all', 'custom'] + const effectiveCostTimeRange = validCostTimeRanges.includes(costTimeRange) + ? costTimeRange + : '7days' + + // 如果是 custom 时间范围,使用实时计算 + if (effectiveCostTimeRange === 'custom') { + // 验证日期参数 + if (!costStartDate || !costEndDate) { + return res.status(400).json({ + success: false, + error: 'INVALID_DATE_RANGE', + message: '自定义时间范围需要提供 costStartDate 和 costEndDate 参数' + }) + } + + const start = new Date(costStartDate) + const end = new Date(costEndDate) + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return res.status(400).json({ + success: false, + error: 'INVALID_DATE_FORMAT', + message: '日期格式无效' + }) + } + + if (start > end) { + return res.status(400).json({ + success: false, + error: 'INVALID_DATE_RANGE', + message: '开始日期不能晚于结束日期' + }) + } + + // 限制最大范围为 365 天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 365) { + return res.status(400).json({ + success: false, + error: 'DATE_RANGE_TOO_LARGE', + message: '日期范围不能超过365天' + }) + } + + logger.info(`📊 Cost sort with custom range: ${costStartDate} to ${costEndDate}`) + + // 实时计算费用排序 + result = await getApiKeysSortedByCostCustom({ + page: pageNum, + pageSize: pageSizeNum, + sortOrder: validSortOrder, + startDate: costStartDate, + endDate: costEndDate, + search, + searchMode, + tag, + isActive, + modelFilter + }) + + costSortStatus = { + status: 'ready', + isRealTimeCalculation: true + } + } else { + // 使用预计算索引 + const rankStatus = await costRankService.getRankStatus() + costSortStatus = rankStatus[effectiveCostTimeRange] + + // 检查索引是否就绪 + if (!costSortStatus || costSortStatus.status !== 'ready') { + return res.status(503).json({ + success: false, + error: 'RANK_NOT_READY', + message: `费用排序索引 (${effectiveCostTimeRange}) 正在更新中,请稍后重试`, + costSortStatus: costSortStatus || { status: 'unknown' } + }) + } + + logger.info(`📊 Cost sort using precomputed index: ${effectiveCostTimeRange}`) + + // 使用预计算索引排序 + result = await getApiKeysSortedByCostPrecomputed({ + page: pageNum, + pageSize: pageSizeNum, + sortOrder: validSortOrder, + costTimeRange: effectiveCostTimeRange, + search, + searchMode, + tag, + isActive, + modelFilter + }) + + costSortStatus.isRealTimeCalculation = false + } + } else { + // 原有的非费用排序逻辑 + result = await redis.getApiKeysPaginated({ + page: pageNum, + pageSize: pageSizeNum, + searchMode, + search, + tag, + isActive, + sortBy: validSortBy, + sortOrder: validSortOrder, + modelFilter + }) + } + + // 为每个API Key添加owner的displayName(批量获取优化) + const userIdsToFetch = [...new Set(result.items.filter((k) => k.userId).map((k) => k.userId))] + const userMap = new Map() + + if (userIdsToFetch.length > 0) { + // 批量获取用户信息 + const users = await Promise.all( + userIdsToFetch.map((id) => userService.getUserById(id, false).catch(() => null)) + ) + userIdsToFetch.forEach((id, i) => { + if (users[i]) { + userMap.set(id, users[i]) + } + }) + } + + for (const apiKey of result.items) { + if (apiKey.userId && userMap.has(apiKey.userId)) { + const user = userMap.get(apiKey.userId) + apiKey.ownerDisplayName = user.displayName || user.username || 'Unknown User' + } else if (apiKey.userId) { + apiKey.ownerDisplayName = 'Unknown User' + } else { + apiKey.ownerDisplayName = + apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin' + } + + // 初始化空的 usage 对象(费用通过 batch-stats 接口获取) + if (!apiKey.usage) { + apiKey.usage = { total: { requests: 0, tokens: 0, cost: 0, formattedCost: '$0.00' } } + } + } + + // 返回分页数据 + const responseData = { + success: true, + data: { + items: result.items, + pagination: result.pagination, + availableTags: result.availableTags + }, + // 标记当前请求的时间范围(供前端参考) + timeRange + } + + // 如果是费用排序,附加排序状态 + if (costSortStatus) { + responseData.data.costSortStatus = costSortStatus + } + + return res.json(responseData) + } catch (error) { + logger.error('❌ Failed to get API keys:', error) + return res.status(500).json({ error: 'Failed to get API keys', message: error.message }) + } +}) + +/** + * 使用预计算索引进行费用排序的分页查询 + */ +async function getApiKeysSortedByCostPrecomputed(options) { + const { + page, + pageSize, + sortOrder, + costTimeRange, + search, + searchMode, + tag, + isActive, + modelFilter = [] + } = options + const costRankService = require('../../services/costRankService') + + // 1. 获取排序后的全量 keyId 列表 + const rankedKeyIds = await costRankService.getSortedKeyIds(costTimeRange, sortOrder) + + if (rankedKeyIds.length === 0) { + return { + items: [], + pagination: { page: 1, pageSize, total: 0, totalPages: 1 }, + availableTags: [] + } + } + + // 2. 批量获取 API Key 基础数据 + const allKeys = await redis.batchGetApiKeys(rankedKeyIds) + + // 3. 保持排序顺序(使用 Map 优化查找) + const keyMap = new Map(allKeys.map((k) => [k.id, k])) + let orderedKeys = rankedKeyIds.map((id) => keyMap.get(id)).filter((k) => k && !k.isDeleted) + + // 4. 应用筛选条件 + // 状态筛选 + if (isActive !== '' && isActive !== undefined && isActive !== null) { + const activeValue = isActive === 'true' || isActive === true + orderedKeys = orderedKeys.filter((k) => k.isActive === activeValue) + } + + // 标签筛选 + if (tag) { + orderedKeys = orderedKeys.filter((k) => { + const tags = Array.isArray(k.tags) ? k.tags : [] + return tags.includes(tag) + }) + } + + // 搜索筛选 + if (search) { + const lowerSearch = search.toLowerCase().trim() + if (searchMode === 'apiKey') { + orderedKeys = orderedKeys.filter((k) => k.name && k.name.toLowerCase().includes(lowerSearch)) + } else if (searchMode === 'bindingAccount') { + const accountNameCacheService = require('../../services/accountNameCacheService') + orderedKeys = accountNameCacheService.searchByBindingAccount(orderedKeys, lowerSearch) + } + } + + // 模型筛选 + if (modelFilter.length > 0) { + const keyIdsWithModels = await redis.getKeyIdsWithModels( + orderedKeys.map((k) => k.id), + modelFilter + ) + orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id)) + } + + // 5. 收集所有可用标签 + const allTags = new Set() + for (const key of allKeys) { + if (!key.isDeleted) { + const tags = Array.isArray(key.tags) ? key.tags : [] + tags.forEach((t) => allTags.add(t)) + } + } + const availableTags = [...allTags].sort() + + // 6. 分页 + const total = orderedKeys.length + const totalPages = Math.ceil(total / pageSize) || 1 + const validPage = Math.min(Math.max(1, page), totalPages) + const start = (validPage - 1) * pageSize + const items = orderedKeys.slice(start, start + pageSize) + + // 7. 为当前页的 Keys 附加费用数据 + const keyCosts = await costRankService.getBatchKeyCosts( + costTimeRange, + items.map((k) => k.id) + ) + for (const key of items) { + key._cost = keyCosts.get(key.id) || 0 + } + + return { + items, + pagination: { + page: validPage, + pageSize, + total, + totalPages + }, + availableTags + } +} + +/** + * 使用实时计算进行 custom 时间范围的费用排序 + */ +async function getApiKeysSortedByCostCustom(options) { + const { + page, + pageSize, + sortOrder, + startDate, + endDate, + search, + searchMode, + tag, + isActive, + modelFilter = [] + } = options + const costRankService = require('../../services/costRankService') + + // 1. 实时计算所有 Keys 的费用 + const costs = await costRankService.calculateCustomRangeCosts(startDate, endDate) + + if (costs.size === 0) { + return { + items: [], + pagination: { page: 1, pageSize, total: 0, totalPages: 1 }, + availableTags: [] + } + } + + // 2. 转换为数组并排序 + const sortedEntries = [...costs.entries()].sort((a, b) => + sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1] + ) + const rankedKeyIds = sortedEntries.map(([keyId]) => keyId) + + // 3. 批量获取 API Key 基础数据 + const allKeys = await redis.batchGetApiKeys(rankedKeyIds) + + // 4. 保持排序顺序 + const keyMap = new Map(allKeys.map((k) => [k.id, k])) + let orderedKeys = rankedKeyIds.map((id) => keyMap.get(id)).filter((k) => k && !k.isDeleted) + + // 5. 应用筛选条件 + // 状态筛选 + if (isActive !== '' && isActive !== undefined && isActive !== null) { + const activeValue = isActive === 'true' || isActive === true + orderedKeys = orderedKeys.filter((k) => k.isActive === activeValue) + } + + // 标签筛选 + if (tag) { + orderedKeys = orderedKeys.filter((k) => { + const tags = Array.isArray(k.tags) ? k.tags : [] + return tags.includes(tag) + }) + } + + // 搜索筛选 + if (search) { + const lowerSearch = search.toLowerCase().trim() + if (searchMode === 'apiKey') { + orderedKeys = orderedKeys.filter((k) => k.name && k.name.toLowerCase().includes(lowerSearch)) + } else if (searchMode === 'bindingAccount') { + const accountNameCacheService = require('../../services/accountNameCacheService') + orderedKeys = accountNameCacheService.searchByBindingAccount(orderedKeys, lowerSearch) + } + } + + // 模型筛选 + if (modelFilter.length > 0) { + const keyIdsWithModels = await redis.getKeyIdsWithModels( + orderedKeys.map((k) => k.id), + modelFilter + ) + orderedKeys = orderedKeys.filter((k) => keyIdsWithModels.has(k.id)) + } + + // 6. 收集所有可用标签 + const allTags = new Set() + for (const key of allKeys) { + if (!key.isDeleted) { + const tags = Array.isArray(key.tags) ? key.tags : [] + tags.forEach((t) => allTags.add(t)) + } + } + const availableTags = [...allTags].sort() + + // 7. 分页 + const total = orderedKeys.length + const totalPages = Math.ceil(total / pageSize) || 1 + const validPage = Math.min(Math.max(1, page), totalPages) + const start = (validPage - 1) * pageSize + const items = orderedKeys.slice(start, start + pageSize) + + // 8. 为当前页的 Keys 附加费用数据 + for (const key of items) { + key._cost = costs.get(key.id) || 0 + } + + return { + items, + pagination: { + page: validPage, + pageSize, + total, + totalPages + }, + availableTags + } +} + +// 获取费用排序索引状态 +router.get('/api-keys/cost-sort-status', authenticateAdmin, async (req, res) => { + try { + const costRankService = require('../../services/costRankService') + const status = await costRankService.getRankStatus() + return res.json({ success: true, data: status }) + } catch (error) { + logger.error('❌ Failed to get cost sort status:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get cost sort status', + message: error.message + }) + } +}) + +// 获取 API Key 索引状态 +router.get('/api-keys/index-status', authenticateAdmin, async (req, res) => { + try { + const apiKeyIndexService = require('../../services/apiKeyIndexService') + const status = await apiKeyIndexService.getStatus() + return res.json({ success: true, data: status }) + } catch (error) { + logger.error('❌ Failed to get API Key index status:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get index status', + message: error.message + }) + } +}) + +// 手动重建 API Key 索引 +router.post('/api-keys/index-rebuild', authenticateAdmin, async (req, res) => { + try { + const apiKeyIndexService = require('../../services/apiKeyIndexService') + const status = await apiKeyIndexService.getStatus() + + if (status.building) { + return res.status(409).json({ + success: false, + error: 'INDEX_BUILDING', + message: '索引正在重建中,请稍后再试', + progress: status.progress + }) + } + + // 异步重建,不等待完成 + apiKeyIndexService.rebuildIndexes().catch((err) => { + logger.error('❌ Failed to rebuild API Key index:', err) + }) + + return res.json({ + success: true, + message: 'API Key 索引重建已开始' + }) + } catch (error) { + logger.error('❌ Failed to trigger API Key index rebuild:', error) + return res.status(500).json({ + success: false, + error: 'Failed to trigger rebuild', + message: error.message + }) + } +}) + +// 强制刷新费用排序索引 +router.post('/api-keys/cost-sort-refresh', authenticateAdmin, async (req, res) => { + try { + const { timeRange } = req.body + const costRankService = require('../../services/costRankService') + + // 验证时间范围 + if (timeRange) { + const validTimeRanges = ['today', '7days', '30days', 'all'] + if (!validTimeRanges.includes(timeRange)) { + return res.status(400).json({ + success: false, + error: 'INVALID_TIME_RANGE', + message: '无效的时间范围,可选值:today, 7days, 30days, all' + }) + } + } + + // 异步刷新,不等待完成 + costRankService.forceRefresh(timeRange || null).catch((err) => { + logger.error('❌ Failed to refresh cost rank:', err) + }) + + return res.json({ + success: true, + message: timeRange ? `费用排序索引 (${timeRange}) 刷新已开始` : '所有费用排序索引刷新已开始' + }) + } catch (error) { + logger.error('❌ Failed to trigger cost sort refresh:', error) + return res.status(500).json({ + success: false, + error: 'Failed to trigger refresh', + message: error.message + }) + } +}) + +// 获取支持的客户端列表(使用新的验证器) +router.get('/supported-clients', authenticateAdmin, async (req, res) => { + try { + // 使用新的 ClientValidator 获取所有可用客户端 + const ClientValidator = require('../../validators/clientValidator') + const availableClients = ClientValidator.getAvailableClients() + + // 格式化返回数据 + const clients = availableClients.map((client) => ({ + id: client.id, + name: client.name, + description: client.description, + icon: client.icon + })) + + logger.info(`📱 Returning ${clients.length} supported clients`) + return res.json({ success: true, data: clients }) + } catch (error) { + logger.error('❌ Failed to get supported clients:', error) + return res + .status(500) + .json({ error: 'Failed to get supported clients', message: error.message }) + } +}) + +// 获取已存在的标签列表 +router.get('/api-keys/tags', authenticateAdmin, async (req, res) => { + try { + const tags = await apiKeyService.getAllTags() + + logger.info(`📋 Retrieved ${tags.length} unique tags from API keys`) + return res.json({ success: true, data: tags }) + } catch (error) { + logger.error('❌ Failed to get API key tags:', error) + return res.status(500).json({ error: 'Failed to get API key tags', message: error.message }) + } +}) + +// 获取标签详情(含使用数量) +router.get('/api-keys/tags/details', authenticateAdmin, async (req, res) => { + try { + const tagDetails = await apiKeyService.getTagsWithCount() + logger.info(`📋 Retrieved ${tagDetails.length} tags with usage counts`) + return res.json({ success: true, data: tagDetails }) + } catch (error) { + logger.error('❌ Failed to get tag details:', error) + return res.status(500).json({ error: 'Failed to get tag details', message: error.message }) + } +}) + +// 创建新标签 +router.post('/api-keys/tags', authenticateAdmin, async (req, res) => { + try { + const { name } = req.body + if (!name || !name.trim()) { + return res.status(400).json({ error: '标签名称不能为空' }) + } + + const result = await apiKeyService.createTag(name.trim()) + if (!result.success) { + return res.status(400).json({ error: result.error }) + } + + logger.info(`🏷️ Created new tag: ${name}`) + return res.json({ success: true, message: '标签创建成功' }) + } catch (error) { + logger.error('❌ Failed to create tag:', error) + return res.status(500).json({ error: 'Failed to create tag', message: error.message }) + } +}) + +// 删除标签(从所有 API Key 中移除) +router.delete('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => { + try { + const { tagName } = req.params + if (!tagName) { + return res.status(400).json({ error: 'Tag name is required' }) + } + + const decodedTagName = decodeURIComponent(tagName) + const result = await apiKeyService.removeTagFromAllKeys(decodedTagName) + + logger.info(`🏷️ Removed tag "${decodedTagName}" from ${result.affectedCount} API keys`) + return res.json({ + success: true, + message: `Tag "${decodedTagName}" removed from ${result.affectedCount} API keys`, + affectedCount: result.affectedCount + }) + } catch (error) { + logger.error('❌ Failed to delete tag:', error) + return res.status(500).json({ error: 'Failed to delete tag', message: error.message }) + } +}) + +// 重命名标签 +router.put('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => { + try { + const { tagName } = req.params + const { newName } = req.body + if (!tagName || !newName || !newName.trim()) { + return res.status(400).json({ error: 'Tag name and new name are required' }) + } + + const decodedTagName = decodeURIComponent(tagName) + const trimmedNewName = newName.trim() + const result = await apiKeyService.renameTag(decodedTagName, trimmedNewName) + + if (result.error) { + return res.status(400).json({ error: result.error }) + } + + logger.info( + `🏷️ Renamed tag "${decodedTagName}" to "${trimmedNewName}" in ${result.affectedCount} API keys` + ) + return res.json({ + success: true, + message: `Tag renamed in ${result.affectedCount} API keys`, + affectedCount: result.affectedCount + }) + } catch (error) { + logger.error('❌ Failed to rename tag:', error) + return res.status(500).json({ error: 'Failed to rename tag', message: error.message }) + } +}) + +/** + * 获取账户绑定的 API Key 数量统计 + * GET /admin/accounts/binding-counts + * + * 返回每种账户类型的绑定数量统计,用于账户列表页面显示"绑定: X 个API Key" + * 这是一个轻量级接口,只返回计数而不是完整的 API Key 数据 + */ +router.get('/accounts/binding-counts', authenticateAdmin, async (req, res) => { + try { + // 使用优化的分页方法获取所有非删除的 API Keys(只需要绑定字段) + const result = await redis.getApiKeysPaginated({ + page: 1, + pageSize: 10000, // 获取所有 + excludeDeleted: true + }) + + const apiKeys = result.items + + // 初始化统计对象 + const bindingCounts = { + claudeAccountId: {}, + claudeConsoleAccountId: {}, + geminiAccountId: {}, + openaiAccountId: {}, + azureOpenaiAccountId: {}, + bedrockAccountId: {}, + droidAccountId: {}, + ccrAccountId: {} + } + + // 遍历一次,统计每个账户的绑定数量 + for (const key of apiKeys) { + // Claude 账户 + if (key.claudeAccountId) { + const id = key.claudeAccountId + bindingCounts.claudeAccountId[id] = (bindingCounts.claudeAccountId[id] || 0) + 1 + } + + // Claude Console 账户 + if (key.claudeConsoleAccountId) { + const id = key.claudeConsoleAccountId + bindingCounts.claudeConsoleAccountId[id] = + (bindingCounts.claudeConsoleAccountId[id] || 0) + 1 + } + + // Gemini 账户(包括 api: 前缀的 Gemini-API 账户) + if (key.geminiAccountId) { + const id = key.geminiAccountId + bindingCounts.geminiAccountId[id] = (bindingCounts.geminiAccountId[id] || 0) + 1 + } + + // OpenAI 账户(包括 responses: 前缀的 OpenAI-Responses 账户) + if (key.openaiAccountId) { + const id = key.openaiAccountId + bindingCounts.openaiAccountId[id] = (bindingCounts.openaiAccountId[id] || 0) + 1 + } + + // Azure OpenAI 账户 + if (key.azureOpenaiAccountId) { + const id = key.azureOpenaiAccountId + bindingCounts.azureOpenaiAccountId[id] = (bindingCounts.azureOpenaiAccountId[id] || 0) + 1 + } + + // Bedrock 账户 + if (key.bedrockAccountId) { + const id = key.bedrockAccountId + bindingCounts.bedrockAccountId[id] = (bindingCounts.bedrockAccountId[id] || 0) + 1 + } + + // Droid 账户 + if (key.droidAccountId) { + const id = key.droidAccountId + bindingCounts.droidAccountId[id] = (bindingCounts.droidAccountId[id] || 0) + 1 + } + + // CCR 账户 + if (key.ccrAccountId) { + const id = key.ccrAccountId + bindingCounts.ccrAccountId[id] = (bindingCounts.ccrAccountId[id] || 0) + 1 + } + } + + logger.debug(`📊 Account binding counts calculated from ${apiKeys.length} API keys`) + return res.json({ success: true, data: bindingCounts }) + } catch (error) { + logger.error('❌ Failed to get account binding counts:', error) + return res.status(500).json({ + error: 'Failed to get account binding counts', + message: error.message + }) + } +}) + +/** + * 批量获取指定 Keys 的统计数据和费用 + * POST /admin/api-keys/batch-stats + * + * 用于 API Keys 列表页面异步加载统计数据 + */ +router.post('/api-keys/batch-stats', authenticateAdmin, async (req, res) => { + try { + const { + keyIds, // 必需:API Key ID 数组 + timeRange = 'all', // 时间范围:all, today, 7days, monthly, custom + startDate, // custom 时必需 + endDate // custom 时必需 + } = req.body + + // 参数验证 + if (!Array.isArray(keyIds) || keyIds.length === 0) { + return res.status(400).json({ + success: false, + error: 'keyIds is required and must be a non-empty array' + }) + } + + // 限制单次最多处理 100 个 Key + if (keyIds.length > 100) { + return res.status(400).json({ + success: false, + error: 'Max 100 keys per request' + }) + } + + // 验证 custom 时间范围的参数 + if (timeRange === 'custom') { + if (!startDate || !endDate) { + return res.status(400).json({ + success: false, + error: 'startDate and endDate are required for custom time range' + }) + } + const start = new Date(startDate) + const end = new Date(endDate) + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + return res.status(400).json({ + success: false, + error: 'Invalid date format' + }) + } + if (start > end) { + return res.status(400).json({ + success: false, + error: 'startDate must be before or equal to endDate' + }) + } + // 限制最大范围为 365 天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 365) { + return res.status(400).json({ + success: false, + error: 'Date range cannot exceed 365 days' + }) + } + } + + logger.info( + `📊 Batch stats request: ${keyIds.length} keys, timeRange=${timeRange}`, + timeRange === 'custom' ? `, ${startDate} to ${endDate}` : '' + ) + + const stats = {} + + // 并行计算每个 Key 的统计数据 + await Promise.all( + keyIds.map(async (keyId) => { + try { + stats[keyId] = await calculateKeyStats(keyId, timeRange, startDate, endDate) + } catch (error) { + logger.error(`❌ Failed to calculate stats for key ${keyId}:`, error) + stats[keyId] = { + requests: 0, + tokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + cost: 0, + formattedCost: '$0.00', + dailyCost: 0, + weeklyOpusCost: 0, + currentWindowCost: 0, + currentWindowRequests: 0, + currentWindowTokens: 0, + windowRemainingSeconds: null, + windowStartTime: null, + windowEndTime: null, + allTimeCost: 0, + error: error.message + } + } + }) + ) + + return res.json({ success: true, data: stats }) + } catch (error) { + logger.error('❌ Failed to calculate batch stats:', error) + return res.status(500).json({ + success: false, + error: 'Failed to calculate stats', + message: error.message + }) + } +}) + +/** + * 计算单个 Key 的统计数据 + * @param {string} keyId - API Key ID + * @param {string} timeRange - 时间范围 + * @param {string} startDate - 开始日期 (custom 模式) + * @param {string} endDate - 结束日期 (custom 模式) + * @returns {Object} 统计数据 + */ +async function calculateKeyStats(keyId, timeRange, startDate, endDate) { + const client = redis.getClientSafe() + const tzDate = redis.getDateInTimezone() + const today = redis.getDateStringInTimezone() + + // 构建搜索模式 + const searchPatterns = [] + + if (timeRange === 'custom' && startDate && endDate) { + // 自定义日期范围 + const start = new Date(startDate) + const end = new Date(endDate) + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const dateStr = redis.getDateStringInTimezone(d) + searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`) + } + } else if (timeRange === 'today') { + searchPatterns.push(`usage:${keyId}:model:daily:*:${today}`) + } else if (timeRange === '7days') { + // 最近7天 + for (let i = 0; i < 7; i++) { + const d = new Date(tzDate) + d.setDate(d.getDate() - i) + const dateStr = redis.getDateStringInTimezone(d) + searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`) + } + } else if (timeRange === 'monthly') { + // 当月 + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + searchPatterns.push(`usage:${keyId}:model:monthly:*:${currentMonth}`) + } else { + // all - 使用 alltime key(无 TTL,数据完整),避免 daily/monthly 键过期导致数据丢失 + searchPatterns.push(`usage:${keyId}:model:alltime:*`) + } + + // 使用 SCAN 收集所有匹配的 keys + const allKeys = [] + for (const pattern of searchPatterns) { + let cursor = '0' + do { + const [newCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100) + cursor = newCursor + allKeys.push(...keys) + } while (cursor !== '0') + } + + // 去重 + const uniqueKeys = [...new Set(allKeys)] + + // 获取实时限制数据(窗口数据不受时间范围筛选影响,始终获取当前窗口状态) + let dailyCost = 0 + let weeklyOpusCost = 0 // 字段名沿用 weeklyOpusCost*,语义为"Claude 周费用" + let currentWindowCost = 0 + let currentWindowRequests = 0 // 当前窗口请求次数 + let currentWindowTokens = 0 // 当前窗口 Token 使用量 + let windowRemainingSeconds = null + let windowStartTime = null + let windowEndTime = null + let allTimeCost = 0 + + try { + // 先获取 API Key 配置,判断是否需要查询限制相关数据 + const apiKey = await redis.getApiKey(keyId) + const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0 + const dailyCostLimit = parseFloat(apiKey?.dailyCostLimit) || 0 + const weeklyOpusCostLimit = parseFloat(apiKey?.weeklyOpusCostLimit) || 0 + + // 只在启用了每日费用限制时查询 + if (dailyCostLimit > 0) { + dailyCost = await redis.getDailyCost(keyId) + } + + // 始终查询 allTimeCost(用于展示和限额校验) + const totalCostKey = `usage:cost:total:${keyId}` + allTimeCost = parseFloat((await client.get(totalCostKey)) || '0') + + // 只在启用了 Claude 周费用限制时查询(字段名沿用 weeklyOpusCostLimit) + if (weeklyOpusCostLimit > 0) { + const resetDay = parseInt(apiKey?.weeklyResetDay || 1) + const resetHour = parseInt(apiKey?.weeklyResetHour || 0) + weeklyOpusCost = await redis.getWeeklyOpusCost(keyId, resetDay, resetHour) + } + + // 只在启用了窗口限制时查询窗口数据 + if (rateLimitWindow > 0) { + const requestCountKey = `rate_limit:requests:${keyId}` + const tokenCountKey = `rate_limit:tokens:${keyId}` + const costCountKey = `rate_limit:cost:${keyId}` + const windowStartKey = `rate_limit:window_start:${keyId}` + + currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') + currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') + currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') + + // 获取窗口开始时间和计算剩余时间 + const windowStart = await client.get(windowStartKey) + if (windowStart) { + const now = Date.now() + windowStartTime = parseInt(windowStart) + const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒 + windowEndTime = windowStartTime + windowDuration + + // 如果窗口还有效 + if (now < windowEndTime) { + windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000)) + } else { + // 窗口已过期 + windowRemainingSeconds = 0 + currentWindowRequests = 0 + currentWindowTokens = 0 + currentWindowCost = 0 + } + } + } + } catch (error) { + logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message) + } + + // 构建实时限制数据对象(各分支复用) + const limitData = { + dailyCost, + weeklyOpusCost, + currentWindowCost, + currentWindowRequests, + currentWindowTokens, + windowRemainingSeconds, + windowStartTime, + windowEndTime, + allTimeCost + } + + // 如果没有使用数据,返回零值但包含窗口数据 + if (uniqueKeys.length === 0) { + return { + requests: 0, + tokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + cost: 0, + realCost: 0, + formattedCost: '$0.00', + ...limitData + } + } + + // 使用 Pipeline 批量获取数据 + const pipeline = client.pipeline() + for (const key of uniqueKeys) { + pipeline.hgetall(key) + } + const results = await pipeline.exec() + + // 汇总计算 + const modelStatsMap = new Map() + let totalRequests = 0 + + // alltime key 的模式:usage:{keyId}:model:alltime:{model} + const alltimeKeyPattern = /usage:.+:model:alltime:(.+)$/ + // 用于去重:先统计月数据,避免与日数据重复 + const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const isAlltimeQuery = timeRange === 'all' + + for (let i = 0; i < results.length; i++) { + const [err, data] = results[i] + if (err || !data || Object.keys(data).length === 0) { + continue + } + + const key = uniqueKeys[i] + let model = null + let isMonthly = false + + // 提取模型名称 + if (isAlltimeQuery) { + const alltimeMatch = key.match(alltimeKeyPattern) + if (alltimeMatch) { + model = alltimeMatch[1] + } + } else { + const dailyMatch = key.match(dailyKeyPattern) + const monthlyMatch = key.match(monthlyKeyPattern) + + if (dailyMatch) { + model = dailyMatch[1] + } else if (monthlyMatch) { + model = monthlyMatch[1] + isMonthly = true + } + } + + if (!model) { + continue + } + + // 日/月去重逻辑(alltime 不需要去重) + if (!isAlltimeQuery) { + // 跳过当前月的月数据(当前月用日数据更精确) + if (isMonthly && key.includes(`:${currentMonth}`)) { + continue + } + // 跳过非当前月的日数据(非当前月用月数据) + if (!isMonthly && !key.includes(`:${currentMonth}-`)) { + continue + } + } + + if (!modelStatsMap.has(model)) { + modelStatsMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + ephemeral5mTokens: 0, + ephemeral1hTokens: 0, + requests: 0, + realCostMicro: 0, + ratedCostMicro: 0, + hasStoredCost: false + }) + } + + const stats = modelStatsMap.get(model) + stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0 + stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0 + stats.cacheCreateTokens += + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0 + stats.cacheReadTokens += + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + stats.ephemeral5mTokens += + parseInt(data.totalEphemeral5mTokens) || parseInt(data.ephemeral5mTokens) || 0 + stats.ephemeral1hTokens += + parseInt(data.totalEphemeral1hTokens) || parseInt(data.ephemeral1hTokens) || 0 + stats.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0 + + // 累加已存储的费用(微美元) + if ('realCostMicro' in data || 'ratedCostMicro' in data) { + stats.realCostMicro += parseInt(data.realCostMicro) || 0 + stats.ratedCostMicro += parseInt(data.ratedCostMicro) || 0 + stats.hasStoredCost = true + } + + totalRequests += parseInt(data.totalRequests) || parseInt(data.requests) || 0 + } + + // 汇总费用:优先使用已存储的费用,仅对无存储费用的旧数据 fallback 到 token 重算 + let totalRatedCost = 0 + let totalRealCost = 0 + let inputTokens = 0 + let outputTokens = 0 + let cacheCreateTokens = 0 + let cacheReadTokens = 0 + + for (const [model, stats] of modelStatsMap) { + inputTokens += stats.inputTokens + outputTokens += stats.outputTokens + cacheCreateTokens += stats.cacheCreateTokens + cacheReadTokens += stats.cacheReadTokens + + if (stats.hasStoredCost) { + // 使用请求时已计算并存储的费用(精确,包含 1M 上下文、特殊计费等) + totalRatedCost += stats.ratedCostMicro / 1000000 + totalRealCost += stats.realCostMicro / 1000000 + } else { + // Legacy fallback:旧数据没有存储费用,从 token 重算(不精确但聊胜于无) + const costUsage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + } + + if (stats.ephemeral5mTokens > 0 || stats.ephemeral1hTokens > 0) { + costUsage.cache_creation = { + ephemeral_5m_input_tokens: stats.ephemeral5mTokens, + ephemeral_1h_input_tokens: stats.ephemeral1hTokens + } + } + + const costResult = CostCalculator.calculateCost(costUsage, model) + totalRatedCost += costResult.costs.total + totalRealCost += costResult.costs.total + } + } + + const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + return { + requests: totalRequests, + tokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + cost: totalRatedCost, + realCost: totalRealCost, + formattedCost: CostCalculator.formatCost(totalRatedCost), + ...limitData + } +} + +/** + * 批量获取指定 Keys 的最后使用账号信息 + * POST /admin/api-keys/batch-last-usage + * + * 用于 API Keys 列表页面异步加载最后使用账号数据 + */ +router.post('/api-keys/batch-last-usage', authenticateAdmin, async (req, res) => { + try { + const { keyIds } = req.body + + // 参数验证 + if (!Array.isArray(keyIds) || keyIds.length === 0) { + return res.status(400).json({ + success: false, + error: 'keyIds is required and must be a non-empty array' + }) + } + + // 限制单次最多处理 100 个 Key + if (keyIds.length > 100) { + return res.status(400).json({ + success: false, + error: 'Max 100 keys per request' + }) + } + + logger.debug(`📊 Batch last-usage request: ${keyIds.length} keys`) + + const client = redis.getClientSafe() + const lastUsageData = {} + const accountInfoCache = new Map() + + // 并行获取每个 Key 的最后使用记录 + await Promise.all( + keyIds.map(async (keyId) => { + try { + // 获取最新的使用记录 + const usageRecords = await redis.getUsageRecords(keyId, 1) + if (!Array.isArray(usageRecords) || usageRecords.length === 0) { + lastUsageData[keyId] = null + return + } + + const lastUsageRecord = usageRecords[0] + if (!lastUsageRecord || (!lastUsageRecord.accountId && !lastUsageRecord.accountType)) { + lastUsageData[keyId] = null + return + } + + // 解析账号信息 + const resolvedAccount = await apiKeyService._resolveAccountByUsageRecord( + lastUsageRecord, + accountInfoCache, + client + ) + + if (resolvedAccount) { + lastUsageData[keyId] = { + accountId: resolvedAccount.accountId, + rawAccountId: lastUsageRecord.accountId || resolvedAccount.accountId, + accountType: resolvedAccount.accountType, + accountCategory: resolvedAccount.accountCategory, + accountName: resolvedAccount.accountName, + recordedAt: lastUsageRecord.timestamp || null + } + } else { + // 账号已删除 + lastUsageData[keyId] = { + accountId: null, + rawAccountId: lastUsageRecord.accountId || null, + accountType: 'deleted', + accountCategory: 'deleted', + accountName: '已删除', + recordedAt: lastUsageRecord.timestamp || null + } + } + } catch (error) { + logger.debug(`获取 API Key ${keyId} 的最后使用记录失败:`, error) + lastUsageData[keyId] = null + } + }) + ) + + return res.json({ success: true, data: lastUsageData }) + } catch (error) { + logger.error('❌ Failed to get batch last-usage:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get last-usage data', + message: error.message + }) + } +}) + +// 创建新的API Key +router.post('/api-keys', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + tokenLimit, + expiresAt, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + openaiAccountId, + bedrockAccountId, + droidAccountId, + permissions, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + rateLimitCost, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + tags, + activationDays, // 新增:激活后有效天数 + activationUnit, // 新增:激活时间单位 (hours/days) + expirationMode, // 新增:过期模式 + icon, // 新增:图标 + serviceRates, // API Key 级别服务倍率 + weeklyResetDay, // 周费用重置日 (1-7) + weeklyResetHour, // 周费用重置时 (0-23) + enableOpenAIResponsesCodexAdaptation, + enableOpenAIResponsesPayloadRules, + openaiResponsesPayloadRules + } = req.body + + // 输入验证 + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return res.status(400).json({ error: 'Name is required and must be a non-empty string' }) + } + + if (name.length > 100) { + return res.status(400).json({ error: 'Name must be less than 100 characters' }) + } + + if (description && (typeof description !== 'string' || description.length > 500)) { + return res + .status(400) + .json({ error: 'Description must be a string with less than 500 characters' }) + } + + if (tokenLimit && (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0)) { + return res.status(400).json({ error: 'Token limit must be a non-negative integer' }) + } + + if ( + concurrencyLimit !== undefined && + concurrencyLimit !== null && + concurrencyLimit !== '' && + (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0) + ) { + return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' }) + } + + if ( + rateLimitWindow !== undefined && + rateLimitWindow !== null && + rateLimitWindow !== '' && + (!Number.isInteger(Number(rateLimitWindow)) || Number(rateLimitWindow) < 1) + ) { + return res + .status(400) + .json({ error: 'Rate limit window must be a positive integer (minutes)' }) + } + + if ( + rateLimitRequests !== undefined && + rateLimitRequests !== null && + rateLimitRequests !== '' && + (!Number.isInteger(Number(rateLimitRequests)) || Number(rateLimitRequests) < 1) + ) { + return res.status(400).json({ error: 'Rate limit requests must be a positive integer' }) + } + + // 验证模型限制字段 + if (enableModelRestriction !== undefined && typeof enableModelRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable model restriction must be a boolean' }) + } + + if (restrictedModels !== undefined && !Array.isArray(restrictedModels)) { + return res.status(400).json({ error: 'Restricted models must be an array' }) + } + + // 验证客户端限制字段 + if (enableClientRestriction !== undefined && typeof enableClientRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable client restriction must be a boolean' }) + } + + if (allowedClients !== undefined && !Array.isArray(allowedClients)) { + return res.status(400).json({ error: 'Allowed clients must be an array' }) + } + + // 验证标签字段 + if (tags !== undefined && !Array.isArray(tags)) { + return res.status(400).json({ error: 'Tags must be an array' }) + } + + if (tags && tags.some((tag) => typeof tag !== 'string' || tag.trim().length === 0)) { + return res.status(400).json({ error: 'All tags must be non-empty strings' }) + } + + if ( + totalCostLimit !== undefined && + totalCostLimit !== null && + totalCostLimit !== '' && + (Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0) + ) { + return res.status(400).json({ error: 'Total cost limit must be a non-negative number' }) + } + + // 验证激活相关字段 + if (expirationMode && !['fixed', 'activation'].includes(expirationMode)) { + return res + .status(400) + .json({ error: 'Expiration mode must be either "fixed" or "activation"' }) + } + + if (expirationMode === 'activation') { + // 验证激活时间单位 + if (!activationUnit || !['hours', 'days'].includes(activationUnit)) { + return res.status(400).json({ + error: 'Activation unit must be either "hours" or "days" when using activation mode' + }) + } + + // 验证激活时间数值 + if ( + !activationDays || + !Number.isInteger(Number(activationDays)) || + Number(activationDays) < 1 + ) { + const unitText = activationUnit === 'hours' ? 'hours' : 'days' + return res.status(400).json({ + error: `Activation ${unitText} must be a positive integer when using activation mode` + }) + } + // 激活模式下不应该设置固定过期时间 + if (expiresAt) { + return res + .status(400) + .json({ error: 'Cannot set fixed expiration date when using activation mode' }) + } + } + + // 验证服务权限字段(支持数组格式) + const permissionsError = validatePermissions(permissions) + if (permissionsError) { + return res.status(400).json({ error: permissionsError }) + } + + // 验证服务倍率 + const serviceRatesError = validateServiceRates(serviceRates) + if (serviceRatesError) { + return res.status(400).json({ error: serviceRatesError }) + } + + if ( + enableOpenAIResponsesCodexAdaptation !== undefined && + typeof enableOpenAIResponsesCodexAdaptation !== 'boolean' + ) { + return res + .status(400) + .json({ error: 'enableOpenAIResponsesCodexAdaptation must be a boolean' }) + } + + if ( + enableOpenAIResponsesPayloadRules !== undefined && + typeof enableOpenAIResponsesPayloadRules !== 'boolean' + ) { + return res.status(400).json({ error: 'enableOpenAIResponsesPayloadRules must be a boolean' }) + } + + const payloadRulesValidation = requestBodyRuleService.validateAndNormalizeRules( + openaiResponsesPayloadRules + ) + if (!payloadRulesValidation.valid) { + return res.status(400).json({ error: payloadRulesValidation.error }) + } + + // 验证周费用重置配置 + if (weeklyResetDay !== undefined && weeklyResetDay !== null && weeklyResetDay !== '') { + const day = Number(weeklyResetDay) + if (!Number.isInteger(day) || day < 1 || day > 7) { + return res + .status(400) + .json({ error: 'Weekly reset day must be an integer from 1 (Mon) to 7 (Sun)' }) + } + } + if (weeklyResetHour !== undefined && weeklyResetHour !== null && weeklyResetHour !== '') { + const hour = Number(weeklyResetHour) + if (!Number.isInteger(hour) || hour < 0 || hour > 23) { + return res.status(400).json({ error: 'Weekly reset hour must be an integer from 0 to 23' }) + } + } + + const newKey = await apiKeyService.generateApiKey({ + name, + description, + tokenLimit, + expiresAt, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + openaiAccountId, + bedrockAccountId, + droidAccountId, + permissions, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + rateLimitCost, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + tags, + activationDays, + activationUnit, + expirationMode, + icon, + serviceRates, + weeklyResetDay: + weeklyResetDay !== undefined && weeklyResetDay !== null && weeklyResetDay !== '' + ? Number(weeklyResetDay) + : 1, + weeklyResetHour: + weeklyResetHour !== undefined && weeklyResetHour !== null && weeklyResetHour !== '' + ? Number(weeklyResetHour) + : 0, + enableOpenAIResponsesCodexAdaptation: + enableOpenAIResponsesCodexAdaptation !== undefined + ? enableOpenAIResponsesCodexAdaptation + : true, + enableOpenAIResponsesPayloadRules: + enableOpenAIResponsesPayloadRules !== undefined ? enableOpenAIResponsesPayloadRules : false, + openaiResponsesPayloadRules: payloadRulesValidation.rules + }) + + logger.success(`🔑 Admin created new API key: ${name}`) + return res.json({ success: true, data: newKey }) + } catch (error) { + logger.error('❌ Failed to create API key:', error) + return res.status(500).json({ error: 'Failed to create API key', message: error.message }) + } +}) + +// 批量创建API Keys +router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { + try { + const { + baseName, + count, + description, + tokenLimit, + expiresAt, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + openaiAccountId, + bedrockAccountId, + droidAccountId, + permissions, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + rateLimitCost, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + tags, + activationDays, + activationUnit, + expirationMode, + icon, + serviceRates + } = req.body + + // 输入验证 + if (!baseName || typeof baseName !== 'string' || baseName.trim().length === 0) { + return res.status(400).json({ error: 'Base name is required and must be a non-empty string' }) + } + + if (!count || !Number.isInteger(count) || count < 2 || count > 500) { + return res.status(400).json({ error: 'Count must be an integer between 2 and 500' }) + } + + if (baseName.length > 90) { + return res + .status(400) + .json({ error: 'Base name must be less than 90 characters to allow for numbering' }) + } + + // 验证服务权限字段(支持数组格式) + const batchPermissionsError = validatePermissions(permissions) + if (batchPermissionsError) { + return res.status(400).json({ error: batchPermissionsError }) + } + + // 验证服务倍率 + const batchServiceRatesError = validateServiceRates(serviceRates) + if (batchServiceRatesError) { + return res.status(400).json({ error: batchServiceRatesError }) + } + + // 生成批量API Keys + const createdKeys = [] + const errors = [] + + for (let i = 1; i <= count; i++) { + try { + const name = `${baseName}_${i}` + const newKey = await apiKeyService.generateApiKey({ + name, + description, + tokenLimit, + expiresAt, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + openaiAccountId, + bedrockAccountId, + droidAccountId, + permissions, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + rateLimitCost, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + tags, + activationDays, + activationUnit, + expirationMode, + icon, + serviceRates + }) + + // 保留原始 API Key 供返回 + createdKeys.push({ + ...newKey, + apiKey: newKey.apiKey + }) + } catch (error) { + errors.push({ + index: i, + name: `${baseName}_${i}`, + error: error.message + }) + } + } + + // 如果有部分失败,返回部分成功的结果 + if (errors.length > 0 && createdKeys.length === 0) { + return res.status(400).json({ + success: false, + error: 'Failed to create any API keys', + errors + }) + } + + // 返回创建的keys(包含完整的apiKey) + return res.json({ + success: true, + data: createdKeys, + errors: errors.length > 0 ? errors : undefined, + summary: { + requested: count, + created: createdKeys.length, + failed: errors.length + } + }) + } catch (error) { + logger.error('Failed to batch create API keys:', error) + return res.status(500).json({ + success: false, + error: 'Failed to batch create API keys', + message: error.message + }) + } +}) + +// 批量编辑API Keys +router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { + try { + const { keyIds, updates } = req.body + + if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'keyIds must be a non-empty array' + }) + } + + if (!updates || typeof updates !== 'object') { + return res.status(400).json({ + error: 'Invalid input', + message: 'updates must be an object' + }) + } + + // 验证服务权限字段(支持数组格式) + if (updates.permissions !== undefined) { + const updatePermissionsError = validatePermissions(updates.permissions) + if (updatePermissionsError) { + return res.status(400).json({ error: updatePermissionsError }) + } + } + + // 验证服务倍率 + if (updates.serviceRates !== undefined) { + const updateServiceRatesError = validateServiceRates(updates.serviceRates) + if (updateServiceRatesError) { + return res.status(400).json({ error: updateServiceRatesError }) + } + } + + logger.info( + `🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}` + ) + logger.info(`🔍 Debug: keyIds received: ${JSON.stringify(keyIds)}`) + + const results = { + successCount: 0, + failedCount: 0, + errors: [] + } + + // 处理每个API Key + for (const keyId of keyIds) { + try { + // 获取当前API Key信息 + const currentKey = await redis.getApiKey(keyId) + if (!currentKey || Object.keys(currentKey).length === 0) { + results.failedCount++ + results.errors.push(`API key ${keyId} not found`) + continue + } + + // 构建最终更新数据 + const finalUpdates = {} + + // 处理普通字段 + if (updates.name) { + finalUpdates.name = updates.name + } + if (updates.tokenLimit !== undefined) { + finalUpdates.tokenLimit = updates.tokenLimit + } + if (updates.rateLimitCost !== undefined) { + finalUpdates.rateLimitCost = updates.rateLimitCost + } + if (updates.concurrencyLimit !== undefined) { + finalUpdates.concurrencyLimit = updates.concurrencyLimit + } + if (updates.rateLimitWindow !== undefined) { + finalUpdates.rateLimitWindow = updates.rateLimitWindow + } + if (updates.rateLimitRequests !== undefined) { + finalUpdates.rateLimitRequests = updates.rateLimitRequests + } + if (updates.dailyCostLimit !== undefined) { + finalUpdates.dailyCostLimit = updates.dailyCostLimit + } + if (updates.totalCostLimit !== undefined) { + finalUpdates.totalCostLimit = updates.totalCostLimit + } + if (updates.weeklyOpusCostLimit !== undefined) { + finalUpdates.weeklyOpusCostLimit = updates.weeklyOpusCostLimit + } + if (updates.permissions !== undefined) { + finalUpdates.permissions = updates.permissions + } + if (updates.isActive !== undefined) { + finalUpdates.isActive = updates.isActive + } + if (updates.monthlyLimit !== undefined) { + finalUpdates.monthlyLimit = updates.monthlyLimit + } + if (updates.priority !== undefined) { + finalUpdates.priority = updates.priority + } + if (updates.enabled !== undefined) { + finalUpdates.enabled = updates.enabled + } + if (updates.serviceRates !== undefined) { + finalUpdates.serviceRates = updates.serviceRates + } + if (updates.weeklyResetDay !== undefined) { + const day = Number(updates.weeklyResetDay) + if (Number.isInteger(day) && day >= 1 && day <= 7) { + finalUpdates.weeklyResetDay = day + } + } + if (updates.weeklyResetHour !== undefined) { + const hour = Number(updates.weeklyResetHour) + if (Number.isInteger(hour) && hour >= 0 && hour <= 23) { + finalUpdates.weeklyResetHour = hour + } + } + + // 处理账户绑定 + if (updates.claudeAccountId !== undefined) { + finalUpdates.claudeAccountId = updates.claudeAccountId + } + if (updates.claudeConsoleAccountId !== undefined) { + finalUpdates.claudeConsoleAccountId = updates.claudeConsoleAccountId + } + if (updates.geminiAccountId !== undefined) { + finalUpdates.geminiAccountId = updates.geminiAccountId + } + if (updates.openaiAccountId !== undefined) { + finalUpdates.openaiAccountId = updates.openaiAccountId + } + if (updates.bedrockAccountId !== undefined) { + finalUpdates.bedrockAccountId = updates.bedrockAccountId + } + if (updates.droidAccountId !== undefined) { + finalUpdates.droidAccountId = updates.droidAccountId || '' + } + + // 处理标签操作 + if (updates.tags !== undefined) { + if (updates.tagOperation) { + const currentTags = currentKey.tags ? JSON.parse(currentKey.tags) : [] + const operationTags = updates.tags + + switch (updates.tagOperation) { + case 'replace': { + finalUpdates.tags = operationTags + break + } + case 'add': { + const newTags = [...currentTags] + operationTags.forEach((tag) => { + if (!newTags.includes(tag)) { + newTags.push(tag) + } + }) + finalUpdates.tags = newTags + break + } + case 'remove': { + finalUpdates.tags = currentTags.filter((tag) => !operationTags.includes(tag)) + break + } + } + } else { + // 如果没有指定操作类型,默认为替换 + finalUpdates.tags = updates.tags + } + } + + // 执行更新 + await apiKeyService.updateApiKey(keyId, finalUpdates) + + // 重置配置变更后触发单 Key 回填 + if ( + finalUpdates.weeklyResetDay !== undefined || + finalUpdates.weeklyResetHour !== undefined + ) { + setImmediate(async () => { + try { + const weeklyInitService = require('../../services/weeklyClaudeCostInitService') + await weeklyInitService.backfillSingleKey(keyId) + } catch (err) { + logger.error(`❌ 批量编辑回填单 Key 周费用失败 (${keyId}):`, err) + } + }) + } + + results.successCount++ + logger.success(`Batch edit: API key ${keyId} updated successfully`) + } catch (error) { + results.failedCount++ + results.errors.push(`Failed to update key ${keyId}: ${error.message}`) + logger.error(`❌ Batch edit failed for key ${keyId}:`, error) + } + } + + // 记录批量编辑结果 + if (results.successCount > 0) { + logger.success( + `🎉 Batch edit completed: ${results.successCount} successful, ${results.failedCount} failed` + ) + } else { + logger.warn( + `⚠️ Batch edit completed with no successful updates: ${results.failedCount} failed` + ) + } + + return res.json({ + success: true, + message: `批量编辑完成`, + data: results + }) + } catch (error) { + logger.error('❌ Failed to batch edit API keys:', error) + return res.status(500).json({ + error: 'Batch edit failed', + message: error.message + }) + } +}) + +// 更新API Key +router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const { + name, // 添加名称字段 + tokenLimit, + concurrencyLimit, + rateLimitWindow, + rateLimitRequests, + rateLimitCost, + isActive, + claudeAccountId, + claudeConsoleAccountId, + geminiAccountId, + openaiAccountId, + bedrockAccountId, + droidAccountId, + permissions, + enableModelRestriction, + restrictedModels, + enableClientRestriction, + allowedClients, + expiresAt, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + tags, + ownerId, // 新增:所有者ID字段 + serviceRates, // API Key 级别服务倍率 + weeklyResetDay, // 周费用重置日 (1-7) + weeklyResetHour, // 周费用重置时 (0-23) + enableOpenAIResponsesCodexAdaptation, + enableOpenAIResponsesPayloadRules, + openaiResponsesPayloadRules + } = req.body + + // 只允许更新指定字段 + const updates = {} + + // 处理名称字段 + if (name !== undefined && name !== null && name !== '') { + const trimmedName = name.toString().trim() + if (trimmedName.length === 0) { + return res.status(400).json({ error: 'API Key name cannot be empty' }) + } + if (trimmedName.length > 100) { + return res.status(400).json({ error: 'API Key name must be less than 100 characters' }) + } + updates.name = trimmedName + } + + if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { + if (!Number.isInteger(Number(tokenLimit)) || Number(tokenLimit) < 0) { + return res.status(400).json({ error: 'Token limit must be a non-negative integer' }) + } + updates.tokenLimit = Number(tokenLimit) + } + + if (concurrencyLimit !== undefined && concurrencyLimit !== null && concurrencyLimit !== '') { + if (!Number.isInteger(Number(concurrencyLimit)) || Number(concurrencyLimit) < 0) { + return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' }) + } + updates.concurrencyLimit = Number(concurrencyLimit) + } + + if (rateLimitWindow !== undefined && rateLimitWindow !== null && rateLimitWindow !== '') { + if (!Number.isInteger(Number(rateLimitWindow)) || Number(rateLimitWindow) < 0) { + return res + .status(400) + .json({ error: 'Rate limit window must be a non-negative integer (minutes)' }) + } + updates.rateLimitWindow = Number(rateLimitWindow) + } + + if (rateLimitRequests !== undefined && rateLimitRequests !== null && rateLimitRequests !== '') { + if (!Number.isInteger(Number(rateLimitRequests)) || Number(rateLimitRequests) < 0) { + return res.status(400).json({ error: 'Rate limit requests must be a non-negative integer' }) + } + updates.rateLimitRequests = Number(rateLimitRequests) + } + + if (rateLimitCost !== undefined && rateLimitCost !== null && rateLimitCost !== '') { + const cost = Number(rateLimitCost) + if (isNaN(cost) || cost < 0) { + return res.status(400).json({ error: 'Rate limit cost must be a non-negative number' }) + } + updates.rateLimitCost = cost + } + + if (claudeAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.claudeAccountId = claudeAccountId || '' + } + + if (claudeConsoleAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.claudeConsoleAccountId = claudeConsoleAccountId || '' + } + + if (geminiAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.geminiAccountId = geminiAccountId || '' + } + + if (openaiAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.openaiAccountId = openaiAccountId || '' + } + + if (bedrockAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.bedrockAccountId = bedrockAccountId || '' + } + + if (droidAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.droidAccountId = droidAccountId || '' + } + + if (permissions !== undefined) { + // 验证服务权限字段(支持数组格式) + const singlePermissionsError = validatePermissions(permissions) + if (singlePermissionsError) { + return res.status(400).json({ error: singlePermissionsError }) + } + updates.permissions = permissions + } + + // 处理模型限制字段 + if (enableModelRestriction !== undefined) { + if (typeof enableModelRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable model restriction must be a boolean' }) + } + updates.enableModelRestriction = enableModelRestriction + } + + if (restrictedModels !== undefined) { + if (!Array.isArray(restrictedModels)) { + return res.status(400).json({ error: 'Restricted models must be an array' }) + } + updates.restrictedModels = restrictedModels + } + + // 处理客户端限制字段 + if (enableClientRestriction !== undefined) { + if (typeof enableClientRestriction !== 'boolean') { + return res.status(400).json({ error: 'Enable client restriction must be a boolean' }) + } + updates.enableClientRestriction = enableClientRestriction + } + + if (allowedClients !== undefined) { + if (!Array.isArray(allowedClients)) { + return res.status(400).json({ error: 'Allowed clients must be an array' }) + } + updates.allowedClients = allowedClients + } + + // 处理过期时间字段 + if (expiresAt !== undefined) { + if (expiresAt === null) { + // null 表示永不过期 + updates.expiresAt = null + updates.isActive = true + } else { + // 验证日期格式 + const expireDate = new Date(expiresAt) + if (isNaN(expireDate.getTime())) { + return res.status(400).json({ error: 'Invalid expiration date format' }) + } + updates.expiresAt = expiresAt + updates.isActive = expireDate > new Date() // 如果过期时间在当前时间之后,则设置为激活状态 + } + } + + // 处理每日费用限制 + if (dailyCostLimit !== undefined && dailyCostLimit !== null && dailyCostLimit !== '') { + const costLimit = Number(dailyCostLimit) + if (isNaN(costLimit) || costLimit < 0) { + return res.status(400).json({ error: 'Daily cost limit must be a non-negative number' }) + } + updates.dailyCostLimit = costLimit + } + + if (totalCostLimit !== undefined && totalCostLimit !== null && totalCostLimit !== '') { + const costLimit = Number(totalCostLimit) + if (isNaN(costLimit) || costLimit < 0) { + return res.status(400).json({ error: 'Total cost limit must be a non-negative number' }) + } + updates.totalCostLimit = costLimit + } + + // 处理 Opus 周费用限制 + if ( + weeklyOpusCostLimit !== undefined && + weeklyOpusCostLimit !== null && + weeklyOpusCostLimit !== '' + ) { + const costLimit = Number(weeklyOpusCostLimit) + // 明确验证非负数(0 表示禁用,负数无意义) + if (isNaN(costLimit) || costLimit < 0) { + return res + .status(400) + .json({ error: 'Weekly Opus cost limit must be a non-negative number' }) + } + updates.weeklyOpusCostLimit = costLimit + } + + // 处理标签 + if (tags !== undefined) { + if (!Array.isArray(tags)) { + return res.status(400).json({ error: 'Tags must be an array' }) + } + if (tags.some((tag) => typeof tag !== 'string' || tag.trim().length === 0)) { + return res.status(400).json({ error: 'All tags must be non-empty strings' }) + } + updates.tags = tags + } + + // 处理服务倍率 + if (serviceRates !== undefined) { + const singleServiceRatesError = validateServiceRates(serviceRates) + if (singleServiceRatesError) { + return res.status(400).json({ error: singleServiceRatesError }) + } + updates.serviceRates = serviceRates + } + + if (enableOpenAIResponsesCodexAdaptation !== undefined) { + if (typeof enableOpenAIResponsesCodexAdaptation !== 'boolean') { + return res + .status(400) + .json({ error: 'enableOpenAIResponsesCodexAdaptation must be a boolean' }) + } + updates.enableOpenAIResponsesCodexAdaptation = enableOpenAIResponsesCodexAdaptation + } + + if (enableOpenAIResponsesPayloadRules !== undefined) { + if (typeof enableOpenAIResponsesPayloadRules !== 'boolean') { + return res + .status(400) + .json({ error: 'enableOpenAIResponsesPayloadRules must be a boolean' }) + } + updates.enableOpenAIResponsesPayloadRules = enableOpenAIResponsesPayloadRules + } + + if (openaiResponsesPayloadRules !== undefined) { + const payloadRulesValidation = requestBodyRuleService.validateAndNormalizeRules( + openaiResponsesPayloadRules + ) + if (!payloadRulesValidation.valid) { + return res.status(400).json({ error: payloadRulesValidation.error }) + } + updates.openaiResponsesPayloadRules = payloadRulesValidation.rules + } + + // 处理周费用重置配置 + let resetConfigChanged = false + if (weeklyResetDay !== undefined && weeklyResetDay !== null && weeklyResetDay !== '') { + const day = Number(weeklyResetDay) + if (!Number.isInteger(day) || day < 1 || day > 7) { + return res + .status(400) + .json({ error: 'Weekly reset day must be an integer from 1 (Mon) to 7 (Sun)' }) + } + updates.weeklyResetDay = day + resetConfigChanged = true + } + if (weeklyResetHour !== undefined && weeklyResetHour !== null && weeklyResetHour !== '') { + const hour = Number(weeklyResetHour) + if (!Number.isInteger(hour) || hour < 0 || hour > 23) { + return res.status(400).json({ error: 'Weekly reset hour must be an integer from 0 to 23' }) + } + updates.weeklyResetHour = hour + resetConfigChanged = true + } + + // 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能 + if (isActive !== undefined) { + if (typeof isActive !== 'boolean') { + return res.status(400).json({ error: 'isActive must be a boolean' }) + } + updates.isActive = isActive + } + + // 处理所有者变更 + if (ownerId !== undefined) { + const userService = require('../../services/userService') + + if (ownerId === 'admin') { + // 分配给Admin + updates.userId = '' + updates.userUsername = '' + updates.createdBy = 'admin' + } else if (ownerId) { + // 分配给用户 + try { + const user = await userService.getUserById(ownerId, false) + if (!user) { + return res.status(400).json({ error: 'Invalid owner: User not found' }) + } + if (!user.isActive) { + return res.status(400).json({ error: 'Cannot assign to inactive user' }) + } + + // 设置新的所有者信息 + updates.userId = ownerId + updates.userUsername = user.username + updates.createdBy = user.username + + // 管理员重新分配时,不检查用户的API Key数量限制 + logger.info(`🔄 Admin reassigning API key ${keyId} to user ${user.username}`) + } catch (error) { + logger.error('Error fetching user for owner reassignment:', error) + return res.status(400).json({ error: 'Invalid owner ID' }) + } + } else { + // 清空所有者(分配给Admin) + updates.userId = '' + updates.userUsername = '' + updates.createdBy = 'admin' + } + } + + await apiKeyService.updateApiKey(keyId, updates) + + // 重置配置变更后触发单 Key 回填 + if (resetConfigChanged) { + setImmediate(async () => { + try { + const weeklyInitService = require('../../services/weeklyClaudeCostInitService') + await weeklyInitService.backfillSingleKey(keyId) + } catch (err) { + logger.error(`❌ 回填单 Key 周费用失败 (${keyId}):`, err) + } + }) + } + + logger.success(`📝 Admin updated API key: ${keyId}`) + return res.json({ success: true, message: 'API key updated successfully' }) + } catch (error) { + logger.error('❌ Failed to update API key:', error) + return res.status(500).json({ error: 'Failed to update API key', message: error.message }) + } +}) + +// 修改API Key过期时间(包括手动激活功能) +router.patch('/api-keys/:keyId/expiration', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const { expiresAt, activateNow } = req.body + + // 获取当前API Key信息 + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ error: 'API key not found' }) + } + + const updates = {} + + // 如果是激活操作(用于未激活的key) + if (activateNow === true) { + if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') { + const now = new Date() + const activationDays = parseInt(keyData.activationDays || 30) + const newExpiresAt = new Date(now.getTime() + activationDays * 24 * 60 * 60 * 1000) + + updates.isActivated = 'true' + updates.activatedAt = now.toISOString() + updates.expiresAt = newExpiresAt.toISOString() + + logger.success( + `🔓 API key manually activated by admin: ${keyId} (${ + keyData.name + }), expires at ${newExpiresAt.toISOString()}` + ) + } else { + return res.status(400).json({ + error: 'Cannot activate', + message: 'Key is either already activated or not in activation mode' + }) + } + } + + // 如果提供了新的过期时间(但不是激活操作) + if (expiresAt !== undefined && activateNow !== true) { + // 验证过期时间格式 + if (expiresAt && isNaN(Date.parse(expiresAt))) { + return res.status(400).json({ error: 'Invalid expiration date format' }) + } + + // 如果设置了过期时间,确保key是激活状态 + if (expiresAt) { + updates.expiresAt = new Date(expiresAt).toISOString() + // 如果之前是未激活状态,现在激活它 + if (keyData.isActivated !== 'true') { + updates.isActivated = 'true' + updates.activatedAt = new Date().toISOString() + } + } else { + // 清除过期时间(永不过期) + updates.expiresAt = '' + } + } + + if (Object.keys(updates).length === 0) { + return res.status(400).json({ error: 'No valid updates provided' }) + } + + // 更新API Key + await apiKeyService.updateApiKey(keyId, updates) + + logger.success(`📝 Updated API key expiration: ${keyId} (${keyData.name})`) + return res.json({ + success: true, + message: 'API key expiration updated successfully', + updates + }) + } catch (error) { + logger.error('❌ Failed to update API key expiration:', error) + return res.status(500).json({ + error: 'Failed to update API key expiration', + message: error.message + }) + } +}) + +// 批量删除API Keys(必须在 :keyId 路由之前定义) +router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { + try { + const { keyIds } = req.body + + // 调试信息 + logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`) + logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`) + + // 参数验证 + if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { + logger.warn( + `🚨 Invalid keyIds: ${JSON.stringify({ + keyIds, + type: typeof keyIds, + isArray: Array.isArray(keyIds) + })}` + ) + return res.status(400).json({ + error: 'Invalid request', + message: 'keyIds 必须是一个非空数组' + }) + } + + if (keyIds.length > 100) { + return res.status(400).json({ + error: 'Too many keys', + message: '每次最多只能删除100个API Keys' + }) + } + + // 验证keyIds格式 + const invalidKeys = keyIds.filter((id) => !id || typeof id !== 'string') + if (invalidKeys.length > 0) { + return res.status(400).json({ + error: 'Invalid key IDs', + message: '包含无效的API Key ID' + }) + } + + logger.info( + `🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}` + ) + + const results = { + successCount: 0, + failedCount: 0, + errors: [] + } + + // 逐个删除,记录成功和失败情况 + for (const keyId of keyIds) { + try { + // 检查API Key是否存在 + const apiKey = await redis.getApiKey(keyId) + if (!apiKey || Object.keys(apiKey).length === 0) { + results.failedCount++ + results.errors.push({ keyId, error: 'API Key 不存在' }) + continue + } + + // 执行删除 + await apiKeyService.deleteApiKey(keyId) + results.successCount++ + + logger.success(`Batch delete: API key ${keyId} deleted successfully`) + } catch (error) { + results.failedCount++ + results.errors.push({ + keyId, + error: error.message || '删除失败' + }) + + logger.error(`❌ Batch delete failed for key ${keyId}:`, error) + } + } + + // 记录批量删除结果 + if (results.successCount > 0) { + logger.success( + `🎉 Batch delete completed: ${results.successCount} successful, ${results.failedCount} failed` + ) + } else { + logger.warn( + `⚠️ Batch delete completed with no successful deletions: ${results.failedCount} failed` + ) + } + + return res.json({ + success: true, + message: `批量删除完成`, + data: results + }) + } catch (error) { + logger.error('❌ Failed to batch delete API keys:', error) + return res.status(500).json({ + error: 'Batch delete failed', + message: error.message + }) + } +}) + +// 删除单个API Key(必须在批量删除路由之后定义) +router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + + await apiKeyService.deleteApiKey(keyId, req.admin.username, 'admin') + + logger.success(`🗑️ Admin deleted API key: ${keyId}`) + return res.json({ success: true, message: 'API key deleted successfully' }) + } catch (error) { + logger.error('❌ Failed to delete API key:', error) + return res.status(500).json({ error: 'Failed to delete API key', message: error.message }) + } +}) + +// 📋 获取已删除的API Keys +router.get('/api-keys/deleted', authenticateAdmin, async (req, res) => { + try { + const deletedApiKeys = await apiKeyService.getAllApiKeysFast(true) // Include deleted + const onlyDeleted = deletedApiKeys.filter((key) => key.isDeleted === true) + + // Add additional metadata for deleted keys + const enrichedKeys = onlyDeleted.map((key) => ({ + ...key, + isDeleted: key.isDeleted === true, + deletedAt: key.deletedAt, + deletedBy: key.deletedBy, + deletedByType: key.deletedByType, + canRestore: true // 已删除的API Key可以恢复 + })) + + logger.success(`📋 Admin retrieved ${enrichedKeys.length} deleted API keys`) + return res.json({ success: true, apiKeys: enrichedKeys, total: enrichedKeys.length }) + } catch (error) { + logger.error('❌ Failed to get deleted API keys:', error) + return res + .status(500) + .json({ error: 'Failed to retrieve deleted API keys', message: error.message }) + } +}) + +// 🔄 恢复已删除的API Key +router.post('/api-keys/:keyId/restore', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的恢复方法 + const result = await apiKeyService.restoreApiKey(keyId, adminUsername, 'admin') + + if (result.success) { + logger.success(`Admin ${adminUsername} restored API key: ${keyId}`) + return res.json({ + success: true, + message: 'API Key 已成功恢复', + apiKey: result.apiKey + }) + } else { + return res.status(400).json({ + success: false, + error: 'Failed to restore API key' + }) + } + } catch (error) { + logger.error('❌ Failed to restore API key:', error) + + // 根据错误类型返回适当的响应 + if (error.message === 'API key not found') { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } else if (error.message === 'API key is not deleted') { + return res.status(400).json({ + success: false, + error: '该 API Key 未被删除,无需恢复' + }) + } + + return res.status(500).json({ + success: false, + error: '恢复 API Key 失败', + message: error.message + }) + } +}) + +// 🗑️ 彻底删除API Key(物理删除) +router.delete('/api-keys/:keyId/permanent', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的彻底删除方法 + const result = await apiKeyService.permanentDeleteApiKey(keyId) + + if (result.success) { + logger.success(`🗑️ Admin ${adminUsername} permanently deleted API key: ${keyId}`) + return res.json({ + success: true, + message: 'API Key 已彻底删除' + }) + } + } catch (error) { + logger.error('❌ Failed to permanently delete API key:', error) + + if (error.message === 'API key not found') { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } else if (error.message === '只能彻底删除已经删除的API Key') { + return res.status(400).json({ + success: false, + error: '只能彻底删除已经删除的API Key' + }) + } + + return res.status(500).json({ + success: false, + error: '彻底删除 API Key 失败', + message: error.message + }) + } +}) + +// 🧹 清空所有已删除的API Keys +router.delete('/api-keys/deleted/clear-all', authenticateAdmin, async (req, res) => { + try { + const adminUsername = req.session?.admin?.username || 'unknown' + + // 调用服务层的清空方法 + const result = await apiKeyService.clearAllDeletedApiKeys() + + logger.success( + `🧹 Admin ${adminUsername} cleared deleted API keys: ${result.successCount}/${result.total}` + ) + + return res.json({ + success: true, + message: `成功清空 ${result.successCount} 个已删除的 API Keys`, + details: { + total: result.total, + successCount: result.successCount, + failedCount: result.failedCount, + errors: result.errors + } + }) + } catch (error) { + logger.error('❌ Failed to clear all deleted API keys:', error) + return res.status(500).json({ + success: false, + error: '清空已删除的 API Keys 失败', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/azureOpenaiAccounts.js b/src/routes/admin/azureOpenaiAccounts.js new file mode 100644 index 0000000..58170a1 --- /dev/null +++ b/src/routes/admin/azureOpenaiAccounts.js @@ -0,0 +1,513 @@ +const express = require('express') +const azureOpenaiAccountService = require('../../services/account/azureOpenaiAccountService') +const accountGroupService = require('../../services/accountGroupService') +const apiKeyService = require('../../services/apiKeyService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const webhookNotifier = require('../../utils/webhookNotifier') +const axios = require('axios') +const { formatAccountExpiry, mapExpiryField } = require('./utils') + +const router = express.Router() + +// 获取所有 Azure OpenAI 账户 +router.get('/azure-openai-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await azureOpenaiAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'azure_openai') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息和分组信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (error) { + logger.debug(`Failed to get usage stats for Azure OpenAI account ${account.id}:`, error) + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.debug(`Failed to get group info for account ${account.id}:`, groupError) + return { + ...account, + groupInfos: [], + usage: { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + + res.json({ + success: true, + data: accountsWithStats + }) + } catch (error) { + logger.error('Failed to fetch Azure OpenAI accounts:', error) + res.status(500).json({ + success: false, + message: 'Failed to fetch accounts', + error: error.message + }) + } +}) + +// 创建 Azure OpenAI 账户 +router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + accountType, + azureEndpoint, + apiVersion, + deploymentName, + apiKey, + supportedModels, + proxy, + groupId, + groupIds, + priority, + isActive, + schedulable + } = req.body + + // 验证必填字段 + if (!name) { + return res.status(400).json({ + success: false, + message: 'Account name is required' + }) + } + + if (!azureEndpoint) { + return res.status(400).json({ + success: false, + message: 'Azure endpoint is required' + }) + } + + if (!apiKey) { + return res.status(400).json({ + success: false, + message: 'API key is required' + }) + } + + if (!deploymentName) { + return res.status(400).json({ + success: false, + message: 'Deployment name is required' + }) + } + + // 验证 Azure endpoint 格式 + if (!azureEndpoint.match(/^https:\/\/[\w-]+\.openai\.azure\.com$/)) { + return res.status(400).json({ + success: false, + message: + 'Invalid Azure OpenAI endpoint format. Expected: https://your-resource.openai.azure.com' + }) + } + + // 测试连接 + try { + const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${ + apiVersion || '2024-02-01' + }` + await axios.get(testUrl, { + headers: { + 'api-key': apiKey + }, + timeout: 5000 + }) + } catch (testError) { + if (testError.response?.status === 404) { + logger.warn('Azure OpenAI deployment not found, but continuing with account creation') + } else if (testError.response?.status === 401) { + return res.status(400).json({ + success: false, + message: 'Invalid API key or unauthorized access' + }) + } + } + + const account = await azureOpenaiAccountService.createAccount({ + name, + description, + accountType: accountType || 'shared', + azureEndpoint, + apiVersion: apiVersion || '2024-02-01', + deploymentName, + apiKey, + supportedModels, + proxy, + groupId, + priority: priority || 50, + isActive: isActive !== false, + schedulable: schedulable !== false + }) + + // 如果是分组类型,将账户添加到分组 + if (accountType === 'group') { + if (groupIds && groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(account.id, groupIds, 'azure_openai') + } else if (groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(account.id, groupId, 'azure_openai') + } + } + + res.json({ + success: true, + data: account, + message: 'Azure OpenAI account created successfully' + }) + } catch (error) { + logger.error('Failed to create Azure OpenAI account:', error) + res.status(500).json({ + success: false, + message: 'Failed to create account', + error: error.message + }) + } +}) + +// 更新 Azure OpenAI 账户 +router.put('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = req.body + + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'Azure OpenAI', id) + + const account = await azureOpenaiAccountService.updateAccount(id, mappedUpdates) + + res.json({ + success: true, + data: account, + message: 'Azure OpenAI account updated successfully' + }) + } catch (error) { + logger.error('Failed to update Azure OpenAI account:', error) + res.status(500).json({ + success: false, + message: 'Failed to update account', + error: error.message + }) + } +}) + +// 删除 Azure OpenAI 账户 +router.delete('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'azure_openai') + + await azureOpenaiAccountService.deleteAccount(id) + + let message = 'Azure OpenAI账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Azure OpenAI account: ${id}, unbound ${unboundCount} keys`) + + res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('Failed to delete Azure OpenAI account:', error) + res.status(500).json({ + success: false, + message: 'Failed to delete account', + error: error.message + }) + } +}) + +// 切换 Azure OpenAI 账户状态 +router.put('/azure-openai-accounts/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await azureOpenaiAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + const newStatus = account.isActive === 'true' ? 'false' : 'true' + await azureOpenaiAccountService.updateAccount(id, { isActive: newStatus }) + + res.json({ + success: true, + message: `Account ${newStatus === 'true' ? 'activated' : 'deactivated'} successfully`, + isActive: newStatus === 'true' + }) + } catch (error) { + logger.error('Failed to toggle Azure OpenAI account status:', error) + res.status(500).json({ + success: false, + message: 'Failed to toggle account status', + error: error.message + }) + } +}) + +// 切换 Azure OpenAI 账户调度状态 +router.put( + '/azure-openai-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + + const result = await azureOpenaiAccountService.toggleSchedulable(accountId) + + // 如果账号被禁用,发送webhook通知 + if (!result.schedulable) { + // 获取账号信息 + const account = await azureOpenaiAccountService.getAccount(accountId) + if (account) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Azure OpenAI Account', + platform: 'azure-openai', + status: 'disabled', + errorCode: 'AZURE_OPENAI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + } + + return res.json({ + success: true, + schedulable: result.schedulable, + message: result.schedulable ? '已启用调度' : '已禁用调度' + }) + } catch (error) { + logger.error('切换 Azure OpenAI 账户调度状态失败:', error) + return res.status(500).json({ + success: false, + message: '切换调度状态失败', + error: error.message + }) + } + } +) + +// 健康检查单个 Azure OpenAI 账户 +router.post('/azure-openai-accounts/:id/health-check', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const healthResult = await azureOpenaiAccountService.healthCheckAccount(id) + + res.json({ + success: true, + data: healthResult + }) + } catch (error) { + logger.error('Failed to perform health check:', error) + res.status(500).json({ + success: false, + message: 'Failed to perform health check', + error: error.message + }) + } +}) + +// 批量健康检查所有 Azure OpenAI 账户 +router.post('/azure-openai-accounts/health-check-all', authenticateAdmin, async (req, res) => { + try { + const healthResults = await azureOpenaiAccountService.performHealthChecks() + + res.json({ + success: true, + data: healthResults + }) + } catch (error) { + logger.error('Failed to perform batch health check:', error) + res.status(500).json({ + success: false, + message: 'Failed to perform batch health check', + error: error.message + }) + } +}) + +// 迁移 API Keys 以支持 Azure OpenAI +router.post('/migrate-api-keys-azure', authenticateAdmin, async (req, res) => { + try { + const migratedCount = await azureOpenaiAccountService.migrateApiKeysForAzureSupport() + + res.json({ + success: true, + message: `Successfully migrated ${migratedCount} API keys for Azure OpenAI support` + }) + } catch (error) { + logger.error('Failed to migrate API keys:', error) + res.status(500).json({ + success: false, + message: 'Failed to migrate API keys', + error: error.message + }) + } +}) + +// 测试 Azure OpenAI 账户连通性 +router.post('/azure-openai-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const startTime = Date.now() + const { + createChatCompletionsTestPayload, + extractErrorMessage + } = require('../../utils/testPayloadHelper') + + try { + // 获取账户信息 + const account = await azureOpenaiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 获取解密后的 API Key + const apiKey = await azureOpenaiAccountService.getDecryptedApiKey(accountId) + if (!apiKey) { + return res.status(401).json({ error: 'API Key not found or decryption failed' }) + } + + // 构造测试请求 + const { getProxyAgent } = require('../../utils/proxyHelper') + + const deploymentName = account.deploymentName || 'gpt-4o-mini' + const apiVersion = account.apiVersion || '2024-02-15-preview' + const apiUrl = `${account.endpoint}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}` + const payload = createChatCompletionsTestPayload(deploymentName) + + const requestConfig = { + headers: { + 'Content-Type': 'application/json', + 'api-key': apiKey + }, + timeout: 30000 + } + + // 配置代理 + if (account.proxy) { + const agent = getProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + const response = await axios.post(apiUrl, payload, requestConfig) + const latency = Date.now() - startTime + + // 提取响应文本 + let responseText = '' + if (response.data?.choices?.[0]?.message?.content) { + responseText = response.data.choices[0].message.content + } + + logger.success( + `✅ Azure OpenAI account test passed: ${account.name} (${accountId}), latency: ${latency}ms` + ) + + return res.json({ + success: true, + data: { + accountId, + accountName: account.name, + model: deploymentName, + latency, + responseText: responseText.substring(0, 200) + } + }) + } catch (error) { + const latency = Date.now() - startTime + logger.error(`❌ Azure OpenAI account test failed: ${accountId}`, error.message) + + return res.status(500).json({ + success: false, + error: 'Test failed', + message: extractErrorMessage(error.response?.data, error.message), + latency + }) + } +}) + +// 重置 Azure OpenAI 账户状态 +router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const result = await azureOpenaiAccountService.resetAccountStatus(accountId) + logger.success(`Admin reset status for Azure OpenAI account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset Azure OpenAI account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/balanceScripts.js b/src/routes/admin/balanceScripts.js new file mode 100644 index 0000000..ef7ffa0 --- /dev/null +++ b/src/routes/admin/balanceScripts.js @@ -0,0 +1,41 @@ +const express = require('express') +const { authenticateAdmin } = require('../../middleware/auth') +const balanceScriptService = require('../../services/balanceScriptService') +const router = express.Router() + +// 获取全部脚本配置列表 +router.get('/balance-scripts', authenticateAdmin, (req, res) => { + const items = balanceScriptService.listConfigs() + return res.json({ success: true, data: items }) +}) + +// 获取单个脚本配置 +router.get('/balance-scripts/:name', authenticateAdmin, (req, res) => { + const { name } = req.params + const config = balanceScriptService.getConfig(name || 'default') + return res.json({ success: true, data: config }) +}) + +// 保存脚本配置 +router.put('/balance-scripts/:name', authenticateAdmin, (req, res) => { + try { + const { name } = req.params + const saved = balanceScriptService.saveConfig(name || 'default', req.body || {}) + return res.json({ success: true, data: saved }) + } catch (error) { + return res.status(400).json({ success: false, error: error.message }) + } +}) + +// 测试脚本(不落库) +router.post('/balance-scripts/:name/test', authenticateAdmin, async (req, res) => { + try { + const { name } = req.params + const result = await balanceScriptService.testScript(name || 'default', req.body || {}) + return res.json({ success: true, data: result }) + } catch (error) { + return res.status(400).json({ success: false, error: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/bedrockAccounts.js b/src/routes/admin/bedrockAccounts.js new file mode 100644 index 0000000..0c11921 --- /dev/null +++ b/src/routes/admin/bedrockAccounts.js @@ -0,0 +1,379 @@ +/** + * Admin Routes - Bedrock Accounts Management + * AWS Bedrock 账户管理路由 + */ + +const express = require('express') +const router = express.Router() +const bedrockAccountService = require('../../services/account/bedrockAccountService') +const apiKeyService = require('../../services/apiKeyService') +const accountGroupService = require('../../services/accountGroupService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const webhookNotifier = require('../../utils/webhookNotifier') +const { formatAccountExpiry, mapExpiryField } = require('./utils') + +// ☁️ Bedrock 账户管理 + +// 获取所有Bedrock账户 +router.get('/', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + const result = await bedrockAccountService.getAllAccounts() + if (!result.success) { + return res + .status(500) + .json({ error: 'Failed to get Bedrock accounts', message: result.error }) + } + + let accounts = result.data + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'bedrock') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await accountGroupService.getAccountGroups(account.id) + + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for Bedrock account ${account.id}:`, + statsError.message + ) + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + + return res.json({ success: true, data: accountsWithStats }) + } catch (error) { + logger.error('❌ Failed to get Bedrock accounts:', error) + return res.status(500).json({ error: 'Failed to get Bedrock accounts', message: error.message }) + } +}) + +// 创建新的Bedrock账户 +router.post('/', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + region, + awsCredentials, + bearerToken, + defaultModel, + priority, + accountType, + credentialType + } = req.body + + if (!name) { + return res.status(400).json({ error: 'Name is required' }) + } + + // 验证priority的有效性(1-100) + if (priority !== undefined && (priority < 1 || priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated'].includes(accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }) + } + + // 验证credentialType的有效性 + if (credentialType && !['access_key', 'bearer_token'].includes(credentialType)) { + return res.status(400).json({ + error: 'Invalid credential type. Must be "access_key" or "bearer_token"' + }) + } + + const result = await bedrockAccountService.createAccount({ + name, + description: description || '', + region: region || 'us-east-1', + awsCredentials, + bearerToken, + defaultModel, + priority: priority || 50, + accountType: accountType || 'shared', + credentialType: credentialType || 'access_key' + }) + + if (!result.success) { + return res + .status(500) + .json({ error: 'Failed to create Bedrock account', message: result.error }) + } + + logger.success(`☁️ Admin created Bedrock account: ${name}`) + const formattedAccount = formatAccountExpiry(result.data) + return res.json({ success: true, data: formattedAccount }) + } catch (error) { + logger.error('❌ Failed to create Bedrock account:', error) + return res + .status(500) + .json({ error: 'Failed to create Bedrock account', message: error.message }) + } +}) + +// 更新Bedrock账户 +router.put('/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const updates = req.body + + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'Bedrock', accountId) + + // 验证priority的有效性(1-100) + if ( + mappedUpdates.priority !== undefined && + (mappedUpdates.priority < 1 || mappedUpdates.priority > 100) + ) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证accountType的有效性 + if (mappedUpdates.accountType && !['shared', 'dedicated'].includes(mappedUpdates.accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }) + } + + // 验证credentialType的有效性 + if ( + mappedUpdates.credentialType && + !['access_key', 'bearer_token'].includes(mappedUpdates.credentialType) + ) { + return res.status(400).json({ + error: 'Invalid credential type. Must be "access_key" or "bearer_token"' + }) + } + + const result = await bedrockAccountService.updateAccount(accountId, mappedUpdates) + + if (!result.success) { + return res + .status(500) + .json({ error: 'Failed to update Bedrock account', message: result.error }) + } + + logger.success(`📝 Admin updated Bedrock account: ${accountId}`) + return res.json({ success: true, message: 'Bedrock account updated successfully' }) + } catch (error) { + logger.error('❌ Failed to update Bedrock account:', error) + return res + .status(500) + .json({ error: 'Failed to update Bedrock account', message: error.message }) + } +}) + +// 删除Bedrock账户 +router.delete('/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'bedrock') + + const result = await bedrockAccountService.deleteAccount(accountId) + + if (!result.success) { + return res + .status(500) + .json({ error: 'Failed to delete Bedrock account', message: result.error }) + } + + let message = 'Bedrock账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Bedrock account: ${accountId}, unbound ${unboundCount} keys`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('❌ Failed to delete Bedrock account:', error) + return res + .status(500) + .json({ error: 'Failed to delete Bedrock account', message: error.message }) + } +}) + +// 切换Bedrock账户状态 +router.put('/:accountId/toggle', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const accountResult = await bedrockAccountService.getAccount(accountId) + if (!accountResult.success) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newStatus = !accountResult.data.isActive + const updateResult = await bedrockAccountService.updateAccount(accountId, { + isActive: newStatus + }) + + if (!updateResult.success) { + return res + .status(500) + .json({ error: 'Failed to toggle account status', message: updateResult.error }) + } + + logger.success( + `🔄 Admin toggled Bedrock account status: ${accountId} -> ${ + newStatus ? 'active' : 'inactive' + }` + ) + return res.json({ success: true, isActive: newStatus }) + } catch (error) { + logger.error('❌ Failed to toggle Bedrock account status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle account status', message: error.message }) + } +}) + +// 切换Bedrock账户调度状态 +router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const accountResult = await bedrockAccountService.getAccount(accountId) + if (!accountResult.success) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newSchedulable = !accountResult.data.schedulable + const updateResult = await bedrockAccountService.updateAccount(accountId, { + schedulable: newSchedulable + }) + + if (!updateResult.success) { + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: updateResult.error }) + } + + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: accountResult.data.id, + accountName: accountResult.data.name || 'Bedrock Account', + platform: 'bedrock', + status: 'disabled', + errorCode: 'BEDROCK_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + return res.json({ success: true, schedulable: newSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Bedrock account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } +}) + +// 测试Bedrock账户连接(SSE 流式) +router.post('/:accountId/test', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + await bedrockAccountService.testAccountConnection(accountId, res) + } catch (error) { + logger.error('❌ Failed to test Bedrock account:', error) + // 错误已在服务层处理,这里仅做日志记录 + } +}) + +// 重置 Bedrock 账户状态 +router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const result = await bedrockAccountService.resetAccountStatus(accountId) + logger.success(`Admin reset status for Bedrock account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset Bedrock account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/ccrAccounts.js b/src/routes/admin/ccrAccounts.js new file mode 100644 index 0000000..66657a6 --- /dev/null +++ b/src/routes/admin/ccrAccounts.js @@ -0,0 +1,502 @@ +const express = require('express') +const ccrAccountService = require('../../services/account/ccrAccountService') +const accountGroupService = require('../../services/accountGroupService') +const apiKeyService = require('../../services/apiKeyService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const webhookNotifier = require('../../utils/webhookNotifier') +const { formatAccountExpiry, mapExpiryField } = require('./utils') +const { extractErrorMessage } = require('../../utils/testPayloadHelper') + +const router = express.Router() + +// 🔧 CCR 账户管理 + +// 获取所有CCR账户 +router.get('/', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await ccrAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'ccr') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id) + const groupInfos = await accountGroupService.getAccountGroups(account.id) + + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for CCR account ${account.id}:`, + statsError.message + ) + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for CCR account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + + return res.json({ success: true, data: accountsWithStats }) + } catch (error) { + logger.error('❌ Failed to get CCR accounts:', error) + return res.status(500).json({ error: 'Failed to get CCR accounts', message: error.message }) + } +}) + +// 创建新的CCR账户 +router.post('/', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + apiUrl, + apiKey, + priority, + supportedModels, + userAgent, + rateLimitDuration, + proxy, + accountType, + groupId, + dailyQuota, + quotaResetTime + } = req.body + + if (!name || !apiUrl || !apiKey) { + return res.status(400).json({ error: 'Name, API URL and API Key are required' }) + } + + // 验证priority的有效性(1-100) + if (priority !== undefined && (priority < 1 || priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果是分组类型,验证groupId + if (accountType === 'group' && !groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + const newAccount = await ccrAccountService.createAccount({ + name, + description, + apiUrl, + apiKey, + priority: priority || 50, + supportedModels: supportedModels || [], + userAgent, + rateLimitDuration: + rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, + proxy, + accountType: accountType || 'shared', + dailyQuota: dailyQuota || 0, + quotaResetTime: quotaResetTime || '00:00' + }) + + // 如果是分组类型,将账户添加到分组 + if (accountType === 'group' && groupId) { + await accountGroupService.addAccountToGroup(newAccount.id, groupId) + } + + logger.success(`🔧 Admin created CCR account: ${name}`) + const formattedAccount = formatAccountExpiry(newAccount) + return res.json({ success: true, data: formattedAccount }) + } catch (error) { + logger.error('❌ Failed to create CCR account:', error) + return res.status(500).json({ error: 'Failed to create CCR account', message: error.message }) + } +}) + +// 更新CCR账户 +router.put('/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const updates = req.body + + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'CCR', accountId) + + // 验证priority的有效性(1-100) + if ( + mappedUpdates.priority !== undefined && + (mappedUpdates.priority < 1 || mappedUpdates.priority > 100) + ) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证accountType的有效性 + if ( + mappedUpdates.accountType && + !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) + ) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果更新为分组类型,验证groupId + if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await ccrAccountService.getAccount(accountId) + if (!currentAccount) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 处理分组的变更 + if (mappedUpdates.accountType !== undefined) { + // 如果之前是分组类型,需要从所有分组中移除 + if (currentAccount.accountType === 'group') { + const oldGroups = await accountGroupService.getAccountGroups(accountId) + for (const oldGroup of oldGroups) { + await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) + } + } + // 如果新类型是分组,处理多分组支持 + if (mappedUpdates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { + // 如果明确提供了 groupIds 参数(包括空数组) + if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { + // 设置新的多分组 + await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (mappedUpdates.groupId) { + // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 + await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude') + } + } + } + + await ccrAccountService.updateAccount(accountId, mappedUpdates) + + logger.success(`📝 Admin updated CCR account: ${accountId}`) + return res.json({ success: true, message: 'CCR account updated successfully' }) + } catch (error) { + logger.error('❌ Failed to update CCR account:', error) + return res.status(500).json({ error: 'Failed to update CCR account', message: error.message }) + } +}) + +// 删除CCR账户 +router.delete('/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + // 尝试自动解绑(CCR账户实际上不会绑定API Key,但保持代码一致性) + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'ccr') + + // 获取账户信息以检查是否在分组中 + const account = await ccrAccountService.getAccount(accountId) + if (account && account.accountType === 'group') { + const groups = await accountGroupService.getAccountGroups(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + } + + await ccrAccountService.deleteAccount(accountId) + + let message = 'CCR账号已成功删除' + if (unboundCount > 0) { + // 理论上不会发生,但保持消息格式一致 + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted CCR account: ${accountId}`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('❌ Failed to delete CCR account:', error) + return res.status(500).json({ error: 'Failed to delete CCR account', message: error.message }) + } +}) + +// 切换CCR账户状态 +router.put('/:accountId/toggle', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const account = await ccrAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newStatus = !account.isActive + await ccrAccountService.updateAccount(accountId, { isActive: newStatus }) + + logger.success( + `🔄 Admin toggled CCR account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}` + ) + return res.json({ success: true, isActive: newStatus }) + } catch (error) { + logger.error('❌ Failed to toggle CCR account status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle account status', message: error.message }) + } +}) + +// 切换CCR账户调度状态 +router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const account = await ccrAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newSchedulable = !account.schedulable + await ccrAccountService.updateAccount(accountId, { schedulable: newSchedulable }) + + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'CCR Account', + platform: 'ccr', + status: 'disabled', + errorCode: 'CCR_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled CCR account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + return res.json({ success: true, schedulable: newSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle CCR account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } +}) + +// 获取CCR账户的使用统计 +router.get('/:accountId/usage', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const usageStats = await ccrAccountService.getAccountUsageStats(accountId) + + if (!usageStats) { + return res.status(404).json({ error: 'Account not found' }) + } + + return res.json(usageStats) + } catch (error) { + logger.error('❌ Failed to get CCR account usage stats:', error) + return res.status(500).json({ error: 'Failed to get usage stats', message: error.message }) + } +}) + +// 手动重置CCR账户的每日使用量 +router.post('/:accountId/reset-usage', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + await ccrAccountService.resetDailyUsage(accountId) + + logger.success(`Admin manually reset daily usage for CCR account: ${accountId}`) + return res.json({ success: true, message: 'Daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset CCR account daily usage:', error) + return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message }) + } +}) + +// 重置CCR账户状态(清除所有异常状态) +router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const result = await ccrAccountService.resetAccountStatus(accountId) + logger.success(`Admin reset status for CCR account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset CCR account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 手动重置所有CCR账户的每日使用量 +router.post('/reset-all-usage', authenticateAdmin, async (req, res) => { + try { + await ccrAccountService.resetAllDailyUsage() + + logger.success('Admin manually reset daily usage for all CCR accounts') + return res.json({ success: true, message: 'All daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset all CCR accounts daily usage:', error) + return res + .status(500) + .json({ error: 'Failed to reset all daily usage', message: error.message }) + } +}) + +// 测试 CCR 账户连通性 +router.post('/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { model = 'claude-sonnet-4-20250514' } = req.body + const startTime = Date.now() + + try { + // 获取账户信息 + const account = await ccrAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 获取解密后的凭据 + const credentials = await ccrAccountService.getDecryptedCredentials(accountId) + if (!credentials) { + return res.status(401).json({ error: 'Credentials not found or decryption failed' }) + } + + // 构造测试请求 + const axios = require('axios') + const { getProxyAgent } = require('../../utils/proxyHelper') + + const baseUrl = account.baseUrl || 'https://api.anthropic.com' + const apiUrl = `${baseUrl}/v1/messages` + const payload = { + model, + max_tokens: 100, + messages: [{ role: 'user', content: 'Say "Hello" in one word.' }] + } + + const requestConfig = { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': credentials.apiKey, + 'anthropic-version': '2023-06-01' + }, + timeout: 30000 + } + + // 配置代理 + if (account.proxy) { + const agent = getProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + const response = await axios.post(apiUrl, payload, requestConfig) + const latency = Date.now() - startTime + + // 提取响应文本 + let responseText = '' + if (response.data?.content?.[0]?.text) { + responseText = response.data.content[0].text + } + + logger.success( + `✅ CCR account test passed: ${account.name} (${accountId}), latency: ${latency}ms` + ) + + return res.json({ + success: true, + data: { + accountId, + accountName: account.name, + model, + latency, + responseText: responseText.substring(0, 200) + } + }) + } catch (error) { + const latency = Date.now() - startTime + logger.error(`❌ CCR account test failed: ${accountId}`, error.message) + + return res.status(500).json({ + success: false, + error: 'Test failed', + message: extractErrorMessage(error.response?.data, error.message), + latency + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/claudeAccounts.js b/src/routes/admin/claudeAccounts.js new file mode 100644 index 0000000..ff1215d --- /dev/null +++ b/src/routes/admin/claudeAccounts.js @@ -0,0 +1,1188 @@ +/** + * Admin Routes - Claude 官方账户管理 + * OAuth 方式授权的 Claude 账户 + */ + +const express = require('express') +const router = express.Router() + +const claudeAccountService = require('../../services/account/claudeAccountService') +const claudeRelayService = require('../../services/relay/claudeRelayService') +const accountGroupService = require('../../services/accountGroupService') +const accountTestSchedulerService = require('../../services/accountTestSchedulerService') +const apiKeyService = require('../../services/apiKeyService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const oauthHelper = require('../../utils/oauthHelper') +const CostCalculator = require('../../utils/costCalculator') +const webhookNotifier = require('../../utils/webhookNotifier') +const { + isEmptyValue, + parseBooleanLike, + normalizeOptionalNonNegativeInteger +} = require('../../utils/tempUnavailablePolicy') +const { formatAccountExpiry, mapExpiryField } = require('./utils') + +const TEMP_UNAVAILABLE_TTL_FIELDS = ['tempUnavailable503TtlSeconds', 'tempUnavailable5xxTtlSeconds'] + +const normalizeTempUnavailablePolicyPayload = (payload, options = {}) => { + const { partial = false } = options + const normalized = {} + + for (const field of TEMP_UNAVAILABLE_TTL_FIELDS) { + if (partial && !Object.prototype.hasOwnProperty.call(payload, field)) { + continue + } + + const rawValue = payload[field] + const parsedValue = normalizeOptionalNonNegativeInteger(rawValue) + if (!isEmptyValue(rawValue) && parsedValue === null) { + return { error: `${field} must be a non-negative integer` } + } + normalized[field] = parsedValue + } + + if (!partial || Object.prototype.hasOwnProperty.call(payload, 'disableTempUnavailable')) { + normalized.disableTempUnavailable = parseBooleanLike(payload.disableTempUnavailable) + } + + return { normalized } +} + +// 生成OAuth授权URL +router.post('/claude-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { + try { + const { proxy } = req.body // 接收代理配置 + const oauthParams = await oauthHelper.generateOAuthParams() + + // 将codeVerifier和state临时存储到Redis,用于后续验证 + const sessionId = require('crypto').randomUUID() + await redis.setOAuthSession(sessionId, { + codeVerifier: oauthParams.codeVerifier, + state: oauthParams.state, + codeChallenge: oauthParams.codeChallenge, + proxy: proxy || null, // 存储代理配置 + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期 + }) + + logger.success('Generated OAuth authorization URL with proxy support') + return res.json({ + success: true, + data: { + authUrl: oauthParams.authUrl, + sessionId, + instructions: [ + '1. 复制上面的链接到浏览器中打开', + '2. 登录您的 Anthropic 账户', + '3. 同意应用权限', + '4. 复制浏览器地址栏中的完整 URL', + '5. 在添加账户表单中粘贴完整的回调 URL 和授权码' + ] + } + }) + } catch (error) { + logger.error('❌ Failed to generate OAuth URL:', error) + return res.status(500).json({ error: 'Failed to generate OAuth URL', message: error.message }) + } +}) + +// 验证授权码并获取token +router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res) => { + try { + const { sessionId, authorizationCode, callbackUrl } = req.body + + if (!sessionId || (!authorizationCode && !callbackUrl)) { + return res + .status(400) + .json({ error: 'Session ID and authorization code (or callback URL) are required' }) + } + + // 从Redis获取OAuth会话信息 + const oauthSession = await redis.getOAuthSession(sessionId) + if (!oauthSession) { + return res.status(400).json({ error: 'Invalid or expired OAuth session' }) + } + + // 检查会话是否过期 + if (new Date() > new Date(oauthSession.expiresAt)) { + await redis.deleteOAuthSession(sessionId) + return res + .status(400) + .json({ error: 'OAuth session has expired, please generate a new authorization URL' }) + } + + // 统一处理授权码输入(可能是直接的code或完整的回调URL) + let finalAuthCode + const inputValue = callbackUrl || authorizationCode + + try { + finalAuthCode = oauthHelper.parseCallbackUrl(inputValue) + } catch (parseError) { + return res + .status(400) + .json({ error: 'Failed to parse authorization input', message: parseError.message }) + } + + // 交换访问令牌 + const tokenData = await oauthHelper.exchangeCodeForTokens( + finalAuthCode, + oauthSession.codeVerifier, + oauthSession.state, + oauthSession.proxy // 传递代理配置 + ) + + // 清理OAuth会话 + await redis.deleteOAuthSession(sessionId) + + logger.success('🎉 Successfully exchanged authorization code for tokens') + return res.json({ + success: true, + data: { + claudeAiOauth: tokenData + } + }) + } catch (error) { + logger.error('❌ Failed to exchange authorization code:', { + error: error.message, + sessionId: req.body.sessionId, + // 不记录完整的授权码,只记录长度和前几个字符 + codeLength: req.body.callbackUrl + ? req.body.callbackUrl.length + : req.body.authorizationCode + ? req.body.authorizationCode.length + : 0, + codePrefix: req.body.callbackUrl + ? `${req.body.callbackUrl.substring(0, 10)}...` + : req.body.authorizationCode + ? `${req.body.authorizationCode.substring(0, 10)}...` + : 'N/A' + }) + return res + .status(500) + .json({ error: 'Failed to exchange authorization code', message: error.message }) + } +}) + +// 生成Claude setup-token授权URL +router.post('/claude-accounts/generate-setup-token-url', authenticateAdmin, async (req, res) => { + try { + const { proxy } = req.body // 接收代理配置 + const setupTokenParams = await oauthHelper.generateSetupTokenParams() + + // 将codeVerifier和state临时存储到Redis,用于后续验证 + const sessionId = require('crypto').randomUUID() + await redis.setOAuthSession(sessionId, { + type: 'setup-token', // 标记为setup-token类型 + codeVerifier: setupTokenParams.codeVerifier, + state: setupTokenParams.state, + codeChallenge: setupTokenParams.codeChallenge, + proxy: proxy || null, // 存储代理配置 + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() // 10分钟过期 + }) + + logger.success('Generated Setup Token authorization URL with proxy support') + return res.json({ + success: true, + data: { + authUrl: setupTokenParams.authUrl, + sessionId, + instructions: [ + '1. 复制上面的链接到浏览器中打开', + '2. 登录您的 Claude 账户并授权 Claude Code', + '3. 完成授权后,从返回页面复制 Authorization Code', + '4. 在添加账户表单中粘贴 Authorization Code' + ] + } + }) + } catch (error) { + logger.error('❌ Failed to generate Setup Token URL:', error) + return res + .status(500) + .json({ error: 'Failed to generate Setup Token URL', message: error.message }) + } +}) + +// 验证setup-token授权码并获取token +router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, async (req, res) => { + try { + const { sessionId, authorizationCode, callbackUrl } = req.body + + if (!sessionId || (!authorizationCode && !callbackUrl)) { + return res + .status(400) + .json({ error: 'Session ID and authorization code (or callback URL) are required' }) + } + + // 从Redis获取OAuth会话信息 + const oauthSession = await redis.getOAuthSession(sessionId) + if (!oauthSession) { + return res.status(400).json({ error: 'Invalid or expired OAuth session' }) + } + + // 检查是否是setup-token类型 + if (oauthSession.type !== 'setup-token') { + return res.status(400).json({ error: 'Invalid session type for setup token exchange' }) + } + + // 检查会话是否过期 + if (new Date() > new Date(oauthSession.expiresAt)) { + await redis.deleteOAuthSession(sessionId) + return res + .status(400) + .json({ error: 'OAuth session has expired, please generate a new authorization URL' }) + } + + // 统一处理授权码输入(可能是直接的code或完整的回调URL) + let finalAuthCode + const inputValue = callbackUrl || authorizationCode + + try { + finalAuthCode = oauthHelper.parseCallbackUrl(inputValue) + } catch (parseError) { + return res + .status(400) + .json({ error: 'Failed to parse authorization input', message: parseError.message }) + } + + // 交换Setup Token + const tokenData = await oauthHelper.exchangeSetupTokenCode( + finalAuthCode, + oauthSession.codeVerifier, + oauthSession.state, + oauthSession.proxy // 传递代理配置 + ) + + // 清理OAuth会话 + await redis.deleteOAuthSession(sessionId) + + logger.success('🎉 Successfully exchanged setup token authorization code for tokens') + return res.json({ + success: true, + data: { + claudeAiOauth: tokenData + } + }) + } catch (error) { + logger.error('❌ Failed to exchange setup token authorization code:', { + error: error.message, + sessionId: req.body.sessionId, + // 不记录完整的授权码,只记录长度和前几个字符 + codeLength: req.body.callbackUrl + ? req.body.callbackUrl.length + : req.body.authorizationCode + ? req.body.authorizationCode.length + : 0, + codePrefix: req.body.callbackUrl + ? `${req.body.callbackUrl.substring(0, 10)}...` + : req.body.authorizationCode + ? `${req.body.authorizationCode.substring(0, 10)}...` + : 'N/A' + }) + return res + .status(500) + .json({ error: 'Failed to exchange setup token authorization code', message: error.message }) + } +}) + +// ============================================================================= +// Cookie自动授权端点 (基于sessionKey自动完成OAuth流程) +// ============================================================================= + +// 普通OAuth的Cookie自动授权 +router.post('/claude-accounts/oauth-with-cookie', authenticateAdmin, async (req, res) => { + try { + const { sessionKey, proxy } = req.body + + // 验证sessionKey参数 + if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) { + return res.status(400).json({ + success: false, + error: 'sessionKey不能为空', + message: '请提供有效的sessionKey值' + }) + } + + const trimmedSessionKey = sessionKey.trim() + + logger.info('🍪 Starting Cookie-based OAuth authorization', { + sessionKeyLength: trimmedSessionKey.length, + sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`, + hasProxy: !!proxy + }) + + // 执行Cookie自动授权流程 + const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, false) + + logger.success('🎉 Cookie-based OAuth authorization completed successfully') + + return res.json({ + success: true, + data: { + claudeAiOauth: result.claudeAiOauth, + organizationUuid: result.organizationUuid, + capabilities: result.capabilities + } + }) + } catch (error) { + logger.error('❌ Cookie-based OAuth authorization failed:', { + error: error.message, + sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0 + }) + + return res.status(500).json({ + success: false, + error: 'Cookie授权失败', + message: error.message + }) + } +}) + +// Setup Token的Cookie自动授权 +router.post('/claude-accounts/setup-token-with-cookie', authenticateAdmin, async (req, res) => { + try { + const { sessionKey, proxy } = req.body + + // 验证sessionKey参数 + if (!sessionKey || typeof sessionKey !== 'string' || sessionKey.trim().length === 0) { + return res.status(400).json({ + success: false, + error: 'sessionKey不能为空', + message: '请提供有效的sessionKey值' + }) + } + + const trimmedSessionKey = sessionKey.trim() + + logger.info('🍪 Starting Cookie-based Setup Token authorization', { + sessionKeyLength: trimmedSessionKey.length, + sessionKeyPrefix: `${trimmedSessionKey.substring(0, 10)}...`, + hasProxy: !!proxy + }) + + // 执行Cookie自动授权流程(Setup Token模式) + const result = await oauthHelper.oauthWithCookie(trimmedSessionKey, proxy, true) + + logger.success('🎉 Cookie-based Setup Token authorization completed successfully') + + return res.json({ + success: true, + data: { + claudeAiOauth: result.claudeAiOauth, + organizationUuid: result.organizationUuid, + capabilities: result.capabilities + } + }) + } catch (error) { + logger.error('❌ Cookie-based Setup Token authorization failed:', { + error: error.message, + sessionKeyLength: req.body.sessionKey ? req.body.sessionKey.length : 0 + }) + + return res.status(500).json({ + success: false, + error: 'Cookie授权失败', + message: error.message + }) + } +}) + +// 获取所有Claude账户 +router.get('/claude-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await claudeAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'claude') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await accountGroupService.getAccountGroups(account.id) + + // 获取会话窗口使用统计(仅对有活跃窗口的账户) + let sessionWindowUsage = null + if (account.sessionWindow && account.sessionWindow.hasActiveWindow) { + const windowUsage = await redis.getAccountSessionWindowUsage( + account.id, + account.sessionWindow.windowStart, + account.sessionWindow.windowEnd + ) + + // 计算会话窗口的总费用 + let totalCost = 0 + const modelCosts = {} + + for (const [modelName, usage] of Object.entries(windowUsage.modelUsage)) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + // 添加 cache_creation 子对象以支持精确 ephemeral 定价 + if (usage.ephemeral5mTokens > 0 || usage.ephemeral1hTokens > 0) { + usageData.cache_creation = { + ephemeral_5m_input_tokens: usage.ephemeral5mTokens, + ephemeral_1h_input_tokens: usage.ephemeral1hTokens + } + } + + logger.debug(`💰 Calculating cost for model ${modelName}:`, JSON.stringify(usageData)) + const costResult = CostCalculator.calculateCost(usageData, modelName) + logger.debug(`💰 Cost result for ${modelName}: total=${costResult.costs.total}`) + + modelCosts[modelName] = { + ...usage, + cost: costResult.costs.total + } + totalCost += costResult.costs.total + } + + sessionWindowUsage = { + totalTokens: windowUsage.totalAllTokens, + totalRequests: windowUsage.totalRequests, + totalCost, + modelUsage: modelCosts + } + } + + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages, + sessionWindow: sessionWindowUsage + } + } + } catch (statsError) { + logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message) + // 如果获取统计失败,返回空统计 + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 }, + sessionWindow: null + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 }, + sessionWindow: null + } + } + } + } + }) + ) + + return res.json({ success: true, data: accountsWithStats }) + } catch (error) { + logger.error('❌ Failed to get Claude accounts:', error) + return res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message }) + } +}) + +// 批量获取 Claude 账户的 OAuth Usage 数据 +router.get('/claude-accounts/usage', authenticateAdmin, async (req, res) => { + try { + const accounts = await redis.getAllClaudeAccounts() + const now = Date.now() + const usageCacheTtlMs = 300 * 1000 + + // 批量并发获取所有活跃 OAuth 账户的 Usage + const usagePromises = accounts.map(async (account) => { + // 检查是否为 OAuth 账户:scopes 包含 OAuth 相关权限 + const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [] + const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference') + + // 仅为 OAuth 授权的活跃账户调用 usage API + if ( + isOAuth && + account.isActive === 'true' && + account.accessToken && + account.status === 'active' + ) { + // 若快照在 300 秒内更新,直接使用缓存避免频繁请求 + const cachedUsage = claudeAccountService.buildClaudeUsageSnapshot(account) + const lastUpdatedAt = account.claudeUsageUpdatedAt + ? new Date(account.claudeUsageUpdatedAt).getTime() + : 0 + const isCacheFresh = cachedUsage && lastUpdatedAt && now - lastUpdatedAt < usageCacheTtlMs + if (isCacheFresh) { + return { + accountId: account.id, + claudeUsage: cachedUsage + } + } + + try { + const usageData = await claudeAccountService.fetchOAuthUsage(account.id) + if (usageData) { + await claudeAccountService.updateClaudeUsageSnapshot(account.id, usageData) + } + // 重新读取更新后的数据 + const updatedAccount = await redis.getClaudeAccount(account.id) + return { + accountId: account.id, + claudeUsage: claudeAccountService.buildClaudeUsageSnapshot(updatedAccount) + } + } catch (error) { + logger.debug(`Failed to fetch OAuth usage for ${account.id}:`, error.message) + return { accountId: account.id, claudeUsage: null } + } + } + // Setup Token 账户不调用 usage API,直接返回 null + return { accountId: account.id, claudeUsage: null } + }) + + const results = await Promise.allSettled(usagePromises) + + // 转换为 { accountId: usage } 映射 + const usageMap = {} + results.forEach((result) => { + if (result.status === 'fulfilled' && result.value) { + usageMap[result.value.accountId] = result.value.claudeUsage + } + }) + + res.json({ success: true, data: usageMap }) + } catch (error) { + logger.error('❌ Failed to fetch Claude accounts usage:', error) + res.status(500).json({ error: 'Failed to fetch usage data', message: error.message }) + } +}) + +// 创建新的Claude账户 +router.post('/claude-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + email, + password, + refreshToken, + claudeAiOauth, + proxy, + accountType, + platform = 'claude', + priority, + groupId, + groupIds, + autoStopOnWarning, + useUnifiedUserAgent, + useUnifiedClientId, + unifiedClientId, + expiresAt, + extInfo, + maxConcurrency, + interceptWarmup, + disableTempUnavailable, + tempUnavailable503TtlSeconds, + tempUnavailable5xxTtlSeconds + } = req.body + + if (!name) { + return res.status(400).json({ error: 'Name is required' }) + } + + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果是分组类型,验证groupId或groupIds + if (accountType === 'group' && !groupId && (!groupIds || groupIds.length === 0)) { + return res + .status(400) + .json({ error: 'Group ID or Group IDs are required for group type accounts' }) + } + + // 验证priority的有效性 + if ( + priority !== undefined && + (typeof priority !== 'number' || priority < 1 || priority > 100) + ) { + return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }) + } + + const { normalized: normalizedTempUnavailablePolicy, error: tempUnavailablePolicyError } = + normalizeTempUnavailablePolicyPayload({ + disableTempUnavailable, + tempUnavailable503TtlSeconds, + tempUnavailable5xxTtlSeconds + }) + if (tempUnavailablePolicyError) { + return res.status(400).json({ error: tempUnavailablePolicyError }) + } + + const newAccount = await claudeAccountService.createAccount({ + name, + description, + email, + password, + refreshToken, + claudeAiOauth, + proxy, + accountType: accountType || 'shared', // 默认为共享类型 + platform, + priority: priority || 50, // 默认优先级为50 + autoStopOnWarning: autoStopOnWarning === true, // 默认为false + useUnifiedUserAgent: useUnifiedUserAgent === true, // 默认为false + useUnifiedClientId: useUnifiedClientId === true, // 默认为false + unifiedClientId: unifiedClientId || '', // 统一的客户端标识 + expiresAt: expiresAt || null, // 账户订阅到期时间 + extInfo: extInfo || null, + maxConcurrency: maxConcurrency || 0, // 账户级串行队列:0=使用全局配置,>0=强制启用 + interceptWarmup: interceptWarmup === true, // 拦截预热请求:默认为false + disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable, + tempUnavailable503TtlSeconds: normalizedTempUnavailablePolicy.tempUnavailable503TtlSeconds, + tempUnavailable5xxTtlSeconds: normalizedTempUnavailablePolicy.tempUnavailable5xxTtlSeconds + }) + + // 如果是分组类型,将账户添加到分组 + if (accountType === 'group') { + if (groupIds && groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(newAccount.id, groupIds, newAccount.platform) + } else if (groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(newAccount.id, groupId, newAccount.platform) + } + } + + logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`) + const formattedAccount = formatAccountExpiry(newAccount) + return res.json({ success: true, data: formattedAccount }) + } catch (error) { + logger.error('❌ Failed to create Claude account:', error) + return res + .status(500) + .json({ error: 'Failed to create Claude account', message: error.message }) + } +}) + +// 更新Claude账户 +router.put('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const updates = req.body + + // ✅ 【修改】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt(提前到参数验证之前) + const mappedUpdates = mapExpiryField(updates, 'Claude', accountId) + + // 验证priority的有效性 + if ( + mappedUpdates.priority !== undefined && + (typeof mappedUpdates.priority !== 'number' || + mappedUpdates.priority < 1 || + mappedUpdates.priority > 100) + ) { + return res.status(400).json({ error: 'Priority must be a number between 1 and 100' }) + } + + const { normalized: normalizedTempUnavailablePolicy, error: tempUnavailablePolicyError } = + normalizeTempUnavailablePolicyPayload(mappedUpdates, { partial: true }) + if (tempUnavailablePolicyError) { + return res.status(400).json({ error: tempUnavailablePolicyError }) + } + Object.assign(mappedUpdates, normalizedTempUnavailablePolicy) + + // 验证accountType的有效性 + if ( + mappedUpdates.accountType && + !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) + ) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果更新为分组类型,验证groupId或groupIds + if ( + mappedUpdates.accountType === 'group' && + !mappedUpdates.groupId && + (!mappedUpdates.groupIds || mappedUpdates.groupIds.length === 0) + ) { + return res + .status(400) + .json({ error: 'Group ID or Group IDs are required for group type accounts' }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await claudeAccountService.getAccount(accountId) + if (!currentAccount) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 处理分组的变更 + if (mappedUpdates.accountType !== undefined) { + // 如果之前是分组类型,需要从所有分组中移除 + if (currentAccount.accountType === 'group') { + await accountGroupService.removeAccountFromAllGroups(accountId) + } + + // 如果新类型是分组,添加到新分组 + if (mappedUpdates.accountType === 'group') { + // 处理多分组/单分组的兼容性 + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { + if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (mappedUpdates.groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude') + } + } + } + + await claudeAccountService.updateAccount(accountId, mappedUpdates) + + logger.success(`📝 Admin updated Claude account: ${accountId}`) + return res.json({ success: true, message: 'Claude account updated successfully' }) + } catch (error) { + logger.error('❌ Failed to update Claude account:', error) + return res + .status(500) + .json({ error: 'Failed to update Claude account', message: error.message }) + } +}) + +// 删除Claude账户 +router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude') + + // 获取账户信息以检查是否在分组中 + const account = await claudeAccountService.getAccount(accountId) + if (account && account.accountType === 'group') { + const groups = await accountGroupService.getAccountGroups(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + } + + await claudeAccountService.deleteAccount(accountId) + + let message = 'Claude账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Claude account: ${accountId}, unbound ${unboundCount} keys`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('❌ Failed to delete Claude account:', error) + return res + .status(500) + .json({ error: 'Failed to delete Claude account', message: error.message }) + } +}) + +// 更新单个Claude账户的Profile信息 +router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId) + + logger.success(`Updated profile for Claude account: ${accountId}`) + return res.json({ + success: true, + message: 'Account profile updated successfully', + data: profileInfo + }) + } catch (error) { + logger.error('❌ Failed to update account profile:', error) + return res + .status(500) + .json({ error: 'Failed to update account profile', message: error.message }) + } +}) + +// 批量更新所有Claude账户的Profile信息 +router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (req, res) => { + try { + const result = await claudeAccountService.updateAllAccountProfiles() + + logger.success('Batch profile update completed') + return res.json({ + success: true, + message: 'Batch profile update completed', + data: result + }) + } catch (error) { + logger.error('❌ Failed to update all account profiles:', error) + return res + .status(500) + .json({ error: 'Failed to update all account profiles', message: error.message }) + } +}) + +// 刷新Claude账户token +router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await claudeAccountService.refreshAccountToken(accountId) + + logger.success(`🔄 Admin refreshed token for Claude account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to refresh Claude account token:', error) + return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) + } +}) + +// 重置Claude账户状态(清除所有异常状态) +router.post('/claude-accounts/:accountId/reset-status', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await claudeAccountService.resetAccountStatus(accountId) + + logger.success(`Admin reset status for Claude account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset Claude account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 切换Claude账户调度状态 +router.put( + '/claude-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + + const accounts = await claudeAccountService.getAllAccounts() + const account = accounts.find((acc) => acc.id === accountId) + + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newSchedulable = !account.schedulable + await claudeAccountService.updateAccount(accountId, { schedulable: newSchedulable }) + + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || account.claudeAiOauth?.email || 'Claude Account', + platform: 'claude-oauth', + status: 'disabled', + errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + return res.json({ success: true, schedulable: newSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Claude account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } + } +) + +// 测试Claude OAuth账户连通性(流式响应)- 复用 claudeRelayService +router.post('/claude-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + + try { + // 直接调用服务层的测试方法 + await claudeRelayService.testAccountConnection(accountId, res) + } catch (error) { + logger.error(`❌ Failed to test Claude OAuth account:`, error) + // 错误已在服务层处理,这里仅做日志记录 + } +}) + +// ============================================================================ +// 账户定时测试相关端点 +// ============================================================================ + +// 获取账户测试历史 +router.get('/claude-accounts/:accountId/test-history', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + + try { + const history = await redis.getAccountTestHistory(accountId, 'claude') + return res.json({ + success: true, + data: { + accountId, + platform: 'claude', + history + } + }) + } catch (error) { + logger.error(`❌ Failed to get test history for account ${accountId}:`, error) + return res.status(500).json({ + error: 'Failed to get test history', + message: error.message + }) + } +}) + +// 获取账户定时测试配置 +router.get('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + + try { + const testConfig = await redis.getAccountTestConfig(accountId, 'claude') + return res.json({ + success: true, + data: { + accountId, + platform: 'claude', + config: testConfig || { + enabled: false, + cronExpression: '0 8 * * *', + model: 'claude-sonnet-4-5-20250929' + } + } + }) + } catch (error) { + logger.error(`❌ Failed to get test config for account ${accountId}:`, error) + return res.status(500).json({ + error: 'Failed to get test config', + message: error.message + }) + } +}) + +// 设置账户定时测试配置 +router.put('/claude-accounts/:accountId/test-config', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { enabled, cronExpression, model } = req.body + + try { + // 验证 enabled 参数 + if (typeof enabled !== 'boolean') { + return res.status(400).json({ + error: 'Invalid parameter', + message: 'enabled must be a boolean' + }) + } + + // 验证 cronExpression 参数 + if (!cronExpression || typeof cronExpression !== 'string') { + return res.status(400).json({ + error: 'Invalid parameter', + message: 'cronExpression is required and must be a string' + }) + } + + // 限制 cronExpression 长度防止 DoS + const MAX_CRON_LENGTH = 100 + if (cronExpression.length > MAX_CRON_LENGTH) { + return res.status(400).json({ + error: 'Invalid parameter', + message: `cronExpression too long (max ${MAX_CRON_LENGTH} characters)` + }) + } + + // 使用 service 的方法验证 cron 表达式 + if (!accountTestSchedulerService.validateCronExpression(cronExpression)) { + return res.status(400).json({ + error: 'Invalid parameter', + message: `Invalid cron expression: ${cronExpression}. Format: "minute hour day month weekday" (e.g., "0 8 * * *" for daily at 8:00)` + }) + } + + // 验证模型参数 + const testModel = model || 'claude-sonnet-4-5-20250929' + if (typeof testModel !== 'string' || testModel.length > 256) { + return res.status(400).json({ + error: 'Invalid parameter', + message: 'model must be a valid string (max 256 characters)' + }) + } + + // 检查账户是否存在 + const account = await claudeAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ + error: 'Account not found', + message: `Claude account ${accountId} not found` + }) + } + + // 保存配置 + await redis.saveAccountTestConfig(accountId, 'claude', { + enabled, + cronExpression, + model: testModel + }) + + logger.success( + `📝 Updated test config for Claude account ${accountId}: enabled=${enabled}, cronExpression=${cronExpression}, model=${testModel}` + ) + + return res.json({ + success: true, + message: 'Test config updated successfully', + data: { + accountId, + platform: 'claude', + config: { enabled, cronExpression, model: testModel } + } + }) + } catch (error) { + logger.error(`❌ Failed to update test config for account ${accountId}:`, error) + return res.status(500).json({ + error: 'Failed to update test config', + message: error.message + }) + } +}) + +// 手动触发账户测试(非流式,返回JSON结果) +router.post('/claude-accounts/:accountId/test-sync', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + + try { + // 检查账户是否存在 + const account = await claudeAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ + error: 'Account not found', + message: `Claude account ${accountId} not found` + }) + } + + logger.info(`🧪 Manual sync test triggered for Claude account: ${accountId}`) + + // 执行测试 + const testResult = await claudeRelayService.testAccountConnectionSync(accountId) + + // 保存测试结果到历史 + await redis.saveAccountTestResult(accountId, 'claude', testResult) + await redis.setAccountLastTestTime(accountId, 'claude') + + return res.json({ + success: true, + data: { + accountId, + platform: 'claude', + result: testResult + } + }) + } catch (error) { + logger.error(`❌ Failed to run sync test for account ${accountId}:`, error) + return res.status(500).json({ + error: 'Failed to run test', + message: error.message + }) + } +}) + +// 批量获取多个账户的测试历史 +router.post('/claude-accounts/batch-test-history', authenticateAdmin, async (req, res) => { + const { accountIds } = req.body + + try { + if (!Array.isArray(accountIds) || accountIds.length === 0) { + return res.status(400).json({ + error: 'Invalid parameter', + message: 'accountIds must be a non-empty array' + }) + } + + // 限制批量查询数量 + const limitedIds = accountIds.slice(0, 100) + + const accounts = limitedIds.map((accountId) => ({ + accountId, + platform: 'claude' + })) + + const historyMap = await redis.getAccountsTestHistory(accounts) + + return res.json({ + success: true, + data: historyMap + }) + } catch (error) { + logger.error('❌ Failed to get batch test history:', error) + return res.status(500).json({ + error: 'Failed to get batch test history', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/claudeConsoleAccounts.js b/src/routes/admin/claudeConsoleAccounts.js new file mode 100644 index 0000000..b907d3e --- /dev/null +++ b/src/routes/admin/claudeConsoleAccounts.js @@ -0,0 +1,503 @@ +/** + * Admin Routes - Claude Console 账户管理 + * API Key 方式的 Claude Console 账户 + */ + +const express = require('express') +const router = express.Router() + +const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService') +const claudeConsoleRelayService = require('../../services/relay/claudeConsoleRelayService') +const accountGroupService = require('../../services/accountGroupService') +const apiKeyService = require('../../services/apiKeyService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const webhookNotifier = require('../../utils/webhookNotifier') +const { formatAccountExpiry, mapExpiryField } = require('./utils') + +// 获取所有Claude Console账户 +router.get('/claude-console-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await claudeConsoleAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'claude-console') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await accountGroupService.getAccountGroups(account.id) + + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for Claude Console account ${account.id}:`, + statsError.message + ) + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + // 转换schedulable为布尔值 + schedulable: account.schedulable === 'true' || account.schedulable === true, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for Claude Console account ${account.id}:`, + groupError.message + ) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + + return res.json({ success: true, data: accountsWithStats }) + } catch (error) { + logger.error('❌ Failed to get Claude Console accounts:', error) + return res + .status(500) + .json({ error: 'Failed to get Claude Console accounts', message: error.message }) + } +}) + +// 创建新的Claude Console账户 +router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + apiUrl, + apiKey, + priority, + supportedModels, + userAgent, + rateLimitDuration, + proxy, + accountType, + groupId, + dailyQuota, + quotaResetTime, + maxConcurrentTasks, + disableAutoProtection, + interceptWarmup + } = req.body + + if (!name || !apiUrl || !apiKey) { + return res.status(400).json({ error: 'Name, API URL and API Key are required' }) + } + + // 验证priority的有效性(1-100) + if (priority !== undefined && (priority < 1 || priority > 100)) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证maxConcurrentTasks的有效性(非负整数) + if (maxConcurrentTasks !== undefined && maxConcurrentTasks !== null) { + const concurrent = Number(maxConcurrentTasks) + if (!Number.isInteger(concurrent) || concurrent < 0) { + return res.status(400).json({ error: 'maxConcurrentTasks must be a non-negative integer' }) + } + } + + // 校验上游错误自动防护开关 + const normalizedDisableAutoProtection = + disableAutoProtection === true || disableAutoProtection === 'true' + + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果是分组类型,验证groupId + if (accountType === 'group' && !groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + const newAccount = await claudeConsoleAccountService.createAccount({ + name, + description, + apiUrl, + apiKey, + priority: priority || 50, + supportedModels: supportedModels || [], + userAgent, + rateLimitDuration: + rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, + proxy, + accountType: accountType || 'shared', + dailyQuota: dailyQuota || 0, + quotaResetTime: quotaResetTime || '00:00', + maxConcurrentTasks: + maxConcurrentTasks !== undefined && maxConcurrentTasks !== null + ? Number(maxConcurrentTasks) + : 0, + disableAutoProtection: normalizedDisableAutoProtection, + interceptWarmup: interceptWarmup === true || interceptWarmup === 'true' + }) + + // 如果是分组类型,将账户添加到分组(CCR 归属 Claude 平台分组) + if (accountType === 'group' && groupId) { + await accountGroupService.addAccountToGroup(newAccount.id, groupId, 'claude') + } + + logger.success(`🎮 Admin created Claude Console account: ${name}`) + const formattedAccount = formatAccountExpiry(newAccount) + return res.json({ success: true, data: formattedAccount }) + } catch (error) { + logger.error('❌ Failed to create Claude Console account:', error) + return res + .status(500) + .json({ error: 'Failed to create Claude Console account', message: error.message }) + } +}) + +// 更新Claude Console账户 +router.put('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const updates = req.body + + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'Claude Console', accountId) + + // 验证priority的有效性(1-100) + if ( + mappedUpdates.priority !== undefined && + (mappedUpdates.priority < 1 || mappedUpdates.priority > 100) + ) { + return res.status(400).json({ error: 'Priority must be between 1 and 100' }) + } + + // 验证maxConcurrentTasks的有效性(非负整数) + if ( + mappedUpdates.maxConcurrentTasks !== undefined && + mappedUpdates.maxConcurrentTasks !== null + ) { + const concurrent = Number(mappedUpdates.maxConcurrentTasks) + if (!Number.isInteger(concurrent) || concurrent < 0) { + return res.status(400).json({ error: 'maxConcurrentTasks must be a non-negative integer' }) + } + // 转换为数字类型 + mappedUpdates.maxConcurrentTasks = concurrent + } + + // 验证accountType的有效性 + if ( + mappedUpdates.accountType && + !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) + ) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果更新为分组类型,验证groupId + if (mappedUpdates.accountType === 'group' && !mappedUpdates.groupId) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await claudeConsoleAccountService.getAccount(accountId) + if (!currentAccount) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 规范化上游错误自动防护开关 + if (mappedUpdates.disableAutoProtection !== undefined) { + mappedUpdates.disableAutoProtection = + mappedUpdates.disableAutoProtection === true || + mappedUpdates.disableAutoProtection === 'true' + } + + // 处理分组的变更 + if (mappedUpdates.accountType !== undefined) { + // 如果之前是分组类型,需要从所有分组中移除 + if (currentAccount.accountType === 'group') { + const oldGroups = await accountGroupService.getAccountGroups(accountId) + for (const oldGroup of oldGroups) { + await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) + } + } + // 如果新类型是分组,处理多分组支持 + if (mappedUpdates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { + // 如果明确提供了 groupIds 参数(包括空数组) + if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { + // 设置新的多分组 + await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'claude') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (mappedUpdates.groupId) { + // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 + await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'claude') + } + } + } + + await claudeConsoleAccountService.updateAccount(accountId, mappedUpdates) + + logger.success(`📝 Admin updated Claude Console account: ${accountId}`) + return res.json({ success: true, message: 'Claude Console account updated successfully' }) + } catch (error) { + logger.error('❌ Failed to update Claude Console account:', error) + return res + .status(500) + .json({ error: 'Failed to update Claude Console account', message: error.message }) + } +}) + +// 删除Claude Console账户 +router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude-console') + + // 获取账户信息以检查是否在分组中 + const account = await claudeConsoleAccountService.getAccount(accountId) + if (account && account.accountType === 'group') { + const groups = await accountGroupService.getAccountGroups(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + } + + await claudeConsoleAccountService.deleteAccount(accountId) + + let message = 'Claude Console账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success( + `🗑️ Admin deleted Claude Console account: ${accountId}, unbound ${unboundCount} keys` + ) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('❌ Failed to delete Claude Console account:', error) + return res + .status(500) + .json({ error: 'Failed to delete Claude Console account', message: error.message }) + } +}) + +// 切换Claude Console账户状态 +router.put('/claude-console-accounts/:accountId/toggle', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const account = await claudeConsoleAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newStatus = !account.isActive + await claudeConsoleAccountService.updateAccount(accountId, { isActive: newStatus }) + + logger.success( + `🔄 Admin toggled Claude Console account status: ${accountId} -> ${ + newStatus ? 'active' : 'inactive' + }` + ) + return res.json({ success: true, isActive: newStatus }) + } catch (error) { + logger.error('❌ Failed to toggle Claude Console account status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle account status', message: error.message }) + } +}) + +// 切换Claude Console账户调度状态 +router.put( + '/claude-console-accounts/:accountId/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + + const account = await claudeConsoleAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + const newSchedulable = !account.schedulable + await claudeConsoleAccountService.updateAccount(accountId, { schedulable: newSchedulable }) + + // 如果账号被禁用,发送webhook通知 + if (!newSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'disabled', + errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + return res.json({ success: true, schedulable: newSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Claude Console account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } + } +) + +// 获取Claude Console账户的使用统计 +router.get('/claude-console-accounts/:accountId/usage', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const usageStats = await claudeConsoleAccountService.getAccountUsageStats(accountId) + + if (!usageStats) { + return res.status(404).json({ error: 'Account not found' }) + } + + return res.json(usageStats) + } catch (error) { + logger.error('❌ Failed to get Claude Console account usage stats:', error) + return res.status(500).json({ error: 'Failed to get usage stats', message: error.message }) + } +}) + +// 手动重置Claude Console账户的每日使用量 +router.post( + '/claude-console-accounts/:accountId/reset-usage', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + await claudeConsoleAccountService.resetDailyUsage(accountId) + + logger.success(`Admin manually reset daily usage for Claude Console account: ${accountId}`) + return res.json({ success: true, message: 'Daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset Claude Console account daily usage:', error) + return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message }) + } + } +) + +// 重置Claude Console账户状态(清除所有异常状态) +router.post( + '/claude-console-accounts/:accountId/reset-status', + authenticateAdmin, + async (req, res) => { + try { + const { accountId } = req.params + const result = await claudeConsoleAccountService.resetAccountStatus(accountId) + logger.success(`Admin reset status for Claude Console account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset Claude Console account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } + } +) + +// 手动重置所有Claude Console账户的每日使用量 +router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => { + try { + await claudeConsoleAccountService.resetAllDailyUsage() + + logger.success('Admin manually reset daily usage for all Claude Console accounts') + return res.json({ success: true, message: 'All daily usage reset successfully' }) + } catch (error) { + logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error) + return res + .status(500) + .json({ error: 'Failed to reset all daily usage', message: error.message }) + } +}) + +// 测试Claude Console账户连通性(流式响应)- 复用 claudeConsoleRelayService +router.post('/claude-console-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const model = typeof req.body?.model === 'string' ? req.body.model.trim() : '' + + if (!model) { + return res.status(400).json({ error: 'model is required' }) + } + + try { + // 直接调用服务层的测试方法 + await claudeConsoleRelayService.testAccountConnection(accountId, res, model) + } catch (error) { + logger.error(`❌ Failed to test Claude Console account:`, error) + // 错误已在服务层处理,这里仅做日志记录 + } +}) + +module.exports = router diff --git a/src/routes/admin/claudeRelayConfig.js b/src/routes/admin/claudeRelayConfig.js new file mode 100644 index 0000000..43453b8 --- /dev/null +++ b/src/routes/admin/claudeRelayConfig.js @@ -0,0 +1,316 @@ +/** + * Claude 转发配置 API 路由 + * 管理全局 Claude Code 限制和会话绑定配置 + */ + +const express = require('express') +const { authenticateAdmin } = require('../../middleware/auth') +const claudeRelayConfigService = require('../../services/claudeRelayConfigService') +const requestDetailService = require('../../services/requestDetailService') +const logger = require('../../utils/logger') + +const router = express.Router() + +/** + * GET /admin/claude-relay-config + * 获取 Claude 转发配置 + */ +router.get('/claude-relay-config', authenticateAdmin, async (req, res) => { + try { + const config = await claudeRelayConfigService.getConfig() + return res.json({ + success: true, + config + }) + } catch (error) { + logger.error('❌ Failed to get Claude relay config:', error) + return res.status(500).json({ + error: 'Failed to get configuration', + message: error.message + }) + } +}) + +/** + * PUT /admin/claude-relay-config + * 更新 Claude 转发配置 + */ +router.put('/claude-relay-config', authenticateAdmin, async (req, res) => { + try { + const { + claudeCodeOnlyEnabled, + globalSessionBindingEnabled, + sessionBindingErrorMessage, + sessionBindingTtlDays, + userMessageQueueEnabled, + userMessageQueueDelayMs, + userMessageQueueTimeoutMs, + concurrentRequestQueueEnabled, + concurrentRequestQueueMaxSize, + concurrentRequestQueueMaxSizeMultiplier, + concurrentRequestQueueTimeoutMs, + requestDetailCaptureEnabled, + requestDetailRetentionHours, + requestDetailBodyPreviewEnabled, + purgeRequestDetailBodySnapshots + } = req.body + + // 验证输入 + if (claudeCodeOnlyEnabled !== undefined && typeof claudeCodeOnlyEnabled !== 'boolean') { + return res.status(400).json({ error: 'claudeCodeOnlyEnabled must be a boolean' }) + } + + if ( + globalSessionBindingEnabled !== undefined && + typeof globalSessionBindingEnabled !== 'boolean' + ) { + return res.status(400).json({ error: 'globalSessionBindingEnabled must be a boolean' }) + } + + if (sessionBindingErrorMessage !== undefined) { + if (typeof sessionBindingErrorMessage !== 'string') { + return res.status(400).json({ error: 'sessionBindingErrorMessage must be a string' }) + } + if (sessionBindingErrorMessage.length > 500) { + return res + .status(400) + .json({ error: 'sessionBindingErrorMessage must be less than 500 characters' }) + } + } + + if (sessionBindingTtlDays !== undefined) { + if ( + typeof sessionBindingTtlDays !== 'number' || + sessionBindingTtlDays < 1 || + sessionBindingTtlDays > 365 + ) { + return res + .status(400) + .json({ error: 'sessionBindingTtlDays must be a number between 1 and 365' }) + } + } + + // 验证用户消息队列配置 + if (userMessageQueueEnabled !== undefined && typeof userMessageQueueEnabled !== 'boolean') { + return res.status(400).json({ error: 'userMessageQueueEnabled must be a boolean' }) + } + + if (userMessageQueueDelayMs !== undefined) { + if ( + typeof userMessageQueueDelayMs !== 'number' || + userMessageQueueDelayMs < 0 || + userMessageQueueDelayMs > 10000 + ) { + return res + .status(400) + .json({ error: 'userMessageQueueDelayMs must be a number between 0 and 10000' }) + } + } + + if (userMessageQueueTimeoutMs !== undefined) { + if ( + typeof userMessageQueueTimeoutMs !== 'number' || + userMessageQueueTimeoutMs < 1000 || + userMessageQueueTimeoutMs > 300000 + ) { + return res + .status(400) + .json({ error: 'userMessageQueueTimeoutMs must be a number between 1000 and 300000' }) + } + } + + // 验证并发请求排队配置 + if ( + concurrentRequestQueueEnabled !== undefined && + typeof concurrentRequestQueueEnabled !== 'boolean' + ) { + return res.status(400).json({ error: 'concurrentRequestQueueEnabled must be a boolean' }) + } + + if (concurrentRequestQueueMaxSize !== undefined) { + if ( + typeof concurrentRequestQueueMaxSize !== 'number' || + !Number.isInteger(concurrentRequestQueueMaxSize) || + concurrentRequestQueueMaxSize < 1 || + concurrentRequestQueueMaxSize > 100 + ) { + return res + .status(400) + .json({ error: 'concurrentRequestQueueMaxSize must be an integer between 1 and 100' }) + } + } + + if (concurrentRequestQueueMaxSizeMultiplier !== undefined) { + // 使用 Number.isFinite() 同时排除 NaN、Infinity、-Infinity 和非数字类型 + if ( + !Number.isFinite(concurrentRequestQueueMaxSizeMultiplier) || + concurrentRequestQueueMaxSizeMultiplier < 0 || + concurrentRequestQueueMaxSizeMultiplier > 10 + ) { + return res.status(400).json({ + error: 'concurrentRequestQueueMaxSizeMultiplier must be a finite number between 0 and 10' + }) + } + } + + if (concurrentRequestQueueTimeoutMs !== undefined) { + if ( + typeof concurrentRequestQueueTimeoutMs !== 'number' || + !Number.isInteger(concurrentRequestQueueTimeoutMs) || + concurrentRequestQueueTimeoutMs < 5000 || + concurrentRequestQueueTimeoutMs > 300000 + ) { + return res.status(400).json({ + error: + 'concurrentRequestQueueTimeoutMs must be an integer between 5000 and 300000 (5 seconds to 5 minutes)' + }) + } + } + + if ( + requestDetailCaptureEnabled !== undefined && + typeof requestDetailCaptureEnabled !== 'boolean' + ) { + return res.status(400).json({ error: 'requestDetailCaptureEnabled must be a boolean' }) + } + + if (requestDetailRetentionHours !== undefined) { + if ( + typeof requestDetailRetentionHours !== 'number' || + !Number.isInteger(requestDetailRetentionHours) || + requestDetailRetentionHours < 1 || + requestDetailRetentionHours > 720 + ) { + return res.status(400).json({ + error: 'requestDetailRetentionHours must be an integer between 1 and 720' + }) + } + } + + if ( + requestDetailBodyPreviewEnabled !== undefined && + typeof requestDetailBodyPreviewEnabled !== 'boolean' + ) { + return res.status(400).json({ error: 'requestDetailBodyPreviewEnabled must be a boolean' }) + } + + if ( + purgeRequestDetailBodySnapshots !== undefined && + typeof purgeRequestDetailBodySnapshots !== 'boolean' + ) { + return res.status(400).json({ error: 'purgeRequestDetailBodySnapshots must be a boolean' }) + } + + const updateData = {} + if (claudeCodeOnlyEnabled !== undefined) { + updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled + } + if (globalSessionBindingEnabled !== undefined) { + updateData.globalSessionBindingEnabled = globalSessionBindingEnabled + } + if (sessionBindingErrorMessage !== undefined) { + updateData.sessionBindingErrorMessage = sessionBindingErrorMessage + } + if (sessionBindingTtlDays !== undefined) { + updateData.sessionBindingTtlDays = sessionBindingTtlDays + } + if (userMessageQueueEnabled !== undefined) { + updateData.userMessageQueueEnabled = userMessageQueueEnabled + } + if (userMessageQueueDelayMs !== undefined) { + updateData.userMessageQueueDelayMs = userMessageQueueDelayMs + } + if (userMessageQueueTimeoutMs !== undefined) { + updateData.userMessageQueueTimeoutMs = userMessageQueueTimeoutMs + } + if (concurrentRequestQueueEnabled !== undefined) { + updateData.concurrentRequestQueueEnabled = concurrentRequestQueueEnabled + } + if (concurrentRequestQueueMaxSize !== undefined) { + updateData.concurrentRequestQueueMaxSize = concurrentRequestQueueMaxSize + } + if (concurrentRequestQueueMaxSizeMultiplier !== undefined) { + updateData.concurrentRequestQueueMaxSizeMultiplier = concurrentRequestQueueMaxSizeMultiplier + } + if (concurrentRequestQueueTimeoutMs !== undefined) { + updateData.concurrentRequestQueueTimeoutMs = concurrentRequestQueueTimeoutMs + } + if (requestDetailCaptureEnabled !== undefined) { + updateData.requestDetailCaptureEnabled = requestDetailCaptureEnabled + } + if (requestDetailRetentionHours !== undefined) { + updateData.requestDetailRetentionHours = requestDetailRetentionHours + } + if (requestDetailBodyPreviewEnabled !== undefined) { + updateData.requestDetailBodyPreviewEnabled = requestDetailBodyPreviewEnabled + } + + const updatedConfig = await claudeRelayConfigService.updateConfig( + updateData, + req.admin?.username || 'unknown' + ) + + let warning = null + let requestDetailBodyPreviewPurge = null + if (requestDetailBodyPreviewEnabled === false && purgeRequestDetailBodySnapshots === true) { + try { + requestDetailBodyPreviewPurge = await requestDetailService.purgeRequestBodySnapshots() + } catch (purgeError) { + logger.error('❌ Failed to purge request body previews after config update:', purgeError) + warning = `配置已保存,但历史请求体预览清理失败:${purgeError.message}` + } + } + + if ( + requestDetailBodyPreviewEnabled !== undefined || + purgeRequestDetailBodySnapshots !== undefined + ) { + logger.info('🧾 Request body preview config updated', { + requestDetailBodyPreviewEnabled: + requestDetailBodyPreviewEnabled !== undefined + ? requestDetailBodyPreviewEnabled + : updatedConfig.requestDetailBodyPreviewEnabled, + purgeRequestDetailBodySnapshots: purgeRequestDetailBodySnapshots === true, + purgedSnapshots: + requestDetailBodyPreviewPurge?.updatedRecords ?? + requestDetailBodyPreviewPurge?.matchedRecords + }) + } + + return res.json({ + success: true, + message: 'Configuration updated successfully', + config: updatedConfig, + warning, + requestDetailBodyPreviewPurge + }) + } catch (error) { + logger.error('❌ Failed to update Claude relay config:', error) + return res.status(500).json({ + error: 'Failed to update configuration', + message: error.message + }) + } +}) + +/** + * GET /admin/claude-relay-config/session-bindings + * 获取会话绑定统计 + */ +router.get('/claude-relay-config/session-bindings', authenticateAdmin, async (req, res) => { + try { + const stats = await claudeRelayConfigService.getSessionBindingStats() + return res.json({ + success: true, + data: stats + }) + } catch (error) { + logger.error('❌ Failed to get session binding stats:', error) + return res.status(500).json({ + error: 'Failed to get session binding statistics', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/concurrency.js b/src/routes/admin/concurrency.js new file mode 100644 index 0000000..9325b5a --- /dev/null +++ b/src/routes/admin/concurrency.js @@ -0,0 +1,313 @@ +/** + * 并发管理 API 路由 + * 提供并发状态查看和手动清理功能 + */ + +const express = require('express') +const router = express.Router() +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const { authenticateAdmin } = require('../../middleware/auth') +const { calculateWaitTimeStats } = require('../../utils/statsHelper') + +/** + * GET /admin/concurrency + * 获取所有并发状态 + */ +router.get('/concurrency', authenticateAdmin, async (req, res) => { + try { + const status = await redis.getAllConcurrencyStatus() + + // 为每个 API Key 获取排队计数 + const statusWithQueue = await Promise.all( + status.map(async (s) => { + const queueCount = await redis.getConcurrencyQueueCount(s.apiKeyId) + return { + ...s, + queueCount + } + }) + ) + + // 计算汇总统计 + const summary = { + totalKeys: statusWithQueue.length, + totalActiveRequests: statusWithQueue.reduce((sum, s) => sum + s.activeCount, 0), + totalExpiredRequests: statusWithQueue.reduce((sum, s) => sum + s.expiredCount, 0), + totalQueuedRequests: statusWithQueue.reduce((sum, s) => sum + s.queueCount, 0) + } + + res.json({ + success: true, + summary, + concurrencyStatus: statusWithQueue + }) + } catch (error) { + logger.error('❌ Failed to get concurrency status:', error) + res.status(500).json({ + success: false, + error: 'Failed to get concurrency status', + message: error.message + }) + } +}) + +/** + * GET /admin/concurrency-queue/stats + * 获取排队统计信息 + */ +router.get('/concurrency-queue/stats', authenticateAdmin, async (req, res) => { + try { + // 获取所有有统计数据的 API Key + const statsKeys = await redis.scanConcurrencyQueueStatsKeys() + const queueKeys = await redis.scanConcurrencyQueueKeys() + + // 合并所有相关的 API Key + const allApiKeyIds = [...new Set([...statsKeys, ...queueKeys])] + + // 获取各 API Key 的详细统计 + const perKeyStats = await Promise.all( + allApiKeyIds.map(async (apiKeyId) => { + const [queueCount, stats, waitTimes] = await Promise.all([ + redis.getConcurrencyQueueCount(apiKeyId), + redis.getConcurrencyQueueStats(apiKeyId), + redis.getQueueWaitTimes(apiKeyId) + ]) + + return { + apiKeyId, + currentQueueCount: queueCount, + stats, + waitTimeStats: calculateWaitTimeStats(waitTimes) + } + }) + ) + + // 获取全局等待时间统计 + const globalWaitTimes = await redis.getGlobalQueueWaitTimes() + const globalWaitTimeStats = calculateWaitTimeStats(globalWaitTimes) + + // 计算全局汇总 + const globalStats = { + totalEntered: perKeyStats.reduce((sum, s) => sum + s.stats.entered, 0), + totalSuccess: perKeyStats.reduce((sum, s) => sum + s.stats.success, 0), + totalTimeout: perKeyStats.reduce((sum, s) => sum + s.stats.timeout, 0), + totalCancelled: perKeyStats.reduce((sum, s) => sum + s.stats.cancelled, 0), + totalSocketChanged: perKeyStats.reduce((sum, s) => sum + (s.stats.socket_changed || 0), 0), + totalRejectedOverload: perKeyStats.reduce( + (sum, s) => sum + (s.stats.rejected_overload || 0), + 0 + ), + currentTotalQueued: perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0), + // 队列资源利用率指标 + peakQueueSize: + perKeyStats.length > 0 ? Math.max(...perKeyStats.map((s) => s.currentQueueCount)) : 0, + avgQueueSize: + perKeyStats.length > 0 + ? Math.round( + perKeyStats.reduce((sum, s) => sum + s.currentQueueCount, 0) / perKeyStats.length + ) + : 0, + activeApiKeys: perKeyStats.filter((s) => s.currentQueueCount > 0).length + } + + // 计算成功率 + if (globalStats.totalEntered > 0) { + globalStats.successRate = Math.round( + (globalStats.totalSuccess / globalStats.totalEntered) * 100 + ) + globalStats.timeoutRate = Math.round( + (globalStats.totalTimeout / globalStats.totalEntered) * 100 + ) + globalStats.cancelledRate = Math.round( + (globalStats.totalCancelled / globalStats.totalEntered) * 100 + ) + } + + // 从全局等待时间统计中提取关键指标 + if (globalWaitTimeStats) { + globalStats.avgWaitTimeMs = globalWaitTimeStats.avg + globalStats.p50WaitTimeMs = globalWaitTimeStats.p50 + globalStats.p90WaitTimeMs = globalWaitTimeStats.p90 + globalStats.p99WaitTimeMs = globalWaitTimeStats.p99 + // 多实例采样策略标记(详见 design.md Decision 9) + // 全局 P90 仅用于可视化和监控,不用于系统决策 + // 健康检查使用 API Key 级别的 P90(每 Key 独立采样) + globalWaitTimeStats.globalP90ForVisualizationOnly = true + } + + res.json({ + success: true, + globalStats, + globalWaitTimeStats, + perKeyStats + }) + } catch (error) { + logger.error('❌ Failed to get queue stats:', error) + res.status(500).json({ + success: false, + error: 'Failed to get queue stats', + message: error.message + }) + } +}) + +/** + * DELETE /admin/concurrency-queue/:apiKeyId + * 清理特定 API Key 的排队计数 + */ +router.delete('/concurrency-queue/:apiKeyId', authenticateAdmin, async (req, res) => { + try { + const { apiKeyId } = req.params + await redis.clearConcurrencyQueue(apiKeyId) + + logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared queue for key ${apiKeyId}`) + + res.json({ + success: true, + message: `Successfully cleared queue for API key ${apiKeyId}` + }) + } catch (error) { + logger.error(`❌ Failed to clear queue for ${req.params.apiKeyId}:`, error) + res.status(500).json({ + success: false, + error: 'Failed to clear queue', + message: error.message + }) + } +}) + +/** + * DELETE /admin/concurrency-queue + * 清理所有排队计数 + */ +router.delete('/concurrency-queue', authenticateAdmin, async (req, res) => { + try { + const cleared = await redis.clearAllConcurrencyQueues() + + logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} cleared ALL queues`) + + res.json({ + success: true, + message: 'Successfully cleared all queues', + cleared + }) + } catch (error) { + logger.error('❌ Failed to clear all queues:', error) + res.status(500).json({ + success: false, + error: 'Failed to clear all queues', + message: error.message + }) + } +}) + +/** + * GET /admin/concurrency/:apiKeyId + * 获取特定 API Key 的并发状态详情 + */ +router.get('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => { + try { + const { apiKeyId } = req.params + const status = await redis.getConcurrencyStatus(apiKeyId) + const queueCount = await redis.getConcurrencyQueueCount(apiKeyId) + + res.json({ + success: true, + concurrencyStatus: { + ...status, + queueCount + } + }) + } catch (error) { + logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, error) + res.status(500).json({ + success: false, + error: 'Failed to get concurrency status', + message: error.message + }) + } +}) + +/** + * DELETE /admin/concurrency/:apiKeyId + * 强制清理特定 API Key 的并发计数 + */ +router.delete('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => { + try { + const { apiKeyId } = req.params + const result = await redis.forceClearConcurrency(apiKeyId) + + logger.warn( + `🧹 Admin ${req.admin?.username || 'unknown'} force cleared concurrency for key ${apiKeyId}` + ) + + res.json({ + success: true, + message: `Successfully cleared concurrency for API key ${apiKeyId}`, + result + }) + } catch (error) { + logger.error(`❌ Failed to clear concurrency for ${req.params.apiKeyId}:`, error) + res.status(500).json({ + success: false, + error: 'Failed to clear concurrency', + message: error.message + }) + } +}) + +/** + * DELETE /admin/concurrency + * 强制清理所有并发计数 + */ +router.delete('/concurrency', authenticateAdmin, async (req, res) => { + try { + const result = await redis.forceClearAllConcurrency() + + logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} force cleared ALL concurrency`) + + res.json({ + success: true, + message: 'Successfully cleared all concurrency', + result + }) + } catch (error) { + logger.error('❌ Failed to clear all concurrency:', error) + res.status(500).json({ + success: false, + error: 'Failed to clear all concurrency', + message: error.message + }) + } +}) + +/** + * POST /admin/concurrency/cleanup + * 清理过期的并发条目(不影响活跃请求) + */ +router.post('/concurrency/cleanup', authenticateAdmin, async (req, res) => { + try { + const { apiKeyId } = req.body + const result = await redis.cleanupExpiredConcurrency(apiKeyId || null) + + logger.info(`🧹 Admin ${req.admin?.username || 'unknown'} cleaned up expired concurrency`) + + res.json({ + success: true, + message: apiKeyId + ? `Successfully cleaned up expired concurrency for API key ${apiKeyId}` + : 'Successfully cleaned up all expired concurrency', + result + }) + } catch (error) { + logger.error('❌ Failed to cleanup expired concurrency:', error) + res.status(500).json({ + success: false, + error: 'Failed to cleanup expired concurrency', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/dashboard.js b/src/routes/admin/dashboard.js new file mode 100644 index 0000000..3dc7550 --- /dev/null +++ b/src/routes/admin/dashboard.js @@ -0,0 +1,599 @@ +const express = require('express') +const apiKeyService = require('../../services/apiKeyService') +const claudeAccountService = require('../../services/account/claudeAccountService') +const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService') +const bedrockAccountService = require('../../services/account/bedrockAccountService') +const ccrAccountService = require('../../services/account/ccrAccountService') +const geminiAccountService = require('../../services/account/geminiAccountService') +const droidAccountService = require('../../services/account/droidAccountService') +const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const CostCalculator = require('../../utils/costCalculator') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') +const config = require('../../../config/config') + +const router = express.Router() + +// 📊 系统统计 + +// 获取系统概览 +router.get('/dashboard', authenticateAdmin, async (req, res) => { + try { + // 先检查是否有全局预聚合数据 + const globalStats = await redis.getGlobalStats() + + // 根据是否有全局统计决定查询策略 + let apiKeys = null + let apiKeyCount = null + + const [ + claudeAccounts, + claudeConsoleAccounts, + geminiAccounts, + bedrockAccountsResult, + openaiAccounts, + ccrAccounts, + openaiResponsesAccounts, + droidAccounts, + todayStats, + systemAverages, + realtimeMetrics + ] = await Promise.all([ + claudeAccountService.getAllAccounts(), + claudeConsoleAccountService.getAllAccounts(), + geminiAccountService.getAllAccounts(), + bedrockAccountService.getAllAccounts(), + redis.getAllOpenAIAccounts(), + ccrAccountService.getAllAccounts(), + openaiResponsesAccountService.getAllAccounts(true), + droidAccountService.getAllAccounts(), + redis.getTodayStats(), + redis.getSystemAverages(), + redis.getRealtimeSystemMetrics() + ]) + + // 有全局统计时只获取计数,否则拉全量 + if (globalStats) { + apiKeyCount = await redis.getApiKeyCount() + } else { + apiKeys = await apiKeyService.getAllApiKeysFast() + } + + // 处理Bedrock账户数据 + const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : [] + const normalizeBoolean = (value) => value === true || value === 'true' + const isRateLimitedFlag = (status) => { + if (!status) { + return false + } + if (typeof status === 'string') { + return status === 'limited' + } + if (typeof status === 'object') { + return status.isRateLimited === true + } + return false + } + + // 通用账户统计函数 - 单次遍历完成所有统计 + const countAccountStats = (accounts, opts = {}) => { + const { isStringType = false, checkGeminiRateLimit = false } = opts + let normal = 0, + abnormal = 0, + paused = 0, + rateLimited = 0 + + for (const acc of accounts) { + const isActive = isStringType + ? acc.isActive === 'true' || + acc.isActive === true || + (!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false) + : acc.isActive + const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized' + const isSchedulable = isStringType + ? acc.schedulable !== 'false' && acc.schedulable !== false + : acc.schedulable !== false + const isRateLimited = checkGeminiRateLimit + ? acc.rateLimitStatus === 'limited' || + (acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited) + : acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited + + if (!isActive || isBlocked) { + abnormal++ + } else if (!isSchedulable) { + paused++ + } else if (isRateLimited) { + rateLimited++ + } else { + normal++ + } + } + return { normal, abnormal, paused, rateLimited } + } + + // Droid 账户统计(特殊逻辑) + let normalDroidAccounts = 0, + abnormalDroidAccounts = 0, + pausedDroidAccounts = 0, + rateLimitedDroidAccounts = 0 + for (const acc of droidAccounts) { + const isActive = normalizeBoolean(acc.isActive) + const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized' + const isSchedulable = normalizeBoolean(acc.schedulable) + const isRateLimited = isRateLimitedFlag(acc.rateLimitStatus) + + if (!isActive || isBlocked) { + abnormalDroidAccounts++ + } else if (!isSchedulable) { + pausedDroidAccounts++ + } else if (isRateLimited) { + rateLimitedDroidAccounts++ + } else { + normalDroidAccounts++ + } + } + + // 计算使用统计 + let totalTokensUsed = 0, + totalRequestsUsed = 0, + totalInputTokensUsed = 0, + totalOutputTokensUsed = 0, + totalCacheCreateTokensUsed = 0, + totalCacheReadTokensUsed = 0, + totalAllTokensUsed = 0, + activeApiKeys = 0, + totalApiKeys = 0 + + if (globalStats) { + // 使用预聚合数据(快速路径) + totalRequestsUsed = globalStats.requests + totalInputTokensUsed = globalStats.inputTokens + totalOutputTokensUsed = globalStats.outputTokens + totalCacheCreateTokensUsed = globalStats.cacheCreateTokens + totalCacheReadTokensUsed = globalStats.cacheReadTokens + totalAllTokensUsed = globalStats.allTokens + totalTokensUsed = totalAllTokensUsed + totalApiKeys = apiKeyCount.total + activeApiKeys = apiKeyCount.active + } else { + // 回退到遍历(兼容旧数据) + totalApiKeys = apiKeys.length + for (const key of apiKeys) { + const usage = key.usage?.total + if (usage) { + totalTokensUsed += usage.allTokens || 0 + totalRequestsUsed += usage.requests || 0 + totalInputTokensUsed += usage.inputTokens || 0 + totalOutputTokensUsed += usage.outputTokens || 0 + totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0 + totalCacheReadTokensUsed += usage.cacheReadTokens || 0 + totalAllTokensUsed += usage.allTokens || 0 + } + if (key.isActive) { + activeApiKeys++ + } + } + } + + // 各平台账户统计(单次遍历) + const claudeStats = countAccountStats(claudeAccounts) + const claudeConsoleStats = countAccountStats(claudeConsoleAccounts) + const geminiStats = countAccountStats(geminiAccounts, { checkGeminiRateLimit: true }) + const bedrockStats = countAccountStats(bedrockAccounts) + const openaiStats = countAccountStats(openaiAccounts, { isStringType: true }) + const ccrStats = countAccountStats(ccrAccounts) + const openaiResponsesStats = countAccountStats(openaiResponsesAccounts, { isStringType: true }) + + const dashboard = { + overview: { + totalApiKeys, + activeApiKeys, + // 总账户统计(所有平台) + totalAccounts: + claudeAccounts.length + + claudeConsoleAccounts.length + + geminiAccounts.length + + bedrockAccounts.length + + openaiAccounts.length + + openaiResponsesAccounts.length + + ccrAccounts.length, + normalAccounts: + claudeStats.normal + + claudeConsoleStats.normal + + geminiStats.normal + + bedrockStats.normal + + openaiStats.normal + + openaiResponsesStats.normal + + ccrStats.normal, + abnormalAccounts: + claudeStats.abnormal + + claudeConsoleStats.abnormal + + geminiStats.abnormal + + bedrockStats.abnormal + + openaiStats.abnormal + + openaiResponsesStats.abnormal + + ccrStats.abnormal + + abnormalDroidAccounts, + pausedAccounts: + claudeStats.paused + + claudeConsoleStats.paused + + geminiStats.paused + + bedrockStats.paused + + openaiStats.paused + + openaiResponsesStats.paused + + ccrStats.paused + + pausedDroidAccounts, + rateLimitedAccounts: + claudeStats.rateLimited + + claudeConsoleStats.rateLimited + + geminiStats.rateLimited + + bedrockStats.rateLimited + + openaiStats.rateLimited + + openaiResponsesStats.rateLimited + + ccrStats.rateLimited + + rateLimitedDroidAccounts, + // 各平台详细统计 + accountsByPlatform: { + claude: { + total: claudeAccounts.length, + normal: claudeStats.normal, + abnormal: claudeStats.abnormal, + paused: claudeStats.paused, + rateLimited: claudeStats.rateLimited + }, + 'claude-console': { + total: claudeConsoleAccounts.length, + normal: claudeConsoleStats.normal, + abnormal: claudeConsoleStats.abnormal, + paused: claudeConsoleStats.paused, + rateLimited: claudeConsoleStats.rateLimited + }, + gemini: { + total: geminiAccounts.length, + normal: geminiStats.normal, + abnormal: geminiStats.abnormal, + paused: geminiStats.paused, + rateLimited: geminiStats.rateLimited + }, + bedrock: { + total: bedrockAccounts.length, + normal: bedrockStats.normal, + abnormal: bedrockStats.abnormal, + paused: bedrockStats.paused, + rateLimited: bedrockStats.rateLimited + }, + openai: { + total: openaiAccounts.length, + normal: openaiStats.normal, + abnormal: openaiStats.abnormal, + paused: openaiStats.paused, + rateLimited: openaiStats.rateLimited + }, + ccr: { + total: ccrAccounts.length, + normal: ccrStats.normal, + abnormal: ccrStats.abnormal, + paused: ccrStats.paused, + rateLimited: ccrStats.rateLimited + }, + 'openai-responses': { + total: openaiResponsesAccounts.length, + normal: openaiResponsesStats.normal, + abnormal: openaiResponsesStats.abnormal, + paused: openaiResponsesStats.paused, + rateLimited: openaiResponsesStats.rateLimited + }, + droid: { + total: droidAccounts.length, + normal: normalDroidAccounts, + abnormal: abnormalDroidAccounts, + paused: pausedDroidAccounts, + rateLimited: rateLimitedDroidAccounts + } + }, + // 保留旧字段以兼容 + activeAccounts: + claudeStats.normal + + claudeConsoleStats.normal + + geminiStats.normal + + bedrockStats.normal + + openaiStats.normal + + openaiResponsesStats.normal + + ccrStats.normal + + normalDroidAccounts, + totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length, + activeClaudeAccounts: claudeStats.normal + claudeConsoleStats.normal, + rateLimitedClaudeAccounts: claudeStats.rateLimited + claudeConsoleStats.rateLimited, + totalGeminiAccounts: geminiAccounts.length, + activeGeminiAccounts: geminiStats.normal, + rateLimitedGeminiAccounts: geminiStats.rateLimited, + totalTokensUsed, + totalRequestsUsed, + totalInputTokensUsed, + totalOutputTokensUsed, + totalCacheCreateTokensUsed, + totalCacheReadTokensUsed, + totalAllTokensUsed + }, + recentActivity: { + apiKeysCreatedToday: todayStats.apiKeysCreatedToday, + requestsToday: todayStats.requestsToday, + tokensToday: todayStats.tokensToday, + inputTokensToday: todayStats.inputTokensToday, + outputTokensToday: todayStats.outputTokensToday, + cacheCreateTokensToday: todayStats.cacheCreateTokensToday || 0, + cacheReadTokensToday: todayStats.cacheReadTokensToday || 0 + }, + systemAverages: { + rpm: systemAverages.systemRPM, + tpm: systemAverages.systemTPM + }, + realtimeMetrics: { + rpm: realtimeMetrics.realtimeRPM, + tpm: realtimeMetrics.realtimeTPM, + windowMinutes: realtimeMetrics.windowMinutes, + isHistorical: realtimeMetrics.windowMinutes === 0 // 标识是否使用了历史数据 + }, + systemHealth: { + redisConnected: redis.isConnected, + claudeAccountsHealthy: claudeStats.normal + claudeConsoleStats.normal > 0, + geminiAccountsHealthy: geminiStats.normal > 0, + droidAccountsHealthy: normalDroidAccounts > 0, + uptime: process.uptime() + }, + systemTimezone: config.system.timezoneOffset || 8 + } + + return res.json({ success: true, data: dashboard }) + } catch (error) { + logger.error('❌ Failed to get dashboard data:', error) + return res.status(500).json({ error: 'Failed to get dashboard data', message: error.message }) + } +}) + +// 获取所有临时不可用账户状态 +router.get('/temp-unavailable', authenticateAdmin, async (req, res) => { + try { + const statuses = await upstreamErrorHelper.getAllTempUnavailable() + return res.json({ success: true, data: statuses }) + } catch (error) { + logger.error('❌ Failed to get temp unavailable statuses:', error) + return res.status(500).json({ error: 'Failed to get temp unavailable statuses' }) + } +}) + +// 获取使用统计 +router.get('/usage-stats', authenticateAdmin, async (req, res) => { + try { + const { period = 'daily' } = req.query // daily, monthly + + // 获取基础API Key统计 + const apiKeys = await apiKeyService.getAllApiKeysFast() + + const stats = apiKeys.map((key) => ({ + keyId: key.id, + keyName: key.name, + usage: key.usage + })) + + return res.json({ success: true, data: { period, stats } }) + } catch (error) { + logger.error('❌ Failed to get usage stats:', error) + return res.status(500).json({ error: 'Failed to get usage stats', message: error.message }) + } +}) + +// 获取按模型的使用统计和费用 +router.get('/model-stats', authenticateAdmin, async (req, res) => { + try { + const { period = 'daily', startDate, endDate } = req.query // daily, monthly, 支持自定义时间范围 + const today = redis.getDateStringInTimezone() + const tzDate = redis.getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + + logger.info( + `📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}` + ) + + // 收集所有需要扫描的日期 + const datePatterns = [] + + if (startDate && endDate) { + // 自定义日期范围 + const start = new Date(startDate) + const end = new Date(endDate) + + if (start > end) { + return res.status(400).json({ error: 'Start date must be before or equal to end date' }) + } + + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 365) { + return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) + } + + const currentDate = new Date(start) + while (currentDate <= end) { + const dateStr = redis.getDateStringInTimezone(currentDate) + datePatterns.push({ dateStr, pattern: `usage:model:daily:*:${dateStr}` }) + currentDate.setDate(currentDate.getDate() + 1) + } + + logger.info(`📊 Generated ${datePatterns.length} search patterns for date range`) + } else { + // 使用默认的period + const pattern = + period === 'daily' + ? `usage:model:daily:*:${today}` + : `usage:model:monthly:*:${currentMonth}` + datePatterns.push({ dateStr: period === 'daily' ? today : currentMonth, pattern }) + } + + // 按日期集合扫描,串行避免并行触发多次全库 SCAN + const allResults = [] + for (const { pattern } of datePatterns) { + const results = await redis.scanAndGetAllChunked(pattern) + allResults.push(...results) + } + + logger.info(`📊 Found ${allResults.length} matching keys in total`) + + // 模型名标准化函数(与redis.js保持一致) + const normalizeModelName = (model) => { + if (!model || model === 'unknown') { + return model + } + + // 对于Bedrock模型,去掉区域前缀进行统一 + if (model.includes('.anthropic.') || model.includes('.claude')) { + let normalized = model.replace(/^[a-z0-9-]+\./, '') + normalized = normalized.replace('anthropic.', '') + normalized = normalized.replace(/-v\d+:\d+$/, '') + return normalized + } + + return model.replace(/-v\d+:\d+$|:latest$/, '') + } + + // 聚合相同模型的数据 + const modelStatsMap = new Map() + + for (const { key, data } of allResults) { + // 支持 daily 和 monthly 两种格式 + const match = + key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) || + key.match(/usage:model:monthly:(.+):\d{4}-\d{2}$/) + + if (!match) { + logger.warn(`📊 Pattern mismatch for key: ${key}`) + continue + } + + const rawModel = match[1] + const normalizedModel = normalizeModelName(rawModel) + + if (data && Object.keys(data).length > 0) { + const stats = modelStatsMap.get(normalizedModel) || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + ephemeral5mTokens: 0, + ephemeral1hTokens: 0 + } + + stats.requests += parseInt(data.requests) || 0 + stats.inputTokens += parseInt(data.inputTokens) || 0 + stats.outputTokens += parseInt(data.outputTokens) || 0 + stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + stats.allTokens += parseInt(data.allTokens) || 0 + stats.ephemeral5mTokens += parseInt(data.ephemeral5mTokens) || 0 + stats.ephemeral1hTokens += parseInt(data.ephemeral1hTokens) || 0 + + modelStatsMap.set(normalizedModel, stats) + } + } + + // 转换为数组并计算费用 + const modelStats = [] + + for (const [model, stats] of modelStatsMap) { + const usage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (stats.ephemeral5mTokens > 0 || stats.ephemeral1hTokens > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: stats.ephemeral5mTokens, + ephemeral_1h_input_tokens: stats.ephemeral1hTokens + } + } + + // 计算费用 + const costData = CostCalculator.calculateCost(usage, model) + + modelStats.push({ + model, + period: startDate && endDate ? 'custom' : period, + requests: stats.requests, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + allTokens: stats.allTokens, + usage: { + requests: stats.requests, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + totalTokens: + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + }, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }) + } + + // 按总费用排序 + modelStats.sort((a, b) => b.costs.total - a.costs.total) + + logger.info( + `📊 Returning ${modelStats.length} global model stats for period ${period}:`, + modelStats + ) + + return res.json({ success: true, data: modelStats }) + } catch (error) { + logger.error('❌ Failed to get model stats:', error) + return res.status(500).json({ error: 'Failed to get model stats', message: error.message }) + } +}) + +// 🔧 系统管理 + +// 清理过期数据 +router.post('/cleanup', authenticateAdmin, async (req, res) => { + try { + const [expiredKeys, errorAccounts] = await Promise.all([ + apiKeyService.cleanupExpiredKeys(), + claudeAccountService.cleanupErrorAccounts() + ]) + + await redis.cleanup() + + logger.success( + `🧹 Admin triggered cleanup: ${expiredKeys} expired keys, ${errorAccounts} error accounts` + ) + + return res.json({ + success: true, + message: 'Cleanup completed', + data: { + expiredKeysRemoved: expiredKeys, + errorAccountsReset: errorAccounts + } + }) + } catch (error) { + logger.error('❌ Cleanup failed:', error) + return res.status(500).json({ error: 'Cleanup failed', message: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/droidAccounts.js b/src/routes/admin/droidAccounts.js new file mode 100644 index 0000000..de0dd02 --- /dev/null +++ b/src/routes/admin/droidAccounts.js @@ -0,0 +1,706 @@ +const express = require('express') +const crypto = require('crypto') +const droidAccountService = require('../../services/account/droidAccountService') +const accountGroupService = require('../../services/accountGroupService') +const apiKeyService = require('../../services/apiKeyService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const { + startDeviceAuthorization, + pollDeviceAuthorization, + WorkOSDeviceAuthError +} = require('../../utils/workosOAuthHelper') +const webhookNotifier = require('../../utils/webhookNotifier') +const { formatAccountExpiry, mapExpiryField } = require('./utils') +const { extractErrorMessage } = require('../../utils/testPayloadHelper') + +const router = express.Router() + +// ==================== Droid 账户管理 API ==================== + +// 生成 Droid 设备码授权信息 +router.post('/droid-accounts/generate-auth-url', authenticateAdmin, async (req, res) => { + try { + const { proxy } = req.body || {} + const deviceAuth = await startDeviceAuthorization(proxy || null) + + const sessionId = crypto.randomUUID() + const expiresAt = new Date(Date.now() + deviceAuth.expiresIn * 1000).toISOString() + + await redis.setOAuthSession(sessionId, { + deviceCode: deviceAuth.deviceCode, + userCode: deviceAuth.userCode, + verificationUri: deviceAuth.verificationUri, + verificationUriComplete: deviceAuth.verificationUriComplete, + interval: deviceAuth.interval, + proxy: proxy || null, + createdAt: new Date().toISOString(), + expiresAt + }) + + logger.success('🤖 生成 Droid 设备码授权信息成功', { sessionId }) + return res.json({ + success: true, + data: { + sessionId, + userCode: deviceAuth.userCode, + verificationUri: deviceAuth.verificationUri, + verificationUriComplete: deviceAuth.verificationUriComplete, + expiresIn: deviceAuth.expiresIn, + interval: deviceAuth.interval, + instructions: [ + '1. 使用下方验证码进入授权页面并确认访问权限。', + '2. 在授权页面登录 Factory / Droid 账户并点击允许。', + '3. 回到此处点击"完成授权"完成凭证获取。' + ] + } + }) + } catch (error) { + const message = + error instanceof WorkOSDeviceAuthError ? error.message : error.message || '未知错误' + logger.error('❌ 生成 Droid 设备码授权失败:', message) + return res.status(500).json({ error: 'Failed to start Droid device authorization', message }) + } +}) + +// 交换 Droid 授权码 +router.post('/droid-accounts/exchange-code', authenticateAdmin, async (req, res) => { + const { sessionId, proxy } = req.body || {} + try { + if (!sessionId) { + return res.status(400).json({ error: 'Session ID is required' }) + } + + const oauthSession = await redis.getOAuthSession(sessionId) + if (!oauthSession) { + return res.status(400).json({ error: 'Invalid or expired OAuth session' }) + } + + if (oauthSession.expiresAt && new Date() > new Date(oauthSession.expiresAt)) { + await redis.deleteOAuthSession(sessionId) + return res + .status(400) + .json({ error: 'OAuth session has expired, please generate a new authorization URL' }) + } + + if (!oauthSession.deviceCode) { + await redis.deleteOAuthSession(sessionId) + return res.status(400).json({ error: 'OAuth session missing device code, please retry' }) + } + + const proxyConfig = proxy || oauthSession.proxy || null + const tokens = await pollDeviceAuthorization(oauthSession.deviceCode, proxyConfig) + + await redis.deleteOAuthSession(sessionId) + + logger.success('🤖 成功获取 Droid 访问令牌', { sessionId }) + return res.json({ success: true, data: { tokens } }) + } catch (error) { + if (error instanceof WorkOSDeviceAuthError) { + if (error.code === 'authorization_pending' || error.code === 'slow_down') { + const oauthSession = await redis.getOAuthSession(sessionId) + const expiresAt = oauthSession?.expiresAt ? new Date(oauthSession.expiresAt) : null + const remainingSeconds = + expiresAt instanceof Date && !Number.isNaN(expiresAt.getTime()) + ? Math.max(0, Math.floor((expiresAt.getTime() - Date.now()) / 1000)) + : null + + return res.json({ + success: false, + pending: true, + error: error.code, + message: error.message, + retryAfter: error.retryAfter || Number(oauthSession?.interval) || 5, + expiresIn: remainingSeconds + }) + } + + if (error.code === 'expired_token') { + await redis.deleteOAuthSession(sessionId) + return res.status(400).json({ + error: 'Device code expired', + message: '授权已过期,请重新生成设备码并再次授权' + }) + } + + logger.error('❌ Droid 授权失败:', error.message) + return res.status(500).json({ + error: 'Failed to exchange Droid authorization code', + message: error.message, + errorCode: error.code + }) + } + + logger.error('❌ 交换 Droid 授权码失败:', error) + return res.status(500).json({ + error: 'Failed to exchange Droid authorization code', + message: error.message + }) + } +}) + +// 获取所有 Droid 账户 +router.get('/droid-accounts', authenticateAdmin, async (req, res) => { + try { + const accounts = await droidAccountService.getAllAccounts() + const accountIds = accounts.map((a) => a.id) + + // 并行获取:轻量 API Keys + 分组信息 + daily cost + const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([ + apiKeyService.getAllApiKeysLite(), + accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'droid'), + redis.batchGetAccountDailyCost(accountIds) + ]) + + // 构建绑定数映射(droid 需要展开 group 绑定) + // 1. 先构建 groupId -> accountIds 映射 + const groupToAccountIds = new Map() + for (const [accountId, groups] of allGroupInfosMap) { + for (const group of groups) { + if (!groupToAccountIds.has(group.id)) { + groupToAccountIds.set(group.id, []) + } + groupToAccountIds.get(group.id).push(accountId) + } + } + + // 2. 单次遍历构建绑定数 + const directBindingCount = new Map() + const groupBindingCount = new Map() + for (const key of allApiKeys) { + const binding = key.droidAccountId + if (!binding) { + continue + } + if (binding.startsWith('group:')) { + const groupId = binding.substring('group:'.length) + groupBindingCount.set(groupId, (groupBindingCount.get(groupId) || 0) + 1) + } else { + directBindingCount.set(binding, (directBindingCount.get(binding) || 0) + 1) + } + } + + // 批量获取使用统计 + const client = redis.getClientSafe() + const today = redis.getDateStringInTimezone() + const tzDate = redis.getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + + const statsPipeline = client.pipeline() + for (const accountId of accountIds) { + statsPipeline.hgetall(`account_usage:${accountId}`) + statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`) + statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`) + } + const statsResults = await statsPipeline.exec() + + // 处理统计数据 + const allUsageStatsMap = new Map() + const parseUsage = (data) => ({ + requests: parseInt(data?.totalRequests || data?.requests) || 0, + tokens: parseInt(data?.totalTokens || data?.tokens) || 0, + inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0, + outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0, + cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0, + cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0, + allTokens: + parseInt(data?.totalAllTokens || data?.allTokens) || + (parseInt(data?.totalInputTokens || data?.inputTokens) || 0) + + (parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) + + (parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) + + (parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0) + }) + + // 构建 accountId -> createdAt 映射用于计算 averages + const accountCreatedAtMap = new Map() + for (const account of accounts) { + accountCreatedAtMap.set( + account.id, + account.createdAt ? new Date(account.createdAt) : new Date() + ) + } + + for (let i = 0; i < accountIds.length; i++) { + const accountId = accountIds[i] + const [errTotal, total] = statsResults[i * 3] + const [errDaily, daily] = statsResults[i * 3 + 1] + const [errMonthly, monthly] = statsResults[i * 3 + 2] + + const totalData = errTotal ? {} : parseUsage(total) + const totalTokens = totalData.tokens || 0 + const totalRequests = totalData.requests || 0 + + // 计算 averages + const createdAt = accountCreatedAtMap.get(accountId) + const now = new Date() + const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))) + const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60) + + allUsageStatsMap.set(accountId, { + total: totalData, + daily: errDaily ? {} : parseUsage(daily), + monthly: errMonthly ? {} : parseUsage(monthly), + averages: { + rpm: Math.round((totalRequests / totalMinutes) * 100) / 100, + tpm: Math.round((totalTokens / totalMinutes) * 100) / 100, + dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100, + dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100 + } + }) + } + + // 处理账户数据 + const accountsWithStats = accounts.map((account) => { + const groupInfos = allGroupInfosMap.get(account.id) || [] + const usageStats = allUsageStatsMap.get(account.id) || { + daily: { tokens: 0, requests: 0 }, + total: { tokens: 0, requests: 0 }, + monthly: { tokens: 0, requests: 0 }, + averages: { rpm: 0, tpm: 0, dailyRequests: 0, dailyTokens: 0 } + } + const dailyCost = dailyCostMap.get(account.id) || 0 + + // 计算绑定数:直接绑定 + 通过 group 绑定 + let boundApiKeysCount = directBindingCount.get(account.id) || 0 + for (const group of groupInfos) { + boundApiKeysCount += groupBindingCount.get(group.id) || 0 + } + + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + schedulable: account.schedulable === 'true', + boundApiKeysCount, + groupInfos, + usage: { + daily: { ...usageStats.daily, cost: dailyCost }, + total: usageStats.total, + monthly: usageStats.monthly, + averages: usageStats.averages + } + } + }) + + return res.json({ success: true, data: accountsWithStats }) + } catch (error) { + logger.error('Failed to get Droid accounts:', error) + return res.status(500).json({ error: 'Failed to get Droid accounts', message: error.message }) + } +}) + +// 创建 Droid 账户 +router.post('/droid-accounts', authenticateAdmin, async (req, res) => { + try { + const { accountType: rawAccountType = 'shared', groupId, groupIds } = req.body + + const normalizedAccountType = rawAccountType || 'shared' + + if (!['shared', 'dedicated', 'group'].includes(normalizedAccountType)) { + return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' }) + } + + const normalizedGroupIds = Array.isArray(groupIds) + ? groupIds.filter((id) => typeof id === 'string' && id.trim()) + : [] + + if ( + normalizedAccountType === 'group' && + normalizedGroupIds.length === 0 && + (!groupId || typeof groupId !== 'string' || !groupId.trim()) + ) { + return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' }) + } + + const accountPayload = { + ...req.body, + accountType: normalizedAccountType + } + + delete accountPayload.groupId + delete accountPayload.groupIds + + const account = await droidAccountService.createAccount(accountPayload) + + if (normalizedAccountType === 'group') { + try { + if (normalizedGroupIds.length > 0) { + await accountGroupService.setAccountGroups(account.id, normalizedGroupIds, 'droid') + } else if (typeof groupId === 'string' && groupId.trim()) { + await accountGroupService.addAccountToGroup(account.id, groupId, 'droid') + } + } catch (groupError) { + logger.error(`Failed to attach Droid account ${account.id} to groups:`, groupError) + return res.status(500).json({ + error: 'Failed to bind Droid account to groups', + message: groupError.message + }) + } + } + + logger.success(`Created Droid account: ${account.name} (${account.id})`) + const formattedAccount = formatAccountExpiry(account) + return res.json({ success: true, data: formattedAccount }) + } catch (error) { + logger.error('Failed to create Droid account:', error) + return res.status(500).json({ error: 'Failed to create Droid account', message: error.message }) + } +}) + +// 更新 Droid 账户 +router.put('/droid-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = { ...req.body } + + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'Droid', id) + + const { accountType: rawAccountType, groupId, groupIds } = mappedUpdates + + if (rawAccountType && !['shared', 'dedicated', 'group'].includes(rawAccountType)) { + return res.status(400).json({ error: '账户类型必须是 shared、dedicated 或 group' }) + } + + if ( + rawAccountType === 'group' && + (!groupId || typeof groupId !== 'string' || !groupId.trim()) && + (!Array.isArray(groupIds) || groupIds.length === 0) + ) { + return res.status(400).json({ error: '分组调度账户必须至少选择一个分组' }) + } + + const currentAccount = await droidAccountService.getAccount(id) + if (!currentAccount) { + return res.status(404).json({ error: 'Droid account not found' }) + } + + const normalizedGroupIds = Array.isArray(groupIds) + ? groupIds.filter((gid) => typeof gid === 'string' && gid.trim()) + : [] + const hasGroupIdsField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds') + const hasGroupIdField = Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupId') + const targetAccountType = rawAccountType || currentAccount.accountType || 'shared' + + delete mappedUpdates.groupId + delete mappedUpdates.groupIds + + if (rawAccountType) { + mappedUpdates.accountType = targetAccountType + } + + const account = await droidAccountService.updateAccount(id, mappedUpdates) + + try { + if (currentAccount.accountType === 'group' && targetAccountType !== 'group') { + await accountGroupService.removeAccountFromAllGroups(id) + } else if (targetAccountType === 'group') { + if (hasGroupIdsField) { + if (normalizedGroupIds.length > 0) { + await accountGroupService.setAccountGroups(id, normalizedGroupIds, 'droid') + } else { + await accountGroupService.removeAccountFromAllGroups(id) + } + } else if (hasGroupIdField && typeof groupId === 'string' && groupId.trim()) { + await accountGroupService.setAccountGroups(id, [groupId], 'droid') + } + } + } catch (groupError) { + logger.error(`Failed to update Droid account ${id} groups:`, groupError) + return res.status(500).json({ + error: 'Failed to update Droid account groups', + message: groupError.message + }) + } + + if (targetAccountType === 'group') { + try { + account.groupInfos = await accountGroupService.getAccountGroups(id) + } catch (groupFetchError) { + logger.debug(`Failed to fetch group infos for Droid account ${id}:`, groupFetchError) + } + } + + return res.json({ success: true, data: account }) + } catch (error) { + logger.error(`Failed to update Droid account ${req.params.id}:`, error) + return res.status(500).json({ error: 'Failed to update Droid account', message: error.message }) + } +}) + +// 切换 Droid 账户调度状态 +router.put('/droid-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await droidAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ error: 'Droid account not found' }) + } + + const currentSchedulable = account.schedulable === true || account.schedulable === 'true' + const newSchedulable = !currentSchedulable + + await droidAccountService.updateAccount(id, { schedulable: newSchedulable ? 'true' : 'false' }) + + const updatedAccount = await droidAccountService.getAccount(id) + const actualSchedulable = updatedAccount + ? updatedAccount.schedulable === true || updatedAccount.schedulable === 'true' + : newSchedulable + + if (!actualSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'Droid Account', + platform: 'droid', + status: 'disabled', + errorCode: 'DROID_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Droid account schedulable status: ${id} -> ${ + actualSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + + return res.json({ success: true, schedulable: actualSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Droid account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } +}) + +// 获取单个 Droid 账户详细信息 +router.get('/droid-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + // 获取账户基本信息 + const account = await droidAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + error: 'Not Found', + message: 'Droid account not found' + }) + } + + // 获取使用统计信息 + let usageStats + try { + usageStats = await redis.getAccountUsageStats(account.id, 'droid') + } catch (error) { + logger.debug(`Failed to get usage stats for Droid account ${account.id}:`, error) + usageStats = { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + + // 获取分组信息 + let groupInfos = [] + try { + groupInfos = await accountGroupService.getAccountGroups(account.id) + } catch (error) { + logger.debug(`Failed to get group infos for Droid account ${account.id}:`, error) + groupInfos = [] + } + + // 获取绑定的 API Key 数量 + const allApiKeys = await apiKeyService.getAllApiKeysFast() + const groupIds = groupInfos.map((group) => group.id) + const boundApiKeysCount = allApiKeys.reduce((count, key) => { + const binding = key.droidAccountId + if (!binding) { + return count + } + if (binding === account.id) { + return count + 1 + } + if (binding.startsWith('group:')) { + const groupId = binding.substring('group:'.length) + if (groupIds.includes(groupId)) { + return count + 1 + } + } + return count + }, 0) + + // 获取解密的 API Keys(用于管理界面) + let decryptedApiKeys = [] + try { + decryptedApiKeys = await droidAccountService.getDecryptedApiKeyEntries(id) + } catch (error) { + logger.debug(`Failed to get decrypted API keys for Droid account ${account.id}:`, error) + decryptedApiKeys = [] + } + + // 返回完整的账户信息,包含实际的 API Keys + const accountDetails = { + ...account, + // 映射字段:使用 subscriptionExpiresAt 作为前端显示的 expiresAt + expiresAt: account.subscriptionExpiresAt || null, + schedulable: account.schedulable === 'true', + boundApiKeysCount, + groupInfos, + // 包含实际的 API Keys(用于管理界面) + apiKeys: decryptedApiKeys.map((entry) => ({ + key: entry.key, + id: entry.id, + usageCount: entry.usageCount || 0, + lastUsedAt: entry.lastUsedAt || null, + status: entry.status || 'active', // 使用实际的状态,默认为 active + errorMessage: entry.errorMessage || '', // 包含错误信息 + createdAt: entry.createdAt || null + })), + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + + return res.json({ + success: true, + data: accountDetails + }) + } catch (error) { + logger.error(`Failed to get Droid account ${req.params.id}:`, error) + return res.status(500).json({ + error: 'Failed to get Droid account', + message: error.message + }) + } +}) + +// 删除 Droid 账户 +router.delete('/droid-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + await droidAccountService.deleteAccount(id) + return res.json({ success: true, message: 'Droid account deleted successfully' }) + } catch (error) { + logger.error(`Failed to delete Droid account ${req.params.id}:`, error) + return res.status(500).json({ error: 'Failed to delete Droid account', message: error.message }) + } +}) + +// 刷新 Droid 账户 token +router.post('/droid-accounts/:id/refresh-token', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const result = await droidAccountService.refreshAccessToken(id) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error(`Failed to refresh Droid account token ${req.params.id}:`, error) + return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) + } +}) + +// 测试 Droid 账户连通性 +router.post('/droid-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { model = 'claude-sonnet-4-20250514' } = req.body + const startTime = Date.now() + + try { + // 获取账户信息 + const account = await droidAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 确保 token 有效 + const tokenResult = await droidAccountService.ensureValidToken(accountId) + if (!tokenResult.success) { + return res.status(401).json({ + error: 'Token refresh failed', + message: tokenResult.error + }) + } + + const { accessToken } = tokenResult + + // 构造测试请求 + const axios = require('axios') + const { getProxyAgent } = require('../../utils/proxyHelper') + + const apiUrl = 'https://api.factory.ai/v1/messages' + const payload = { + model, + max_tokens: 100, + messages: [{ role: 'user', content: 'Say "Hello" in one word.' }] + } + + const requestConfig = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}` + }, + timeout: 30000 + } + + // 配置代理 + if (account.proxy) { + const agent = getProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + const response = await axios.post(apiUrl, payload, requestConfig) + const latency = Date.now() - startTime + + // 提取响应文本 + let responseText = '' + if (response.data?.content?.[0]?.text) { + responseText = response.data.content[0].text + } + + logger.success( + `✅ Droid account test passed: ${account.name} (${accountId}), latency: ${latency}ms` + ) + + return res.json({ + success: true, + data: { + accountId, + accountName: account.name, + model, + latency, + responseText: responseText.substring(0, 200) + } + }) + } catch (error) { + const latency = Date.now() - startTime + logger.error(`❌ Droid account test failed: ${accountId}`, error.message) + + return res.status(500).json({ + success: false, + error: 'Test failed', + message: extractErrorMessage(error.response?.data, error.message), + latency + }) + } +}) + +// 重置 Droid 账户状态 +router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const result = await droidAccountService.resetAccountStatus(accountId) + logger.success(`Admin reset status for Droid account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset Droid account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/errorHistory.js b/src/routes/admin/errorHistory.js new file mode 100644 index 0000000..314383f --- /dev/null +++ b/src/routes/admin/errorHistory.js @@ -0,0 +1,44 @@ +const express = require('express') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +const router = express.Router() + +// 查询账户错误历史 +router.get( + '/accounts/:accountType/:accountId/error-history', + authenticateAdmin, + async (req, res) => { + try { + const { accountType, accountId } = req.params + const offset = parseInt(req.query.offset) || 0 + const limit = parseInt(req.query.limit) || 50 + const data = await upstreamErrorHelper.getErrorHistory(accountType, accountId, offset, limit) + return res.json({ success: true, data }) + } catch (error) { + logger.error('Failed to get error history:', error) + return res.status(500).json({ error: 'Failed to get error history', message: error.message }) + } + } +) + +// 清除账户错误历史 +router.delete( + '/accounts/:accountType/:accountId/error-history', + authenticateAdmin, + async (req, res) => { + try { + const { accountType, accountId } = req.params + await upstreamErrorHelper.clearErrorHistory(accountType, accountId) + return res.json({ success: true }) + } catch (error) { + logger.error('Failed to clear error history:', error) + return res + .status(500) + .json({ error: 'Failed to clear error history', message: error.message }) + } + } +) + +module.exports = router diff --git a/src/routes/admin/geminiAccounts.js b/src/routes/admin/geminiAccounts.js new file mode 100644 index 0000000..f756b46 --- /dev/null +++ b/src/routes/admin/geminiAccounts.js @@ -0,0 +1,595 @@ +const express = require('express') +const geminiAccountService = require('../../services/account/geminiAccountService') +const accountGroupService = require('../../services/accountGroupService') +const apiKeyService = require('../../services/apiKeyService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const webhookNotifier = require('../../utils/webhookNotifier') +const { formatAccountExpiry, mapExpiryField } = require('./utils') + +const router = express.Router() + +// 🤖 Gemini OAuth 账户管理 +function getDefaultRedirectUri(oauthProvider) { + if (oauthProvider === 'antigravity') { + return process.env.ANTIGRAVITY_OAUTH_REDIRECT_URI || 'http://localhost:45462' + } + return process.env.GEMINI_OAUTH_REDIRECT_URI || 'https://codeassist.google.com/authcode' +} + +// 生成 Gemini OAuth 授权 URL +router.post('/generate-auth-url', authenticateAdmin, async (req, res) => { + try { + const { state, proxy, oauthProvider } = req.body // 接收代理配置与OAuth Provider + + const redirectUri = getDefaultRedirectUri(oauthProvider) + + logger.info(`Generating Gemini OAuth URL with redirect_uri: ${redirectUri}`) + + const { + authUrl, + state: authState, + codeVerifier, + redirectUri: finalRedirectUri, + oauthProvider: resolvedOauthProvider + } = await geminiAccountService.generateAuthUrl(state, redirectUri, proxy, oauthProvider) + + // 创建 OAuth 会话,包含 codeVerifier 和代理配置 + const sessionId = authState + await redis.setOAuthSession(sessionId, { + state: authState, + type: 'gemini', + redirectUri: finalRedirectUri, + codeVerifier, // 保存 PKCE code verifier + proxy: proxy || null, // 保存代理配置 + oauthProvider: resolvedOauthProvider, + createdAt: new Date().toISOString() + }) + + logger.info(`Generated Gemini OAuth URL with session: ${sessionId}`) + return res.json({ + success: true, + data: { + authUrl, + sessionId, + oauthProvider: resolvedOauthProvider + } + }) + } catch (error) { + logger.error('❌ Failed to generate Gemini auth URL:', error) + return res.status(500).json({ error: 'Failed to generate auth URL', message: error.message }) + } +}) + +// 轮询 Gemini OAuth 授权状态 +router.post('/poll-auth-status', authenticateAdmin, async (req, res) => { + try { + const { sessionId } = req.body + + if (!sessionId) { + return res.status(400).json({ error: 'Session ID is required' }) + } + + const result = await geminiAccountService.pollAuthorizationStatus(sessionId) + + if (result.success) { + logger.success(`Gemini OAuth authorization successful for session: ${sessionId}`) + return res.json({ success: true, data: { tokens: result.tokens } }) + } else { + return res.json({ success: false, error: result.error }) + } + } catch (error) { + logger.error('❌ Failed to poll Gemini auth status:', error) + return res.status(500).json({ error: 'Failed to poll auth status', message: error.message }) + } +}) + +// 交换 Gemini 授权码 +router.post('/exchange-code', authenticateAdmin, async (req, res) => { + try { + const { code, sessionId, proxy: requestProxy, oauthProvider } = req.body + let resolvedOauthProvider = oauthProvider + + if (!code) { + return res.status(400).json({ error: 'Authorization code is required' }) + } + + let redirectUri = getDefaultRedirectUri(resolvedOauthProvider) + let codeVerifier = null + let proxyConfig = null + + // 如果提供了 sessionId,从 OAuth 会话中获取信息 + if (sessionId) { + const sessionData = await redis.getOAuthSession(sessionId) + if (sessionData) { + const { + redirectUri: sessionRedirectUri, + codeVerifier: sessionCodeVerifier, + proxy, + oauthProvider: sessionOauthProvider + } = sessionData + redirectUri = sessionRedirectUri || redirectUri + codeVerifier = sessionCodeVerifier + proxyConfig = proxy // 获取代理配置 + if (!resolvedOauthProvider && sessionOauthProvider) { + // 会话里保存的 provider 仅作为兜底 + resolvedOauthProvider = sessionOauthProvider + } + logger.info( + `Using session redirect_uri: ${redirectUri}, has codeVerifier: ${!!codeVerifier}, has proxy from session: ${!!proxyConfig}` + ) + } + } + + // 如果请求体中直接提供了代理配置,优先使用它 + if (requestProxy) { + proxyConfig = requestProxy + logger.info( + `Using proxy from request body: ${proxyConfig ? JSON.stringify(proxyConfig) : 'none'}` + ) + } + + const tokens = await geminiAccountService.exchangeCodeForTokens( + code, + redirectUri, + codeVerifier, + proxyConfig, // 传递代理配置 + resolvedOauthProvider + ) + + // 清理 OAuth 会话 + if (sessionId) { + await redis.deleteOAuthSession(sessionId) + } + + logger.success('Successfully exchanged Gemini authorization code') + return res.json({ success: true, data: { tokens, oauthProvider: resolvedOauthProvider } }) + } catch (error) { + logger.error('❌ Failed to exchange Gemini authorization code:', error) + return res.status(500).json({ error: 'Failed to exchange code', message: error.message }) + } +}) + +// 获取所有 Gemini 账户 +router.get('/', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await geminiAccountService.getAllAccounts() + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'gemini') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await accountGroupService.getAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息(与Claude账户相同的逻辑) + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await accountGroupService.getAccountGroups(account.id) + + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + } + } catch (statsError) { + logger.warn( + `⚠️ Failed to get usage stats for Gemini account ${account.id}:`, + statsError.message + ) + // 如果获取统计失败,返回空统计 + try { + const groupInfos = await accountGroupService.getAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } catch (groupError) { + logger.warn( + `⚠️ Failed to get group info for account ${account.id}:`, + groupError.message + ) + return { + ...account, + groupInfos: [], + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + } + } + } + }) + ) + + return res.json({ success: true, data: accountsWithStats }) + } catch (error) { + logger.error('❌ Failed to get Gemini accounts:', error) + return res.status(500).json({ error: 'Failed to get accounts', message: error.message }) + } +}) + +// 创建新的 Gemini 账户 +router.post('/', authenticateAdmin, async (req, res) => { + try { + const accountData = req.body + + // 输入验证 + if (!accountData.name) { + return res.status(400).json({ error: 'Account name is required' }) + } + + // 验证accountType的有效性 + if ( + accountData.accountType && + !['shared', 'dedicated', 'group'].includes(accountData.accountType) + ) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果是分组类型,验证groupId或groupIds + if ( + accountData.accountType === 'group' && + !accountData.groupId && + (!accountData.groupIds || accountData.groupIds.length === 0) + ) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + const newAccount = await geminiAccountService.createAccount(accountData) + + // 如果是分组类型,处理分组绑定 + if (accountData.accountType === 'group') { + if (accountData.groupIds && accountData.groupIds.length > 0) { + // 多分组模式 + await accountGroupService.setAccountGroups(newAccount.id, accountData.groupIds, 'gemini') + logger.info( + `🏢 Added Gemini account ${newAccount.id} to groups: ${accountData.groupIds.join(', ')}` + ) + } else if (accountData.groupId) { + // 单分组模式(向后兼容) + await accountGroupService.addAccountToGroup(newAccount.id, accountData.groupId, 'gemini') + } + } + + logger.success(`🏢 Admin created new Gemini account: ${accountData.name}`) + const formattedAccount = formatAccountExpiry(newAccount) + return res.json({ success: true, data: formattedAccount }) + } catch (error) { + logger.error('❌ Failed to create Gemini account:', error) + return res.status(500).json({ error: 'Failed to create account', message: error.message }) + } +}) + +// 更新 Gemini 账户 +router.put('/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const updates = req.body + + // 验证accountType的有效性 + if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果更新为分组类型,验证groupId或groupIds + if ( + updates.accountType === 'group' && + !updates.groupId && + (!updates.groupIds || updates.groupIds.length === 0) + ) { + return res.status(400).json({ error: 'Group ID is required for group type accounts' }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await geminiAccountService.getAccount(accountId) + if (!currentAccount) { + return res.status(404).json({ error: 'Account not found' }) + } + + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'Gemini', accountId) + + // 处理分组的变更 + if (mappedUpdates.accountType !== undefined) { + // 如果之前是分组类型,需要从所有分组中移除 + if (currentAccount.accountType === 'group') { + const oldGroups = await accountGroupService.getAccountGroups(accountId) + for (const oldGroup of oldGroups) { + await accountGroupService.removeAccountFromGroup(accountId, oldGroup.id) + } + } + // 如果新类型是分组,处理多分组支持 + if (mappedUpdates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { + // 如果明确提供了 groupIds 参数(包括空数组) + if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { + // 设置新的多分组 + await accountGroupService.setAccountGroups(accountId, mappedUpdates.groupIds, 'gemini') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(accountId) + } + } else if (mappedUpdates.groupId) { + // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 + await accountGroupService.addAccountToGroup(accountId, mappedUpdates.groupId, 'gemini') + } + } + } + + const updatedAccount = await geminiAccountService.updateAccount(accountId, mappedUpdates) + + logger.success(`📝 Admin updated Gemini account: ${accountId}`) + return res.json({ success: true, data: updatedAccount }) + } catch (error) { + logger.error('❌ Failed to update Gemini account:', error) + return res.status(500).json({ error: 'Failed to update account', message: error.message }) + } +}) + +// 删除 Gemini 账户 +router.delete('/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'gemini') + + // 获取账户信息以检查是否在分组中 + const account = await geminiAccountService.getAccount(accountId) + if (account && account.accountType === 'group') { + const groups = await accountGroupService.getAccountGroups(accountId) + for (const group of groups) { + await accountGroupService.removeAccountFromGroup(accountId, group.id) + } + } + + await geminiAccountService.deleteAccount(accountId) + + let message = 'Gemini账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Gemini account: ${accountId}, unbound ${unboundCount} keys`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('❌ Failed to delete Gemini account:', error) + return res.status(500).json({ error: 'Failed to delete account', message: error.message }) + } +}) + +// 刷新 Gemini 账户 token +router.post('/:accountId/refresh', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await geminiAccountService.refreshAccountToken(accountId) + + logger.success(`🔄 Admin refreshed token for Gemini account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to refresh Gemini account token:', error) + return res.status(500).json({ error: 'Failed to refresh token', message: error.message }) + } +}) + +// 切换 Gemini 账户调度状态 +router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 现在 account.schedulable 已经是布尔值了,直接取反即可 + const newSchedulable = !account.schedulable + + await geminiAccountService.updateAccount(accountId, { schedulable: String(newSchedulable) }) + + // 验证更新是否成功,重新获取账户信息 + const updatedAccount = await geminiAccountService.getAccount(accountId) + const actualSchedulable = updatedAccount ? updatedAccount.schedulable : newSchedulable + + // 如果账号被禁用,发送webhook通知 + if (!actualSchedulable) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.accountName || 'Gemini Account', + platform: 'gemini', + status: 'disabled', + errorCode: 'GEMINI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + + logger.success( + `🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${ + actualSchedulable ? 'schedulable' : 'not schedulable' + }` + ) + + // 返回实际的数据库值,确保前端状态与后端一致 + return res.json({ success: true, schedulable: actualSchedulable }) + } catch (error) { + logger.error('❌ Failed to toggle Gemini account schedulable status:', error) + return res + .status(500) + .json({ error: 'Failed to toggle schedulable status', message: error.message }) + } +}) + +// 重置 Gemini OAuth 账户限流状态 +router.post('/:id/reset-rate-limit', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + await geminiAccountService.updateAccount(id, { + rateLimitedAt: '', + rateLimitStatus: '', + status: 'active', + errorMessage: '' + }) + + logger.info(`🔄 Admin manually reset rate limit for Gemini account ${id}`) + + res.json({ + success: true, + message: 'Rate limit reset successfully' + }) + } catch (error) { + logger.error('Failed to reset Gemini account rate limit:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 重置 Gemini OAuth 账户状态(清除所有异常状态) +router.post('/:id/reset-status', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const result = await geminiAccountService.resetAccountStatus(id) + + logger.success(`Admin reset status for Gemini account: ${id}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset Gemini account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 测试 Gemini 账户连通性 +router.post('/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { model = 'gemini-2.5-flash' } = req.body + const startTime = Date.now() + const { extractErrorMessage } = require('../../utils/testPayloadHelper') + + try { + // 获取账户信息 + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 确保 token 有效 + const tokenResult = await geminiAccountService.ensureValidToken(accountId) + if (!tokenResult.success) { + return res.status(401).json({ + error: 'Token refresh failed', + message: tokenResult.error + }) + } + + const { accessToken } = tokenResult + + // 构造测试请求 + const axios = require('axios') + const { createGeminiTestPayload } = require('../../utils/testPayloadHelper') + const { getProxyAgent } = require('../../utils/proxyHelper') + + const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent` + const payload = createGeminiTestPayload(model) + + const requestConfig = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}` + }, + timeout: 30000 + } + + // 配置代理 + if (account.proxy) { + const agent = getProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + const response = await axios.post(apiUrl, payload, requestConfig) + const latency = Date.now() - startTime + + // 提取响应文本 + let responseText = '' + if (response.data?.candidates?.[0]?.content?.parts?.[0]?.text) { + responseText = response.data.candidates[0].content.parts[0].text + } + + logger.success( + `✅ Gemini account test passed: ${account.name} (${accountId}), latency: ${latency}ms` + ) + + return res.json({ + success: true, + data: { + accountId, + accountName: account.name, + model, + latency, + responseText: responseText.substring(0, 200) + } + }) + } catch (error) { + const latency = Date.now() - startTime + logger.error(`❌ Gemini account test failed: ${accountId}`, error.message) + + return res.status(500).json({ + success: false, + error: 'Test failed', + message: extractErrorMessage(error.response?.data, error.message), + latency + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/geminiApiAccounts.js b/src/routes/admin/geminiApiAccounts.js new file mode 100644 index 0000000..568380a --- /dev/null +++ b/src/routes/admin/geminiApiAccounts.js @@ -0,0 +1,615 @@ +const express = require('express') +const geminiApiAccountService = require('../../services/account/geminiApiAccountService') +const apiKeyService = require('../../services/apiKeyService') +const accountGroupService = require('../../services/accountGroupService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const webhookNotifier = require('../../utils/webhookNotifier') + +const router = express.Router() + +// 获取所有 Gemini-API 账户 +router.get('/gemini-api-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await geminiApiAccountService.getAllAccounts(true) + + // 根据查询参数进行筛选 + if (platform && platform !== 'gemini-api') { + accounts = [] + } + + // 根据分组ID筛选 + if (groupId) { + const group = await accountGroupService.getGroup(groupId) + if (group && group.platform === 'gemini') { + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } else { + accounts = [] + } + } + + const accountIds = accounts.map((a) => a.id) + + // 并行获取:轻量 API Keys + 分组信息 + daily cost + 清除限流状态 + const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([ + apiKeyService.getAllApiKeysLite(), + accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'gemini'), + redis.batchGetAccountDailyCost(accountIds), + // 批量清除限流状态 + Promise.all(accountIds.map((id) => geminiApiAccountService.checkAndClearRateLimit(id))) + ]) + + // 单次遍历构建绑定数映射(只算直连,不算 group) + const bindingCountMap = new Map() + for (const key of allApiKeys) { + const binding = key.geminiAccountId + if (!binding) { + continue + } + // 处理 api: 前缀 + const accountId = binding.startsWith('api:') ? binding.substring(4) : binding + bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1) + } + + // 批量获取使用统计 + const client = redis.getClientSafe() + const today = redis.getDateStringInTimezone() + const tzDate = redis.getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + + const statsPipeline = client.pipeline() + for (const accountId of accountIds) { + statsPipeline.hgetall(`account_usage:${accountId}`) + statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`) + statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`) + } + const statsResults = await statsPipeline.exec() + + // 处理统计数据 + const allUsageStatsMap = new Map() + for (let i = 0; i < accountIds.length; i++) { + const accountId = accountIds[i] + const [errTotal, total] = statsResults[i * 3] + const [errDaily, daily] = statsResults[i * 3 + 1] + const [errMonthly, monthly] = statsResults[i * 3 + 2] + + const parseUsage = (data) => ({ + requests: parseInt(data?.totalRequests || data?.requests) || 0, + tokens: parseInt(data?.totalTokens || data?.tokens) || 0, + inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0, + outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0, + cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0, + cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0, + allTokens: + parseInt(data?.totalAllTokens || data?.allTokens) || + (parseInt(data?.totalInputTokens || data?.inputTokens) || 0) + + (parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) + + (parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) + + (parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0) + }) + + allUsageStatsMap.set(accountId, { + total: errTotal ? {} : parseUsage(total), + daily: errDaily ? {} : parseUsage(daily), + monthly: errMonthly ? {} : parseUsage(monthly) + }) + } + + // 处理账户数据 + const accountsWithStats = accounts.map((account) => { + const groupInfos = allGroupInfosMap.get(account.id) || [] + const usageStats = allUsageStatsMap.get(account.id) || { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + monthly: { requests: 0, tokens: 0, allTokens: 0 } + } + const dailyCost = dailyCostMap.get(account.id) || 0 + const boundCount = bindingCountMap.get(account.id) || 0 + + // 计算 averages(rpm/tpm) + const createdAt = account.createdAt ? new Date(account.createdAt) : new Date() + const daysSinceCreated = Math.max( + 1, + Math.ceil((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24)) + ) + const totalMinutes = daysSinceCreated * 24 * 60 + const totalRequests = usageStats.total.requests || 0 + const totalTokens = usageStats.total.tokens || usageStats.total.allTokens || 0 + + return { + ...account, + groupInfos, + usage: { + daily: { ...usageStats.daily, cost: dailyCost }, + total: usageStats.total, + averages: { + rpm: Math.round((totalRequests / totalMinutes) * 100) / 100, + tpm: Math.round((totalTokens / totalMinutes) * 100) / 100 + } + }, + boundApiKeys: boundCount + } + }) + + res.json({ success: true, data: accountsWithStats }) + } catch (error) { + logger.error('Failed to get Gemini-API accounts:', error) + res.status(500).json({ success: false, message: error.message }) + } +}) + +// 创建 Gemini-API 账户 +router.post('/gemini-api-accounts', authenticateAdmin, async (req, res) => { + try { + const { accountType, groupId, groupIds } = req.body + + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated', 'group'].includes(accountType)) { + return res.status(400).json({ + success: false, + error: 'Invalid account type. Must be "shared", "dedicated" or "group"' + }) + } + + // 如果是分组类型,验证groupId或groupIds + if (accountType === 'group' && !groupId && (!groupIds || groupIds.length === 0)) { + return res.status(400).json({ + success: false, + error: 'Group ID or Group IDs are required for group type accounts' + }) + } + + const account = await geminiApiAccountService.createAccount(req.body) + + // 如果是分组类型,将账户添加到分组 + if (accountType === 'group') { + if (groupIds && groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(account.id, groupIds, 'gemini') + } else if (groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(account.id, groupId, 'gemini') + } + } + + logger.success( + `🏢 Admin created new Gemini-API account: ${account.name} (${accountType || 'shared'})` + ) + + res.json({ success: true, data: account }) + } catch (error) { + logger.error('Failed to create Gemini-API account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 获取单个 Gemini-API 账户 +router.get('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const account = await geminiApiAccountService.getAccount(id) + + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + // 隐藏敏感信息 + account.apiKey = '***' + + res.json({ success: true, data: account }) + } catch (error) { + logger.error('Failed to get Gemini-API account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 更新 Gemini-API 账户 +router.put('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = req.body + + // 验证priority的有效性(1-100) + if (updates.priority !== undefined) { + const priority = parseInt(updates.priority) + if (isNaN(priority) || priority < 1 || priority > 100) { + return res.status(400).json({ + success: false, + message: 'Priority must be a number between 1 and 100' + }) + } + } + + // 验证accountType的有效性 + if (updates.accountType && !['shared', 'dedicated', 'group'].includes(updates.accountType)) { + return res.status(400).json({ + success: false, + error: 'Invalid account type. Must be "shared", "dedicated" or "group"' + }) + } + + // 如果更新为分组类型,验证groupId或groupIds + if ( + updates.accountType === 'group' && + !updates.groupId && + (!updates.groupIds || updates.groupIds.length === 0) + ) { + return res.status(400).json({ + success: false, + error: 'Group ID or Group IDs are required for group type accounts' + }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await geminiApiAccountService.getAccount(id) + if (!currentAccount) { + return res.status(404).json({ + success: false, + error: 'Account not found' + }) + } + + // 处理分组的变更 + if (updates.accountType !== undefined) { + // 如果之前是分组类型,需要从所有分组中移除 + if (currentAccount.accountType === 'group') { + await accountGroupService.removeAccountFromAllGroups(id) + } + + // 如果新类型是分组,添加到新分组 + if (updates.accountType === 'group') { + // 处理多分组/单分组的兼容性 + if (Object.prototype.hasOwnProperty.call(updates, 'groupIds')) { + if (updates.groupIds && updates.groupIds.length > 0) { + // 使用多分组设置 + await accountGroupService.setAccountGroups(id, updates.groupIds, 'gemini') + } + } else if (updates.groupId) { + // 兼容单分组模式 + await accountGroupService.addAccountToGroup(id, updates.groupId, 'gemini') + } + } + } + + const result = await geminiApiAccountService.updateAccount(id, updates) + + if (!result.success) { + return res.status(400).json(result) + } + + logger.success(`📝 Admin updated Gemini-API account: ${currentAccount.name}`) + + res.json({ success: true, ...result }) + } catch (error) { + logger.error('Failed to update Gemini-API account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 删除 Gemini-API 账户 +router.delete('/gemini-api-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await geminiApiAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + // 自动解绑所有绑定的 API Keys(支持 api: 前缀) + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'gemini-api') + + // 从所有分组中移除此账户 + if (account.accountType === 'group') { + await accountGroupService.removeAccountFromAllGroups(id) + logger.info(`Removed Gemini-API account ${id} from all groups`) + } + + const result = await geminiApiAccountService.deleteAccount(id) + + let message = 'Gemini-API账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`${message}`) + + res.json({ + success: true, + ...result, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('Failed to delete Gemini-API account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 切换 Gemini-API 账户调度状态 +router.put('/gemini-api-accounts/:id/toggle-schedulable', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const result = await geminiApiAccountService.toggleSchedulable(id) + + if (!result.success) { + return res.status(400).json(result) + } + + // 仅在停止调度时发送通知 + if (!result.schedulable) { + await webhookNotifier.sendAccountEvent('account.status_changed', { + accountId: id, + platform: 'gemini-api', + schedulable: result.schedulable, + changedBy: 'admin', + action: 'stopped_scheduling' + }) + } + + res.json(result) + } catch (error) { + logger.error('Failed to toggle Gemini-API account schedulable status:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 切换 Gemini-API 账户激活状态 +router.put('/gemini-api-accounts/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await geminiApiAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + const newActiveStatus = account.isActive === 'true' ? 'false' : 'true' + await geminiApiAccountService.updateAccount(id, { + isActive: newActiveStatus + }) + + res.json({ + success: true, + isActive: newActiveStatus === 'true' + }) + } catch (error) { + logger.error('Failed to toggle Gemini-API account status:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 重置 Gemini-API 账户限流状态 +router.post('/gemini-api-accounts/:id/reset-rate-limit', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + await geminiApiAccountService.updateAccount(id, { + rateLimitedAt: '', + rateLimitStatus: '', + status: 'active', + errorMessage: '' + }) + + logger.info(`🔄 Admin manually reset rate limit for Gemini-API account ${id}`) + + res.json({ + success: true, + message: 'Rate limit reset successfully' + }) + } catch (error) { + logger.error('Failed to reset Gemini-API account rate limit:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 重置 Gemini-API 账户状态(清除所有异常状态) +router.post('/gemini-api-accounts/:id/reset-status', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const result = await geminiApiAccountService.resetAccountStatus(id) + + logger.success(`Admin reset status for Gemini-API account: ${id}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset Gemini-API account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 测试 Gemini-API 账户连通性(SSE 流式) +const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096] +const sanitizeMaxTokens = (value) => + ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 500 + +router.post('/gemini-api-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { model = 'gemini-2.5-flash', prompt = 'hi' } = req.body + const maxTokens = sanitizeMaxTokens(req.body.maxTokens) + const { createGeminiTestPayload, extractErrorMessage } = require('../../utils/testPayloadHelper') + const { buildGeminiApiUrl } = require('../../handlers/geminiHandlers') + const ProxyHelper = require('../../utils/proxyHelper') + const axios = require('axios') + + const abortController = new AbortController() + res.on('close', () => abortController.abort()) + + const safeWrite = (data) => { + if (!res.writableEnded && !res.destroyed) { + res.write(data) + } + } + const safeEnd = () => { + if (!res.writableEnded && !res.destroyed) { + res.end() + } + } + + try { + const account = await geminiApiAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + if (!account.apiKey) { + return res.status(401).json({ error: 'API Key not found or decryption failed' }) + } + + const baseUrl = account.baseUrl || 'https://generativelanguage.googleapis.com' + const apiUrl = buildGeminiApiUrl(baseUrl, model, 'streamGenerateContent', account.apiKey, { + stream: true + }) + + // 设置 SSE 响应头 + if (res.writableEnded || res.destroyed) { + return + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + safeWrite(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`) + + const payload = createGeminiTestPayload(model, { prompt, maxTokens }) + const requestConfig = { + headers: { 'Content-Type': 'application/json' }, + timeout: 60000, + responseType: 'stream', + validateStatus: () => true, + signal: abortController.signal + } + + // 配置代理 + if (account.proxy) { + const agent = ProxyHelper.createProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + try { + const response = await axios.post(apiUrl, payload, requestConfig) + + if (response.status !== 200) { + const chunks = [] + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', () => { + const errorData = Buffer.concat(chunks).toString() + let errorMsg = `API Error: ${response.status}` + try { + const json = JSON.parse(errorData) + errorMsg = extractErrorMessage(json, errorMsg) + } catch { + if (errorData.length < 500) { + errorMsg = errorData || errorMsg + } + } + safeWrite( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: errorMsg })}\n\n` + ) + safeEnd() + }) + response.data.on('error', () => { + safeWrite( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: `API Error: ${response.status}` })}\n\n` + ) + safeEnd() + }) + return + } + + let buffer = '' + response.data.on('data', (chunk) => { + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.startsWith('data:')) { + continue + } + const jsonStr = line.substring(5).trim() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonStr) + const text = data.candidates?.[0]?.content?.parts?.[0]?.text + if (text) { + safeWrite(`data: ${JSON.stringify({ type: 'content', text })}\n\n`) + } + } catch { + // ignore parse errors + } + } + }) + + response.data.on('end', () => { + safeWrite(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`) + safeEnd() + }) + + response.data.on('error', (err) => { + safeWrite( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: err.message })}\n\n` + ) + safeEnd() + }) + } catch (axiosError) { + if (axiosError.name === 'CanceledError') { + return + } + safeWrite( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: axiosError.message })}\n\n` + ) + safeEnd() + } + } catch (error) { + logger.error('Gemini-API account test failed:', error) + if (!res.headersSent) { + return res.status(500).json({ error: 'Test failed', message: error.message }) + } + safeWrite(`data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n`) + safeEnd() + } +}) + +module.exports = router diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js new file mode 100644 index 0000000..e35b5c3 --- /dev/null +++ b/src/routes/admin/index.js @@ -0,0 +1,62 @@ +/** + * Admin Routes - 主入口文件 + * 导入并挂载所有子路由模块 + */ + +const express = require('express') +const router = express.Router() + +// 导入所有子路由 +const apiKeysRoutes = require('./apiKeys') +const accountGroupsRoutes = require('./accountGroups') +const claudeAccountsRoutes = require('./claudeAccounts') +const claudeConsoleAccountsRoutes = require('./claudeConsoleAccounts') +const ccrAccountsRoutes = require('./ccrAccounts') +const bedrockAccountsRoutes = require('./bedrockAccounts') +const geminiAccountsRoutes = require('./geminiAccounts') +const geminiApiAccountsRoutes = require('./geminiApiAccounts') +const openaiAccountsRoutes = require('./openaiAccounts') +const azureOpenaiAccountsRoutes = require('./azureOpenaiAccounts') +const openaiResponsesAccountsRoutes = require('./openaiResponsesAccounts') +const droidAccountsRoutes = require('./droidAccounts') +const dashboardRoutes = require('./dashboard') +const usageStatsRoutes = require('./usageStats') +const accountBalanceRoutes = require('./accountBalance') +const systemRoutes = require('./system') +const concurrencyRoutes = require('./concurrency') +const claudeRelayConfigRoutes = require('./claudeRelayConfig') +const syncRoutes = require('./sync') +const serviceRatesRoutes = require('./serviceRates') +const quotaCardsRoutes = require('./quotaCards') +const errorHistoryRoutes = require('./errorHistory') +const requestDetailsRoutes = require('./requestDetails') + +// 挂载所有子路由 +// 使用完整路径的模块(直接挂载到根路径) +router.use('/', apiKeysRoutes) +router.use('/', claudeAccountsRoutes) +router.use('/', claudeConsoleAccountsRoutes) +router.use('/', geminiApiAccountsRoutes) +router.use('/', azureOpenaiAccountsRoutes) +router.use('/', openaiResponsesAccountsRoutes) +router.use('/', droidAccountsRoutes) +router.use('/', dashboardRoutes) +router.use('/', usageStatsRoutes) +router.use('/', accountBalanceRoutes) +router.use('/', systemRoutes) +router.use('/', concurrencyRoutes) +router.use('/', claudeRelayConfigRoutes) +router.use('/', syncRoutes) +router.use('/', serviceRatesRoutes) +router.use('/', quotaCardsRoutes) +router.use('/', errorHistoryRoutes) +router.use('/', requestDetailsRoutes) + +// 使用相对路径的模块(需要指定基础路径前缀) +router.use('/account-groups', accountGroupsRoutes) +router.use('/ccr-accounts', ccrAccountsRoutes) +router.use('/bedrock-accounts', bedrockAccountsRoutes) +router.use('/gemini-accounts', geminiAccountsRoutes) +router.use('/openai-accounts', openaiAccountsRoutes) + +module.exports = router diff --git a/src/routes/admin/openaiAccounts.js b/src/routes/admin/openaiAccounts.js new file mode 100644 index 0000000..f0c350e --- /dev/null +++ b/src/routes/admin/openaiAccounts.js @@ -0,0 +1,829 @@ +/** + * Admin Routes - OpenAI 账户管理 + * 处理 OpenAI 账户的 CRUD 操作和 OAuth 授权流程 + */ + +const express = require('express') +const crypto = require('crypto') +const axios = require('axios') +const openaiAccountService = require('../../services/account/openaiAccountService') +const accountGroupService = require('../../services/accountGroupService') +const apiKeyService = require('../../services/apiKeyService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const ProxyHelper = require('../../utils/proxyHelper') +const webhookNotifier = require('../../utils/webhookNotifier') +const { formatAccountExpiry, mapExpiryField } = require('./utils') + +const router = express.Router() + +// OpenAI OAuth 配置 +const OPENAI_CONFIG = { + BASE_URL: 'https://auth.openai.com', + CLIENT_ID: 'app_EMoamEEZ73f0CkXaXp7hrann', + REDIRECT_URI: 'http://localhost:1455/auth/callback', + SCOPE: 'openid profile email offline_access' +} + +/** + * 生成 PKCE 参数 + * @returns {Object} 包含 codeVerifier 和 codeChallenge 的对象 + */ +function generateOpenAIPKCE() { + const codeVerifier = crypto.randomBytes(64).toString('hex') + const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url') + + return { + codeVerifier, + codeChallenge + } +} + +// 生成 OpenAI OAuth 授权 URL +router.post('/generate-auth-url', authenticateAdmin, async (req, res) => { + try { + const { proxy } = req.body + + // 生成 PKCE 参数 + const pkce = generateOpenAIPKCE() + + // 生成随机 state + const state = crypto.randomBytes(32).toString('hex') + + // 创建会话 ID + const sessionId = crypto.randomUUID() + + // 将 PKCE 参数和代理配置存储到 Redis + await redis.setOAuthSession(sessionId, { + codeVerifier: pkce.codeVerifier, + codeChallenge: pkce.codeChallenge, + state, + proxy: proxy || null, + platform: 'openai', + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString() + }) + + // 构建授权 URL 参数 + const params = new URLSearchParams({ + response_type: 'code', + client_id: OPENAI_CONFIG.CLIENT_ID, + redirect_uri: OPENAI_CONFIG.REDIRECT_URI, + scope: OPENAI_CONFIG.SCOPE, + code_challenge: pkce.codeChallenge, + code_challenge_method: 'S256', + state, + id_token_add_organizations: 'true', + codex_cli_simplified_flow: 'true' + }) + + const authUrl = `${OPENAI_CONFIG.BASE_URL}/oauth/authorize?${params.toString()}` + + logger.success('Generated OpenAI OAuth authorization URL') + + return res.json({ + success: true, + data: { + authUrl, + sessionId, + instructions: [ + '1. 复制上面的链接到浏览器中打开', + '2. 登录您的 OpenAI 账户', + '3. 同意应用权限', + '4. 复制浏览器地址栏中的完整 URL(包含 code 参数)', + '5. 在添加账户表单中粘贴完整的回调 URL' + ] + } + }) + } catch (error) { + logger.error('生成 OpenAI OAuth URL 失败:', error) + return res.status(500).json({ + success: false, + message: '生成授权链接失败', + error: error.message + }) + } +}) + +// 交换 OpenAI 授权码 +router.post('/exchange-code', authenticateAdmin, async (req, res) => { + try { + const { code, sessionId } = req.body + + if (!code || !sessionId) { + return res.status(400).json({ + success: false, + message: '缺少必要参数' + }) + } + + // 从 Redis 获取会话数据 + const sessionData = await redis.getOAuthSession(sessionId) + if (!sessionData) { + return res.status(400).json({ + success: false, + message: '会话已过期或无效' + }) + } + + // 准备 token 交换请求 + const tokenData = { + grant_type: 'authorization_code', + code: code.trim(), + redirect_uri: OPENAI_CONFIG.REDIRECT_URI, + client_id: OPENAI_CONFIG.CLIENT_ID, + code_verifier: sessionData.codeVerifier + } + + logger.info('Exchanging OpenAI authorization code:', { + sessionId, + codeLength: code.length, + hasCodeVerifier: !!sessionData.codeVerifier + }) + + // 配置代理(如果有) + const axiosConfig = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + + // 配置代理(如果有) + const proxyAgent = ProxyHelper.createProxyAgent(sessionData.proxy) + if (proxyAgent) { + axiosConfig.httpAgent = proxyAgent + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + } + + // 交换 authorization code 获取 tokens + const tokenResponse = await axios.post( + `${OPENAI_CONFIG.BASE_URL}/oauth/token`, + new URLSearchParams(tokenData).toString(), + axiosConfig + ) + + const { id_token, access_token, refresh_token, expires_in } = tokenResponse.data + + // 解析 ID token 获取用户信息 + const idTokenParts = id_token.split('.') + if (idTokenParts.length !== 3) { + throw new Error('Invalid ID token format') + } + + // 解码 JWT payload + const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64url').toString()) + + // 获取 OpenAI 特定的声明 + const authClaims = payload['https://api.openai.com/auth'] || {} + const accountId = authClaims.chatgpt_account_id || '' + const chatgptUserId = authClaims.chatgpt_user_id || authClaims.user_id || '' + const planType = authClaims.chatgpt_plan_type || '' + + // 获取组织信息 + const organizations = authClaims.organizations || [] + const defaultOrg = organizations.find((org) => org.is_default) || organizations[0] || {} + const organizationId = defaultOrg.id || '' + const organizationRole = defaultOrg.role || '' + const organizationTitle = defaultOrg.title || '' + + // 清理 Redis 会话 + await redis.deleteOAuthSession(sessionId) + + logger.success('OpenAI OAuth token exchange successful') + + return res.json({ + success: true, + data: { + tokens: { + idToken: id_token, + accessToken: access_token, + refreshToken: refresh_token, + expires_in + }, + accountInfo: { + accountId, + chatgptUserId, + organizationId, + organizationRole, + organizationTitle, + planType, + email: payload.email || '', + name: payload.name || '', + emailVerified: payload.email_verified || false, + organizations + } + } + }) + } catch (error) { + logger.error('OpenAI OAuth token exchange failed:', error) + return res.status(500).json({ + success: false, + message: '交换授权码失败', + error: error.message + }) + } +}) + +// 获取所有 OpenAI 账户 +router.get('/', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await openaiAccountService.getAllAccounts() + + // 缓存账户所属分组,避免重复查询 + const accountGroupCache = new Map() + const fetchAccountGroups = async (accountId) => { + if (!accountGroupCache.has(accountId)) { + const groups = await accountGroupService.getAccountGroups(accountId) + accountGroupCache.set(accountId, groups || []) + } + return accountGroupCache.get(accountId) + } + + // 根据查询参数进行筛选 + if (platform && platform !== 'all' && platform !== 'openai') { + // 如果指定了其他平台,返回空数组 + accounts = [] + } + + // 如果指定了分组筛选 + if (groupId && groupId !== 'all') { + if (groupId === 'ungrouped') { + // 筛选未分组账户 + const filteredAccounts = [] + for (const account of accounts) { + const groups = await fetchAccountGroups(account.id) + if (!groups || groups.length === 0) { + filteredAccounts.push(account) + } + } + accounts = filteredAccounts + } else { + // 筛选特定分组的账户 + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } + } + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all( + accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id, 'openai') + const groupInfos = await fetchAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: usageStats.daily, + total: usageStats.total, + monthly: usageStats.monthly + } + } + } catch (error) { + logger.debug(`Failed to get usage stats for OpenAI account ${account.id}:`, error) + const groupInfos = await fetchAccountGroups(account.id) + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos, + usage: { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + monthly: { requests: 0, tokens: 0, allTokens: 0 } + } + } + } + }) + ) + + logger.info(`获取 OpenAI 账户列表: ${accountsWithStats.length} 个账户`) + + return res.json({ + success: true, + data: accountsWithStats + }) + } catch (error) { + logger.error('获取 OpenAI 账户列表失败:', error) + return res.status(500).json({ + success: false, + message: '获取账户列表失败', + error: error.message + }) + } +}) + +// 创建 OpenAI 账户 +router.post('/', authenticateAdmin, async (req, res) => { + try { + const { + name, + description, + openaiOauth, + accountInfo, + proxy, + accountType, + groupId, + groupIds, // 支持多分组 + rateLimitDuration, + priority, + needsImmediateRefresh, // 是否需要立即刷新 + requireRefreshSuccess // 是否必须刷新成功才能创建 + } = req.body + + if (!name) { + return res.status(400).json({ + success: false, + message: '账户名称不能为空' + }) + } + + // 准备账户数据 + const accountData = { + name, + description: description || '', + accountType: accountType || 'shared', + priority: priority || 50, + rateLimitDuration: + rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60, + openaiOauth: openaiOauth || {}, + accountInfo: accountInfo || {}, + proxy: proxy || null, + isActive: true, + schedulable: true + } + + // 如果需要立即刷新且必须成功(OpenAI 手动模式) + if (needsImmediateRefresh && requireRefreshSuccess) { + // 先创建临时账户以测试刷新 + const tempAccount = await openaiAccountService.createAccount(accountData) + + try { + logger.info(`🔄 测试刷新 OpenAI 账户以获取完整 token 信息`) + + // 尝试刷新 token(会自动使用账户配置的代理) + await openaiAccountService.refreshAccountToken(tempAccount.id) + + // 刷新成功,获取更新后的账户信息 + const refreshedAccount = await openaiAccountService.getAccount(tempAccount.id) + + // 检查是否获取到了 ID Token + if (!refreshedAccount.idToken || refreshedAccount.idToken === '') { + // 没有获取到 ID Token,删除账户 + await openaiAccountService.deleteAccount(tempAccount.id) + throw new Error('无法获取 ID Token,请检查 Refresh Token 是否有效') + } + + // 如果是分组类型,添加到分组(支持多分组) + if (accountType === 'group') { + if (groupIds && groupIds.length > 0) { + await accountGroupService.setAccountGroups(tempAccount.id, groupIds, 'openai') + } else if (groupId) { + await accountGroupService.addAccountToGroup(tempAccount.id, groupId, 'openai') + } + } + + // 清除敏感信息后返回 + delete refreshedAccount.idToken + delete refreshedAccount.accessToken + delete refreshedAccount.refreshToken + + logger.success(`创建并验证 OpenAI 账户成功: ${name} (ID: ${tempAccount.id})`) + + return res.json({ + success: true, + data: refreshedAccount, + message: '账户创建成功,并已获取完整 token 信息' + }) + } catch (refreshError) { + // 刷新失败,删除临时创建的账户 + logger.warn(`❌ 刷新失败,删除临时账户: ${refreshError.message}`) + await openaiAccountService.deleteAccount(tempAccount.id) + + // 构建详细的错误信息 + const errorResponse = { + success: false, + message: '账户创建失败', + error: refreshError.message + } + + // 添加更详细的错误信息 + if (refreshError.status) { + errorResponse.errorCode = refreshError.status + } + if (refreshError.details) { + errorResponse.errorDetails = refreshError.details + } + if (refreshError.code) { + errorResponse.networkError = refreshError.code + } + + // 提供更友好的错误提示 + if (refreshError.message.includes('Refresh Token 无效')) { + errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取' + } else if (refreshError.message.includes('代理')) { + errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息' + } else if (refreshError.message.includes('过于频繁')) { + errorResponse.suggestion = '请稍后再试,或更换代理 IP' + } else if (refreshError.message.includes('连接')) { + errorResponse.suggestion = '请检查网络连接和代理设置' + } + + return res.status(400).json(errorResponse) + } + } + + // 不需要强制刷新的情况(OAuth 模式或其他平台) + const createdAccount = await openaiAccountService.createAccount(accountData) + + // 如果是分组类型,添加到分组(支持多分组) + if (accountType === 'group') { + if (groupIds && groupIds.length > 0) { + await accountGroupService.setAccountGroups(createdAccount.id, groupIds, 'openai') + } else if (groupId) { + await accountGroupService.addAccountToGroup(createdAccount.id, groupId, 'openai') + } + } + + // 如果需要刷新但不强制成功(OAuth 模式可能已有完整信息) + if (needsImmediateRefresh && !requireRefreshSuccess) { + try { + logger.info(`🔄 尝试刷新 OpenAI 账户 ${createdAccount.id}`) + await openaiAccountService.refreshAccountToken(createdAccount.id) + logger.info(`✅ 刷新成功`) + } catch (refreshError) { + logger.warn(`⚠️ 刷新失败,但账户已创建: ${refreshError.message}`) + } + } + + logger.success(`创建 OpenAI 账户成功: ${name} (ID: ${createdAccount.id})`) + + return res.json({ + success: true, + data: createdAccount + }) + } catch (error) { + logger.error('创建 OpenAI 账户失败:', error) + return res.status(500).json({ + success: false, + message: '创建账户失败', + error: error.message + }) + } +}) + +// 更新 OpenAI 账户 +router.put('/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = req.body + + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'OpenAI', id) + + const { needsImmediateRefresh, requireRefreshSuccess } = mappedUpdates + + // 验证accountType的有效性 + if ( + mappedUpdates.accountType && + !['shared', 'dedicated', 'group'].includes(mappedUpdates.accountType) + ) { + return res + .status(400) + .json({ error: 'Invalid account type. Must be "shared", "dedicated" or "group"' }) + } + + // 如果更新为分组类型,验证groupId或groupIds + if ( + mappedUpdates.accountType === 'group' && + !mappedUpdates.groupId && + (!mappedUpdates.groupIds || mappedUpdates.groupIds.length === 0) + ) { + return res + .status(400) + .json({ error: 'Group ID or Group IDs are required for group type accounts' }) + } + + // 获取账户当前信息以处理分组变更 + const currentAccount = await openaiAccountService.getAccount(id) + if (!currentAccount) { + return res.status(404).json({ error: 'Account not found' }) + } + + // 如果更新了 Refresh Token,需要验证其有效性 + if (mappedUpdates.openaiOauth?.refreshToken && needsImmediateRefresh && requireRefreshSuccess) { + // 先更新 token 信息 + const tempUpdateData = {} + if (mappedUpdates.openaiOauth.refreshToken) { + tempUpdateData.refreshToken = mappedUpdates.openaiOauth.refreshToken + } + if (mappedUpdates.openaiOauth.accessToken) { + tempUpdateData.accessToken = mappedUpdates.openaiOauth.accessToken + } + // 更新代理配置(如果有) + if (mappedUpdates.proxy !== undefined) { + tempUpdateData.proxy = mappedUpdates.proxy + } + + // 临时更新账户以测试新的 token + await openaiAccountService.updateAccount(id, tempUpdateData) + + try { + logger.info(`🔄 验证更新的 OpenAI token (账户: ${id})`) + + // 尝试刷新 token(会使用账户配置的代理) + await openaiAccountService.refreshAccountToken(id) + + // 获取刷新后的账户信息 + const refreshedAccount = await openaiAccountService.getAccount(id) + + // 检查是否获取到了 ID Token + if (!refreshedAccount.idToken || refreshedAccount.idToken === '') { + // 恢复原始 token + await openaiAccountService.updateAccount(id, { + refreshToken: currentAccount.refreshToken, + accessToken: currentAccount.accessToken, + idToken: currentAccount.idToken + }) + + return res.status(400).json({ + success: false, + message: '无法获取 ID Token,请检查 Refresh Token 是否有效', + error: 'Invalid refresh token' + }) + } + + logger.success(`Token 验证成功,继续更新账户信息`) + } catch (refreshError) { + // 刷新失败,恢复原始 token + logger.warn(`❌ Token 验证失败,恢复原始配置: ${refreshError.message}`) + await openaiAccountService.updateAccount(id, { + refreshToken: currentAccount.refreshToken, + accessToken: currentAccount.accessToken, + idToken: currentAccount.idToken, + proxy: currentAccount.proxy + }) + + // 构建详细的错误信息 + const errorResponse = { + success: false, + message: '更新失败', + error: refreshError.message + } + + // 添加更详细的错误信息 + if (refreshError.status) { + errorResponse.errorCode = refreshError.status + } + if (refreshError.details) { + errorResponse.errorDetails = refreshError.details + } + if (refreshError.code) { + errorResponse.networkError = refreshError.code + } + + // 提供更友好的错误提示 + if (refreshError.message.includes('Refresh Token 无效')) { + errorResponse.suggestion = '请检查 Refresh Token 是否正确,或重新通过 OAuth 授权获取' + } else if (refreshError.message.includes('代理')) { + errorResponse.suggestion = '请检查代理配置是否正确,包括地址、端口和认证信息' + } else if (refreshError.message.includes('过于频繁')) { + errorResponse.suggestion = '请稍后再试,或更换代理 IP' + } else if (refreshError.message.includes('连接')) { + errorResponse.suggestion = '请检查网络连接和代理设置' + } + + return res.status(400).json(errorResponse) + } + } + + // 处理分组的变更 + if (mappedUpdates.accountType !== undefined) { + // 如果之前是分组类型,移除所有原分组关联 + if (currentAccount.accountType === 'group') { + await accountGroupService.removeAccountFromAllGroups(id) + } + // 如果新类型是分组,处理多分组支持 + if (mappedUpdates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { + // 如果明确提供了 groupIds 参数(包括空数组) + if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { + // 设置新的多分组 + await accountGroupService.setAccountGroups(id, mappedUpdates.groupIds, 'openai') + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(id) + } + } else if (mappedUpdates.groupId) { + // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 + await accountGroupService.addAccountToGroup(id, mappedUpdates.groupId, 'openai') + } + } + } + + // 准备更新数据 + const updateData = { ...mappedUpdates } + + // 处理敏感数据加密 + if (mappedUpdates.openaiOauth) { + updateData.openaiOauth = mappedUpdates.openaiOauth + // 编辑时不允许直接输入 ID Token,只能通过刷新获取 + if (mappedUpdates.openaiOauth.accessToken) { + updateData.accessToken = mappedUpdates.openaiOauth.accessToken + } + if (mappedUpdates.openaiOauth.refreshToken) { + updateData.refreshToken = mappedUpdates.openaiOauth.refreshToken + } + if (mappedUpdates.openaiOauth.expires_in) { + updateData.expiresAt = new Date( + Date.now() + mappedUpdates.openaiOauth.expires_in * 1000 + ).toISOString() + } + } + + // 更新账户信息 + if (mappedUpdates.accountInfo) { + updateData.accountId = mappedUpdates.accountInfo.accountId || currentAccount.accountId + updateData.chatgptUserId = + mappedUpdates.accountInfo.chatgptUserId || currentAccount.chatgptUserId + updateData.organizationId = + mappedUpdates.accountInfo.organizationId || currentAccount.organizationId + updateData.organizationRole = + mappedUpdates.accountInfo.organizationRole || currentAccount.organizationRole + updateData.organizationTitle = + mappedUpdates.accountInfo.organizationTitle || currentAccount.organizationTitle + updateData.planType = mappedUpdates.accountInfo.planType || currentAccount.planType + updateData.email = mappedUpdates.accountInfo.email || currentAccount.email + updateData.emailVerified = + mappedUpdates.accountInfo.emailVerified !== undefined + ? mappedUpdates.accountInfo.emailVerified + : currentAccount.emailVerified + } + + const updatedAccount = await openaiAccountService.updateAccount(id, updateData) + + // 如果需要刷新但不强制成功(非关键更新) + if (needsImmediateRefresh && !requireRefreshSuccess) { + try { + logger.info(`🔄 尝试刷新 OpenAI 账户 ${id}`) + await openaiAccountService.refreshAccountToken(id) + logger.info(`✅ 刷新成功`) + } catch (refreshError) { + logger.warn(`⚠️ 刷新失败,但账户信息已更新: ${refreshError.message}`) + } + } + + logger.success(`📝 Admin updated OpenAI account: ${id}`) + return res.json({ success: true, data: updatedAccount }) + } catch (error) { + logger.error('❌ Failed to update OpenAI account:', error) + return res.status(500).json({ error: 'Failed to update account', message: error.message }) + } +}) + +// 删除 OpenAI 账户 +router.delete('/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await openaiAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: '账户不存在' + }) + } + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai') + + // 如果账户在分组中,从分组中移除 + if (account.accountType === 'group') { + const group = await accountGroupService.getAccountGroup(id) + if (group) { + await accountGroupService.removeAccountFromGroup(id, group.id) + } + } + + await openaiAccountService.deleteAccount(id) + + let message = 'OpenAI账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success( + `✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id}), unbound ${unboundCount} keys` + ) + + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('删除 OpenAI 账户失败:', error) + return res.status(500).json({ + success: false, + message: '删除账户失败', + error: error.message + }) + } +}) + +// 切换 OpenAI 账户状态 +router.put('/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await redis.getOpenAiAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: '账户不存在' + }) + } + + // 切换启用状态 + account.enabled = !account.enabled + account.updatedAt = new Date().toISOString() + + // TODO: 更新方法 + // await redis.updateOpenAiAccount(id, account) + + logger.success( + `✅ ${account.enabled ? '启用' : '禁用'} OpenAI 账户: ${account.name} (ID: ${id})` + ) + + return res.json({ + success: true, + data: account + }) + } catch (error) { + logger.error('切换 OpenAI 账户状态失败:', error) + return res.status(500).json({ + success: false, + message: '切换账户状态失败', + error: error.message + }) + } +}) + +// 重置 OpenAI 账户状态(清除所有异常状态) +router.post('/:accountId/reset-status', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await openaiAccountService.resetAccountStatus(accountId) + + logger.success(`Admin reset status for OpenAI account: ${accountId}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset OpenAI account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 切换 OpenAI 账户调度状态 +router.put('/:accountId/toggle-schedulable', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const result = await openaiAccountService.toggleSchedulable(accountId) + + // 如果账号被禁用,发送webhook通知 + if (!result.schedulable) { + // 获取账号信息 + const account = await redis.getOpenAiAccount(accountId) + if (account) { + await webhookNotifier.sendAccountAnomalyNotification({ + accountId: account.id, + accountName: account.name || 'OpenAI Account', + platform: 'openai', + status: 'disabled', + errorCode: 'OPENAI_MANUALLY_DISABLED', + reason: '账号已被管理员手动禁用调度', + timestamp: new Date().toISOString() + }) + } + } + + return res.json({ + success: result.success, + schedulable: result.schedulable, + message: result.schedulable ? '已启用调度' : '已禁用调度' + }) + } catch (error) { + logger.error('切换 OpenAI 账户调度状态失败:', error) + return res.status(500).json({ + success: false, + message: '切换调度状态失败', + error: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/openaiResponsesAccounts.js b/src/routes/admin/openaiResponsesAccounts.js new file mode 100644 index 0000000..5a3409e --- /dev/null +++ b/src/routes/admin/openaiResponsesAccounts.js @@ -0,0 +1,551 @@ +/** + * Admin Routes - OpenAI-Responses 账户管理 + * 处理 OpenAI-Responses 账户的增删改查和状态管理 + */ + +const express = require('express') +const axios = require('axios') +const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService') +const apiKeyService = require('../../services/apiKeyService') +const accountGroupService = require('../../services/accountGroupService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const webhookNotifier = require('../../utils/webhookNotifier') +const { formatAccountExpiry, mapExpiryField } = require('./utils') +const { createOpenAITestPayload, extractErrorMessage } = require('../../utils/testPayloadHelper') +const { getProxyAgent } = require('../../utils/proxyHelper') + +const router = express.Router() + +// ==================== OpenAI-Responses 账户管理 API ==================== + +// 获取所有 OpenAI-Responses 账户 +router.get('/openai-responses-accounts', authenticateAdmin, async (req, res) => { + try { + const { platform, groupId } = req.query + let accounts = await openaiResponsesAccountService.getAllAccounts(true) + + // 根据查询参数进行筛选 + if (platform && platform !== 'openai-responses') { + accounts = [] + } + + // 根据分组ID筛选 + if (groupId) { + const group = await accountGroupService.getGroup(groupId) + if (group && group.platform === 'openai') { + const groupMembers = await accountGroupService.getGroupMembers(groupId) + accounts = accounts.filter((account) => groupMembers.includes(account.id)) + } else { + accounts = [] + } + } + + const accountIds = accounts.map((a) => a.id) + + // 并行获取:轻量 API Keys + 分组信息 + daily cost + 清理限流状态 + const [allApiKeys, allGroupInfosMap, dailyCostMap] = await Promise.all([ + apiKeyService.getAllApiKeysLite(), + accountGroupService.batchGetAccountGroupsByIndex(accountIds, 'openai'), + redis.batchGetAccountDailyCost(accountIds), + // 批量清理限流状态 + Promise.all(accountIds.map((id) => openaiResponsesAccountService.checkAndClearRateLimit(id))) + ]) + + // 单次遍历构建绑定数映射(只算直连,不算 group) + const bindingCountMap = new Map() + for (const key of allApiKeys) { + const binding = key.openaiAccountId + if (!binding) { + continue + } + // 处理 responses: 前缀 + const accountId = binding.startsWith('responses:') ? binding.substring(10) : binding + bindingCountMap.set(accountId, (bindingCountMap.get(accountId) || 0) + 1) + } + + // 批量获取使用统计(不含 daily cost,已单独获取) + const client = redis.getClientSafe() + const today = redis.getDateStringInTimezone() + const tzDate = redis.getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + + const statsPipeline = client.pipeline() + for (const accountId of accountIds) { + statsPipeline.hgetall(`account_usage:${accountId}`) + statsPipeline.hgetall(`account_usage:daily:${accountId}:${today}`) + statsPipeline.hgetall(`account_usage:monthly:${accountId}:${currentMonth}`) + } + const statsResults = await statsPipeline.exec() + + // 处理统计数据 + const allUsageStatsMap = new Map() + for (let i = 0; i < accountIds.length; i++) { + const accountId = accountIds[i] + const [errTotal, total] = statsResults[i * 3] + const [errDaily, daily] = statsResults[i * 3 + 1] + const [errMonthly, monthly] = statsResults[i * 3 + 2] + + const parseUsage = (data) => ({ + requests: parseInt(data?.totalRequests || data?.requests) || 0, + tokens: parseInt(data?.totalTokens || data?.tokens) || 0, + inputTokens: parseInt(data?.totalInputTokens || data?.inputTokens) || 0, + outputTokens: parseInt(data?.totalOutputTokens || data?.outputTokens) || 0, + cacheCreateTokens: parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0, + cacheReadTokens: parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0, + allTokens: + parseInt(data?.totalAllTokens || data?.allTokens) || + (parseInt(data?.totalInputTokens || data?.inputTokens) || 0) + + (parseInt(data?.totalOutputTokens || data?.outputTokens) || 0) + + (parseInt(data?.totalCacheCreateTokens || data?.cacheCreateTokens) || 0) + + (parseInt(data?.totalCacheReadTokens || data?.cacheReadTokens) || 0) + }) + + allUsageStatsMap.set(accountId, { + total: errTotal ? {} : parseUsage(total), + daily: errDaily ? {} : parseUsage(daily), + monthly: errMonthly ? {} : parseUsage(monthly) + }) + } + + // 处理额度信息、使用统计和绑定的 API Key 数量 + const accountsWithStats = accounts.map((account) => { + const usageStats = allUsageStatsMap.get(account.id) || { + daily: { requests: 0, tokens: 0, allTokens: 0 }, + total: { requests: 0, tokens: 0, allTokens: 0 }, + monthly: { requests: 0, tokens: 0, allTokens: 0 } + } + + const groupInfos = allGroupInfosMap.get(account.id) || [] + const boundCount = bindingCountMap.get(account.id) || 0 + const dailyCost = dailyCostMap.get(account.id) || 0 + + const formattedAccount = formatAccountExpiry(account) + return { + ...formattedAccount, + groupInfos, + boundApiKeysCount: boundCount, + usage: { + daily: { ...usageStats.daily, cost: dailyCost }, + total: usageStats.total, + monthly: usageStats.monthly + } + } + }) + + res.json({ success: true, data: accountsWithStats }) + } catch (error) { + logger.error('Failed to get OpenAI-Responses accounts:', error) + res.status(500).json({ success: false, message: error.message }) + } +}) + +// 创建 OpenAI-Responses 账户 +router.post('/openai-responses-accounts', authenticateAdmin, async (req, res) => { + try { + const accountData = req.body + + // 验证分组类型 + if ( + accountData.accountType === 'group' && + !accountData.groupId && + (!accountData.groupIds || accountData.groupIds.length === 0) + ) { + return res.status(400).json({ + success: false, + error: 'Group ID is required for group type accounts' + }) + } + + const account = await openaiResponsesAccountService.createAccount(accountData) + + // 如果是分组类型,处理分组绑定 + if (accountData.accountType === 'group') { + if (accountData.groupIds && accountData.groupIds.length > 0) { + // 多分组模式 + await accountGroupService.setAccountGroups(account.id, accountData.groupIds, 'openai') + logger.info( + `🏢 Added OpenAI-Responses account ${account.id} to groups: ${accountData.groupIds.join(', ')}` + ) + } else if (accountData.groupId) { + // 单分组模式(向后兼容) + await accountGroupService.addAccountToGroup(account.id, accountData.groupId, 'openai') + logger.info( + `🏢 Added OpenAI-Responses account ${account.id} to group: ${accountData.groupId}` + ) + } + } + + const formattedAccount = formatAccountExpiry(account) + res.json({ success: true, data: formattedAccount }) + } catch (error) { + logger.error('Failed to create OpenAI-Responses account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 更新 OpenAI-Responses 账户 +router.put('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + const updates = req.body + + // 获取当前账户信息 + const currentAccount = await openaiResponsesAccountService.getAccount(id) + if (!currentAccount) { + return res.status(404).json({ + success: false, + error: 'Account not found' + }) + } + + // ✅ 【新增】映射字段名:前端的 expiresAt -> 后端的 subscriptionExpiresAt + const mappedUpdates = mapExpiryField(updates, 'OpenAI-Responses', id) + + // 验证priority的有效性(1-100) + if (mappedUpdates.priority !== undefined) { + const priority = parseInt(mappedUpdates.priority) + if (isNaN(priority) || priority < 1 || priority > 100) { + return res.status(400).json({ + success: false, + message: 'Priority must be a number between 1 and 100' + }) + } + mappedUpdates.priority = priority.toString() + } + + // 处理分组变更 + if (mappedUpdates.accountType !== undefined) { + // 如果之前是分组类型,需要从所有分组中移除 + if (currentAccount.accountType === 'group') { + const oldGroups = await accountGroupService.getAccountGroups(id) + for (const oldGroup of oldGroups) { + await accountGroupService.removeAccountFromGroup(id, oldGroup.id) + } + logger.info(`📤 Removed OpenAI-Responses account ${id} from all groups`) + } + + // 如果新类型是分组,处理多分组支持 + if (mappedUpdates.accountType === 'group') { + if (Object.prototype.hasOwnProperty.call(mappedUpdates, 'groupIds')) { + if (mappedUpdates.groupIds && mappedUpdates.groupIds.length > 0) { + // 设置新的多分组 + await accountGroupService.setAccountGroups(id, mappedUpdates.groupIds, 'openai') + logger.info( + `📥 Added OpenAI-Responses account ${id} to groups: ${mappedUpdates.groupIds.join(', ')}` + ) + } else { + // groupIds 为空数组,从所有分组中移除 + await accountGroupService.removeAccountFromAllGroups(id) + logger.info( + `📤 Removed OpenAI-Responses account ${id} from all groups (empty groupIds)` + ) + } + } else if (mappedUpdates.groupId) { + // 向后兼容:仅当没有 groupIds 但有 groupId 时使用单分组逻辑 + await accountGroupService.addAccountToGroup(id, mappedUpdates.groupId, 'openai') + logger.info(`📥 Added OpenAI-Responses account ${id} to group: ${mappedUpdates.groupId}`) + } + } + } + + const result = await openaiResponsesAccountService.updateAccount(id, mappedUpdates) + + if (!result.success) { + return res.status(400).json(result) + } + + logger.success(`📝 Admin updated OpenAI-Responses account: ${id}`) + res.json({ success: true, ...result }) + } catch (error) { + logger.error('Failed to update OpenAI-Responses account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 删除 OpenAI-Responses 账户 +router.delete('/openai-responses-accounts/:id', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await openaiResponsesAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai-responses') + + // 从所有分组中移除此账户 + if (account.accountType === 'group') { + await accountGroupService.removeAccountFromAllGroups(id) + logger.info(`Removed OpenAI-Responses account ${id} from all groups`) + } + + const result = await openaiResponsesAccountService.deleteAccount(id) + + let message = 'OpenAI-Responses账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted OpenAI-Responses account: ${id}, unbound ${unboundCount} keys`) + + res.json({ + success: true, + ...result, + message, + unboundKeys: unboundCount + }) + } catch (error) { + logger.error('Failed to delete OpenAI-Responses account:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 切换 OpenAI-Responses 账户调度状态 +router.put( + '/openai-responses-accounts/:id/toggle-schedulable', + authenticateAdmin, + async (req, res) => { + try { + const { id } = req.params + + const result = await openaiResponsesAccountService.toggleSchedulable(id) + + if (!result.success) { + return res.status(400).json(result) + } + + // 仅在停止调度时发送通知 + if (!result.schedulable) { + await webhookNotifier.sendAccountEvent('account.status_changed', { + accountId: id, + platform: 'openai-responses', + schedulable: result.schedulable, + changedBy: 'admin', + action: 'stopped_scheduling' + }) + } + + res.json(result) + } catch (error) { + logger.error('Failed to toggle OpenAI-Responses account schedulable status:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } + } +) + +// 切换 OpenAI-Responses 账户激活状态 +router.put('/openai-responses-accounts/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const account = await openaiResponsesAccountService.getAccount(id) + if (!account) { + return res.status(404).json({ + success: false, + message: 'Account not found' + }) + } + + const newActiveStatus = account.isActive === 'true' ? 'false' : 'true' + await openaiResponsesAccountService.updateAccount(id, { + isActive: newActiveStatus + }) + + res.json({ + success: true, + isActive: newActiveStatus === 'true' + }) + } catch (error) { + logger.error('Failed to toggle OpenAI-Responses account status:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 重置 OpenAI-Responses 账户限流状态 +router.post( + '/openai-responses-accounts/:id/reset-rate-limit', + authenticateAdmin, + async (req, res) => { + try { + const { id } = req.params + + await openaiResponsesAccountService.updateAccount(id, { + rateLimitedAt: '', + rateLimitStatus: '', + status: 'active', + errorMessage: '' + }) + + logger.info(`🔄 Admin manually reset rate limit for OpenAI-Responses account ${id}`) + + res.json({ + success: true, + message: 'Rate limit reset successfully' + }) + } catch (error) { + logger.error('Failed to reset OpenAI-Responses account rate limit:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } + } +) + +// 重置 OpenAI-Responses 账户状态(清除所有异常状态) +router.post('/openai-responses-accounts/:id/reset-status', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + const result = await openaiResponsesAccountService.resetAccountStatus(id) + + logger.success(`Admin reset status for OpenAI-Responses account: ${id}`) + return res.json({ success: true, data: result }) + } catch (error) { + logger.error('❌ Failed to reset OpenAI-Responses account status:', error) + return res.status(500).json({ error: 'Failed to reset status', message: error.message }) + } +}) + +// 手动重置 OpenAI-Responses 账户的每日使用量 +router.post('/openai-responses-accounts/:id/reset-usage', authenticateAdmin, async (req, res) => { + try { + const { id } = req.params + + await openaiResponsesAccountService.updateAccount(id, { + dailyUsage: '0', + lastResetDate: redis.getDateStringInTimezone(), + quotaStoppedAt: '' + }) + + logger.success(`Admin manually reset daily usage for OpenAI-Responses account ${id}`) + + res.json({ + success: true, + message: 'Daily usage reset successfully' + }) + } catch (error) { + logger.error('Failed to reset OpenAI-Responses account usage:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 测试 OpenAI-Responses 账户连通性 +router.post('/openai-responses-accounts/:accountId/test', authenticateAdmin, async (req, res) => { + const { accountId } = req.params + const { model = 'gpt-4o-mini' } = req.body + const startTime = Date.now() + + try { + // 获取账户信息(apiKey 已自动解密) + const account = await openaiResponsesAccountService.getAccount(accountId) + if (!account) { + return res.status(404).json({ error: 'Account not found' }) + } + + if (!account.apiKey) { + return res.status(401).json({ error: 'API Key not found or decryption failed' }) + } + + // 构造测试请求(根据 providerEndpoint 和 baseApi 决定端点路径) + const baseUrl = account.baseApi || 'https://api.openai.com' + const providerEndpoint = account.providerEndpoint || 'responses' + let endpointPath = '/responses' + if (providerEndpoint === 'auto') { + endpointPath = '/responses' // 测试时默认用 responses + } + // 防止 baseApi 已含 /v1 时路径重复 + if (!baseUrl.endsWith('/v1')) { + endpointPath = `/v1${endpointPath}` + } + const apiUrl = `${baseUrl}${endpointPath}` + const payload = createOpenAITestPayload(model, { stream: false }) + + const requestConfig = { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${account.apiKey}` + }, + timeout: 30000 + } + + // 配置代理 + if (account.proxy) { + const agent = getProxyAgent(account.proxy) + if (agent) { + requestConfig.httpsAgent = agent + requestConfig.httpAgent = agent + } + } + + const response = await axios.post(apiUrl, payload, requestConfig) + const latency = Date.now() - startTime + + // 提取响应文本(Responses API 格式) + let responseText = '' + const output = response.data?.output + if (Array.isArray(output)) { + for (const item of output) { + if (item.type === 'message' && Array.isArray(item.content)) { + for (const block of item.content) { + if (block.type === 'output_text' && block.text) { + responseText += block.text + } + } + } + } + } + + logger.success( + `✅ OpenAI-Responses account test passed: ${account.name} (${accountId}), latency: ${latency}ms` + ) + + return res.json({ + success: true, + data: { + accountId, + accountName: account.name, + model, + latency, + responseText: responseText.substring(0, 200) + } + }) + } catch (error) { + const latency = Date.now() - startTime + logger.error(`❌ OpenAI-Responses account test failed: ${accountId}`, error.message) + + return res.status(500).json({ + success: false, + error: 'Test failed', + message: extractErrorMessage(error.response?.data, error.message), + latency + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/quotaCards.js b/src/routes/admin/quotaCards.js new file mode 100644 index 0000000..67fc1a6 --- /dev/null +++ b/src/routes/admin/quotaCards.js @@ -0,0 +1,242 @@ +/** + * 额度卡/时间卡管理路由 + */ +const express = require('express') +const router = express.Router() +const quotaCardService = require('../../services/quotaCardService') +const apiKeyService = require('../../services/apiKeyService') +const logger = require('../../utils/logger') +const { authenticateAdmin } = require('../../middleware/auth') + +// ═══════════════════════════════════════════════════════════════════════════ +// 额度卡管理 +// ═══════════════════════════════════════════════════════════════════════════ + +// 获取额度卡上限配置 +router.get('/quota-cards/limits', authenticateAdmin, async (req, res) => { + try { + const config = await quotaCardService.getLimitsConfig() + res.json({ success: true, data: config }) + } catch (error) { + logger.error('❌ Failed to get quota card limits:', error) + res.status(500).json({ success: false, error: error.message }) + } +}) + +// 更新额度卡上限配置 +router.put('/quota-cards/limits', authenticateAdmin, async (req, res) => { + try { + const { enabled, maxExpiryDays, maxTotalCostLimit } = req.body + const config = await quotaCardService.saveLimitsConfig({ + enabled, + maxExpiryDays, + maxTotalCostLimit + }) + res.json({ success: true, data: config }) + } catch (error) { + logger.error('❌ Failed to save quota card limits:', error) + res.status(500).json({ success: false, error: error.message }) + } +}) + +// 获取额度卡列表 +router.get('/quota-cards', authenticateAdmin, async (req, res) => { + try { + const { status, limit = 100, offset = 0 } = req.query + const result = await quotaCardService.getAllCards({ + status, + limit: parseInt(limit), + offset: parseInt(offset) + }) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to get quota cards:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 获取额度卡统计 +router.get('/quota-cards/stats', authenticateAdmin, async (req, res) => { + try { + const stats = await quotaCardService.getCardStats() + res.json({ + success: true, + data: stats + }) + } catch (error) { + logger.error('❌ Failed to get quota card stats:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 获取单个额度卡详情 +router.get('/quota-cards/:id', authenticateAdmin, async (req, res) => { + try { + const card = await quotaCardService.getCardById(req.params.id) + if (!card) { + return res.status(404).json({ + success: false, + error: 'Card not found' + }) + } + + res.json({ + success: true, + data: card + }) + } catch (error) { + logger.error('❌ Failed to get quota card:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 创建额度卡 +router.post('/quota-cards', authenticateAdmin, async (req, res) => { + try { + const { type, quotaAmount, timeAmount, timeUnit, expiresAt, note, count = 1 } = req.body + + if (!type) { + return res.status(400).json({ + success: false, + error: 'type is required' + }) + } + + const createdBy = req.session?.username || 'admin' + const options = { + type, + quotaAmount: parseFloat(quotaAmount || 0), + timeAmount: parseInt(timeAmount || 0), + timeUnit: timeUnit || 'days', + expiresAt, + note, + createdBy + } + + let result + if (count > 1) { + result = await quotaCardService.createCardsBatch(options, Math.min(count, 100)) + } else { + result = await quotaCardService.createCard(options) + } + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to create quota card:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 删除未使用的额度卡 +router.delete('/quota-cards/:id', authenticateAdmin, async (req, res) => { + try { + const result = await quotaCardService.deleteCard(req.params.id) + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to delete quota card:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 核销记录管理 +// ═══════════════════════════════════════════════════════════════════════════ + +// 获取核销记录列表 +router.get('/redemptions', authenticateAdmin, async (req, res) => { + try { + const { userId, apiKeyId, limit = 100, offset = 0 } = req.query + const result = await quotaCardService.getRedemptions({ + userId, + apiKeyId, + limit: parseInt(limit), + offset: parseInt(offset) + }) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to get redemptions:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 撤销核销 +router.post('/redemptions/:id/revoke', authenticateAdmin, async (req, res) => { + try { + const { reason } = req.body + const revokedBy = req.session?.username || 'admin' + + const result = await quotaCardService.revokeRedemption(req.params.id, revokedBy, reason) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to revoke redemption:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 延长有效期 +router.post('/api-keys/:id/extend-expiry', authenticateAdmin, async (req, res) => { + try { + const { amount, unit = 'days' } = req.body + + if (!amount || amount <= 0) { + return res.status(400).json({ + success: false, + error: 'amount must be a positive number' + }) + } + + const result = await apiKeyService.extendExpiry(req.params.id, parseInt(amount), unit) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to extend expiry:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/requestDetails.js b/src/routes/admin/requestDetails.js new file mode 100644 index 0000000..fbdfad1 --- /dev/null +++ b/src/routes/admin/requestDetails.js @@ -0,0 +1,94 @@ +const express = require('express') +const { authenticateAdmin } = require('../../middleware/auth') +const requestDetailService = require('../../services/requestDetailService') +const logger = require('../../utils/logger') + +const router = express.Router() + +router.get('/request-details', authenticateAdmin, async (req, res) => { + try { + const data = await requestDetailService.listRequestDetails(req.query || {}) + return res.json({ + success: true, + data + }) + } catch (error) { + if (error?.statusCode === 400) { + return res.status(400).json({ + success: false, + error: 'Invalid request detail query', + message: error.message + }) + } + + logger.error('❌ Failed to list request details:', error) + return res.status(500).json({ + success: false, + error: 'Failed to list request details', + message: error.message + }) + } +}) + +router.get('/request-details/body-preview-stats', authenticateAdmin, async (_req, res) => { + try { + const data = await requestDetailService.getRequestBodyPreviewStats() + return res.json({ + success: true, + data + }) + } catch (error) { + logger.error('❌ Failed to get request body preview stats:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get request body preview stats', + message: error.message + }) + } +}) + +router.post('/request-details/body-preview-purge', authenticateAdmin, async (_req, res) => { + try { + const data = await requestDetailService.purgeRequestBodySnapshots() + return res.json({ + success: true, + message: '清理完毕', + data + }) + } catch (error) { + logger.error('❌ Failed to purge request body previews:', error) + return res.status(500).json({ + success: false, + error: 'Failed to purge request body previews', + message: error.message + }) + } +}) + +router.get('/request-details/:requestId', authenticateAdmin, async (req, res) => { + try { + const { requestId } = req.params + const data = await requestDetailService.getRequestDetail(requestId) + + if (!data.record) { + return res.status(404).json({ + success: false, + error: 'Request detail not found' + }) + } + + return res.json({ + success: true, + data + }) + } catch (error) { + logger.error('❌ Failed to get request detail:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get request detail', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/serviceRates.js b/src/routes/admin/serviceRates.js new file mode 100644 index 0000000..fa8cdaf --- /dev/null +++ b/src/routes/admin/serviceRates.js @@ -0,0 +1,72 @@ +/** + * 服务倍率配置管理路由 + */ +const express = require('express') +const router = express.Router() +const serviceRatesService = require('../../services/serviceRatesService') +const logger = require('../../utils/logger') +const { authenticateAdmin } = require('../../middleware/auth') + +// 获取服务倍率配置 +router.get('/service-rates', authenticateAdmin, async (req, res) => { + try { + const rates = await serviceRatesService.getRates() + res.json({ + success: true, + data: rates + }) + } catch (error) { + logger.error('❌ Failed to get service rates:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 更新服务倍率配置 +router.put('/service-rates', authenticateAdmin, async (req, res) => { + try { + const { rates, baseService } = req.body + + if (!rates || typeof rates !== 'object') { + return res.status(400).json({ + success: false, + error: 'rates is required and must be an object' + }) + } + + const updatedBy = req.session?.username || 'admin' + const result = await serviceRatesService.saveRates({ rates, baseService }, updatedBy) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to update service rates:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +// 获取可用服务列表 +router.get('/service-rates/services', authenticateAdmin, async (req, res) => { + try { + const services = await serviceRatesService.getAvailableServices() + res.json({ + success: true, + data: services + }) + } catch (error) { + logger.error('❌ Failed to get available services:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/sync.js b/src/routes/admin/sync.js new file mode 100644 index 0000000..a194f0e --- /dev/null +++ b/src/routes/admin/sync.js @@ -0,0 +1,464 @@ +/** + * Admin Routes - Sync / Export (for migration) + * Exports account data (including secrets) for safe server-to-server syncing. + */ + +const express = require('express') +const router = express.Router() + +const { authenticateAdmin } = require('../../middleware/auth') +const redis = require('../../models/redis') +const claudeAccountService = require('../../services/account/claudeAccountService') +const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService') +const openaiAccountService = require('../../services/account/openaiAccountService') +const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService') +const logger = require('../../utils/logger') + +function toBool(value, defaultValue = false) { + if (value === undefined || value === null || value === '') { + return defaultValue + } + if (value === true || value === 'true') { + return true + } + if (value === false || value === 'false') { + return false + } + return defaultValue +} + +function normalizeProxy(proxy) { + if (!proxy || typeof proxy !== 'object') { + return null + } + + const protocol = proxy.protocol || proxy.type || proxy.scheme || '' + const host = proxy.host || '' + const port = Number(proxy.port || 0) + + if (!protocol || !host || !Number.isFinite(port) || port <= 0) { + return null + } + + return { + protocol: String(protocol), + host: String(host), + port, + username: proxy.username ? String(proxy.username) : '', + password: proxy.password ? String(proxy.password) : '' + } +} + +function buildModelMappingFromSupportedModels(supportedModels) { + if (!supportedModels) { + return null + } + + if (Array.isArray(supportedModels)) { + const mapping = {} + for (const model of supportedModels) { + if (typeof model === 'string' && model.trim()) { + mapping[model.trim()] = model.trim() + } + } + return Object.keys(mapping).length ? mapping : null + } + + if (typeof supportedModels === 'object') { + const mapping = {} + for (const [from, to] of Object.entries(supportedModels)) { + if (typeof from === 'string' && typeof to === 'string' && from.trim() && to.trim()) { + mapping[from.trim()] = to.trim() + } + } + return Object.keys(mapping).length ? mapping : null + } + + return null +} + +function safeParseJson(raw, fallback = null) { + if (!raw || typeof raw !== 'string') { + return fallback + } + try { + return JSON.parse(raw) + } catch (_) { + return fallback + } +} + +// Export accounts for migration (includes secrets). +// GET /admin/sync/export-accounts?include_secrets=true +router.get('/sync/export-accounts', authenticateAdmin, async (req, res) => { + try { + const includeSecrets = toBool(req.query.include_secrets, false) + if (!includeSecrets) { + return res.status(400).json({ + success: false, + error: 'include_secrets_required', + message: 'Set include_secrets=true to export secrets' + }) + } + + // ===== Claude official OAuth / Setup Token accounts ===== + const rawClaudeAccounts = await redis.getAllClaudeAccounts() + const claudeAccounts = rawClaudeAccounts.map((account) => { + // Backward compatible extraction: prefer individual fields, fallback to claudeAiOauth JSON blob. + let decryptedClaudeAiOauth = null + if (account.claudeAiOauth) { + try { + const raw = claudeAccountService._decryptSensitiveData(account.claudeAiOauth) + decryptedClaudeAiOauth = raw ? JSON.parse(raw) : null + } catch (_) { + decryptedClaudeAiOauth = null + } + } + + const rawScopes = + account.scopes && account.scopes.trim() + ? account.scopes + : decryptedClaudeAiOauth?.scopes + ? decryptedClaudeAiOauth.scopes.join(' ') + : '' + + const scopes = rawScopes && rawScopes.trim() ? rawScopes.trim().split(' ') : [] + const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference') + const authType = isOAuth ? 'oauth' : 'setup-token' + + const accessToken = + account.accessToken && String(account.accessToken).trim() + ? claudeAccountService._decryptSensitiveData(account.accessToken) + : decryptedClaudeAiOauth?.accessToken || '' + + const refreshToken = + account.refreshToken && String(account.refreshToken).trim() + ? claudeAccountService._decryptSensitiveData(account.refreshToken) + : decryptedClaudeAiOauth?.refreshToken || '' + + let expiresAt = null + const expiresAtMs = Number.parseInt(account.expiresAt, 10) + if (Number.isFinite(expiresAtMs) && expiresAtMs > 0) { + expiresAt = new Date(expiresAtMs).toISOString() + } else if (decryptedClaudeAiOauth?.expiresAt) { + try { + expiresAt = new Date(Number(decryptedClaudeAiOauth.expiresAt)).toISOString() + } catch (_) { + expiresAt = null + } + } + + const proxy = account.proxy ? normalizeProxy(safeParseJson(account.proxy)) : null + + // 🔧 Parse subscriptionInfo to extract org_uuid and account_uuid + let orgUuid = null + let accountUuid = null + if (account.subscriptionInfo) { + try { + const subscriptionInfo = JSON.parse(account.subscriptionInfo) + orgUuid = subscriptionInfo.organizationUuid || null + accountUuid = subscriptionInfo.accountUuid || null + } catch (_) { + // Ignore parse errors + } + } + + // 🔧 Calculate expires_in from expires_at + let expiresIn = null + if (expiresAt) { + try { + const expiresAtTime = new Date(expiresAt).getTime() + const nowTime = Date.now() + const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000) + if (diffSeconds > 0) { + expiresIn = diffSeconds + } + } catch (_) { + // Ignore calculation errors + } + } + // 🔧 Use default expires_in if calculation failed (Anthropic OAuth: 8 hours) + if (!expiresIn && isOAuth) { + expiresIn = 28800 // 8 hours + } + + const credentials = { + access_token: accessToken, + refresh_token: refreshToken || undefined, + expires_at: expiresAt || undefined, + expires_in: expiresIn || undefined, + scope: scopes.join(' ') || undefined, + token_type: 'Bearer' + } + // 🔧 Add auth info as top-level credentials fields + if (orgUuid) { + credentials.org_uuid = orgUuid + } + if (accountUuid) { + credentials.account_uuid = accountUuid + } + + // 🔧 Store complete original CRS data in extra + const extra = { + crs_account_id: account.id, + crs_kind: 'claude-account', + crs_id: account.id, + crs_name: account.name, + crs_description: account.description || '', + crs_platform: account.platform || 'claude', + crs_auth_type: authType, + crs_is_active: account.isActive === 'true', + crs_schedulable: account.schedulable !== 'false', + crs_priority: Number.parseInt(account.priority, 10) || 50, + crs_status: account.status || 'active', + crs_scopes: scopes, + crs_subscription_info: account.subscriptionInfo || undefined + } + + return { + kind: 'claude-account', + id: account.id, + name: account.name, + description: account.description || '', + platform: account.platform || 'claude', + authType, + isActive: account.isActive === 'true', + schedulable: account.schedulable !== 'false', + priority: Number.parseInt(account.priority, 10) || 50, + status: account.status || 'active', + proxy, + credentials, + extra + } + }) + + // ===== Claude Console API Key accounts ===== + const claudeConsoleSummaries = await claudeConsoleAccountService.getAllAccounts() + const claudeConsoleAccounts = [] + for (const summary of claudeConsoleSummaries) { + const full = await claudeConsoleAccountService.getAccount(summary.id) + if (!full) { + continue + } + + const proxy = normalizeProxy(full.proxy) + const modelMapping = buildModelMappingFromSupportedModels(full.supportedModels) + + const credentials = { + api_key: full.apiKey, + base_url: full.apiUrl + } + + if (modelMapping) { + credentials.model_mapping = modelMapping + } + + if (full.userAgent) { + credentials.user_agent = full.userAgent + } + + claudeConsoleAccounts.push({ + kind: 'claude-console-account', + id: full.id, + name: full.name, + description: full.description || '', + platform: full.platform || 'claude-console', + isActive: full.isActive === true, + schedulable: full.schedulable !== false, + priority: Number.parseInt(full.priority, 10) || 50, + status: full.status || 'active', + proxy, + maxConcurrentTasks: Number.parseInt(full.maxConcurrentTasks, 10) || 0, + credentials, + extra: { + crs_account_id: full.id, + crs_kind: 'claude-console-account', + crs_id: full.id, + crs_name: full.name, + crs_description: full.description || '', + crs_platform: full.platform || 'claude-console', + crs_is_active: full.isActive === true, + crs_schedulable: full.schedulable !== false, + crs_priority: Number.parseInt(full.priority, 10) || 50, + crs_status: full.status || 'active' + } + }) + } + + // ===== OpenAI OAuth accounts ===== + const openaiOAuthAccounts = [] + { + const openaiIds = await redis.getAllIdsByIndex( + 'openai:account:index', + 'openai:account:*', + /^openai:account:(.+)$/ + ) + for (const id of openaiIds) { + const account = await openaiAccountService.getAccount(id) + if (!account) { + continue + } + + const accessToken = account.accessToken + ? openaiAccountService.decrypt(account.accessToken) + : '' + if (!accessToken) { + // Skip broken/legacy records without decryptable token + continue + } + + const scopes = + account.scopes && typeof account.scopes === 'string' && account.scopes.trim() + ? account.scopes.trim().split(' ') + : [] + + const proxy = normalizeProxy(account.proxy) + + // 🔧 Calculate expires_in from expires_at + let expiresIn = null + if (account.expiresAt) { + try { + const expiresAtTime = new Date(account.expiresAt).getTime() + const nowTime = Date.now() + const diffSeconds = Math.floor((expiresAtTime - nowTime) / 1000) + if (diffSeconds > 0) { + expiresIn = diffSeconds + } + } catch (_) { + // Ignore calculation errors + } + } + // 🔧 Use default expires_in if calculation failed (OpenAI OAuth: 10 days) + if (!expiresIn) { + expiresIn = 864000 // 10 days + } + + const credentials = { + access_token: accessToken, + refresh_token: account.refreshToken || undefined, + id_token: account.idToken || undefined, + expires_at: account.expiresAt || undefined, + expires_in: expiresIn || undefined, + scope: scopes.join(' ') || undefined, + token_type: 'Bearer' + } + // 🔧 Add auth info as top-level credentials fields + if (account.accountId) { + credentials.chatgpt_account_id = account.accountId + } + if (account.chatgptUserId) { + credentials.chatgpt_user_id = account.chatgptUserId + } + if (account.organizationId) { + credentials.organization_id = account.organizationId + } + + // 🔧 Store complete original CRS data in extra + const extra = { + crs_account_id: account.id, + crs_kind: 'openai-oauth-account', + crs_id: account.id, + crs_name: account.name, + crs_description: account.description || '', + crs_platform: account.platform || 'openai', + crs_is_active: account.isActive === 'true', + crs_schedulable: account.schedulable !== 'false', + crs_priority: Number.parseInt(account.priority, 10) || 50, + crs_status: account.status || 'active', + crs_scopes: scopes, + crs_email: account.email || undefined, + crs_chatgpt_account_id: account.accountId || undefined, + crs_chatgpt_user_id: account.chatgptUserId || undefined, + crs_organization_id: account.organizationId || undefined + } + + openaiOAuthAccounts.push({ + kind: 'openai-oauth-account', + id: account.id, + name: account.name, + description: account.description || '', + platform: account.platform || 'openai', + authType: 'oauth', + isActive: account.isActive === 'true', + schedulable: account.schedulable !== 'false', + priority: Number.parseInt(account.priority, 10) || 50, + status: account.status || 'active', + proxy, + credentials, + extra + }) + } + } + + // ===== OpenAI Responses API Key accounts ===== + const openaiResponsesAccounts = [] + const openaiResponseIds = await redis.getAllIdsByIndex( + 'openai_responses_account:index', + 'openai_responses_account:*', + /^openai_responses_account:(.+)$/ + ) + for (const id of openaiResponseIds) { + const full = await openaiResponsesAccountService.getAccount(id) + if (!full) { + continue + } + + const proxy = normalizeProxy(full.proxy) + + const credentials = { + api_key: full.apiKey, + base_url: full.baseApi + } + + if (full.userAgent) { + credentials.user_agent = full.userAgent + } + + openaiResponsesAccounts.push({ + kind: 'openai-responses-account', + id: full.id, + name: full.name, + description: full.description || '', + platform: full.platform || 'openai-responses', + isActive: full.isActive === 'true', + schedulable: full.schedulable !== 'false', + priority: Number.parseInt(full.priority, 10) || 50, + status: full.status || 'active', + proxy, + credentials, + extra: { + crs_account_id: full.id, + crs_kind: 'openai-responses-account', + crs_id: full.id, + crs_name: full.name, + crs_description: full.description || '', + crs_platform: full.platform || 'openai-responses', + crs_is_active: full.isActive === 'true', + crs_schedulable: full.schedulable !== 'false', + crs_priority: Number.parseInt(full.priority, 10) || 50, + crs_status: full.status || 'active' + } + }) + } + + return res.json({ + success: true, + data: { + exportedAt: new Date().toISOString(), + claudeAccounts, + claudeConsoleAccounts, + openaiOAuthAccounts, + openaiResponsesAccounts + } + }) + } catch (error) { + logger.error('❌ Failed to export accounts for sync:', error) + return res.status(500).json({ + success: false, + error: 'export_failed', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/system.js b/src/routes/admin/system.js new file mode 100644 index 0000000..9937a6c --- /dev/null +++ b/src/routes/admin/system.js @@ -0,0 +1,454 @@ +const express = require('express') +const fs = require('fs') +const path = require('path') +const axios = require('axios') +const claudeCodeHeadersService = require('../../services/claudeCodeHeadersService') +const claudeAccountService = require('../../services/account/claudeAccountService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const config = require('../../../config/config') + +const router = express.Router() + +// ==================== Claude Code Headers 管理 ==================== + +// 获取所有 Claude Code headers +router.get('/claude-code-headers', authenticateAdmin, async (req, res) => { + try { + const allHeaders = await claudeCodeHeadersService.getAllAccountHeaders() + + // 获取所有 Claude 账号信息 + const accounts = await claudeAccountService.getAllAccounts() + const accountMap = {} + accounts.forEach((account) => { + accountMap[account.id] = account.name + }) + + // 格式化输出 + const formattedData = Object.entries(allHeaders).map(([accountId, data]) => ({ + accountId, + accountName: accountMap[accountId] || 'Unknown', + version: data.version, + userAgent: data.headers['user-agent'], + updatedAt: data.updatedAt, + headers: data.headers + })) + + return res.json({ + success: true, + data: formattedData + }) + } catch (error) { + logger.error('❌ Failed to get Claude Code headers:', error) + return res + .status(500) + .json({ error: 'Failed to get Claude Code headers', message: error.message }) + } +}) + +// 🗑️ 清除指定账号的 Claude Code headers +router.delete('/claude-code-headers/:accountId', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + await claudeCodeHeadersService.clearAccountHeaders(accountId) + + return res.json({ + success: true, + message: `Claude Code headers cleared for account ${accountId}` + }) + } catch (error) { + logger.error('❌ Failed to clear Claude Code headers:', error) + return res + .status(500) + .json({ error: 'Failed to clear Claude Code headers', message: error.message }) + } +}) + +// ==================== 系统更新检查 ==================== + +// 版本比较函数 +function compareVersions(current, latest) { + const parseVersion = (v) => { + const parts = v.split('.').map(Number) + return { + major: parts[0] || 0, + minor: parts[1] || 0, + patch: parts[2] || 0 + } + } + + const currentV = parseVersion(current) + const latestV = parseVersion(latest) + + if (currentV.major !== latestV.major) { + return currentV.major - latestV.major + } + if (currentV.minor !== latestV.minor) { + return currentV.minor - latestV.minor + } + return currentV.patch - latestV.patch +} + +router.get('/check-updates', authenticateAdmin, async (req, res) => { + // 读取当前版本 + const versionPath = path.join(__dirname, '../../../VERSION') + let currentVersion = '1.0.0' + try { + currentVersion = fs.readFileSync(versionPath, 'utf8').trim() + } catch (err) { + logger.warn('⚠️ Could not read VERSION file:', err.message) + } + + try { + // 从缓存获取 + const cacheKey = 'version_check_cache' + const cached = await redis.getClient().get(cacheKey) + + if (cached && !req.query.force) { + const cachedData = JSON.parse(cached) + const cacheAge = Date.now() - cachedData.timestamp + + // 缓存有效期1小时 + if (cacheAge < 3600000) { + // 实时计算 hasUpdate,不使用缓存的值 + const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0 + + return res.json({ + success: true, + data: { + current: currentVersion, + latest: cachedData.latest, + hasUpdate, // 实时计算,不用缓存 + releaseInfo: cachedData.releaseInfo, + cached: true + } + }) + } + } + + // 请求 GitHub API + const githubRepo = 'wei-shaw/claude-relay-service' + const response = await axios.get(`https://api.github.com/repos/${githubRepo}/releases/latest`, { + headers: { + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'Claude-Relay-Service' + }, + timeout: 10000 + }) + + const release = response.data + const latestVersion = release.tag_name.replace(/^v/, '') + + // 比较版本 + const hasUpdate = compareVersions(currentVersion, latestVersion) < 0 + + const releaseInfo = { + name: release.name, + body: release.body, + publishedAt: release.published_at, + htmlUrl: release.html_url + } + + // 缓存结果(不缓存 hasUpdate,因为它应该实时计算) + await redis.getClient().set( + cacheKey, + JSON.stringify({ + latest: latestVersion, + releaseInfo, + timestamp: Date.now() + }), + 'EX', + 3600 + ) // 1小时过期 + + return res.json({ + success: true, + data: { + current: currentVersion, + latest: latestVersion, + hasUpdate, + releaseInfo, + cached: false + } + }) + } catch (error) { + // 改进错误日志记录 + const errorDetails = { + message: error.message || 'Unknown error', + code: error.code, + response: error.response + ? { + status: error.response.status, + statusText: error.response.statusText, + data: error.response.data + } + : null, + request: error.request ? 'Request was made but no response received' : null + } + + logger.error('❌ Failed to check for updates:', errorDetails.message) + + // 处理 404 错误 - 仓库或版本不存在 + if (error.response && error.response.status === 404) { + return res.json({ + success: true, + data: { + current: currentVersion, + latest: currentVersion, + hasUpdate: false, + releaseInfo: { + name: 'No releases found', + body: 'The GitHub repository has no releases yet.', + publishedAt: new Date().toISOString(), + htmlUrl: '#' + }, + warning: 'GitHub repository has no releases' + } + }) + } + + // 如果是网络错误,尝试返回缓存的数据 + if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { + const cacheKey = 'version_check_cache' + const cached = await redis.getClient().get(cacheKey) + + if (cached) { + const cachedData = JSON.parse(cached) + // 实时计算 hasUpdate + const hasUpdate = compareVersions(currentVersion, cachedData.latest) < 0 + + return res.json({ + success: true, + data: { + current: currentVersion, + latest: cachedData.latest, + hasUpdate, // 实时计算 + releaseInfo: cachedData.releaseInfo, + cached: true, + warning: 'Using cached data due to network error' + } + }) + } + } + + // 其他错误返回当前版本信息 + return res.json({ + success: true, + data: { + current: currentVersion, + latest: currentVersion, + hasUpdate: false, + releaseInfo: { + name: 'Update check failed', + body: `Unable to check for updates: ${error.message || 'Unknown error'}`, + publishedAt: new Date().toISOString(), + htmlUrl: '#' + }, + error: true, + warning: error.message || 'Failed to check for updates' + } + }) + } +}) + +// ==================== OEM 设置管理 ==================== + +// 获取OEM设置(公开接口,用于显示) +// 注意:这个端点没有 authenticateAdmin 中间件,因为前端登录页也需要访问 +router.get('/oem-settings', async (req, res) => { + try { + const client = redis.getClient() + const oemSettings = await client.get('oem:settings') + + // 默认设置 + const defaultSettings = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', // Base64编码的图标数据 + showAdminButton: true, // 是否显示管理后台按钮 + apiStatsNotice: { + enabled: false, + title: '', + content: '' + }, + updatedAt: new Date().toISOString() + } + + let settings = defaultSettings + if (oemSettings) { + try { + settings = { ...defaultSettings, ...JSON.parse(oemSettings) } + } catch (err) { + logger.warn('⚠️ Failed to parse OEM settings, using defaults:', err.message) + } + } + + // 添加 LDAP 启用状态到响应中 + return res.json({ + success: true, + data: { + ...settings, + ldapEnabled: config.ldap && config.ldap.enabled === true + } + }) + } catch (error) { + logger.error('❌ Failed to get OEM settings:', error) + return res.status(500).json({ error: 'Failed to get OEM settings', message: error.message }) + } +}) + +// 更新OEM设置 +router.put('/oem-settings', authenticateAdmin, async (req, res) => { + try { + const { siteName, siteIcon, siteIconData, showAdminButton, apiStatsNotice } = req.body + + // 验证输入 + if (!siteName || typeof siteName !== 'string' || siteName.trim().length === 0) { + return res.status(400).json({ error: 'Site name is required' }) + } + + if (siteName.length > 100) { + return res.status(400).json({ error: 'Site name must be less than 100 characters' }) + } + + // 验证图标数据大小(如果是base64) + if (siteIconData && siteIconData.length > 500000) { + // 约375KB + return res.status(400).json({ error: 'Icon file must be less than 350KB' }) + } + + // 验证图标URL(如果提供) + if (siteIcon && !siteIconData) { + // 简单验证URL格式 + try { + new URL(siteIcon) + } catch (err) { + return res.status(400).json({ error: 'Invalid icon URL format' }) + } + } + + const settings = { + siteName: siteName.trim(), + siteIcon: (siteIcon || '').trim(), + siteIconData: (siteIconData || '').trim(), // Base64数据 + showAdminButton: showAdminButton !== false, // 默认为true + apiStatsNotice: { + enabled: apiStatsNotice?.enabled === true, + title: (apiStatsNotice?.title || '').trim().slice(0, 100), + content: (apiStatsNotice?.content || '').trim().slice(0, 2000) + }, + updatedAt: new Date().toISOString() + } + + const client = redis.getClient() + await client.set('oem:settings', JSON.stringify(settings)) + + logger.info(`✅ OEM settings updated: ${siteName}`) + + return res.json({ + success: true, + message: 'OEM settings updated successfully', + data: settings + }) + } catch (error) { + logger.error('❌ Failed to update OEM settings:', error) + return res.status(500).json({ error: 'Failed to update OEM settings', message: error.message }) + } +}) + +// ==================== Claude Code 版本管理 ==================== + +router.get('/claude-code-version', authenticateAdmin, async (req, res) => { + try { + const CACHE_KEY = 'claude_code_user_agent:daily' + + // 获取缓存的统一User-Agent + const unifiedUserAgent = await redis.client.get(CACHE_KEY) + const ttl = unifiedUserAgent ? await redis.client.ttl(CACHE_KEY) : 0 + + res.json({ + success: true, + userAgent: unifiedUserAgent, + isActive: !!unifiedUserAgent, + ttlSeconds: ttl, + lastUpdated: unifiedUserAgent ? new Date().toISOString() : null + }) + } catch (error) { + logger.error('❌ Get unified Claude Code User-Agent error:', error) + res.status(500).json({ + success: false, + message: 'Failed to get User-Agent information', + error: error.message + }) + } +}) + +// 🗑️ 清除统一Claude Code User-Agent缓存 +router.post('/claude-code-version/clear', authenticateAdmin, async (req, res) => { + try { + const CACHE_KEY = 'claude_code_user_agent:daily' + + // 删除缓存的统一User-Agent + await redis.client.del(CACHE_KEY) + + logger.info(`🗑️ Admin manually cleared unified Claude Code User-Agent cache`) + + res.json({ + success: true, + message: 'Unified User-Agent cache cleared successfully' + }) + } catch (error) { + logger.error('❌ Clear unified User-Agent cache error:', error) + res.status(500).json({ + success: false, + message: 'Failed to clear cache', + error: error.message + }) + } +}) + +// ==================== 模型价格管理 ==================== + +const pricingService = require('../../services/pricingService') + +// 获取所有模型价格数据 +router.get('/models/pricing', authenticateAdmin, async (req, res) => { + try { + if (!pricingService.pricingData || Object.keys(pricingService.pricingData).length === 0) { + await pricingService.loadPricingData() + } + const data = pricingService.pricingData + res.json({ + success: true, + data: data || {} + }) + } catch (error) { + logger.error('Failed to get model pricing:', error) + res.status(500).json({ error: 'Failed to get model pricing', message: error.message }) + } +}) + +// 获取价格服务状态 +router.get('/models/pricing/status', authenticateAdmin, async (req, res) => { + try { + const status = pricingService.getStatus() + res.json({ success: true, data: status }) + } catch (error) { + logger.error('Failed to get pricing status:', error) + res.status(500).json({ error: 'Failed to get pricing status', message: error.message }) + } +}) + +// 强制刷新价格数据 +router.post('/models/pricing/refresh', authenticateAdmin, async (req, res) => { + try { + const result = await pricingService.forceUpdate() + res.json({ success: result.success, message: result.message }) + } catch (error) { + logger.error('Failed to refresh pricing:', error) + res.status(500).json({ error: 'Failed to refresh pricing', message: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/usageStats.js b/src/routes/admin/usageStats.js new file mode 100644 index 0000000..c8b5660 --- /dev/null +++ b/src/routes/admin/usageStats.js @@ -0,0 +1,3338 @@ +const express = require('express') +const apiKeyService = require('../../services/apiKeyService') +const ccrAccountService = require('../../services/account/ccrAccountService') +const claudeAccountService = require('../../services/account/claudeAccountService') +const claudeConsoleAccountService = require('../../services/account/claudeConsoleAccountService') +const geminiAccountService = require('../../services/account/geminiAccountService') +const geminiApiAccountService = require('../../services/account/geminiApiAccountService') +const openaiAccountService = require('../../services/account/openaiAccountService') +const openaiResponsesAccountService = require('../../services/account/openaiResponsesAccountService') +const droidAccountService = require('../../services/account/droidAccountService') +const bedrockAccountService = require('../../services/account/bedrockAccountService') +const redis = require('../../models/redis') +const { authenticateAdmin } = require('../../middleware/auth') +const logger = require('../../utils/logger') +const CostCalculator = require('../../utils/costCalculator') +const pricingService = require('../../services/pricingService') + +const router = express.Router() + +// 辅助函数:通过索引获取数据,回退到 SCAN +// keyPattern 支持占位符:{id}、{keyId}+{model}、{accountId}+{model} +async function getUsageDataByIndex(indexKey, keyPattern, scanPattern) { + const members = await redis.client.smembers(indexKey) + if (members && members.length > 0) { + const keys = members.map((id) => { + // 检查是否是 keymodel 格式 (keyId:model) + if (keyPattern.includes('{keyId}') && keyPattern.includes('{model}')) { + const [keyId, ...modelParts] = id.split(':') + const model = modelParts.join(':') + return keyPattern.replace('{keyId}', keyId).replace('{model}', model) + } + // 检查是否是 accountId:model 格式 + if (keyPattern.includes('{accountId}') && keyPattern.includes('{model}')) { + const [accountId, ...modelParts] = id.split(':') + const model = modelParts.join(':') + return keyPattern.replace('{accountId}', accountId).replace('{model}', model) + } + return keyPattern.replace('{id}', id) + }) + const dataList = await redis.batchHgetallChunked(keys) + const result = [] + keys.forEach((key, i) => { + if (dataList[i] && Object.keys(dataList[i]).length > 0) { + result.push({ key, data: dataList[i] }) + } + }) + return result + } + // 索引为空,检查空标记 + const emptyMarker = await redis.client.get(`${indexKey}:empty`) + if (emptyMarker === '1') { + return [] + } + // 回退到 SCAN(兼容历史数据) + const keys = await redis.scanKeys(scanPattern) + if (keys.length === 0) { + // 设置空标记,1小时过期 + await redis.client.setex(`${indexKey}:empty`, 3600, '1') + return [] + } + // 建立索引 + const ids = keys.map((k) => { + if (keyPattern.includes('{keyId}') && keyPattern.includes('{model}')) { + // keymodel 格式:usage:{keyId}:model:daily:{model}:{date} 或 hourly + const match = + k.match(/usage:([^:]+):model:daily:(.+):\d{4}-\d{2}-\d{2}$/) || + k.match(/usage:([^:]+):model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/) + if (match) { + return `${match[1]}:${match[2]}` + } + } + if (keyPattern.includes('{accountId}') && keyPattern.includes('{model}')) { + // account_usage:model:daily 或 hourly + const match = + k.match(/account_usage:model:daily:([^:]+):(.+):\d{4}-\d{2}-\d{2}$/) || + k.match(/account_usage:model:hourly:([^:]+):(.+):\d{4}-\d{2}-\d{2}:\d{2}$/) + if (match) { + return `${match[1]}:${match[2]}` + } + } + // 通用格式:根据 keyPattern 中 {id} 的位置提取 id + const patternParts = keyPattern.split(':') + const idIndex = patternParts.findIndex((p) => p === '{id}') + if (idIndex !== -1) { + const parts = k.split(':') + return parts[idIndex] + } + // 回退:提取最后一个 : 前的 id + const parts = k.split(':') + return parts[parts.length - 2] + }) + const validIds = ids.filter(Boolean) + if (validIds.length > 0) { + await redis.client.sadd(indexKey, ...validIds) + } + const dataList = await redis.batchHgetallChunked(keys) + const result = [] + keys.forEach((key, i) => { + if (dataList[i] && Object.keys(dataList[i]).length > 0) { + result.push({ key, data: dataList[i] }) + } + }) + return result +} + +const accountTypeNames = { + claude: 'Claude官方', + 'claude-official': 'Claude官方', + 'claude-console': 'Claude Console', + ccr: 'Claude Console Relay', + openai: 'OpenAI', + 'openai-responses': 'OpenAI Responses', + gemini: 'Gemini', + 'gemini-api': 'Gemini API', + droid: 'Droid', + bedrock: 'AWS Bedrock', + unknown: '未知渠道' +} + +const resolveAccountByPlatform = async (accountId, platform) => { + const serviceMap = { + claude: claudeAccountService, + 'claude-console': claudeConsoleAccountService, + gemini: geminiAccountService, + 'gemini-api': geminiApiAccountService, + openai: openaiAccountService, + 'openai-responses': openaiResponsesAccountService, + droid: droidAccountService, + ccr: ccrAccountService, + bedrock: bedrockAccountService + } + + if (platform && serviceMap[platform]) { + try { + const account = await serviceMap[platform].getAccount(accountId) + if (account) { + return { ...account, platform } + } + } catch (error) { + logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`) + } + } + + for (const [platformName, service] of Object.entries(serviceMap)) { + try { + const account = await service.getAccount(accountId) + if (account) { + return { ...account, platform: platformName } + } + } catch (error) { + logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`) + } + } + + return null +} + +const getApiKeyName = async (keyId) => { + try { + const keyData = await redis.getApiKey(keyId) + return keyData?.name || keyData?.label || keyId + } catch (error) { + logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`) + return keyId + } +} + +// 📊 账户使用统计 + +// 获取所有账户的使用统计 +router.get('/accounts/usage-stats', authenticateAdmin, async (req, res) => { + try { + const accountsStats = await redis.getAllAccountsUsageStats() + + return res.json({ + success: true, + data: accountsStats, + summary: { + totalAccounts: accountsStats.length, + activeToday: accountsStats.filter((account) => account.daily.requests > 0).length, + totalDailyTokens: accountsStats.reduce( + (sum, account) => sum + (account.daily.allTokens || 0), + 0 + ), + totalDailyRequests: accountsStats.reduce( + (sum, account) => sum + (account.daily.requests || 0), + 0 + ) + }, + timestamp: new Date().toISOString() + }) + } catch (error) { + logger.error('❌ Failed to get accounts usage stats:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get accounts usage stats', + message: error.message + }) + } +}) + +// 获取单个账户的使用统计 +router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const accountStats = await redis.getAccountUsageStats(accountId) + + // 获取账户基本信息 + const accountData = await claudeAccountService.getAccount(accountId) + if (!accountData) { + return res.status(404).json({ + success: false, + error: 'Account not found' + }) + } + + return res.json({ + success: true, + data: { + ...accountStats, + accountInfo: { + name: accountData.name, + email: accountData.email, + status: accountData.status, + isActive: accountData.isActive, + createdAt: accountData.createdAt + } + }, + timestamp: new Date().toISOString() + }) + } catch (error) { + logger.error('❌ Failed to get account usage stats:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get account usage stats', + message: error.message + }) + } +}) + +// 获取账号近30天使用历史 +router.get('/accounts/:accountId/usage-history', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { platform = 'claude', days = 30 } = req.query + + const allowedPlatforms = [ + 'claude', + 'claude-console', + 'openai', + 'openai-responses', + 'gemini', + 'gemini-api', + 'droid', + 'bedrock' + ] + if (!allowedPlatforms.includes(platform)) { + return res.status(400).json({ + success: false, + error: 'Unsupported account platform' + }) + } + + const accountTypeMap = { + openai: 'openai', + 'openai-responses': 'openai-responses', + 'gemini-api': 'gemini-api', + droid: 'droid', + bedrock: 'bedrock' + } + + const fallbackModelMap = { + claude: 'claude-3-5-sonnet-20241022', + 'claude-console': 'claude-3-5-sonnet-20241022', + openai: 'gpt-4o-mini-2024-07-18', + 'openai-responses': 'gpt-4o-mini-2024-07-18', + gemini: 'gemini-1.5-flash', + 'gemini-api': 'gemini-2.0-flash', + droid: 'unknown', + bedrock: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0' + } + + // 获取账户信息以获取创建时间 + let accountData = null + let accountCreatedAt = null + + try { + switch (platform) { + case 'claude': + accountData = await claudeAccountService.getAccount(accountId) + break + case 'claude-console': + accountData = await claudeConsoleAccountService.getAccount(accountId) + break + case 'openai': + accountData = await openaiAccountService.getAccount(accountId) + break + case 'openai-responses': + accountData = await openaiResponsesAccountService.getAccount(accountId) + break + case 'gemini': + accountData = await geminiAccountService.getAccount(accountId) + break + case 'gemini-api': { + accountData = await geminiApiAccountService.getAccount(accountId) + break + } + case 'droid': + accountData = await droidAccountService.getAccount(accountId) + break + case 'bedrock': { + const result = await bedrockAccountService.getAccount(accountId) + accountData = result?.success ? result.data : null + break + } + } + + if (accountData && accountData.createdAt) { + accountCreatedAt = new Date(accountData.createdAt) + } + } catch (error) { + logger.warn(`Failed to get account data for avgDailyCost calculation: ${error.message}`) + } + + const fallbackModel = fallbackModelMap[platform] || 'unknown' + const daysCount = Math.min(Math.max(parseInt(days, 10) || 30, 1), 60) + + // 获取概览统计数据 + const accountUsageStats = await redis.getAccountUsageStats( + accountId, + accountTypeMap[platform] || null + ) + + const history = [] + let totalCost = 0 + let totalRequests = 0 + let totalTokens = 0 + + let highestCostDay = null + let highestRequestDay = null + + const sumModelCostsForDay = async (dateKey) => { + const modelPattern = `account_usage:model:daily:${accountId}:*:${dateKey}` + const modelResults = await redis.scanAndGetAllChunked(modelPattern) + let summedCost = 0 + + if (modelResults.length === 0) { + return summedCost + } + + for (const { key: modelKey, data: modelData } of modelResults) { + const modelParts = modelKey.split(':') + const modelName = modelParts[4] || 'unknown' + if (!modelData || Object.keys(modelData).length === 0) { + continue + } + + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const eph5m = parseInt(modelData.ephemeral5mTokens) || 0 + const eph1h = parseInt(modelData.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, modelName) + summedCost += costResult.costs.total + } + + return summedCost + } + + const today = new Date() + + for (let offset = daysCount - 1; offset >= 0; offset--) { + const date = new Date(today) + date.setDate(date.getDate() - offset) + + const tzDate = redis.getDateInTimezone(date) + const dateKey = redis.getDateStringInTimezone(date) + const monthLabel = String(tzDate.getUTCMonth() + 1).padStart(2, '0') + const dayLabel = String(tzDate.getUTCDate()).padStart(2, '0') + const label = `${monthLabel}/${dayLabel}` + + const client = redis.getClientSafe() + const dailyKey = `account_usage:daily:${accountId}:${dateKey}` + const dailyData = await client.hgetall(dailyKey) + + const inputTokens = parseInt(dailyData?.inputTokens) || 0 + const outputTokens = parseInt(dailyData?.outputTokens) || 0 + const cacheCreateTokens = parseInt(dailyData?.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(dailyData?.cacheReadTokens) || 0 + const allTokens = + parseInt(dailyData?.allTokens) || + inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + const requests = parseInt(dailyData?.requests) || 0 + + let cost = await sumModelCostsForDay(dateKey) + + if (cost === 0 && allTokens > 0) { + const fallbackUsage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const fbEph5m = parseInt(dailyData?.ephemeral5mTokens) || 0 + const fbEph1h = parseInt(dailyData?.ephemeral1hTokens) || 0 + if (fbEph5m > 0 || fbEph1h > 0) { + fallbackUsage.cache_creation = { + ephemeral_5m_input_tokens: fbEph5m, + ephemeral_1h_input_tokens: fbEph1h + } + } + const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel) + cost = fallbackResult.costs.total + } + + const normalizedCost = Math.round(cost * 1_000_000) / 1_000_000 + + totalCost += normalizedCost + totalRequests += requests + totalTokens += allTokens + + if (!highestCostDay || normalizedCost > highestCostDay.cost) { + highestCostDay = { + date: dateKey, + label, + cost: normalizedCost, + formattedCost: CostCalculator.formatCost(normalizedCost) + } + } + + if (!highestRequestDay || requests > highestRequestDay.requests) { + highestRequestDay = { + date: dateKey, + label, + requests + } + } + + history.push({ + date: dateKey, + label, + cost: normalizedCost, + formattedCost: CostCalculator.formatCost(normalizedCost), + requests, + tokens: allTokens + }) + } + + // 计算实际使用天数(从账户创建到现在) + let actualDaysForAvg = daysCount + if (accountCreatedAt) { + const now = new Date() + const diffTime = Math.abs(now - accountCreatedAt) + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + // 使用实际使用天数,但不超过请求的天数范围 + actualDaysForAvg = Math.min(diffDays, daysCount) + // 至少为1天,避免除零 + actualDaysForAvg = Math.max(actualDaysForAvg, 1) + } + + // 使用实际天数计算日均值 + const avgDailyCost = actualDaysForAvg > 0 ? totalCost / actualDaysForAvg : 0 + const avgDailyRequests = actualDaysForAvg > 0 ? totalRequests / actualDaysForAvg : 0 + const avgDailyTokens = actualDaysForAvg > 0 ? totalTokens / actualDaysForAvg : 0 + + const todayData = history.length > 0 ? history[history.length - 1] : null + + return res.json({ + success: true, + data: { + history, + summary: { + days: daysCount, + actualDaysUsed: actualDaysForAvg, // 实际使用的天数(用于计算日均值) + accountCreatedAt: accountCreatedAt ? accountCreatedAt.toISOString() : null, + totalCost, + totalCostFormatted: CostCalculator.formatCost(totalCost), + totalRequests, + totalTokens, + avgDailyCost, + avgDailyCostFormatted: CostCalculator.formatCost(avgDailyCost), + avgDailyRequests, + avgDailyTokens, + today: todayData + ? { + date: todayData.date, + cost: todayData.cost, + costFormatted: todayData.formattedCost, + requests: todayData.requests, + tokens: todayData.tokens + } + : null, + highestCostDay, + highestRequestDay + }, + overview: accountUsageStats, + generatedAt: new Date().toISOString() + } + }) + } catch (error) { + logger.error('❌ Failed to get account usage history:', error) + return res.status(500).json({ + success: false, + error: 'Failed to get account usage history', + message: error.message + }) + } +}) + +// 📊 使用趋势和成本分析 + +// 获取使用趋势数据 +router.get('/usage-trend', authenticateAdmin, async (req, res) => { + try { + const { days = 7, granularity = 'day', startDate, endDate } = req.query + + const trendData = [] + + if (granularity === 'hour') { + // 小时粒度统计 + let startTime, endTime + + if (startDate && endDate) { + startTime = new Date(startDate) + endTime = new Date(endDate) + } else { + endTime = new Date() + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000) + } + + // 确保时间范围不超过24小时 + const timeDiff = endTime - startTime + if (timeDiff > 24 * 60 * 60 * 1000) { + return res.status(400).json({ + error: '小时粒度查询时间范围不能超过24小时' + }) + } + + // 收集所有小时的元数据和涉及的日期 + const hourInfos = [] + const dateSet = new Set() + const currentHour = new Date(startTime) + currentHour.setMinutes(0, 0, 0) + + while (currentHour <= endTime) { + const tzCurrentHour = redis.getDateInTimezone(currentHour) + const dateStr = redis.getDateStringInTimezone(currentHour) + const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0') + const hourKey = `${dateStr}:${hour}` + + dateSet.add(dateStr) + + const tzDateForLabel = redis.getDateInTimezone(currentHour) + const month = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0') + const day = String(tzDateForLabel.getUTCDate()).padStart(2, '0') + const hourStr = String(tzDateForLabel.getUTCHours()).padStart(2, '0') + + hourInfos.push({ + hourKey, + dateStr, + isoTime: currentHour.toISOString(), + label: `${month}/${day} ${hourStr}:00` + }) + + currentHour.setHours(currentHour.getHours() + 1) + } + + // 使用索引获取数据,按小时批量查询 + const modelDataMap = new Map() + const usageDataMap = new Map() + + // 并行获取所有小时的数据 + const fetchPromises = hourInfos.map(async (hourInfo) => { + const [modelResults, usageResults] = await Promise.all([ + getUsageDataByIndex( + `usage:model:hourly:index:${hourInfo.hourKey}`, + `usage:model:hourly:{id}:${hourInfo.hourKey}`, + `usage:model:hourly:*:${hourInfo.hourKey}` + ), + getUsageDataByIndex( + `usage:hourly:index:${hourInfo.hourKey}`, + `usage:hourly:{id}:${hourInfo.hourKey}`, + `usage:hourly:*:${hourInfo.hourKey}` + ) + ]) + return { modelResults, usageResults } + }) + + const allResults = await Promise.all(fetchPromises) + allResults.forEach(({ modelResults, usageResults }) => { + modelResults.forEach(({ key, data }) => modelDataMap.set(key, data)) + usageResults.forEach(({ key, data }) => usageDataMap.set(key, data)) + }) + + // 按 hourKey 分组 + const modelKeysByHour = new Map() + const usageKeysByHour = new Map() + for (const key of modelDataMap.keys()) { + const match = key.match(/usage:model:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) + if (match) { + const hourKey = match[1] + if (!modelKeysByHour.has(hourKey)) { + modelKeysByHour.set(hourKey, []) + } + modelKeysByHour.get(hourKey).push(key) + } + } + for (const key of usageDataMap.keys()) { + const match = key.match(/usage:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) + if (match) { + const hourKey = match[1] + if (!usageKeysByHour.has(hourKey)) { + usageKeysByHour.set(hourKey, []) + } + usageKeysByHour.get(hourKey).push(key) + } + } + + // 处理每个小时的数据 + for (const hourInfo of hourInfos) { + const modelKeys = modelKeysByHour.get(hourInfo.hourKey) || [] + const usageKeys = usageKeysByHour.get(hourInfo.hourKey) || [] + + let hourInputTokens = 0 + let hourOutputTokens = 0 + let hourRequests = 0 + let hourCacheCreateTokens = 0 + let hourCacheReadTokens = 0 + let hourCost = 0 + + // 处理模型级别数据 + for (const modelKey of modelKeys) { + const modelMatch = modelKey.match(/usage:model:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) + if (!modelMatch) { + continue + } + + const model = modelMatch[1] + const data = modelDataMap.get(modelKey) + if (!data || Object.keys(data).length === 0) { + continue + } + + const modelInputTokens = parseInt(data.inputTokens) || 0 + const modelOutputTokens = parseInt(data.outputTokens) || 0 + const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const modelRequests = parseInt(data.requests) || 0 + + hourInputTokens += modelInputTokens + hourOutputTokens += modelOutputTokens + hourCacheCreateTokens += modelCacheCreateTokens + hourCacheReadTokens += modelCacheReadTokens + hourRequests += modelRequests + + const modelUsage = { + input_tokens: modelInputTokens, + output_tokens: modelOutputTokens, + cache_creation_input_tokens: modelCacheCreateTokens, + cache_read_input_tokens: modelCacheReadTokens + } + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const mEph5m = parseInt(data.ephemeral5mTokens) || 0 + const mEph1h = parseInt(data.ephemeral1hTokens) || 0 + if (mEph5m > 0 || mEph1h > 0) { + modelUsage.cache_creation = { + ephemeral_5m_input_tokens: mEph5m, + ephemeral_1h_input_tokens: mEph1h + } + } + const modelCostResult = CostCalculator.calculateCost(modelUsage, model) + hourCost += modelCostResult.costs.total + } + + // 如果没有模型级别的数据,尝试API Key级别的数据 + if (modelKeys.length === 0) { + let hourEph5m = 0 + let hourEph1h = 0 + for (const key of usageKeys) { + const data = usageDataMap.get(key) + if (data) { + hourInputTokens += parseInt(data.inputTokens) || 0 + hourOutputTokens += parseInt(data.outputTokens) || 0 + hourRequests += parseInt(data.requests) || 0 + hourCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + hourCacheReadTokens += parseInt(data.cacheReadTokens) || 0 + hourEph5m += parseInt(data.ephemeral5mTokens) || 0 + hourEph1h += parseInt(data.ephemeral1hTokens) || 0 + } + } + + const usage = { + input_tokens: hourInputTokens, + output_tokens: hourOutputTokens, + cache_creation_input_tokens: hourCacheCreateTokens, + cache_read_input_tokens: hourCacheReadTokens + } + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (hourEph5m > 0 || hourEph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: hourEph5m, + ephemeral_1h_input_tokens: hourEph1h + } + } + const costResult = CostCalculator.calculateCost(usage, 'unknown') + hourCost = costResult.costs.total + } + + trendData.push({ + hour: hourInfo.isoTime, + label: hourInfo.label, + inputTokens: hourInputTokens, + outputTokens: hourOutputTokens, + requests: hourRequests, + cacheCreateTokens: hourCacheCreateTokens, + cacheReadTokens: hourCacheReadTokens, + totalTokens: + hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens, + cost: hourCost + }) + } + } else { + // 天粒度统计(按日期集合扫描) + const daysCount = parseInt(days) || 7 + const today = new Date() + + // 收集所有天的元数据 + const dayInfos = [] + for (let i = 0; i < daysCount; i++) { + const date = new Date(today) + date.setDate(date.getDate() - i) + const dateStr = redis.getDateStringInTimezone(date) + dayInfos.push({ dateStr }) + } + + // 使用索引获取数据,按日期批量查询 + const modelDataMap = new Map() + const usageDataMap = new Map() + + const fetchPromises = dayInfos.map(async (dayInfo) => { + const [modelResults, usageResults] = await Promise.all([ + getUsageDataByIndex( + `usage:model:daily:index:${dayInfo.dateStr}`, + `usage:model:daily:{id}:${dayInfo.dateStr}`, + `usage:model:daily:*:${dayInfo.dateStr}` + ), + getUsageDataByIndex( + `usage:daily:index:${dayInfo.dateStr}`, + `usage:daily:{id}:${dayInfo.dateStr}`, + `usage:daily:*:${dayInfo.dateStr}` + ) + ]) + return { modelResults, usageResults } + }) + + const allResults = await Promise.all(fetchPromises) + allResults.forEach(({ modelResults, usageResults }) => { + modelResults.forEach(({ key, data }) => modelDataMap.set(key, data)) + usageResults.forEach(({ key, data }) => usageDataMap.set(key, data)) + }) + + // 按 dateStr 分组 + const modelKeysByDate = new Map() + const usageKeysByDate = new Map() + for (const key of modelDataMap.keys()) { + const match = key.match(/usage:model:daily:.+?:(\d{4}-\d{2}-\d{2})/) + if (match) { + const dateStr = match[1] + if (!modelKeysByDate.has(dateStr)) { + modelKeysByDate.set(dateStr, []) + } + modelKeysByDate.get(dateStr).push(key) + } + } + for (const key of usageDataMap.keys()) { + const match = key.match(/usage:daily:.+?:(\d{4}-\d{2}-\d{2})/) + if (match) { + const dateStr = match[1] + if (!usageKeysByDate.has(dateStr)) { + usageKeysByDate.set(dateStr, []) + } + usageKeysByDate.get(dateStr).push(key) + } + } + + // 处理每天的数据 + for (const dayInfo of dayInfos) { + const modelKeys = modelKeysByDate.get(dayInfo.dateStr) || [] + const usageKeys = usageKeysByDate.get(dayInfo.dateStr) || [] + + let dayInputTokens = 0 + let dayOutputTokens = 0 + let dayRequests = 0 + let dayCacheCreateTokens = 0 + let dayCacheReadTokens = 0 + let dayCost = 0 + + // 处理模型级别数据 + for (const modelKey of modelKeys) { + const modelMatch = modelKey.match(/usage:model:daily:(.+?):\d{4}-\d{2}-\d{2}/) + if (!modelMatch) { + continue + } + + const model = modelMatch[1] + const data = modelDataMap.get(modelKey) + if (!data || Object.keys(data).length === 0) { + continue + } + + const modelInputTokens = parseInt(data.inputTokens) || 0 + const modelOutputTokens = parseInt(data.outputTokens) || 0 + const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const modelEphemeral5mTokens = parseInt(data.ephemeral5mTokens) || 0 + const modelEphemeral1hTokens = parseInt(data.ephemeral1hTokens) || 0 + const modelRequests = parseInt(data.requests) || 0 + + dayInputTokens += modelInputTokens + dayOutputTokens += modelOutputTokens + dayCacheCreateTokens += modelCacheCreateTokens + dayCacheReadTokens += modelCacheReadTokens + dayRequests += modelRequests + + const modelUsage = { + input_tokens: modelInputTokens, + output_tokens: modelOutputTokens, + cache_creation_input_tokens: modelCacheCreateTokens, + cache_read_input_tokens: modelCacheReadTokens + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (modelEphemeral5mTokens > 0 || modelEphemeral1hTokens > 0) { + modelUsage.cache_creation = { + ephemeral_5m_input_tokens: modelEphemeral5mTokens, + ephemeral_1h_input_tokens: modelEphemeral1hTokens + } + } + + const modelCostResult = CostCalculator.calculateCost(modelUsage, model) + dayCost += modelCostResult.costs.total + } + + // 如果没有模型级别的数据,回退到原始方法 + if (modelKeys.length === 0 && usageKeys.length > 0) { + let dayEph5m = 0 + let dayEph1h = 0 + for (const key of usageKeys) { + const data = usageDataMap.get(key) + if (data) { + dayInputTokens += parseInt(data.inputTokens) || 0 + dayOutputTokens += parseInt(data.outputTokens) || 0 + dayRequests += parseInt(data.requests) || 0 + dayCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + dayCacheReadTokens += parseInt(data.cacheReadTokens) || 0 + dayEph5m += parseInt(data.ephemeral5mTokens) || 0 + dayEph1h += parseInt(data.ephemeral1hTokens) || 0 + } + } + + const usage = { + input_tokens: dayInputTokens, + output_tokens: dayOutputTokens, + cache_creation_input_tokens: dayCacheCreateTokens, + cache_read_input_tokens: dayCacheReadTokens + } + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (dayEph5m > 0 || dayEph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: dayEph5m, + ephemeral_1h_input_tokens: dayEph1h + } + } + const costResult = CostCalculator.calculateCost(usage, 'unknown') + dayCost = costResult.costs.total + } + + trendData.push({ + date: dayInfo.dateStr, + inputTokens: dayInputTokens, + outputTokens: dayOutputTokens, + requests: dayRequests, + cacheCreateTokens: dayCacheCreateTokens, + cacheReadTokens: dayCacheReadTokens, + totalTokens: dayInputTokens + dayOutputTokens + dayCacheCreateTokens + dayCacheReadTokens, + cost: dayCost, + formattedCost: CostCalculator.formatCost(dayCost) + }) + } + } + + // 按日期正序排列 + if (granularity === 'hour') { + trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour)) + } else { + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)) + } + + return res.json({ success: true, data: trendData, granularity }) + } catch (error) { + logger.error('❌ Failed to get usage trend:', error) + return res.status(500).json({ error: 'Failed to get usage trend', message: error.message }) + } +}) + +// 获取单个API Key的模型统计 +router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const { period = 'monthly', startDate, endDate } = req.query + + logger.info( + `📊 Getting model stats for API key: ${keyId}, period: ${period}, startDate: ${startDate}, endDate: ${endDate}` + ) + + const _client = redis.getClientSafe() + const today = redis.getDateStringInTimezone() + const tzDate = redis.getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + + let searchPatterns = [] + + if (period === 'custom' && startDate && endDate) { + // 自定义日期范围,生成多个日期的搜索模式 + const start = new Date(startDate) + const end = new Date(endDate) + + // 确保日期范围有效 + if (start > end) { + return res.status(400).json({ error: 'Start date must be before or equal to end date' }) + } + + // 限制最大范围为365天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 365) { + return res.status(400).json({ error: 'Date range cannot exceed 365 days' }) + } + + // 生成日期范围内所有日期的搜索模式 + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const dateStr = redis.getDateStringInTimezone(d) + searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`) + } + + logger.info( + `📊 Custom date range patterns: ${searchPatterns.length} days from ${startDate} to ${endDate}` + ) + } else { + // 原有的预设期间逻辑 + const pattern = + period === 'daily' + ? `usage:${keyId}:model:daily:*:${today}` + : `usage:${keyId}:model:monthly:*:${currentMonth}` + searchPatterns = [pattern] + logger.info(`📊 Preset period pattern: ${pattern}`) + } + + // 汇总所有匹配的数据 + const modelStatsMap = new Map() + const modelStats = [] // 定义结果数组 + + if (period === 'custom' && startDate && endDate) { + // 自定义日期范围,使用索引 + const start = new Date(startDate) + const end = new Date(endDate) + const fetchPromises = [] + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const dateStr = redis.getDateStringInTimezone(d) + fetchPromises.push( + getUsageDataByIndex( + `usage:keymodel:daily:index:${dateStr}`, + `usage:{keyId}:model:daily:{model}:${dateStr}`, + `usage:*:model:daily:*:${dateStr}` + ) + ) + } + const allResults = await Promise.all(fetchPromises) + for (const results of allResults) { + for (const { key, data } of results) { + // 过滤出属于该 keyId 的记录 + if (!key.startsWith(`usage:${keyId}:model:`)) { + continue + } + const match = key.match(/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) + if (!match) { + continue + } + const model = match[1] + if (!modelStatsMap.has(model)) { + modelStatsMap.set(model, { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + ephemeral5mTokens: 0, + ephemeral1hTokens: 0, + allTokens: 0, + realCostMicro: 0, + ratedCostMicro: 0, + hasStoredCost: false + }) + } + const stats = modelStatsMap.get(model) + stats.requests += parseInt(data.requests) || 0 + stats.inputTokens += parseInt(data.inputTokens) || 0 + stats.outputTokens += parseInt(data.outputTokens) || 0 + stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + stats.ephemeral5mTokens += parseInt(data.ephemeral5mTokens) || 0 + stats.ephemeral1hTokens += parseInt(data.ephemeral1hTokens) || 0 + stats.allTokens += parseInt(data.allTokens) || 0 + if ('realCostMicro' in data || 'ratedCostMicro' in data) { + stats.realCostMicro += parseInt(data.realCostMicro) || 0 + stats.ratedCostMicro += parseInt(data.ratedCostMicro) || 0 + stats.hasStoredCost = true + } + } + } + } else { + // 预设期间,使用索引 + let results + if (period === 'daily') { + results = await getUsageDataByIndex( + `usage:keymodel:daily:index:${today}`, + `usage:{keyId}:model:daily:{model}:${today}`, + `usage:*:model:daily:*:${today}` + ) + } else { + // monthly - 需要月度 keymodel 索引,暂时回退到 SCAN + const pattern = `usage:${keyId}:model:monthly:*:${currentMonth}` + results = await redis.scanAndGetAllChunked(pattern) + } + for (const { key, data } of results) { + if (!key.startsWith(`usage:${keyId}:model:`)) { + continue + } + const match = + key.match(/usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) || + key.match(/usage:.+:model:monthly:(.+):\d{4}-\d{2}$/) + if (!match) { + continue + } + const model = match[1] + if (!modelStatsMap.has(model)) { + modelStatsMap.set(model, { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + ephemeral5mTokens: 0, + ephemeral1hTokens: 0, + allTokens: 0, + realCostMicro: 0, + ratedCostMicro: 0, + hasStoredCost: false + }) + } + const stats = modelStatsMap.get(model) + stats.requests += parseInt(data.requests) || 0 + stats.inputTokens += parseInt(data.inputTokens) || 0 + stats.outputTokens += parseInt(data.outputTokens) || 0 + stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + stats.ephemeral5mTokens += parseInt(data.ephemeral5mTokens) || 0 + stats.ephemeral1hTokens += parseInt(data.ephemeral1hTokens) || 0 + stats.allTokens += parseInt(data.allTokens) || 0 + if ('realCostMicro' in data || 'ratedCostMicro' in data) { + stats.realCostMicro += parseInt(data.realCostMicro) || 0 + stats.ratedCostMicro += parseInt(data.ratedCostMicro) || 0 + stats.hasStoredCost = true + } + } + } + + // 将汇总的数据转换为最终结果 + for (const [model, stats] of modelStatsMap) { + logger.info(`📊 Model ${model} aggregated data:`, stats) + + let costData + if (stats.hasStoredCost) { + // 使用请求时已计算并存储的费用(精确,包含 1M 上下文、Fast Mode 等特殊计费) + const ratedCost = stats.ratedCostMicro / 1000000 + const realCost = stats.realCostMicro / 1000000 + costData = { + costs: { total: ratedCost, real: realCost }, + formatted: { total: CostCalculator.formatCost(ratedCost) }, + pricing: null, + usingDynamicPricing: false, + usingStoredCost: true + } + } else { + // Legacy fallback:旧数据没有存储费用,从 token 重算 + const usage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + } + + if (stats.ephemeral5mTokens > 0 || stats.ephemeral1hTokens > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: stats.ephemeral5mTokens, + ephemeral_1h_input_tokens: stats.ephemeral1hTokens + } + } + + costData = CostCalculator.calculateCost(usage, model) + } + + modelStats.push({ + model, + requests: stats.requests, + inputTokens: stats.inputTokens, + outputTokens: stats.outputTokens, + cacheCreateTokens: stats.cacheCreateTokens, + cacheReadTokens: stats.cacheReadTokens, + allTokens: stats.allTokens, + // 添加费用信息 + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing, + usingDynamicPricing: costData.usingDynamicPricing + }) + } + + // 如果没有找到模型级别的详细数据,尝试从汇总数据中生成展示 + if (modelStats.length === 0) { + logger.info( + `📊 No detailed model stats found, trying to get aggregate data for API key ${keyId}` + ) + + // 尝试从API Keys列表中获取usage数据作为备选方案 + try { + const apiKeys = await apiKeyService.getAllApiKeysFast() + const targetApiKey = apiKeys.find((key) => key.id === keyId) + + if (targetApiKey && targetApiKey.usage) { + logger.info( + `📊 Found API key usage data from getAllApiKeys for ${keyId}:`, + targetApiKey.usage + ) + + // 从汇总数据创建展示条目 + let usageData + if (period === 'custom' || period === 'daily') { + // 对于自定义或日统计,使用daily数据或total数据 + usageData = targetApiKey.usage.daily || targetApiKey.usage.total + } else { + // 对于月统计,使用monthly数据或total数据 + usageData = targetApiKey.usage.monthly || targetApiKey.usage.total + } + + if (usageData && usageData.allTokens > 0) { + const usage = { + input_tokens: usageData.inputTokens || 0, + output_tokens: usageData.outputTokens || 0, + cache_creation_input_tokens: usageData.cacheCreateTokens || 0, + cache_read_input_tokens: usageData.cacheReadTokens || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const histEph5m = usageData.ephemeral5mTokens || 0 + const histEph1h = usageData.ephemeral1hTokens || 0 + if (histEph5m > 0 || histEph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: histEph5m, + ephemeral_1h_input_tokens: histEph1h + } + } + + // 对于汇总数据,使用默认模型计算费用 + const costData = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022') + + modelStats.push({ + model: '总体使用 (历史数据)', + requests: usageData.requests || 0, + inputTokens: usageData.inputTokens || 0, + outputTokens: usageData.outputTokens || 0, + cacheCreateTokens: usageData.cacheCreateTokens || 0, + cacheReadTokens: usageData.cacheReadTokens || 0, + allTokens: usageData.allTokens || 0, + // 添加费用信息 + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing, + usingDynamicPricing: costData.usingDynamicPricing + }) + + logger.info('📊 Generated display data from API key usage stats') + } else { + logger.info(`📊 No usage data found for period ${period} in API key data`) + } + } else { + logger.info(`📊 API key ${keyId} not found or has no usage data`) + } + } catch (error) { + logger.error('❌ Error fetching API key usage data:', error) + } + } + + // 按总token数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens) + + logger.info(`📊 Returning ${modelStats.length} model stats for API key ${keyId}:`, modelStats) + + return res.json({ success: true, data: modelStats }) + } catch (error) { + logger.error('❌ Failed to get API key model stats:', error) + return res + .status(500) + .json({ error: 'Failed to get API key model stats', message: error.message }) + } +}) + +// 获取按账号分组的使用趋势 +router.get('/account-usage-trend', authenticateAdmin, async (req, res) => { + try { + const { granularity = 'day', group = 'claude', days = 7, startDate, endDate } = req.query + + const allowedGroups = ['claude', 'openai', 'gemini', 'droid', 'bedrock'] + if (!allowedGroups.includes(group)) { + return res.status(400).json({ + success: false, + error: 'Invalid account group' + }) + } + + const groupLabels = { + claude: 'Claude账户', + openai: 'OpenAI账户', + gemini: 'Gemini账户', + droid: 'Droid账户', + bedrock: 'Bedrock账户' + } + + // 拉取各平台账号列表 + let accounts = [] + if (group === 'claude') { + const [claudeAccounts, claudeConsoleAccounts] = await Promise.all([ + claudeAccountService.getAllAccounts(), + claudeConsoleAccountService.getAllAccounts() + ]) + + accounts = [ + ...claudeAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || account.email || `Claude账号 ${shortId}`, + platform: 'claude' + } + }), + ...claudeConsoleAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || `Console账号 ${shortId}`, + platform: 'claude-console' + } + }) + ] + } else if (group === 'openai') { + const [openaiAccounts, openaiResponsesAccounts] = await Promise.all([ + openaiAccountService.getAllAccounts(), + openaiResponsesAccountService.getAllAccounts(true) + ]) + + accounts = [ + ...openaiAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || account.email || `OpenAI账号 ${shortId}`, + platform: 'openai' + } + }), + ...openaiResponsesAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || `Responses账号 ${shortId}`, + platform: 'openai-responses' + } + }) + ] + } else if (group === 'gemini') { + const [geminiAccounts, geminiApiAccounts] = await Promise.all([ + geminiAccountService.getAllAccounts(), + geminiApiAccountService.getAllAccounts(true) + ]) + + accounts = [ + ...geminiAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || account.email || `Gemini账号 ${shortId}`, + platform: 'gemini' + } + }), + ...geminiApiAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || `Gemini-API账号 ${shortId}`, + platform: 'gemini-api' + } + }) + ] + } else if (group === 'droid') { + const droidAccounts = await droidAccountService.getAllAccounts() + accounts = droidAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || account.ownerEmail || account.ownerName || `Droid账号 ${shortId}`, + platform: 'droid' + } + }) + } else if (group === 'bedrock') { + const result = await bedrockAccountService.getAllAccounts() + const bedrockAccounts = result?.success ? result.data : [] + accounts = bedrockAccounts.map((account) => { + const id = String(account.id || '') + const shortId = id ? id.slice(0, 8) : '未知' + return { + id, + name: account.name || `Bedrock账号 ${shortId}`, + platform: 'bedrock' + } + }) + } + + if (!accounts || accounts.length === 0) { + return res.json({ + success: true, + data: [], + granularity, + group, + groupLabel: groupLabels[group], + topAccounts: [], + totalAccounts: 0 + }) + } + + const accountMap = new Map() + const accountIdSet = new Set() + for (const account of accounts) { + accountMap.set(account.id, { + name: account.name, + platform: account.platform + }) + accountIdSet.add(account.id) + } + + const fallbackModelByGroup = { + claude: 'claude-3-5-sonnet-20241022', + openai: 'gpt-4o-mini-2024-07-18', + gemini: 'gemini-1.5-flash' + } + const fallbackModel = fallbackModelByGroup[group] || 'unknown' + + const trendData = [] + const accountCostTotals = new Map() + + if (granularity === 'hour') { + let startTime + let endTime + + if (startDate && endDate) { + startTime = new Date(startDate) + endTime = new Date(endDate) + } else { + endTime = new Date() + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000) + } + + // 收集所有小时的元数据和涉及的日期 + const hourInfos = [] + const dateSet = new Set() + const currentHour = new Date(startTime) + currentHour.setMinutes(0, 0, 0) + + while (currentHour <= endTime) { + const tzCurrentHour = redis.getDateInTimezone(currentHour) + const dateStr = redis.getDateStringInTimezone(currentHour) + const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0') + const hourKey = `${dateStr}:${hour}` + + dateSet.add(dateStr) + + const tzDateForLabel = redis.getDateInTimezone(currentHour) + const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0') + const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0') + const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0') + + hourInfos.push({ + hourKey, + dateStr, + isoTime: currentHour.toISOString(), + label: `${monthLabel}/${dayLabel} ${hourLabel}:00` + }) + + currentHour.setHours(currentHour.getHours() + 1) + } + + // 按小时获取 account_usage 数据(避免全库扫描) + const _dates = [...dateSet] + const usageDataMap = new Map() + const modelDataMap = new Map() + + // 并行获取每个小时的数据 + const fetchPromises = hourInfos.map(async (hourInfo) => { + const [usageResults, modelResults] = await Promise.all([ + getUsageDataByIndex( + `account_usage:hourly:index:${hourInfo.hourKey}`, + `account_usage:hourly:{id}:${hourInfo.hourKey}`, + `account_usage:hourly:*:${hourInfo.hourKey}` + ), + getUsageDataByIndex( + `account_usage:model:hourly:index:${hourInfo.hourKey}`, + `account_usage:model:hourly:{accountId}:{model}:${hourInfo.hourKey}`, + `account_usage:model:hourly:*:${hourInfo.hourKey}` + ) + ]) + return { usageResults, modelResults } + }) + + const allResults = await Promise.all(fetchPromises) + allResults.forEach(({ usageResults, modelResults }) => { + usageResults.forEach(({ key, data }) => usageDataMap.set(key, data)) + modelResults.forEach(({ key, data }) => modelDataMap.set(key, data)) + }) + + // 按 hourKey 分组 + const usageKeysByHour = new Map() + const modelKeysByHour = new Map() + for (const key of usageDataMap.keys()) { + const match = key.match(/account_usage:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) + if (match) { + const hourKey = match[1] + if (!usageKeysByHour.has(hourKey)) { + usageKeysByHour.set(hourKey, []) + } + usageKeysByHour.get(hourKey).push(key) + } + } + for (const key of modelDataMap.keys()) { + const match = key.match(/account_usage:model:hourly:(.+?):.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) + if (match) { + const accountId = match[1] + const hourKey = match[2] + const mapKey = `${accountId}:${hourKey}` + if (!modelKeysByHour.has(mapKey)) { + modelKeysByHour.set(mapKey, []) + } + modelKeysByHour.get(mapKey).push(key) + } + } + + // 处理每个小时的数据 + for (const hourInfo of hourInfos) { + const usageKeys = usageKeysByHour.get(hourInfo.hourKey) || [] + + const hourData = { + hour: hourInfo.isoTime, + label: hourInfo.label, + accounts: {} + } + + for (const key of usageKeys) { + const match = key.match(/account_usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) + if (!match) { + continue + } + + const accountId = match[1] + if (!accountIdSet.has(accountId)) { + continue + } + + const data = usageDataMap.get(key) + if (!data) { + continue + } + + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const allTokens = + parseInt(data.allTokens) || + inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + const requests = parseInt(data.requests) || 0 + + // 计算模型费用(从预加载的数据中) + let cost = 0 + const modelKeys = modelKeysByHour.get(`${accountId}:${hourInfo.hourKey}`) || [] + for (const modelKey of modelKeys) { + const modelData = modelDataMap.get(modelKey) + if (!modelData) { + continue + } + + const parts = modelKey.split(':') + if (parts.length < 5) { + continue + } + + const modelName = parts[4] + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const eph5m = parseInt(modelData.ephemeral5mTokens) || 0 + const eph1h = parseInt(modelData.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, modelName) + cost += costResult.costs.total + } + + if (cost === 0 && allTokens > 0) { + const fallbackUsage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const fbEph5m = parseInt(data.ephemeral5mTokens) || 0 + const fbEph1h = parseInt(data.ephemeral1hTokens) || 0 + if (fbEph5m > 0 || fbEph1h > 0) { + fallbackUsage.cache_creation = { + ephemeral_5m_input_tokens: fbEph5m, + ephemeral_1h_input_tokens: fbEph1h + } + } + const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel) + cost = fallbackResult.costs.total + } + + const formattedCost = CostCalculator.formatCost(cost) + const accountInfo = accountMap.get(accountId) + + hourData.accounts[accountId] = { + name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`, + cost, + formattedCost, + requests + } + + accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost) + } + + trendData.push(hourData) + } + } else { + const daysCount = parseInt(days) || 7 + const today = new Date() + + // 收集所有天的元数据 + const dayInfos = [] + for (let i = 0; i < daysCount; i++) { + const date = new Date(today) + date.setDate(date.getDate() - i) + const dateStr = redis.getDateStringInTimezone(date) + dayInfos.push({ dateStr }) + } + + // 使用索引获取数据 + const usagePromises = dayInfos.map((d) => + getUsageDataByIndex( + `account_usage:daily:index:${d.dateStr}`, + `account_usage:daily:{id}:${d.dateStr}`, + `account_usage:daily:*:${d.dateStr}` + ) + ) + const modelPromises = dayInfos.map((d) => + getUsageDataByIndex( + `account_usage:model:daily:index:${d.dateStr}`, + `account_usage:model:daily:{accountId}:{model}:${d.dateStr}`, + `account_usage:model:daily:*:${d.dateStr}` + ) + ) + const [usageResultsArr, modelResultsArr] = await Promise.all([ + Promise.all(usagePromises), + Promise.all(modelPromises) + ]) + + const usageDataMap = new Map() + const modelDataMap = new Map() + for (const results of usageResultsArr) { + for (const { key, data } of results) { + usageDataMap.set(key, data) + } + } + for (const results of modelResultsArr) { + for (const { key, data } of results) { + modelDataMap.set(key, data) + } + } + + // 按 dateStr 分组 + const usageKeysByDate = new Map() + const modelKeysByDate = new Map() + for (const key of usageDataMap.keys()) { + const match = key.match(/account_usage:daily:.+?:(\d{4}-\d{2}-\d{2})/) + if (match) { + const dateStr = match[1] + if (!usageKeysByDate.has(dateStr)) { + usageKeysByDate.set(dateStr, []) + } + usageKeysByDate.get(dateStr).push(key) + } + } + for (const key of modelDataMap.keys()) { + const match = key.match(/account_usage:model:daily:(.+?):.+?:(\d{4}-\d{2}-\d{2})/) + if (match) { + const accountId = match[1] + const dateStr = match[2] + const mapKey = `${accountId}:${dateStr}` + if (!modelKeysByDate.has(mapKey)) { + modelKeysByDate.set(mapKey, []) + } + modelKeysByDate.get(mapKey).push(key) + } + } + + // 处理每天的数据 + for (const dayInfo of dayInfos) { + const usageKeys = usageKeysByDate.get(dayInfo.dateStr) || [] + + const dayData = { + date: dayInfo.dateStr, + accounts: {} + } + + for (const key of usageKeys) { + const match = key.match(/account_usage:daily:(.+?):\d{4}-\d{2}-\d{2}/) + if (!match) { + continue + } + + const accountId = match[1] + if (!accountIdSet.has(accountId)) { + continue + } + + const data = usageDataMap.get(key) + if (!data) { + continue + } + + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + const allTokens = + parseInt(data.allTokens) || + inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + const requests = parseInt(data.requests) || 0 + + // 计算模型费用(从预加载的数据中) + let cost = 0 + const modelKeys = modelKeysByDate.get(`${accountId}:${dayInfo.dateStr}`) || [] + for (const modelKey of modelKeys) { + const modelData = modelDataMap.get(modelKey) + if (!modelData) { + continue + } + + const parts = modelKey.split(':') + if (parts.length < 5) { + continue + } + + const modelName = parts[4] + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const eph5m = parseInt(modelData.ephemeral5mTokens) || 0 + const eph1h = parseInt(modelData.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, modelName) + cost += costResult.costs.total + } + + if (cost === 0 && allTokens > 0) { + const fallbackUsage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const fbEph5m = parseInt(data.ephemeral5mTokens) || 0 + const fbEph1h = parseInt(data.ephemeral1hTokens) || 0 + if (fbEph5m > 0 || fbEph1h > 0) { + fallbackUsage.cache_creation = { + ephemeral_5m_input_tokens: fbEph5m, + ephemeral_1h_input_tokens: fbEph1h + } + } + const fallbackResult = CostCalculator.calculateCost(fallbackUsage, fallbackModel) + cost = fallbackResult.costs.total + } + + const formattedCost = CostCalculator.formatCost(cost) + const accountInfo = accountMap.get(accountId) + + dayData.accounts[accountId] = { + name: accountInfo ? accountInfo.name : `账号 ${accountId.slice(0, 8)}`, + cost, + formattedCost, + requests + } + + accountCostTotals.set(accountId, (accountCostTotals.get(accountId) || 0) + cost) + } + + trendData.push(dayData) + } + } + + if (granularity === 'hour') { + trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour)) + } else { + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)) + } + + const topAccounts = Array.from(accountCostTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([accountId]) => accountId) + + return res.json({ + success: true, + data: trendData, + granularity, + group, + groupLabel: groupLabels[group], + topAccounts, + totalAccounts: accountCostTotals.size + }) + } catch (error) { + logger.error('❌ Failed to get account usage trend:', error) + return res + .status(500) + .json({ error: 'Failed to get account usage trend', message: error.message }) + } +}) + +// 获取按API Key分组的使用趋势 +router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => { + try { + const { granularity = 'day', days = 7, startDate, endDate } = req.query + + logger.info(`📊 Getting API keys usage trend, granularity: ${granularity}, days: ${days}`) + + const trendData = [] + + // 获取所有API Keys(只需要 id 和 name,过滤已删除的) + const apiKeyIds = await redis.scanApiKeyIds() + const apiKeyBasicData = await redis.batchGetApiKeys(apiKeyIds) + const apiKeyMap = new Map( + apiKeyBasicData.filter((key) => !key.isDeleted).map((key) => [key.id, key]) + ) + + if (granularity === 'hour') { + // 小时粒度统计 + let endTime, startTime + + if (startDate && endDate) { + startTime = new Date(startDate) + endTime = new Date(endDate) + } else { + endTime = new Date() + startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000) + } + + // 收集所有小时的元数据和涉及的日期 + const hourInfos = [] + const dateSet = new Set() + const currentHour = new Date(startTime) + currentHour.setMinutes(0, 0, 0) + + while (currentHour <= endTime) { + const tzCurrentHour = redis.getDateInTimezone(currentHour) + const dateStr = redis.getDateStringInTimezone(currentHour) + const hour = String(tzCurrentHour.getUTCHours()).padStart(2, '0') + const hourKey = `${dateStr}:${hour}` + + dateSet.add(dateStr) + + const tzDateForLabel = redis.getDateInTimezone(currentHour) + const monthLabel = String(tzDateForLabel.getUTCMonth() + 1).padStart(2, '0') + const dayLabel = String(tzDateForLabel.getUTCDate()).padStart(2, '0') + const hourLabel = String(tzDateForLabel.getUTCHours()).padStart(2, '0') + + hourInfos.push({ + hourKey, + dateStr, + isoTime: currentHour.toISOString(), + label: `${monthLabel}/${dayLabel} ${hourLabel}:00` + }) + + currentHour.setHours(currentHour.getHours() + 1) + } + + // 使用索引获取数据,按小时批量查询 + const _dates = [...dateSet] + const usageDataMap = new Map() + const modelDataMap = new Map() + + const fetchPromises = hourInfos.map(async (hourInfo) => { + const [usageResults, modelResults] = await Promise.all([ + getUsageDataByIndex( + `usage:hourly:index:${hourInfo.hourKey}`, + `usage:hourly:{id}:${hourInfo.hourKey}`, + `usage:hourly:*:${hourInfo.hourKey}` + ), + getUsageDataByIndex( + `usage:keymodel:hourly:index:${hourInfo.hourKey}`, + `usage:{keyId}:model:hourly:{model}:${hourInfo.hourKey}`, + `usage:*:model:hourly:*:${hourInfo.hourKey}` + ) + ]) + return { usageResults, modelResults } + }) + + const allResults = await Promise.all(fetchPromises) + allResults.forEach(({ usageResults, modelResults }) => { + usageResults.forEach(({ key, data }) => usageDataMap.set(key, data)) + modelResults.forEach(({ key, data }) => modelDataMap.set(key, data)) + }) + + // 按 hourKey 分组 keys + const usageKeysByHour = new Map() + const modelKeysByHour = new Map() + for (const key of usageDataMap.keys()) { + const match = key.match(/usage:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) + if (match) { + const hourKey = match[1] + if (!usageKeysByHour.has(hourKey)) { + usageKeysByHour.set(hourKey, []) + } + usageKeysByHour.get(hourKey).push(key) + } + } + for (const key of modelDataMap.keys()) { + const match = key.match(/usage:.+?:model:hourly:.+?:(\d{4}-\d{2}-\d{2}:\d{2})/) + if (match) { + const hourKey = match[1] + if (!modelKeysByHour.has(hourKey)) { + modelKeysByHour.set(hourKey, []) + } + modelKeysByHour.get(hourKey).push(key) + } + } + + // 处理每个小时的数据 + for (const hourInfo of hourInfos) { + const hourUsageKeys = usageKeysByHour.get(hourInfo.hourKey) || [] + const hourModelKeys = modelKeysByHour.get(hourInfo.hourKey) || [] + + const hourData = { + hour: hourInfo.isoTime, + label: hourInfo.label, + apiKeys: {} + } + + // 处理 usage 数据 + const apiKeyDataMap = new Map() + for (const key of hourUsageKeys) { + const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) + if (!match) { + continue + } + + const apiKeyId = match[1] + const data = usageDataMap.get(key) + if (!data || !apiKeyMap.has(apiKeyId)) { + continue + } + + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + + apiKeyDataMap.set(apiKeyId, { + name: apiKeyMap.get(apiKeyId).name, + tokens: inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens, + requests: parseInt(data.requests) || 0, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + ephemeral5mTokens: parseInt(data.ephemeral5mTokens) || 0, + ephemeral1hTokens: parseInt(data.ephemeral1hTokens) || 0 + }) + } + + // 处理 model 数据计算费用 + const apiKeyCostMap = new Map() + for (const modelKey of hourModelKeys) { + const match = modelKey.match(/usage:(.+?):model:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/) + if (!match) { + continue + } + + const apiKeyId = match[1] + const model = match[2] + const modelData = modelDataMap.get(modelKey) + if (!modelData || !apiKeyDataMap.has(apiKeyId)) { + continue + } + + // 优先使用已存储的费用 + const hasStoredCost = 'realCostMicro' in modelData || 'ratedCostMicro' in modelData + let modelCost = 0 + + if (hasStoredCost) { + modelCost = (parseInt(modelData.ratedCostMicro) || 0) / 1000000 + } else { + // Legacy fallback:旧数据没有存储费用,从 token 重算 + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const eph5m = parseInt(modelData.ephemeral5mTokens) || 0 + const eph1h = parseInt(modelData.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, model) + modelCost = costResult.costs.total + } + + const currentCost = apiKeyCostMap.get(apiKeyId) || 0 + apiKeyCostMap.set(apiKeyId, currentCost + modelCost) + } + + // 组合数据 + for (const [apiKeyId, data] of apiKeyDataMap) { + let cost = apiKeyCostMap.get(apiKeyId) || 0 + let formattedCost = CostCalculator.formatCost(cost) + + // 降级方案 + if (cost === 0 && data.tokens > 0) { + const usage = { + input_tokens: data.inputTokens, + output_tokens: data.outputTokens, + cache_creation_input_tokens: data.cacheCreateTokens, + cache_read_input_tokens: data.cacheReadTokens + } + if (data.ephemeral5mTokens > 0 || data.ephemeral1hTokens > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: data.ephemeral5mTokens, + ephemeral_1h_input_tokens: data.ephemeral1hTokens + } + } + const fallbackResult = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022') + cost = fallbackResult.costs.total + formattedCost = fallbackResult.formatted.total + } + + hourData.apiKeys[apiKeyId] = { + name: data.name, + tokens: data.tokens, + requests: data.requests, + cost, + formattedCost + } + } + + trendData.push(hourData) + } + } else { + // 天粒度统计(按日期集合扫描) + const daysCount = parseInt(days) || 7 + const today = new Date() + + // 收集所有天的元数据 + const dayInfos = [] + for (let i = 0; i < daysCount; i++) { + const date = new Date(today) + date.setDate(date.getDate() - i) + const dateStr = redis.getDateStringInTimezone(date) + dayInfos.push({ dateStr }) + } + + // 使用索引获取数据,按日期批量查询 + const usageDataMap = new Map() + const modelDataMap = new Map() + + const fetchPromises = dayInfos.map(async (dayInfo) => { + const [usageResults, modelResults] = await Promise.all([ + getUsageDataByIndex( + `usage:daily:index:${dayInfo.dateStr}`, + `usage:daily:{id}:${dayInfo.dateStr}`, + `usage:daily:*:${dayInfo.dateStr}` + ), + getUsageDataByIndex( + `usage:keymodel:daily:index:${dayInfo.dateStr}`, + `usage:{keyId}:model:daily:{model}:${dayInfo.dateStr}`, + `usage:*:model:daily:*:${dayInfo.dateStr}` + ) + ]) + return { usageResults, modelResults } + }) + + const allResults = await Promise.all(fetchPromises) + allResults.forEach(({ usageResults, modelResults }) => { + usageResults.forEach(({ key, data }) => usageDataMap.set(key, data)) + modelResults.forEach(({ key, data }) => modelDataMap.set(key, data)) + }) + + // 按 dateStr 分组 keys + const usageKeysByDate = new Map() + const modelKeysByDate = new Map() + for (const key of usageDataMap.keys()) { + const match = key.match(/usage:daily:.+?:(\d{4}-\d{2}-\d{2})/) + if (match) { + const dateStr = match[1] + if (!usageKeysByDate.has(dateStr)) { + usageKeysByDate.set(dateStr, []) + } + usageKeysByDate.get(dateStr).push(key) + } + } + for (const key of modelDataMap.keys()) { + const match = key.match(/usage:.+?:model:daily:.+?:(\d{4}-\d{2}-\d{2})/) + if (match) { + const dateStr = match[1] + if (!modelKeysByDate.has(dateStr)) { + modelKeysByDate.set(dateStr, []) + } + modelKeysByDate.get(dateStr).push(key) + } + } + + // 处理每天的数据 + for (const dayInfo of dayInfos) { + const dayUsageKeys = usageKeysByDate.get(dayInfo.dateStr) || [] + const dayModelKeys = modelKeysByDate.get(dayInfo.dateStr) || [] + + const dayData = { + date: dayInfo.dateStr, + apiKeys: {} + } + + // 处理 usage 数据 + const apiKeyDataMap = new Map() + for (const key of dayUsageKeys) { + const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/) + if (!match) { + continue + } + + const apiKeyId = match[1] + const data = usageDataMap.get(key) + if (!data || !apiKeyMap.has(apiKeyId)) { + continue + } + + const inputTokens = parseInt(data.inputTokens) || 0 + const outputTokens = parseInt(data.outputTokens) || 0 + const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0 + const cacheReadTokens = parseInt(data.cacheReadTokens) || 0 + + apiKeyDataMap.set(apiKeyId, { + name: apiKeyMap.get(apiKeyId).name, + tokens: inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens, + requests: parseInt(data.requests) || 0, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + ephemeral5mTokens: parseInt(data.ephemeral5mTokens) || 0, + ephemeral1hTokens: parseInt(data.ephemeral1hTokens) || 0 + }) + } + + // 处理 model 数据计算费用 + const apiKeyCostMap = new Map() + for (const modelKey of dayModelKeys) { + const match = modelKey.match(/usage:(.+?):model:daily:(.+?):\d{4}-\d{2}-\d{2}/) + if (!match) { + continue + } + + const apiKeyId = match[1] + const model = match[2] + const modelData = modelDataMap.get(modelKey) + if (!modelData || !apiKeyDataMap.has(apiKeyId)) { + continue + } + + // 优先使用已存储的费用 + const hasStoredCost = 'realCostMicro' in modelData || 'ratedCostMicro' in modelData + let modelCost = 0 + + if (hasStoredCost) { + modelCost = (parseInt(modelData.ratedCostMicro) || 0) / 1000000 + } else { + // Legacy fallback:旧数据没有存储费用,从 token 重算 + const usage = { + input_tokens: parseInt(modelData.inputTokens) || 0, + output_tokens: parseInt(modelData.outputTokens) || 0, + cache_creation_input_tokens: parseInt(modelData.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(modelData.cacheReadTokens) || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const eph5m = parseInt(modelData.ephemeral5mTokens) || 0 + const eph1h = parseInt(modelData.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, model) + modelCost = costResult.costs.total + } + + const currentCost = apiKeyCostMap.get(apiKeyId) || 0 + apiKeyCostMap.set(apiKeyId, currentCost + modelCost) + } + + // 组合数据 + for (const [apiKeyId, data] of apiKeyDataMap) { + let cost = apiKeyCostMap.get(apiKeyId) || 0 + let formattedCost = CostCalculator.formatCost(cost) + + // 降级方案 + if (cost === 0 && data.tokens > 0) { + const usage = { + input_tokens: data.inputTokens, + output_tokens: data.outputTokens, + cache_creation_input_tokens: data.cacheCreateTokens, + cache_read_input_tokens: data.cacheReadTokens + } + if (data.ephemeral5mTokens > 0 || data.ephemeral1hTokens > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: data.ephemeral5mTokens, + ephemeral_1h_input_tokens: data.ephemeral1hTokens + } + } + const fallbackResult = CostCalculator.calculateCost(usage, 'claude-3-5-sonnet-20241022') + cost = fallbackResult.costs.total + formattedCost = fallbackResult.formatted.total + } + + dayData.apiKeys[apiKeyId] = { + name: data.name, + tokens: data.tokens, + requests: data.requests, + cost, + formattedCost + } + } + + trendData.push(dayData) + } + } + + // 按时间正序排列 + if (granularity === 'hour') { + trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour)) + } else { + trendData.sort((a, b) => new Date(a.date) - new Date(b.date)) + } + + // 计算每个API Key的总token数,用于排序 + const apiKeyTotals = new Map() + for (const point of trendData) { + for (const [apiKeyId, data] of Object.entries(point.apiKeys)) { + apiKeyTotals.set(apiKeyId, (apiKeyTotals.get(apiKeyId) || 0) + data.tokens) + } + } + + // 获取前10个使用量最多的API Key + const topApiKeys = Array.from(apiKeyTotals.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([apiKeyId]) => apiKeyId) + + return res.json({ + success: true, + data: trendData, + granularity, + topApiKeys, + totalApiKeys: apiKeyTotals.size + }) + } catch (error) { + logger.error('❌ Failed to get API keys usage trend:', error) + return res + .status(500) + .json({ error: 'Failed to get API keys usage trend', message: error.message }) + } +}) + +// 计算总体使用费用 +router.get('/usage-costs', authenticateAdmin, async (req, res) => { + try { + const { period = 'all' } = req.query // all, today, monthly, 7days + + logger.info(`💰 Calculating usage costs for period: ${period}`) + + // 模型名标准化函数(与redis.js保持一致) + const normalizeModelName = (model) => { + if (!model || model === 'unknown') { + return model + } + + // 对于Bedrock模型,去掉区域前缀进行统一 + if (model.includes('.anthropic.') || model.includes('.claude')) { + // 匹配所有AWS区域格式:region.anthropic.model-name-v1:0 -> claude-model-name + // 支持所有AWS区域格式,如:us-east-1, eu-west-1, ap-southeast-1, ca-central-1等 + let normalized = model.replace(/^[a-z0-9-]+\./, '') // 去掉任何区域前缀(更通用) + normalized = normalized.replace('anthropic.', '') // 去掉anthropic前缀 + normalized = normalized.replace(/-v\d+:\d+$/, '') // 去掉版本后缀(如-v1:0, -v2:1等) + return normalized + } + + // 对于其他模型,去掉常见的版本后缀 + return model.replace(/-v\d+:\d+$|:latest$/, '') + } + + const totalCosts = { + inputCost: 0, + outputCost: 0, + cacheCreateCost: 0, + cacheReadCost: 0, + totalCost: 0 + } + + const modelCosts = {} + + // 按模型统计费用 + const _client = redis.getClientSafe() + const today = redis.getDateStringInTimezone() + const tzDate = redis.getDateInTimezone() + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + + let _pattern + if (period === 'today') { + _pattern = `usage:model:daily:*:${today}` + } else if (period === 'monthly') { + _pattern = `usage:model:monthly:*:${currentMonth}` + } else if (period === '7days') { + // 最近7天:汇总daily数据(使用 SCAN + Pipeline 优化) + const modelUsageMap = new Map() + + // 收集最近7天的所有日期 + const dateStrs = [] + for (let i = 0; i < 7; i++) { + const date = new Date() + date.setDate(date.getDate() - i) + const currentTzDate = redis.getDateInTimezone(date) + const dateStr = `${currentTzDate.getUTCFullYear()}-${String( + currentTzDate.getUTCMonth() + 1 + ).padStart(2, '0')}-${String(currentTzDate.getUTCDate()).padStart(2, '0')}` + dateStrs.push(dateStr) + } + + // 使用索引获取数据 + const fetchPromises = dateStrs.map((dateStr) => + getUsageDataByIndex( + `usage:model:daily:index:${dateStr}`, + `usage:model:daily:{id}:${dateStr}`, + `usage:model:daily:*:${dateStr}` + ) + ) + const allResults = await Promise.all(fetchPromises) + const allData = allResults.flat() + + // 处理数据 + for (const { key, data } of allData) { + if (!data) { + continue + } + + const modelMatch = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) + if (!modelMatch) { + continue + } + + const rawModel = modelMatch[1] + const normalizedModel = normalizeModelName(rawModel) + + if (!modelUsageMap.has(normalizedModel)) { + modelUsageMap.set(normalizedModel, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + ephemeral5mTokens: 0, + ephemeral1hTokens: 0 + }) + } + + const modelUsage = modelUsageMap.get(normalizedModel) + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + modelUsage.ephemeral5mTokens += parseInt(data.ephemeral5mTokens) || 0 + modelUsage.ephemeral1hTokens += parseInt(data.ephemeral1hTokens) || 0 + } + + // 计算7天统计的费用 + logger.info(`💰 Processing ${modelUsageMap.size} unique models for 7days cost calculation`) + + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (usage.ephemeral5mTokens > 0 || usage.ephemeral1hTokens > 0) { + usageData.cache_creation = { + ephemeral_5m_input_tokens: usage.ephemeral5mTokens, + ephemeral_1h_input_tokens: usage.ephemeral1hTokens + } + } + + const costResult = CostCalculator.calculateCost(usageData, model) + totalCosts.inputCost += costResult.costs.input + totalCosts.outputCost += costResult.costs.output + totalCosts.cacheCreateCost += costResult.costs.cacheWrite + totalCosts.cacheReadCost += costResult.costs.cacheRead + totalCosts.totalCost += costResult.costs.total + + logger.info( + `💰 Model ${model} (7days): ${ + usage.inputTokens + usage.outputTokens + usage.cacheCreateTokens + usage.cacheReadTokens + } tokens, cost: ${costResult.formatted.total}` + ) + + // 记录模型费用 + modelCosts[model] = { + model, + requests: 0, // 7天汇总数据没有请求数统计 + usage: usageData, + costs: costResult.costs, + formatted: costResult.formatted, + usingDynamicPricing: costResult.usingDynamicPricing + } + } + + // 返回7天统计结果 + return res.json({ + success: true, + data: { + period, + totalCosts: { + ...totalCosts, + formatted: { + inputCost: CostCalculator.formatCost(totalCosts.inputCost), + outputCost: CostCalculator.formatCost(totalCosts.outputCost), + cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost), + cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost), + totalCost: CostCalculator.formatCost(totalCosts.totalCost) + } + }, + modelCosts: Object.values(modelCosts) + } + }) + } else { + // 全部时间,使用月份索引 + const months = await redis.client.smembers('usage:model:monthly:months') + const allData = [] + if (months && months.length > 0) { + const fetchPromises = months.map((month) => + getUsageDataByIndex( + `usage:model:monthly:index:${month}`, + `usage:model:monthly:{id}:${month}`, + `usage:model:monthly:*:${month}` + ) + ) + const results = await Promise.all(fetchPromises) + results.forEach((r) => allData.push(...r)) + } + logger.info(`💰 Total period calculation: found ${allData.length} monthly model keys`) + + if (allData.length > 0) { + const modelUsageMap = new Map() + + for (const { key, data } of allData) { + if (!data) { + continue + } + + const modelMatch = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/) + if (!modelMatch) { + continue + } + + const model = modelMatch[1] + + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + ephemeral5mTokens: 0, + ephemeral1hTokens: 0 + }) + } + + const modelUsage = modelUsageMap.get(model) + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + modelUsage.ephemeral5mTokens += parseInt(data.ephemeral5mTokens) || 0 + modelUsage.ephemeral1hTokens += parseInt(data.ephemeral1hTokens) || 0 + } + + // 使用模型级别的数据计算费用 + logger.info(`💰 Processing ${modelUsageMap.size} unique models for total cost calculation`) + + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (usage.ephemeral5mTokens > 0 || usage.ephemeral1hTokens > 0) { + usageData.cache_creation = { + ephemeral_5m_input_tokens: usage.ephemeral5mTokens, + ephemeral_1h_input_tokens: usage.ephemeral1hTokens + } + } + + const costResult = CostCalculator.calculateCost(usageData, model) + totalCosts.inputCost += costResult.costs.input + totalCosts.outputCost += costResult.costs.output + totalCosts.cacheCreateCost += costResult.costs.cacheWrite + totalCosts.cacheReadCost += costResult.costs.cacheRead + totalCosts.totalCost += costResult.costs.total + + logger.info( + `💰 Model ${model}: ${ + usage.inputTokens + + usage.outputTokens + + usage.cacheCreateTokens + + usage.cacheReadTokens + } tokens, cost: ${costResult.formatted.total}` + ) + + // 记录模型费用 + modelCosts[model] = { + model, + requests: 0, // 历史汇总数据没有请求数 + usage: usageData, + costs: costResult.costs, + formatted: costResult.formatted, + usingDynamicPricing: costResult.usingDynamicPricing + } + } + } else { + // 如果没有详细的模型统计数据,回退到API Key汇总数据(延迟加载) + logger.warn('No detailed model statistics found, falling back to API Key aggregated data') + const apiKeys = await apiKeyService.getAllApiKeysFast() + + for (const apiKey of apiKeys) { + if (apiKey.usage && apiKey.usage.total) { + const usage = { + input_tokens: apiKey.usage.total.inputTokens || 0, + output_tokens: apiKey.usage.total.outputTokens || 0, + cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0, + cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const totalEph5m = apiKey.usage.total.ephemeral5mTokens || 0 + const totalEph1h = apiKey.usage.total.ephemeral1hTokens || 0 + if (totalEph5m > 0 || totalEph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: totalEph5m, + ephemeral_1h_input_tokens: totalEph1h + } + } + + // 使用加权平均价格计算(基于当前活跃模型的价格分布) + const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022') + totalCosts.inputCost += costResult.costs.input + totalCosts.outputCost += costResult.costs.output + totalCosts.cacheCreateCost += costResult.costs.cacheWrite + totalCosts.cacheReadCost += costResult.costs.cacheRead + totalCosts.totalCost += costResult.costs.total + } + } + } + + return res.json({ + success: true, + data: { + period, + totalCosts: { + ...totalCosts, + formatted: { + inputCost: CostCalculator.formatCost(totalCosts.inputCost), + outputCost: CostCalculator.formatCost(totalCosts.outputCost), + cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost), + cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost), + totalCost: CostCalculator.formatCost(totalCosts.totalCost) + } + }, + modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total), + pricingServiceStatus: pricingService.getStatus() + } + }) + } + + // 对于今日或本月,使用索引查询 + let allData + if (period === 'today') { + const results = await getUsageDataByIndex( + `usage:model:daily:index:${today}`, + `usage:model:daily:{id}:${today}`, + `usage:model:daily:*:${today}` + ) + allData = results + } else { + // 本月 - 使用月度索引 + const results = await getUsageDataByIndex( + `usage:model:monthly:index:${currentMonth}`, + `usage:model:monthly:{id}:${currentMonth}`, + `usage:model:monthly:*:${currentMonth}` + ) + allData = results + } + const regex = + period === 'today' + ? /usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + : /usage:model:monthly:(.+):\d{4}-\d{2}$/ + + for (const { key, data } of allData) { + if (!data) { + continue + } + + const match = key.match(regex) + if (!match) { + continue + } + + const model = match[1] + const usage = { + input_tokens: parseInt(data.inputTokens) || 0, + output_tokens: parseInt(data.outputTokens) || 0, + cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const eph5m = parseInt(data.ephemeral5mTokens) || 0 + const eph1h = parseInt(data.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, model) + + // 累加总费用 + totalCosts.inputCost += costResult.costs.input + totalCosts.outputCost += costResult.costs.output + totalCosts.cacheCreateCost += costResult.costs.cacheWrite + totalCosts.cacheReadCost += costResult.costs.cacheRead + totalCosts.totalCost += costResult.costs.total + + // 记录模型费用 + modelCosts[model] = { + model, + requests: parseInt(data.requests) || 0, + usage, + costs: costResult.costs, + formatted: costResult.formatted, + usingDynamicPricing: costResult.usingDynamicPricing + } + } + + return res.json({ + success: true, + data: { + period, + totalCosts: { + ...totalCosts, + formatted: { + inputCost: CostCalculator.formatCost(totalCosts.inputCost), + outputCost: CostCalculator.formatCost(totalCosts.outputCost), + cacheCreateCost: CostCalculator.formatCost(totalCosts.cacheCreateCost), + cacheReadCost: CostCalculator.formatCost(totalCosts.cacheReadCost), + totalCost: CostCalculator.formatCost(totalCosts.totalCost) + } + }, + modelCosts: Object.values(modelCosts).sort((a, b) => b.costs.total - a.costs.total), + pricingServiceStatus: pricingService.getStatus() + } + }) + } catch (error) { + logger.error('❌ Failed to calculate usage costs:', error) + return res + .status(500) + .json({ error: 'Failed to calculate usage costs', message: error.message }) + } +}) + +// 获取 API Key 的请求记��时间线 +router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params + const { + page = 1, + pageSize = 50, + startDate, + endDate, + model, + accountId, + sortOrder = 'desc' + } = req.query + + const pageNumber = Math.max(parseInt(page, 10) || 1, 1) + const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200) + const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc' + + const startTime = startDate ? new Date(startDate) : null + const endTime = endDate ? new Date(endDate) : null + + if ( + (startDate && Number.isNaN(startTime?.getTime())) || + (endDate && Number.isNaN(endTime?.getTime())) + ) { + return res.status(400).json({ success: false, error: 'Invalid date range' }) + } + + if (startTime && endTime && startTime > endTime) { + return res + .status(400) + .json({ success: false, error: 'Start date must be before or equal to end date' }) + } + + const apiKeyInfo = await redis.getApiKey(keyId) + if (!apiKeyInfo || Object.keys(apiKeyInfo).length === 0) { + return res.status(404).json({ success: false, error: 'API key not found' }) + } + + const rawRecords = await redis.getUsageRecords(keyId, 5000) + + const accountServices = [ + { type: 'claude', getter: (id) => claudeAccountService.getAccount(id) }, + { type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) }, + { type: 'ccr', getter: (id) => ccrAccountService.getAccount(id) }, + { type: 'openai', getter: (id) => openaiAccountService.getAccount(id) }, + { type: 'openai-responses', getter: (id) => openaiResponsesAccountService.getAccount(id) }, + { type: 'gemini', getter: (id) => geminiAccountService.getAccount(id) }, + { type: 'gemini-api', getter: (id) => geminiApiAccountService.getAccount(id) }, + { type: 'droid', getter: (id) => droidAccountService.getAccount(id) } + ] + + const accountCache = new Map() + const resolveAccountInfo = async (id, type) => { + if (!id) { + return null + } + + const cacheKey = `${type || 'any'}:${id}` + if (accountCache.has(cacheKey)) { + return accountCache.get(cacheKey) + } + + let servicesToTry = type + ? accountServices.filter((svc) => svc.type === type) + : accountServices + + // 若渠道改名或传入未知类型,回退尝试全量服务,避免漏解析历史账号 + if (!servicesToTry.length) { + servicesToTry = accountServices + } + + for (const service of servicesToTry) { + try { + const account = await service.getter(id) + if (account) { + const info = { + id, + name: account.name || account.email || id, + type: service.type, + status: account.status || account.isActive + } + accountCache.set(cacheKey, info) + return info + } + } catch (error) { + logger.debug(`⚠️ Failed to resolve account ${id} via ${service.type}: ${error.message}`) + } + } + + accountCache.set(cacheKey, null) + return null + } + + const toUsageObject = (record) => { + const usage = { + input_tokens: record.inputTokens || 0, + output_tokens: record.outputTokens || 0, + cache_creation_input_tokens: record.cacheCreateTokens || 0, + cache_read_input_tokens: record.cacheReadTokens || 0, + cache_creation: record.cacheCreation || record.cache_creation || null + } + // 如果没有 cache_creation 但有独立存储的 ephemeral 字段,构建子对象 + if (!usage.cache_creation) { + const eph5m = parseInt(record.ephemeral5mTokens) || 0 + const eph1h = parseInt(record.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + } + return usage + } + + const withinRange = (record) => { + if (!record.timestamp) { + return false + } + const ts = new Date(record.timestamp) + if (Number.isNaN(ts.getTime())) { + return false + } + if (startTime && ts < startTime) { + return false + } + if (endTime && ts > endTime) { + return false + } + return true + } + + const filteredRecords = rawRecords.filter((record) => { + if (!withinRange(record)) { + return false + } + if (model && record.model !== model) { + return false + } + if (accountId && record.accountId !== accountId) { + return false + } + return true + }) + + filteredRecords.sort((a, b) => { + const aTime = new Date(a.timestamp).getTime() + const bTime = new Date(b.timestamp).getTime() + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return 0 + } + return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime + }) + + const summary = { + totalRequests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0 + } + + const modelSet = new Set() + const accountOptionMap = new Map() + let earliestTimestamp = null + let latestTimestamp = null + + for (const record of filteredRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + summary.totalRequests += 1 + summary.inputTokens += usage.input_tokens + summary.outputTokens += usage.output_tokens + summary.cacheCreateTokens += usage.cache_creation_input_tokens + summary.cacheReadTokens += usage.cache_read_input_tokens + summary.totalTokens += totalTokens + summary.totalCost += computedCost + + if (record.model) { + modelSet.add(record.model) + } + + if (record.accountId) { + const normalizedType = record.accountType || 'unknown' + if (!accountOptionMap.has(record.accountId)) { + accountOptionMap.set(record.accountId, { + id: record.accountId, + accountTypes: new Set([normalizedType]) + }) + } else { + accountOptionMap.get(record.accountId).accountTypes.add(normalizedType) + } + } + + if (record.timestamp) { + const ts = new Date(record.timestamp) + if (!Number.isNaN(ts.getTime())) { + if (!earliestTimestamp || ts < earliestTimestamp) { + earliestTimestamp = ts + } + if (!latestTimestamp || ts > latestTimestamp) { + latestTimestamp = ts + } + } + } + } + + const totalRecords = filteredRecords.length + const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0 + const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1 + const startIndex = (safePage - 1) * pageSizeNumber + const pageRecords = + totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber) + + const enrichedRecords = [] + for (const record of pageRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const realCost = + typeof record.realCost === 'number' ? record.realCost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + const accountInfo = await resolveAccountInfo(record.accountId, record.accountType) + const resolvedAccountType = accountInfo?.type || record.accountType || 'unknown' + + enrichedRecords.push({ + timestamp: record.timestamp, + model: record.model || 'unknown', + accountId: record.accountId || null, + accountName: accountInfo?.name || null, + accountStatus: accountInfo?.status ?? null, + accountType: resolvedAccountType, + accountTypeName: accountTypeNames[resolvedAccountType] || '未知渠道', + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + ephemeral5mTokens: record.ephemeral5mTokens || 0, + ephemeral1hTokens: record.ephemeral1hTokens || 0, + totalTokens, + isLongContextRequest: record.isLongContext || record.isLongContextRequest || false, + cost: Number(computedCost.toFixed(6)), + costFormatted: CostCalculator.formatCost(computedCost), + realCost: Number(realCost.toFixed(6)), + realCostFormatted: CostCalculator.formatCost(realCost), + costBreakdown: record.realCostBreakdown || + record.costBreakdown || { + input: costData?.costs?.input || 0, + output: costData?.costs?.output || 0, + cacheCreate: costData?.costs?.cacheWrite || 0, + cacheRead: costData?.costs?.cacheRead || 0, + total: costData?.costs?.total || computedCost + }, + responseTime: record.responseTime || null + }) + } + + const accountOptions = [] + for (const option of accountOptionMap.values()) { + const types = Array.from(option.accountTypes || []) + + // 优先按历史出现的 accountType 解析,若失败则回退全量解析 + let resolvedInfo = null + for (const type of types) { + resolvedInfo = await resolveAccountInfo(option.id, type) + if (resolvedInfo && resolvedInfo.name) { + break + } + } + if (!resolvedInfo) { + resolvedInfo = await resolveAccountInfo(option.id) + } + + const chosenType = resolvedInfo?.type || types[0] || 'unknown' + const chosenTypeName = accountTypeNames[chosenType] || '未知渠道' + + if (!resolvedInfo) { + logger.warn(`⚠️ 保留无法解析的账户筛选项: ${option.id}, types=${types.join(',') || 'none'}`) + } + + accountOptions.push({ + id: option.id, + name: resolvedInfo?.name || option.id, + accountType: chosenType, + accountTypeName: chosenTypeName, + rawTypes: types + }) + } + + return res.json({ + success: true, + data: { + records: enrichedRecords, + pagination: { + currentPage: safePage, + pageSize: pageSizeNumber, + totalRecords, + totalPages, + hasNextPage: totalPages > 0 && safePage < totalPages, + hasPreviousPage: totalPages > 0 && safePage > 1 + }, + filters: { + startDate: startTime ? startTime.toISOString() : null, + endDate: endTime ? endTime.toISOString() : null, + model: model || null, + accountId: accountId || null, + sortOrder: normalizedSortOrder + }, + apiKeyInfo: { + id: keyId, + name: apiKeyInfo.name || apiKeyInfo.label || keyId + }, + summary: { + ...summary, + totalCost: Number(summary.totalCost.toFixed(6)), + avgCost: + summary.totalRequests > 0 + ? Number((summary.totalCost / summary.totalRequests).toFixed(6)) + : 0 + }, + availableFilters: { + models: Array.from(modelSet), + accounts: accountOptions, + dateRange: { + earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null, + latest: latestTimestamp ? latestTimestamp.toISOString() : null + } + } + } + }) + } catch (error) { + logger.error('❌ Failed to get API key usage records:', error) + return res + .status(500) + .json({ error: 'Failed to get API key usage records', message: error.message }) + } +}) + +// 获取账户的请求记录时间线 +router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { + platform, + page = 1, + pageSize = 50, + startDate, + endDate, + model, + apiKeyId, + sortOrder = 'desc' + } = req.query + + const pageNumber = Math.max(parseInt(page, 10) || 1, 1) + const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200) + const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc' + + const startTime = startDate ? new Date(startDate) : null + const endTime = endDate ? new Date(endDate) : null + + if ( + (startDate && Number.isNaN(startTime?.getTime())) || + (endDate && Number.isNaN(endTime?.getTime())) + ) { + return res.status(400).json({ success: false, error: 'Invalid date range' }) + } + + if (startTime && endTime && startTime > endTime) { + return res + .status(400) + .json({ success: false, error: 'Start date must be before or equal to end date' }) + } + + const accountInfo = await resolveAccountByPlatform(accountId, platform) + if (!accountInfo) { + return res.status(404).json({ success: false, error: 'Account not found' }) + } + + const allApiKeys = await apiKeyService.getAllApiKeysFast(true) + const apiKeyNameCache = new Map( + allApiKeys.map((key) => [key.id, key.name || key.label || key.id]) + ) + + let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys + if (apiKeyId && keysToUse.length === 0) { + keysToUse = [{ id: apiKeyId }] + } + + const toUsageObject = (record) => { + const usage = { + input_tokens: record.inputTokens || 0, + output_tokens: record.outputTokens || 0, + cache_creation_input_tokens: record.cacheCreateTokens || 0, + cache_read_input_tokens: record.cacheReadTokens || 0, + cache_creation: record.cacheCreation || record.cache_creation || null + } + // 如果没有 cache_creation 但有独立存储的 ephemeral 字段,构建子对象 + if (!usage.cache_creation) { + const eph5m = parseInt(record.ephemeral5mTokens) || 0 + const eph1h = parseInt(record.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + } + return usage + } + + const withinRange = (record) => { + if (!record.timestamp) { + return false + } + const ts = new Date(record.timestamp) + if (Number.isNaN(ts.getTime())) { + return false + } + if (startTime && ts < startTime) { + return false + } + if (endTime && ts > endTime) { + return false + } + return true + } + + const filteredRecords = [] + const modelSet = new Set() + const apiKeyOptionMap = new Map() + let earliestTimestamp = null + let latestTimestamp = null + + const batchSize = 10 + for (let i = 0; i < keysToUse.length; i += batchSize) { + const batch = keysToUse.slice(i, i + batchSize) + const batchResults = await Promise.all( + batch.map(async (key) => { + try { + const records = await redis.getUsageRecords(key.id, 5000) + return { keyId: key.id, records: records || [] } + } catch (error) { + logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`) + return { keyId: key.id, records: [] } + } + }) + ) + + for (const { keyId, records } of batchResults) { + const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId)) + for (const record of records) { + if (record.accountId !== accountId) { + continue + } + if (!withinRange(record)) { + continue + } + if (model && record.model !== model) { + continue + } + + const accountType = record.accountType || accountInfo.platform || 'unknown' + const normalizedModel = record.model || 'unknown' + + modelSet.add(normalizedModel) + apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName }) + + if (record.timestamp) { + const ts = new Date(record.timestamp) + if (!Number.isNaN(ts.getTime())) { + if (!earliestTimestamp || ts < earliestTimestamp) { + earliestTimestamp = ts + } + if (!latestTimestamp || ts > latestTimestamp) { + latestTimestamp = ts + } + } + } + + filteredRecords.push({ + ...record, + model: normalizedModel, + accountType, + apiKeyId: keyId, + apiKeyName + }) + } + } + } + + filteredRecords.sort((a, b) => { + const aTime = new Date(a.timestamp).getTime() + const bTime = new Date(b.timestamp).getTime() + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return 0 + } + return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime + }) + + const summary = { + totalRequests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0 + } + + for (const record of filteredRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + summary.totalRequests += 1 + summary.inputTokens += usage.input_tokens + summary.outputTokens += usage.output_tokens + summary.cacheCreateTokens += usage.cache_creation_input_tokens + summary.cacheReadTokens += usage.cache_read_input_tokens + summary.totalTokens += totalTokens + summary.totalCost += computedCost + } + + const totalRecords = filteredRecords.length + const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0 + const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1 + const startIndex = (safePage - 1) * pageSizeNumber + const pageRecords = + totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber) + + const enrichedRecords = [] + for (const record of pageRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const realCost = + typeof record.realCost === 'number' ? record.realCost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + enrichedRecords.push({ + timestamp: record.timestamp, + model: record.model || 'unknown', + apiKeyId: record.apiKeyId, + apiKeyName: record.apiKeyName, + accountId, + accountName: accountInfo.name || accountInfo.email || accountId, + accountType: record.accountType, + accountTypeName: accountTypeNames[record.accountType] || '未知渠道', + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + ephemeral5mTokens: record.ephemeral5mTokens || 0, + ephemeral1hTokens: record.ephemeral1hTokens || 0, + totalTokens, + isLongContextRequest: record.isLongContext || record.isLongContextRequest || false, + cost: Number(computedCost.toFixed(6)), + costFormatted: CostCalculator.formatCost(computedCost), + realCost: Number(realCost.toFixed(6)), + realCostFormatted: CostCalculator.formatCost(realCost), + costBreakdown: record.realCostBreakdown || + record.costBreakdown || { + input: costData?.costs?.input || 0, + output: costData?.costs?.output || 0, + cacheCreate: costData?.costs?.cacheWrite || 0, + cacheRead: costData?.costs?.cacheRead || 0, + total: costData?.costs?.total || computedCost + }, + responseTime: record.responseTime || null + }) + } + + return res.json({ + success: true, + data: { + records: enrichedRecords, + pagination: { + currentPage: safePage, + pageSize: pageSizeNumber, + totalRecords, + totalPages, + hasNextPage: totalPages > 0 && safePage < totalPages, + hasPreviousPage: totalPages > 0 && safePage > 1 + }, + filters: { + startDate: startTime ? startTime.toISOString() : null, + endDate: endTime ? endTime.toISOString() : null, + model: model || null, + apiKeyId: apiKeyId || null, + platform: accountInfo.platform, + sortOrder: normalizedSortOrder + }, + accountInfo: { + id: accountId, + name: accountInfo.name || accountInfo.email || accountId, + platform: accountInfo.platform || platform || 'unknown', + status: accountInfo.status ?? accountInfo.isActive ?? null + }, + summary: { + ...summary, + totalCost: Number(summary.totalCost.toFixed(6)), + avgCost: + summary.totalRequests > 0 + ? Number((summary.totalCost / summary.totalRequests).toFixed(6)) + : 0 + }, + availableFilters: { + models: Array.from(modelSet), + apiKeys: Array.from(apiKeyOptionMap.values()), + dateRange: { + earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null, + latest: latestTimestamp ? latestTimestamp.toISOString() : null + } + } + } + }) + } catch (error) { + logger.error('❌ Failed to get account usage records:', error) + return res + .status(500) + .json({ error: 'Failed to get account usage records', message: error.message }) + } +}) + +module.exports = router diff --git a/src/routes/admin/utils.js b/src/routes/admin/utils.js new file mode 100644 index 0000000..49c0201 --- /dev/null +++ b/src/routes/admin/utils.js @@ -0,0 +1,78 @@ +/** + * Admin Routes - 共享工具函数 + * 供各个子路由模块导入使用 + */ + +const logger = require('../../utils/logger') + +/** + * 处理可为空的时间字段 + * @param {*} value - 输入值 + * @returns {string|null} 规范化后的值 + */ +function normalizeNullableDate(value) { + if (value === undefined || value === null) { + return null + } + if (typeof value === 'string') { + const trimmed = value.trim() + return trimmed === '' ? null : trimmed + } + return value +} + +/** + * 映射前端的 expiresAt 字段到后端的 subscriptionExpiresAt 字段 + * @param {Object} updates - 更新对象 + * @param {string} accountType - 账户类型 (如 'Claude', 'OpenAI' 等) + * @param {string} accountId - 账户 ID + * @returns {Object} 映射后的更新对象 + */ +function mapExpiryField(updates, accountType, accountId) { + const mappedUpdates = { ...updates } + if ('expiresAt' in mappedUpdates) { + mappedUpdates.subscriptionExpiresAt = mappedUpdates.expiresAt + delete mappedUpdates.expiresAt + logger.info( + `Mapping expiresAt to subscriptionExpiresAt for ${accountType} account ${accountId}` + ) + } + return mappedUpdates +} + +/** + * 格式化账户数据,确保前端获取正确的过期时间字段 + * 将 subscriptionExpiresAt(订阅过期时间)映射到 expiresAt 供前端使用 + * 保留原始的 tokenExpiresAt(OAuth token过期时间)供内部使用 + * @param {Object} account - 账户对象 + * @returns {Object} 格式化后的账户对象 + */ +function formatAccountExpiry(account) { + if (!account || typeof account !== 'object') { + return account + } + + const rawSubscription = Object.prototype.hasOwnProperty.call(account, 'subscriptionExpiresAt') + ? account.subscriptionExpiresAt + : null + + const rawToken = Object.prototype.hasOwnProperty.call(account, 'tokenExpiresAt') + ? account.tokenExpiresAt + : account.expiresAt + + const subscriptionExpiresAt = normalizeNullableDate(rawSubscription) + const tokenExpiresAt = normalizeNullableDate(rawToken) + + return { + ...account, + subscriptionExpiresAt, + tokenExpiresAt, + expiresAt: subscriptionExpiresAt + } +} + +module.exports = { + normalizeNullableDate, + mapExpiryField, + formatAccountExpiry +} diff --git a/src/routes/api.js b/src/routes/api.js new file mode 100644 index 0000000..1bad773 --- /dev/null +++ b/src/routes/api.js @@ -0,0 +1,1938 @@ +const express = require('express') +const claudeRelayService = require('../services/relay/claudeRelayService') +const claudeConsoleRelayService = require('../services/relay/claudeConsoleRelayService') +const bedrockRelayService = require('../services/relay/bedrockRelayService') +const ccrRelayService = require('../services/relay/ccrRelayService') +const bedrockAccountService = require('../services/account/bedrockAccountService') +const unifiedClaudeScheduler = require('../services/scheduler/unifiedClaudeScheduler') +const apiKeyService = require('../services/apiKeyService') +const { authenticateApiKey } = require('../middleware/auth') +const logger = require('../utils/logger') +const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper') +const sessionHelper = require('../utils/sessionHelper') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const claudeRelayConfigService = require('../services/claudeRelayConfigService') +const claudeAccountService = require('../services/account/claudeAccountService') +const claudeConsoleAccountService = require('../services/account/claudeConsoleAccountService') +const { + isWarmupRequest, + buildMockWarmupResponse, + sendMockWarmupStream +} = require('../utils/warmupInterceptor') +const { sanitizeUpstreamError } = require('../utils/errorSanitizer') +const { dumpAnthropicMessagesRequest } = require('../utils/anthropicRequestDump') +const { createRequestDetailMeta } = require('../utils/requestDetailHelper') +const { + handleAnthropicMessagesToGemini, + handleAnthropicCountTokensToGemini +} = require('../services/anthropicGeminiBridgeService') +const router = express.Router() + +function queueRateLimitUpdate( + rateLimitInfo, + usageSummary, + model, + context = '', + keyId = null, + accountType = null, + preCalculatedCost = null +) { + if (!rateLimitInfo) { + return Promise.resolve({ totalTokens: 0, totalCost: 0 }) + } + + const label = context ? ` (${context})` : '' + + return updateRateLimitCounters( + rateLimitInfo, + usageSummary, + model, + keyId, + accountType, + preCalculatedCost + ) + .then(({ totalTokens, totalCost }) => { + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + return { totalTokens, totalCost } + }) + .catch((error) => { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + return { totalTokens: 0, totalCost: 0 } + }) +} + +/** + * 判断是否为旧会话(污染的会话) + * Claude Code 发送的请求特点: + * - messages 数组通常只有 1 个元素 + * - 历史对话记录嵌套在单个 message 的 content 数组中 + * - content 数组中包含 开头的系统注入内容 + * + * 污染会话的特征: + * 1. messages.length > 1 + * 2. messages.length === 1 但 content 中有多个用户输入 + * 3. "warmup" 请求:单条简单消息 + 无 tools(真正新会话会带 tools) + * + * @param {Object} body - 请求体 + * @returns {boolean} 是否为旧会话 + */ +function isOldSession(body) { + const messages = body?.messages + const tools = body?.tools + + if (!messages || messages.length === 0) { + return false + } + + // 1. 多条消息 = 旧会话 + if (messages.length > 1) { + return true + } + + // 2. 单条消息,分析 content + const firstMessage = messages[0] + const content = firstMessage?.content + + if (!content) { + return false + } + + // 如果 content 是字符串,只有一条输入,需要检查 tools + if (typeof content === 'string') { + // 有 tools = 正常新会话,无 tools = 可疑 + return !tools || tools.length === 0 + } + + // 如果 content 是数组,统计非 system-reminder 的元素 + if (Array.isArray(content)) { + const userInputs = content.filter((item) => { + if (item.type !== 'text') { + return false + } + const text = item.text || '' + // 剔除以 开头的 + return !text.trimStart().startsWith('') + }) + + // 多个用户输入 = 旧会话 + if (userInputs.length > 1) { + return true + } + + // Warmup 检测:单个消息 + 无 tools = 旧会话 + if (userInputs.length === 1 && (!tools || tools.length === 0)) { + return true + } + } + + return false +} + +// 🔧 共享的消息处理函数 +async function handleMessagesRequest(req, res) { + try { + const startTime = Date.now() + + const forcedVendor = req._anthropicVendor || null + const requiredService = + forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude' + + if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) { + return res.status(403).json({ + error: { + type: 'permission_error', + message: + requiredService === 'gemini' + ? '此 API Key 无权访问 Gemini 服务' + : '此 API Key 无权访问 Claude 服务' + } + }) + } + + // 🔄 并发满额重试标志:最多重试一次(使用req对象存储状态) + if (req._concurrencyRetryAttempted === undefined) { + req._concurrencyRetryAttempted = false + } + + // 严格的输入验证 + if (!req.body || typeof req.body !== 'object') { + return res.status(400).json({ + error: 'Invalid request', + message: 'Request body must be a valid JSON object' + }) + } + + if (!req.body.messages || !Array.isArray(req.body.messages)) { + return res.status(400).json({ + error: 'Invalid request', + message: 'Missing or invalid field: messages (must be an array)' + }) + } + + if (req.body.messages.length === 0) { + return res.status(400).json({ + error: 'Invalid request', + message: 'Messages array cannot be empty' + }) + } + + // 模型限制(黑名单)校验:统一在此处处理(去除供应商前缀) + if ( + req.apiKey.enableModelRestriction && + Array.isArray(req.apiKey.restrictedModels) && + req.apiKey.restrictedModels.length > 0 + ) { + const effectiveModel = getEffectiveModel(req.body.model || '') + if (req.apiKey.restrictedModels.includes(effectiveModel)) { + return res.status(403).json({ + error: { + type: 'forbidden', + message: '暂无该模型访问权限' + } + }) + } + } + + logger.api('📥 /v1/messages request received', { + model: req.body.model || null, + forcedVendor, + stream: req.body.stream === true + }) + + dumpAnthropicMessagesRequest(req, { + route: '/v1/messages', + forcedVendor, + model: req.body?.model || null, + stream: req.body?.stream === true + }) + + // /v1/messages 的扩展:按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱) + if (forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity') { + const baseModel = (req.body.model || '').trim() + return await handleAnthropicMessagesToGemini(req, res, { vendor: forcedVendor, baseModel }) + } + + // 检查是否为流式请求 + const isStream = req.body.stream === true + + // 临时修复新版本客户端,删除context_management字段,避免报错 + // if (req.body.context_management) { + // delete req.body.context_management + // } + + // 遍历tools数组,删除input_examples字段 + // if (req.body.tools && Array.isArray(req.body.tools)) { + // req.body.tools.forEach((tool) => { + // if (tool && typeof tool === 'object' && tool.input_examples) { + // delete tool.input_examples + // } + // }) + // } + + logger.api( + `🚀 Processing ${isStream ? 'stream' : 'non-stream'} request for key: ${req.apiKey.name}` + ) + + if (isStream) { + // 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开) + if (res.destroyed || res.socket?.destroyed || res.writableEnded) { + logger.warn( + `⚠️ Client disconnected before stream response could start for key: ${req.apiKey?.name || 'unknown'}` + ) + return undefined + } + + // 流式响应 - 只使用官方真实usage数据 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲 + // ⚠️ 检查 headers 是否已发送(可能在排队心跳时已设置) + if (!res.headersSent) { + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + // ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close + // 当并发队列功能启用时,auth.js 会设置 Connection: close 来禁用 Keep-Alive + // 这里只在没有设置过 Connection 头时才设置 keep-alive + const existingConnection = res.getHeader('Connection') + if (!existingConnection) { + res.setHeader('Connection', 'keep-alive') + } else { + logger.api( + `🔌 [STREAM] Preserving existing Connection header: ${existingConnection} for key: ${req.apiKey?.name || 'unknown'}` + ) + } + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('X-Accel-Buffering', 'no') // 禁用 Nginx 缓冲 + } else { + logger.debug( + `📤 [STREAM] Headers already sent, skipping setHeader for key: ${req.apiKey?.name || 'unknown'}` + ) + } + + // 禁用 Nagle 算法,确保数据立即发送 + if (res.socket && typeof res.socket.setNoDelay === 'function') { + res.socket.setNoDelay(true) + } + + // 流式响应不需要额外处理,中间件已经设置了监听器 + + let usageDataCaptured = false + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 🔒 全局会话绑定验证 + let forcedAccount = null + let needSessionBinding = false + let originalSessionIdForBinding = null + + try { + const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled() + + if (globalBindingEnabled) { + const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body) + + if (originalSessionId) { + const validation = await claudeRelayConfigService.validateNewSession( + req.body, + originalSessionId + ) + + if (!validation.valid) { + logger.api( + `❌ Session binding validation failed: ${validation.code} for session ${originalSessionId}` + ) + return res.status(403).json({ + error: { + type: 'session_binding_error', + message: validation.error + } + }) + } + + // 如果已有绑定,使用绑定的账户 + if (validation.binding) { + forcedAccount = validation.binding + logger.api( + `🔗 Using bound account for session ${originalSessionId}: ${forcedAccount.accountId}` + ) + } + + // 标记需要在调度成功后建立绑定 + if (validation.isNewSession) { + needSessionBinding = true + originalSessionIdForBinding = originalSessionId + logger.api(`📝 New session detected, will create binding: ${originalSessionId}`) + } + } + } + } catch (error) { + logger.error('❌ Error in global session binding check:', error) + // 配置服务出错时不阻断请求 + } + + // 使用统一调度选择账号(传递请求的模型) + const requestedModel = req.body.model + let accountId + let accountType + try { + const selection = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel, + forcedAccount + ) + ;({ accountId, accountType } = selection) + } catch (error) { + // 处理会话绑定账户不可用的错误 + if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') { + const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage() + return res.status(403).json({ + error: { + type: 'session_binding_error', + message: errorMessage + } + }) + } + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = claudeRelayService._buildStandardRateLimitMessage( + error.rateLimitEndAt + ) + res.status(403) + res.setHeader('Content-Type', 'application/json') + res.end( + JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }) + ) + return + } + throw error + } + + // 🔗 在成功调度后建立会话绑定(仅 claude-official 类型) + // claude-official 只接受:1) 新会话 2) 已绑定的会话 + if ( + needSessionBinding && + originalSessionIdForBinding && + accountId && + accountType === 'claude-official' + ) { + // 🆕 允许新 session ID 创建绑定(支持 Claude Code /clear 等场景) + // 信任客户端的 session ID 作为新会话的标识,不再检查请求内容 + logger.info( + `🔗 Creating new session binding: sessionId=${originalSessionIdForBinding}, ` + + `messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, ` + + `accountId=${accountId}, accountType=${accountType}` + ) + + // 创建绑定 + try { + await claudeRelayConfigService.setOriginalSessionBinding( + originalSessionIdForBinding, + accountId, + accountType + ) + } catch (bindingError) { + logger.warn(`⚠️ Failed to create session binding:`, bindingError) + } + } + + // 🔥 预热请求拦截检查(在转发之前) + if (accountType === 'claude-official' || accountType === 'claude-console') { + const account = + accountType === 'claude-official' + ? await claudeAccountService.getAccount(accountId) + : await claudeConsoleAccountService.getAccount(accountId) + + if (account?.interceptWarmup === 'true' && isWarmupRequest(req.body)) { + logger.api(`🔥 Warmup request intercepted for account: ${account.name} (${accountId})`) + if (isStream) { + return sendMockWarmupStream(res, req.body.model) + } else { + return res.json(buildMockWarmupResponse(req.body.model)) + } + } + } + + // 根据账号类型选择对应的转发服务并调用 + if (accountType === 'claude-official') { + // 官方Claude账号使用原有的转发服务(会自己选择账号) + // 🧹 内存优化:提取需要的值,避免闭包捕获整个 req 对象 + const _apiKeyId = req.apiKey.id + const _rateLimitInfo = req.rateLimitInfo + const _requestBody = req.body // 传递后清除引用 + const _apiKey = req.apiKey + const _headers = req.headers + + await claudeRelayService.relayStreamRequestWithUsageCapture( + _requestBody, + _apiKey, + res, + _headers, + (usageData) => { + // 回调函数:当检测到完整usage数据时记录真实token使用量 + logger.info( + '🎯 Usage callback triggered with complete data:', + JSON.stringify(usageData, null, 2) + ) + + if ( + usageData && + usageData.input_tokens !== undefined && + usageData.output_tokens !== undefined + ) { + const inputTokens = usageData.input_tokens || 0 + const outputTokens = usageData.output_tokens || 0 + // 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens + let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { + ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 + // 总的缓存创建 tokens 是两者之和 + cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens + } + + const cacheReadTokens = usageData.cache_read_input_tokens || 0 + const model = usageData.model || 'unknown' + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const { accountId: usageAccountId } = usageData + + // 构建 usage 对象以传递给 recordUsage + const usageObject = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + const requestBetaHeader = + _headers['anthropic-beta'] || + _headers['Anthropic-Beta'] || + _headers['ANTHROPIC-BETA'] + if (requestBetaHeader) { + usageObject.request_anthropic_beta = requestBetaHeader + } + if (typeof _requestBody?.speed === 'string' && _requestBody.speed.trim()) { + usageObject.request_speed = _requestBody.speed.trim().toLowerCase() + } + if (typeof usageData.speed === 'string' && usageData.speed.trim()) { + usageObject.speed = usageData.speed.trim().toLowerCase() + } + + // 如果有详细的缓存创建数据,添加到 usage 对象中 + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usageObject.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + apiKeyService + .recordUsageWithDetails( + _apiKeyId, + usageObject, + model, + usageAccountId, + accountType, + createRequestDetailMeta(req, { + requestBody: _requestBody, + stream: true, + statusCode: res.statusCode + }) + ) + .then((costs) => { + queueRateLimitUpdate( + _rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-stream', + _apiKeyId, + accountType, + costs + ) + }) + .catch((error) => { + logger.error('❌ Failed to record stream usage:', error) + // Fallback: 仍然更新限流计数(使用 legacy 计算) + queueRateLimitUpdate( + _rateLimitInfo, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-stream', + _apiKeyId, + accountType + ) + }) + + usageDataCaptured = true + logger.api( + `📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) + } else { + logger.warn( + '⚠️ Usage callback triggered but data is incomplete:', + JSON.stringify(usageData) + ) + } + } + ) + } else if (accountType === 'claude-console') { + // Claude Console账号使用Console转发服务(需要传递accountId) + // 🧹 内存优化:提取需要的值 + const _apiKeyIdConsole = req.apiKey.id + const _rateLimitInfoConsole = req.rateLimitInfo + const _requestBodyConsole = req.body + const _apiKeyConsole = req.apiKey + const _headersConsole = req.headers + + await claudeConsoleRelayService.relayStreamRequestWithUsageCapture( + _requestBodyConsole, + _apiKeyConsole, + res, + _headersConsole, + (usageData) => { + // 回调函数:当检测到完整usage数据时记录真实token使用量 + logger.info( + '🎯 Usage callback triggered with complete data:', + JSON.stringify(usageData, null, 2) + ) + + if ( + usageData && + usageData.input_tokens !== undefined && + usageData.output_tokens !== undefined + ) { + const inputTokens = usageData.input_tokens || 0 + const outputTokens = usageData.output_tokens || 0 + // 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens + let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { + ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 + // 总的缓存创建 tokens 是两者之和 + cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens + } + + const cacheReadTokens = usageData.cache_read_input_tokens || 0 + const model = usageData.model || 'unknown' + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const usageAccountId = usageData.accountId + + // 构建 usage 对象以传递给 recordUsage + const usageObject = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + const requestBetaHeader = + _headersConsole['anthropic-beta'] || + _headersConsole['Anthropic-Beta'] || + _headersConsole['ANTHROPIC-BETA'] + if (requestBetaHeader) { + usageObject.request_anthropic_beta = requestBetaHeader + } + if ( + typeof _requestBodyConsole?.speed === 'string' && + _requestBodyConsole.speed.trim() + ) { + usageObject.request_speed = _requestBodyConsole.speed.trim().toLowerCase() + } + if (typeof usageData.speed === 'string' && usageData.speed.trim()) { + usageObject.speed = usageData.speed.trim().toLowerCase() + } + + // 如果有详细的缓存创建数据,添加到 usage 对象中 + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usageObject.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + apiKeyService + .recordUsageWithDetails( + _apiKeyIdConsole, + usageObject, + model, + usageAccountId, + 'claude-console', + createRequestDetailMeta(req, { + requestBody: _requestBodyConsole, + stream: true, + statusCode: res.statusCode + }) + ) + .then((costs) => { + queueRateLimitUpdate( + _rateLimitInfoConsole, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-console-stream', + _apiKeyIdConsole, + accountType, + costs + ) + }) + .catch((error) => { + logger.error('❌ Failed to record stream usage:', error) + queueRateLimitUpdate( + _rateLimitInfoConsole, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-console-stream', + _apiKeyIdConsole, + accountType + ) + }) + + usageDataCaptured = true + logger.api( + `📊 Stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) + } else { + logger.warn( + '⚠️ Usage callback triggered but data is incomplete:', + JSON.stringify(usageData) + ) + } + }, + accountId + ) + } else if (accountType === 'bedrock') { + // Bedrock账号使用Bedrock转发服务 + // 🧹 内存优化:提取需要的值 + const _apiKeyIdBedrock = req.apiKey.id + const _rateLimitInfoBedrock = req.rateLimitInfo + const _requestBodyBedrock = req.body + + try { + const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) + if (!bedrockAccountResult.success) { + throw new Error('Failed to get Bedrock account details') + } + + const result = await bedrockRelayService.handleStreamRequest( + _requestBodyBedrock, + bedrockAccountResult.data, + res, + req + ) + + // 记录Bedrock使用统计 + if (result.usage) { + const inputTokens = result.usage.input_tokens || 0 + const outputTokens = result.usage.output_tokens || 0 + + apiKeyService + .recordUsage( + _apiKeyIdBedrock, + inputTokens, + outputTokens, + 0, + 0, + result.model, + accountId, + 'bedrock', + null, + createRequestDetailMeta(req, { + requestBody: _requestBodyBedrock, + stream: true, + statusCode: res.statusCode + }) + ) + .then((costs) => { + queueRateLimitUpdate( + _rateLimitInfoBedrock, + { + inputTokens, + outputTokens, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }, + result.model, + 'bedrock-stream', + _apiKeyIdBedrock, + 'bedrock', + costs + ) + }) + .catch((error) => { + logger.error('❌ Failed to record Bedrock stream usage:', error) + queueRateLimitUpdate( + _rateLimitInfoBedrock, + { + inputTokens, + outputTokens, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }, + result.model, + 'bedrock-stream', + _apiKeyIdBedrock, + 'bedrock' + ) + }) + + usageDataCaptured = true + logger.api( + `📊 Bedrock stream usage recorded - Model: ${result.model}, Input: ${inputTokens}, Output: ${outputTokens}, Total: ${inputTokens + outputTokens} tokens` + ) + } + } catch (error) { + logger.error('❌ Bedrock stream request failed:', error) + if (!res.headersSent) { + const statusCode = error.$metadata?.httpStatusCode || 500 + return res + .status(statusCode) + .json({ error: 'Bedrock service error', message: error.message }) + } + // SSE 流已开始但出错:确保连接被关闭,防止客户端 pending + if (!res.writableEnded) { + res.end() + } + return undefined + } + } else if (accountType === 'ccr') { + // CCR账号使用CCR转发服务(需要传递accountId) + // 🧹 内存优化:提取需要的值 + const _apiKeyIdCcr = req.apiKey.id + const _rateLimitInfoCcr = req.rateLimitInfo + const _requestBodyCcr = req.body + const _apiKeyCcr = req.apiKey + const _headersCcr = req.headers + + await ccrRelayService.relayStreamRequestWithUsageCapture( + _requestBodyCcr, + _apiKeyCcr, + res, + _headersCcr, + (usageData) => { + // 回调函数:当检测到完整usage数据时记录真实token使用量 + logger.info( + '🎯 CCR usage callback triggered with complete data:', + JSON.stringify(usageData, null, 2) + ) + + if ( + usageData && + usageData.input_tokens !== undefined && + usageData.output_tokens !== undefined + ) { + const inputTokens = usageData.input_tokens || 0 + const outputTokens = usageData.output_tokens || 0 + // 兼容处理:如果有详细的 cache_creation 对象,使用它;否则使用总的 cache_creation_input_tokens + let cacheCreateTokens = usageData.cache_creation_input_tokens || 0 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (usageData.cache_creation && typeof usageData.cache_creation === 'object') { + ephemeral5mTokens = usageData.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = usageData.cache_creation.ephemeral_1h_input_tokens || 0 + // 总的缓存创建 tokens 是两者之和 + cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens + } + + const cacheReadTokens = usageData.cache_read_input_tokens || 0 + const model = usageData.model || 'unknown' + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const usageAccountId = usageData.accountId + + // 构建 usage 对象以传递给 recordUsage + const usageObject = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + const requestBetaHeader = + _headersCcr['anthropic-beta'] || + _headersCcr['Anthropic-Beta'] || + _headersCcr['ANTHROPIC-BETA'] + if (requestBetaHeader) { + usageObject.request_anthropic_beta = requestBetaHeader + } + if (typeof _requestBodyCcr?.speed === 'string' && _requestBodyCcr.speed.trim()) { + usageObject.request_speed = _requestBodyCcr.speed.trim().toLowerCase() + } + if (typeof usageData.speed === 'string' && usageData.speed.trim()) { + usageObject.speed = usageData.speed.trim().toLowerCase() + } + + // 如果有详细的缓存创建数据,添加到 usage 对象中 + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usageObject.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + apiKeyService + .recordUsageWithDetails( + _apiKeyIdCcr, + usageObject, + model, + usageAccountId, + 'ccr', + createRequestDetailMeta(req, { + requestBody: _requestBodyCcr, + stream: true, + statusCode: res.statusCode + }) + ) + .then((costs) => { + queueRateLimitUpdate( + _rateLimitInfoCcr, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'ccr-stream', + _apiKeyIdCcr, + 'ccr', + costs + ) + }) + .catch((error) => { + logger.error('❌ Failed to record CCR stream usage:', error) + queueRateLimitUpdate( + _rateLimitInfoCcr, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'ccr-stream', + _apiKeyIdCcr, + 'ccr' + ) + }) + + usageDataCaptured = true + logger.api( + `📊 CCR stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens}, Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) + } else { + logger.warn( + '⚠️ CCR usage callback triggered but data is incomplete:', + JSON.stringify(usageData) + ) + } + }, + accountId + ) + } + + // 流式请求完成后 - 如果没有捕获到usage数据,记录警告但不进行估算 + setTimeout(() => { + if (!usageDataCaptured) { + logger.warn( + '⚠️ No usage data captured from SSE stream - no statistics recorded (official data only)' + ) + } + }, 1000) // 1秒后检查 + } else { + // 🧹 内存优化:提取需要的值,避免后续回调捕获整个 req + const _apiKeyIdNonStream = req.apiKey.id + const _apiKeyNameNonStream = req.apiKey.name + const _rateLimitInfoNonStream = req.rateLimitInfo + const _requestBodyNonStream = req.body + const _apiKeyNonStream = req.apiKey + const _headersNonStream = req.headers + + // 🔍 检查客户端连接是否仍然有效(可能在并发排队等待期间断开) + if (res.destroyed || res.socket?.destroyed || res.writableEnded) { + logger.warn( + `⚠️ Client disconnected before non-stream request could start for key: ${_apiKeyNameNonStream || 'unknown'}` + ) + return undefined + } + + // 非流式响应 - 只使用官方真实usage数据 + logger.info('📄 Starting non-streaming request', { + apiKeyId: _apiKeyIdNonStream, + apiKeyName: _apiKeyNameNonStream + }) + + // 📊 监听 socket 事件以追踪连接状态变化 + const nonStreamSocket = res.socket + let _clientClosedConnection = false + let _socketCloseTime = null + + if (nonStreamSocket) { + const onSocketEnd = () => { + _clientClosedConnection = true + _socketCloseTime = Date.now() + logger.warn( + `⚠️ [NON-STREAM] Socket 'end' event - client sent FIN | key: ${req.apiKey?.name}, ` + + `requestId: ${req.requestId}, elapsed: ${Date.now() - startTime}ms` + ) + } + const onSocketClose = () => { + _clientClosedConnection = true + logger.warn( + `⚠️ [NON-STREAM] Socket 'close' event | key: ${req.apiKey?.name}, ` + + `requestId: ${req.requestId}, elapsed: ${Date.now() - startTime}ms, ` + + `hadError: ${nonStreamSocket.destroyed}` + ) + } + const onSocketError = (err) => { + logger.error( + `❌ [NON-STREAM] Socket error | key: ${req.apiKey?.name}, ` + + `requestId: ${req.requestId}, error: ${err.message}` + ) + } + + nonStreamSocket.once('end', onSocketEnd) + nonStreamSocket.once('close', onSocketClose) + nonStreamSocket.once('error', onSocketError) + + // 清理监听器(在响应结束后) + res.once('finish', () => { + nonStreamSocket.removeListener('end', onSocketEnd) + nonStreamSocket.removeListener('close', onSocketClose) + nonStreamSocket.removeListener('error', onSocketError) + }) + } + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(req.body) + + // 🔒 全局会话绑定验证(非流式) + let forcedAccountNonStream = null + let needSessionBindingNonStream = false + let originalSessionIdForBindingNonStream = null + + try { + const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled() + + if (globalBindingEnabled) { + const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body) + + if (originalSessionId) { + const validation = await claudeRelayConfigService.validateNewSession( + req.body, + originalSessionId + ) + + if (!validation.valid) { + logger.api( + `❌ Session binding validation failed (non-stream): ${validation.code} for session ${originalSessionId}` + ) + return res.status(403).json({ + error: { + type: 'session_binding_error', + message: validation.error + } + }) + } + + if (validation.binding) { + forcedAccountNonStream = validation.binding + logger.api( + `🔗 Using bound account for session (non-stream) ${originalSessionId}: ${forcedAccountNonStream.accountId}` + ) + } + + if (validation.isNewSession) { + needSessionBindingNonStream = true + originalSessionIdForBindingNonStream = originalSessionId + logger.api( + `📝 New session detected (non-stream), will create binding: ${originalSessionId}` + ) + } + } + } + } catch (error) { + logger.error('❌ Error in global session binding check (non-stream):', error) + } + + // 使用统一调度选择账号(传递请求的模型) + const requestedModel = req.body.model + let accountId + let accountType + try { + const selection = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel, + forcedAccountNonStream + ) + ;({ accountId, accountType } = selection) + } catch (error) { + if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') { + const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage() + return res.status(403).json({ + error: { + type: 'session_binding_error', + message: errorMessage + } + }) + } + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = claudeRelayService._buildStandardRateLimitMessage( + error.rateLimitEndAt + ) + return res.status(403).json({ + error: 'upstream_rate_limited', + message: limitMessage + }) + } + throw error + } + + // 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型) + // claude-official 只接受:1) 新会话 2) 已绑定的会话 + if ( + needSessionBindingNonStream && + originalSessionIdForBindingNonStream && + accountId && + accountType === 'claude-official' + ) { + // 🆕 允许新 session ID 创建绑定(支持 Claude Code /clear 等场景) + // 信任客户端的 session ID 作为新会话的标识,不再检查请求内容 + logger.info( + `🔗 Creating new session binding (non-stream): sessionId=${originalSessionIdForBindingNonStream}, ` + + `messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, ` + + `accountId=${accountId}, accountType=${accountType}` + ) + + // 创建绑定 + try { + await claudeRelayConfigService.setOriginalSessionBinding( + originalSessionIdForBindingNonStream, + accountId, + accountType + ) + } catch (bindingError) { + logger.warn(`⚠️ Failed to create session binding (non-stream):`, bindingError) + } + } + + // 🔥 预热请求拦截检查(非流式,在转发之前) + if (accountType === 'claude-official' || accountType === 'claude-console') { + const account = + accountType === 'claude-official' + ? await claudeAccountService.getAccount(accountId) + : await claudeConsoleAccountService.getAccount(accountId) + + if (account?.interceptWarmup === 'true' && isWarmupRequest(_requestBodyNonStream)) { + logger.api( + `🔥 Warmup request intercepted (non-stream) for account: ${account.name} (${accountId})` + ) + return res.json(buildMockWarmupResponse(_requestBodyNonStream.model)) + } + } + + // 根据账号类型选择对应的转发服务 + let response + logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`) + logger.debug(`[DEBUG] Request URL: ${req.url}`) + logger.debug(`[DEBUG] Request path: ${req.path}`) + + if (accountType === 'claude-official') { + // 官方Claude账号使用原有的转发服务 + response = await claudeRelayService.relayRequest( + _requestBodyNonStream, + _apiKeyNonStream, + req, // clientRequest 用于断开检测,保留但服务层已优化 + res, + _headersNonStream + ) + } else if (accountType === 'claude-console') { + // Claude Console账号使用Console转发服务 + logger.debug( + `[DEBUG] Calling claudeConsoleRelayService.relayRequest with accountId: ${accountId}` + ) + response = await claudeConsoleRelayService.relayRequest( + _requestBodyNonStream, + _apiKeyNonStream, + req, // clientRequest 保留用于断开检测 + res, + _headersNonStream, + accountId + ) + } else if (accountType === 'bedrock') { + // Bedrock账号使用Bedrock转发服务 + try { + const bedrockAccountResult = await bedrockAccountService.getAccount(accountId) + if (!bedrockAccountResult.success) { + throw new Error('Failed to get Bedrock account details') + } + + const result = await bedrockRelayService.handleNonStreamRequest( + _requestBodyNonStream, + bedrockAccountResult.data, + _headersNonStream + ) + + // 构建标准响应格式 + response = { + statusCode: result.success ? 200 : 500, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(result.success ? result.data : { error: result.error }), + accountId + } + + // 如果成功,添加使用统计到响应数据中 + if (result.success && result.usage) { + const responseData = JSON.parse(response.body) + responseData.usage = result.usage + response.body = JSON.stringify(responseData) + } + } catch (error) { + logger.error('❌ Bedrock non-stream request failed:', error) + const statusCode = error.$metadata?.httpStatusCode || 500 + response = { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ error: 'Bedrock service error', message: error.message }), + accountId + } + } + } else if (accountType === 'ccr') { + // CCR账号使用CCR转发服务 + logger.debug(`[DEBUG] Calling ccrRelayService.relayRequest with accountId: ${accountId}`) + response = await ccrRelayService.relayRequest( + _requestBodyNonStream, + _apiKeyNonStream, + req, // clientRequest 保留用于断开检测 + res, + _headersNonStream, + accountId + ) + } + + logger.info('📡 Claude API response received', { + statusCode: response.statusCode, + headers: JSON.stringify(response.headers), + bodyLength: response.body ? response.body.length : 0 + }) + + // 🔍 检查客户端连接是否仍然有效 + // 在长时间请求过程中,客户端可能已经断开连接(超时、用户取消等) + if (res.destroyed || res.socket?.destroyed || res.writableEnded) { + logger.warn( + `⚠️ Client disconnected before non-stream response could be sent for key: ${req.apiKey?.name || 'unknown'}` + ) + return undefined + } + + res.status(response.statusCode) + + // 设置响应头,避免 Content-Length 和 Transfer-Encoding 冲突 + const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length'] + Object.keys(response.headers).forEach((key) => { + if (!skipHeaders.includes(key.toLowerCase())) { + res.setHeader(key, response.headers[key]) + } + }) + + let usageRecorded = false + + // 尝试解析JSON响应并提取usage信息 + try { + const jsonData = JSON.parse(response.body) + + logger.info('📊 Parsed Claude API response:', JSON.stringify(jsonData, null, 2)) + + // 从Claude API响应中提取usage信息(完整的token分类体系) + if ( + jsonData.usage && + jsonData.usage.input_tokens !== undefined && + jsonData.usage.output_tokens !== undefined + ) { + const inputTokens = jsonData.usage.input_tokens || 0 + const outputTokens = jsonData.usage.output_tokens || 0 + let cacheCreateTokens = jsonData.usage.cache_creation_input_tokens || 0 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (jsonData.usage.cache_creation && typeof jsonData.usage.cache_creation === 'object') { + ephemeral5mTokens = jsonData.usage.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = jsonData.usage.cache_creation.ephemeral_1h_input_tokens || 0 + cacheCreateTokens = ephemeral5mTokens + ephemeral1hTokens + } + + const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0 + // Parse the model to remove vendor prefix if present (e.g., "ccr,gemini-2.5-pro" -> "gemini-2.5-pro") + const rawModel = jsonData.model || _requestBodyNonStream.model || 'unknown' + const { baseModel: usageBaseModel } = parseVendorPrefixedModel(rawModel) + const model = usageBaseModel || rawModel + + // 构建 usage 对象以传递给 recordUsageWithDetails + const usageObject = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + // 添加请求元信息 + const requestBetaHeader = + _headersNonStream['anthropic-beta'] || + _headersNonStream['Anthropic-Beta'] || + _headersNonStream['ANTHROPIC-BETA'] + if (requestBetaHeader) { + usageObject.request_anthropic_beta = requestBetaHeader + } + if ( + typeof _requestBodyNonStream?.speed === 'string' && + _requestBodyNonStream.speed.trim() + ) { + usageObject.request_speed = _requestBodyNonStream.speed.trim().toLowerCase() + } + if (typeof jsonData.usage.speed === 'string' && jsonData.usage.speed.trim()) { + usageObject.speed = jsonData.usage.speed.trim().toLowerCase() + } + + // 添加 cache_creation 子对象 + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usageObject.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const { accountId: responseAccountId } = response + const nonStreamCosts = await apiKeyService.recordUsageWithDetails( + _apiKeyIdNonStream, + usageObject, + model, + responseAccountId, + accountType, + createRequestDetailMeta(req, { + requestBody: _requestBodyNonStream, + stream: false, + statusCode: response.statusCode + }) + ) + + await queueRateLimitUpdate( + _rateLimitInfoNonStream, + { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens + }, + model, + 'claude-non-stream', + _apiKeyIdNonStream, + accountType, + nonStreamCosts + ) + + usageRecorded = true + logger.api( + `📊 Non-stream usage recorded (real) - Model: ${model}, Input: ${inputTokens}, Output: ${outputTokens}, Cache Create: ${cacheCreateTokens} (5m: ${ephemeral5mTokens}, 1h: ${ephemeral1hTokens}), Cache Read: ${cacheReadTokens}, Total: ${inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens} tokens` + ) + } else { + logger.warn('⚠️ No usage data found in Claude API JSON response') + } + + // 使用 Express 内建的 res.json() 发送响应(简单可靠) + res.json(jsonData) + } catch (parseError) { + logger.warn('⚠️ Failed to parse Claude API response as JSON:', parseError.message) + logger.info('📄 Raw response body:', response.body) + // 使用 Express 内建的 res.send() 发送响应(简单可靠) + res.send(response.body) + } + + // 如果没有记录usage,只记录警告,不进行估算 + if (!usageRecorded) { + logger.warn( + '⚠️ No usage data recorded for non-stream request - no statistics recorded (official data only)' + ) + } + } + + const duration = Date.now() - startTime + logger.api(`✅ Request completed in ${duration}ms for key: ${req.apiKey.name}`) + return undefined + } catch (error) { + let handledError = error + + // 🔄 并发满额降级处理:捕获CONSOLE_ACCOUNT_CONCURRENCY_FULL错误 + if ( + handledError.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL' && + !req._concurrencyRetryAttempted + ) { + req._concurrencyRetryAttempted = true + logger.warn( + `⚠️ Console account ${handledError.accountId} concurrency full, attempting fallback to other accounts...` + ) + + // 只有在响应头未发送时才能重试 + if (!res.headersSent) { + try { + // 清理粘性会话映射(如果存在) + const sessionHash = sessionHelper.generateSessionHash(req.body) + await unifiedClaudeScheduler.clearSessionMapping(sessionHash) + + logger.info('🔄 Session mapping cleared, retrying handleMessagesRequest...') + + // 递归重试整个请求处理(会选择新账户) + return await handleMessagesRequest(req, res) + } catch (retryError) { + // 重试失败 + if (retryError.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') { + logger.error('❌ All Console accounts reached concurrency limit after retry') + return res.status(503).json({ + error: 'service_unavailable', + message: + 'All available Claude Console accounts have reached their concurrency limit. Please try again later.' + }) + } + // 其他错误继续向下处理 + handledError = retryError + } + } else { + // 响应头已发送,无法重试 + logger.error('❌ Cannot retry concurrency full error - response headers already sent') + if (!res.destroyed && !res.finished) { + res.end() + } + return undefined + } + } + + // 🚫 第二次并发满额错误:已经重试过,直接返回503 + if ( + handledError.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL' && + req._concurrencyRetryAttempted + ) { + logger.error('❌ All Console accounts reached concurrency limit (retry already attempted)') + if (!res.headersSent) { + return res.status(503).json({ + error: 'service_unavailable', + message: + 'All available Claude Console accounts have reached their concurrency limit. Please try again later.' + }) + } else { + if (!res.destroyed && !res.finished) { + res.end() + } + return undefined + } + } + + logger.error('❌ Claude relay error:', handledError.message, { + code: handledError.code, + stack: handledError.stack + }) + + // 确保在任何情况下都能返回有效的JSON响应 + if (!res.headersSent) { + // 根据错误类型设置适当的状态码 + let statusCode = 500 + let errorType = 'Relay service error' + + if ( + handledError.message.includes('Connection reset') || + handledError.message.includes('socket hang up') + ) { + statusCode = 502 + errorType = 'Upstream connection error' + } else if (handledError.message.includes('Connection refused')) { + statusCode = 502 + errorType = 'Upstream service unavailable' + } else if (handledError.message.includes('timeout')) { + statusCode = 504 + errorType = 'Upstream timeout' + } else if ( + handledError.message.includes('resolve') || + handledError.message.includes('ENOTFOUND') + ) { + statusCode = 502 + errorType = 'Upstream hostname resolution failed' + } + + return res.status(statusCode).json({ + error: errorType, + message: handledError.message || 'An unexpected error occurred', + timestamp: new Date().toISOString() + }) + } else { + // 如果响应头已经发送,尝试结束响应 + if (!res.destroyed && !res.finished) { + res.end() + } + return undefined + } + } +} + +// 🚀 Claude API messages 端点 - /api/v1/messages +router.post('/v1/messages', authenticateApiKey, handleMessagesRequest) + +// 🚀 Claude API messages 端点 - /claude/v1/messages (别名) +router.post('/claude/v1/messages', authenticateApiKey, handleMessagesRequest) + +// 📋 模型列表端点 - 支持 Claude, OpenAI, Gemini +router.get('/v1/models', authenticateApiKey, async (req, res) => { + try { + // Claude Code / Anthropic baseUrl 的分流:/antigravity/api/v1/models 返回 Antigravity 实时模型列表 + //(通过 v1internal:fetchAvailableModels),避免依赖静态 modelService 列表。 + const forcedVendor = req._anthropicVendor || null + if (forcedVendor === 'antigravity') { + if (!apiKeyService.hasPermission(req.apiKey?.permissions, 'gemini')) { + return res.status(403).json({ + error: { + type: 'permission_error', + message: '此 API Key 无权访问 Gemini 服务' + } + }) + } + + const unifiedGeminiScheduler = require('../services/scheduler/unifiedGeminiScheduler') + const geminiAccountService = require('../services/account/geminiAccountService') + + let accountSelection + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + null, + null, + { oauthProvider: 'antigravity' } + ) + } catch (error) { + logger.error('Failed to select Gemini OAuth account (antigravity models):', error) + return res.status(503).json({ error: 'No available Gemini OAuth accounts' }) + } + + const account = await geminiAccountService.getAccount(accountSelection.accountId) + if (!account) { + return res.status(503).json({ error: 'Gemini OAuth account not found' }) + } + + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = + typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + const models = await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + proxyConfig, + account.refreshToken + ) + + // 可选:根据 API Key 的模型限制过滤(黑名单语义) + let filteredModels = models + if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) { + filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id)) + } + + return res.json({ object: 'list', data: filteredModels }) + } + + const modelService = require('../services/modelService') + + // 从 modelService 获取所有支持的模型 + const models = modelService.getAllModels() + + // 可选:根据 API Key 的模型限制过滤 + let filteredModels = models + if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) { + // 将 restrictedModels 视为黑名单:过滤掉受限模型 + filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id)) + } + + res.json({ + object: 'list', + data: filteredModels + }) + } catch (error) { + logger.error('❌ Models list error:', error) + res.status(500).json({ + error: 'Failed to get models list', + message: error.message + }) + } +}) + +// 🏥 健康检查端点 +router.get('/health', async (req, res) => { + try { + const healthStatus = await claudeRelayService.healthCheck() + + res.status(healthStatus.healthy ? 200 : 503).json({ + status: healthStatus.healthy ? 'healthy' : 'unhealthy', + service: 'claude-relay-service', + version: '1.0.0', + ...healthStatus + }) + } catch (error) { + logger.error('❌ Health check error:', error) + res.status(503).json({ + status: 'unhealthy', + service: 'claude-relay-service', + error: error.message, + timestamp: new Date().toISOString() + }) + } +}) + +// 📊 API Key状态检查端点 - /api/v1/key-info +router.get('/v1/key-info', authenticateApiKey, async (req, res) => { + try { + const usage = await apiKeyService.getUsageStats(req.apiKey.id) + + res.json({ + keyInfo: { + id: req.apiKey.id, + name: req.apiKey.name, + tokenLimit: req.apiKey.tokenLimit, + usage + }, + timestamp: new Date().toISOString() + }) + } catch (error) { + logger.error('❌ Key info error:', error) + res.status(500).json({ + error: 'Failed to get key info', + message: error.message + }) + } +}) + +// 📈 使用统计端点 - /api/v1/usage +router.get('/v1/usage', authenticateApiKey, async (req, res) => { + try { + const usage = await apiKeyService.getUsageStats(req.apiKey.id) + + res.json({ + usage, + limits: { + tokens: req.apiKey.tokenLimit, + requests: 0 // 请求限制已移除 + }, + timestamp: new Date().toISOString() + }) + } catch (error) { + logger.error('❌ Usage stats error:', error) + res.status(500).json({ + error: 'Failed to get usage stats', + message: error.message + }) + } +}) + +// 👤 用户信息端点 - Claude Code 客户端需要 +router.get('/v1/me', authenticateApiKey, async (req, res) => { + try { + // 返回基础用户信息 + res.json({ + id: `user_${req.apiKey.id}`, + type: 'user', + display_name: req.apiKey.name || 'API User', + created_at: new Date().toISOString() + }) + } catch (error) { + logger.error('❌ User info error:', error) + res.status(500).json({ + error: 'Failed to get user info', + message: error.message + }) + } +}) + +// 💰 余额/限制端点 - Claude Code 客户端需要 +router.get('/v1/organizations/:org_id/usage', authenticateApiKey, async (req, res) => { + try { + const usage = await apiKeyService.getUsageStats(req.apiKey.id) + + res.json({ + object: 'usage', + data: [ + { + type: 'credit_balance', + credit_balance: req.apiKey.tokenLimit - (usage.totalTokens || 0) + } + ] + }) + } catch (error) { + logger.error('❌ Organization usage error:', error) + res.status(500).json({ + error: 'Failed to get usage info', + message: error.message + }) + } +}) + +// 🔢 Token计数端点 - count_tokens beta API +router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) => { + // 按路径强制分流到 Gemini OAuth 账户(避免 model 前缀混乱) + const forcedVendor = req._anthropicVendor || null + const requiredService = + forcedVendor === 'gemini-cli' || forcedVendor === 'antigravity' ? 'gemini' : 'claude' + + if (!apiKeyService.hasPermission(req.apiKey?.permissions, requiredService)) { + return res.status(403).json({ + error: { + type: 'permission_error', + message: + requiredService === 'gemini' + ? 'This API key does not have permission to access Gemini' + : 'This API key does not have permission to access Claude' + } + }) + } + + if (requiredService === 'gemini') { + return await handleAnthropicCountTokensToGemini(req, res, { vendor: forcedVendor }) + } + + // 🔗 会话绑定验证(与 messages 端点保持一致) + const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body) + const sessionValidation = await claudeRelayConfigService.validateNewSession( + req.body, + originalSessionId + ) + + if (!sessionValidation.valid) { + logger.warn( + `🚫 Session binding validation failed (count_tokens): ${sessionValidation.code} for session ${originalSessionId}` + ) + return res.status(400).json({ + error: { + type: 'session_binding_error', + message: sessionValidation.error + } + }) + } + + // 🔗 检测旧会话(污染的会话)- 仅对需要绑定的新会话检查 + if (sessionValidation.isNewSession && originalSessionId) { + if (isOldSession(req.body)) { + const cfg = await claudeRelayConfigService.getConfig() + logger.warn( + `🚫 Old session rejected (count_tokens): sessionId=${originalSessionId}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true` + ) + return res.status(400).json({ + error: { + type: 'session_binding_error', + message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。' + } + }) + } + } + + logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`) + + const sessionHash = sessionHelper.generateSessionHash(req.body) + const requestedModel = req.body.model + const maxAttempts = 2 + let attempt = 0 + + const processRequest = async () => { + const { accountId, accountType } = await unifiedClaudeScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + requestedModel + ) + + if (accountType === 'ccr') { + throw Object.assign(new Error('Token counting is not supported for CCR accounts'), { + httpStatus: 501, + errorPayload: { + error: { + type: 'not_supported', + message: 'Token counting is not supported for CCR accounts' + } + } + }) + } + + if (accountType === 'bedrock') { + throw Object.assign(new Error('Token counting is not supported for Bedrock accounts'), { + httpStatus: 501, + errorPayload: { + error: { + type: 'not_supported', + message: 'Token counting is not supported for Bedrock accounts' + } + } + }) + } + + // 🔍 claude-console 账户特殊处理:检查 count_tokens 端点是否可用 + if (accountType === 'claude-console') { + const isUnavailable = await claudeConsoleAccountService.isCountTokensUnavailable(accountId) + if (isUnavailable) { + logger.info( + `⏭️ count_tokens unavailable for Claude Console account ${accountId}, returning fallback response` + ) + return { fallbackResponse: true } + } + } + + const relayOptions = { + skipUsageRecord: true, + customPath: '/v1/messages/count_tokens' + } + + const response = + accountType === 'claude-official' + ? await claudeRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + relayOptions + ) + : await claudeConsoleRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + accountId, + relayOptions + ) + + // 🔍 claude-console 账户:检测上游 404 响应并标记 + if (accountType === 'claude-console' && response.statusCode === 404) { + logger.warn( + `⚠️ count_tokens endpoint returned 404 for Claude Console account ${accountId}, marking as unavailable` + ) + // 标记失败不应影响 fallback 响应 + try { + await claudeConsoleAccountService.markCountTokensUnavailable(accountId) + } catch (markError) { + logger.error( + `❌ Failed to mark count_tokens unavailable for account ${accountId}, but will still return fallback:`, + markError + ) + } + return { fallbackResponse: true } + } + + res.status(response.statusCode) + + const skipHeaders = ['content-encoding', 'transfer-encoding', 'content-length'] + Object.keys(response.headers).forEach((key) => { + if (!skipHeaders.includes(key.toLowerCase())) { + res.setHeader(key, response.headers[key]) + } + }) + + try { + const jsonData = JSON.parse(response.body) + if (response.statusCode < 200 || response.statusCode >= 300) { + const sanitizedData = sanitizeUpstreamError(jsonData) + res.json(sanitizedData) + } else { + res.json(jsonData) + } + } catch (parseError) { + res.send(response.body) + } + + logger.info(`✅ Token count request completed for key: ${req.apiKey.name}`) + return { fallbackResponse: false } + } + + while (attempt < maxAttempts) { + try { + const result = await processRequest() + + // 🔍 处理 fallback 响应(claude-console 账户 count_tokens 不可用) + if (result && result.fallbackResponse) { + if (!res.headersSent) { + return res.status(200).json({ input_tokens: 0 }) + } + return + } + + return + } catch (error) { + if (error.code === 'CONSOLE_ACCOUNT_CONCURRENCY_FULL') { + logger.warn( + `⚠️ Console account concurrency full during count_tokens (attempt ${attempt + 1}/${maxAttempts})` + ) + if (attempt < maxAttempts - 1) { + try { + await unifiedClaudeScheduler.clearSessionMapping(sessionHash) + } catch (clearError) { + logger.error('❌ Failed to clear session mapping for count_tokens retry:', clearError) + if (!res.headersSent) { + return res.status(500).json({ + error: { + type: 'server_error', + message: 'Failed to count tokens' + } + }) + } + if (!res.destroyed && !res.finished) { + res.end() + } + return + } + attempt += 1 + continue + } + if (!res.headersSent) { + return res.status(503).json({ + error: 'service_unavailable', + message: + 'All available Claude Console accounts have reached their concurrency limit. Please try again later.' + }) + } + if (!res.destroyed && !res.finished) { + res.end() + } + return + } + + if (error.httpStatus) { + return res.status(error.httpStatus).json(error.errorPayload) + } + + // 客户端断开连接不是错误,使用 INFO 级别 + if (error.message === 'Client disconnected') { + logger.info('🔌 Client disconnected during token count request') + if (!res.headersSent) { + return res.status(499).end() // 499 Client Closed Request + } + if (!res.destroyed && !res.finished) { + res.end() + } + return + } + + logger.error('❌ Token count error:', error) + if (!res.headersSent) { + return res.status(500).json({ + error: { + type: 'server_error', + message: 'Failed to count tokens' + } + }) + } + + if (!res.destroyed && !res.finished) { + res.end() + } + return + } + } +}) + +// Claude Code 客户端遥测端点 - 返回成功响应避免 404 日志 +router.post('/api/event_logging/batch', (req, res) => { + res.status(200).json({ success: true }) +}) + +module.exports = router +module.exports.handleMessagesRequest = handleMessagesRequest diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js new file mode 100644 index 0000000..a4016c5 --- /dev/null +++ b/src/routes/apiStats.js @@ -0,0 +1,1677 @@ +const express = require('express') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const apiKeyService = require('../services/apiKeyService') +const CostCalculator = require('../utils/costCalculator') +const claudeAccountService = require('../services/account/claudeAccountService') +const openaiAccountService = require('../services/account/openaiAccountService') +const serviceRatesService = require('../services/serviceRatesService') +const { + createClaudeTestPayload, + extractErrorMessage, + sanitizeErrorMsg +} = require('../utils/testPayloadHelper') +const modelsConfig = require('../../config/models') +const { getSafeMessage } = require('../utils/errorSanitizer') + +const router = express.Router() + +// 📋 获取可用模型列表(公开接口) +router.get('/models', (req, res) => { + const { service } = req.query + + if (service) { + // 返回指定服务的模型 + const models = modelsConfig.getModelsByService(service) + return res.json({ + success: true, + data: models + }) + } + + // 返回所有模型(按服务分组 + 平台维度) + res.json({ + success: true, + data: { + claude: modelsConfig.CLAUDE_MODELS, + gemini: modelsConfig.GEMINI_MODELS, + openai: modelsConfig.OPENAI_MODELS, + other: modelsConfig.OTHER_MODELS, + all: modelsConfig.getAllModels(), + platforms: modelsConfig.PLATFORM_TEST_MODELS + } + }) +}) + +// 🏠 重定向页面请求到新版 admin-spa +router.get('/', (req, res) => { + res.redirect(301, '/admin-next/api-stats') +}) + +// 🔑 获取 API Key 对应的 ID +router.post('/api/get-key-id', async (req, res) => { + try { + const { apiKey } = req.body + + if (!apiKey) { + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }) + } + + // 基本API Key格式验证 + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + // 验证API Key(使用不触发激活的验证方法) + const validation = await apiKeyService.validateApiKeyForStats(apiKey) + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security(`Invalid API key in get-key-id: ${validation.error} from ${clientIP}`) + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + const { keyData } = validation + + return res.json({ + success: true, + data: { + id: keyData.id + } + }) + } catch (error) { + logger.error('❌ Failed to get API key ID:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve API key ID' + }) + } +}) + +// 📊 用户API Key统计查询接口 - 安全的自查询接口 +router.post('/api/user-stats', async (req, res) => { + try { + const { apiKey, apiId } = req.body + + let keyData + let keyId + + if (apiId) { + // 通过 apiId 查询 + if ( + typeof apiId !== 'string' || + !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) + ) { + return res.status(400).json({ + error: 'Invalid API ID format', + message: 'API ID must be a valid UUID' + }) + } + + // 直接通过 ID 获取 API Key 数据 + keyData = await redis.getApiKey(apiId) + + if (!keyData || Object.keys(keyData).length === 0) { + logger.security(`API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`) + return res.status(404).json({ + error: 'API key not found', + message: 'The specified API key does not exist' + }) + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + const keyName = keyData.name || 'Unknown' + return res.status(403).json({ + error: 'API key is disabled', + message: `API Key "${keyName}" 已被禁用`, + keyName + }) + } + + // 检查是否过期 + if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { + const keyName = keyData.name || 'Unknown' + return res.status(403).json({ + error: 'API key has expired', + message: `API Key "${keyName}" 已过期`, + keyName + }) + } + + keyId = apiId + + // 获取使用统计 + const usage = await redis.getUsageStats(keyId) + + // 获取当日费用统计 + const dailyCost = await redis.getDailyCost(keyId) + const costStats = await redis.getCostStats(keyId) + + // 处理数据格式,与 validateApiKey 返回的格式保持一致 + // 解析限制模型数据 + let restrictedModels = [] + try { + restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] + } catch (e) { + restrictedModels = [] + } + + // 解析允许的客户端数据 + let allowedClients = [] + try { + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] + } catch (e) { + allowedClients = [] + } + + // 格式化 keyData + keyData = { + ...keyData, + tokenLimit: parseInt(keyData.tokenLimit) || 0, + concurrencyLimit: parseInt(keyData.concurrencyLimit) || 0, + rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0, + rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0, + dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0, + totalCostLimit: parseFloat(keyData.totalCostLimit) || 0, + dailyCost: dailyCost || 0, + totalCost: costStats.total || 0, + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels, + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients, + permissions: keyData.permissions, + // 添加激活相关字段 + expirationMode: keyData.expirationMode || 'fixed', + isActivated: keyData.isActivated === 'true', + activationDays: parseInt(keyData.activationDays || 0), + activatedAt: keyData.activatedAt || null, + usage // 使用完整的 usage 数据,而不是只有 total + } + } else if (apiKey) { + // 通过 apiKey 查询(保持向后兼容) + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + logger.security(`Invalid API key format in user stats query from ${req.ip || 'unknown'}`) + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + // 验证API Key(使用不触发激活的验证方法) + const validation = await apiKeyService.validateApiKeyForStats(apiKey) + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security( + `🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}` + ) + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + const { keyData: validatedKeyData } = validation + keyData = validatedKeyData + keyId = keyData.id + } else { + logger.security(`Missing API key or ID in user stats query from ${req.ip || 'unknown'}`) + return res.status(400).json({ + error: 'API Key or ID is required', + message: 'Please provide your API Key or API ID' + }) + } + + // 记录合法查询 + logger.api( + `📊 User stats query from key: ${keyData.name} (${keyId}) from ${req.ip || 'unknown'}` + ) + + // 获取验证结果中的完整keyData(包含isActive状态和cost信息) + const fullKeyData = keyData + + // 🔧 FIX: 使用 allTimeCost 而不是扫描月度键 + // 计算总费用 - 优先使用持久化的总费用计数器 + let totalCost = 0 + let formattedCost = '$0.000000' + + try { + const client = redis.getClientSafe() + + // 读取累积的总费用(没有 TTL 的持久键) + const totalCostKey = `usage:cost:total:${keyId}` + const allTimeCost = parseFloat((await client.get(totalCostKey)) || '0') + + if (allTimeCost > 0) { + totalCost = allTimeCost + formattedCost = CostCalculator.formatCost(allTimeCost) + logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`) + } else { + // Fallback: 如果 allTimeCost 为空(旧键),尝试月度键 + const allModelResults = await redis.scanAndGetAllChunked(`usage:${keyId}:model:monthly:*:*`) + const modelUsageMap = new Map() + + for (const { key, data } of allModelResults) { + const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/) + if (!modelMatch) { + continue + } + + const model = modelMatch[1] + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + ephemeral5mTokens: 0, + ephemeral1hTokens: 0, + realCostMicro: 0, + ratedCostMicro: 0, + hasStoredCost: false + }) + } + + const modelUsage = modelUsageMap.get(model) + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + modelUsage.ephemeral5mTokens += parseInt(data.ephemeral5mTokens) || 0 + modelUsage.ephemeral1hTokens += parseInt(data.ephemeral1hTokens) || 0 + if ('realCostMicro' in data || 'ratedCostMicro' in data) { + modelUsage.realCostMicro += parseInt(data.realCostMicro) || 0 + modelUsage.ratedCostMicro += parseInt(data.ratedCostMicro) || 0 + modelUsage.hasStoredCost = true + } + } + } + + // 按模型计算费用并汇总 + for (const [model, usage] of modelUsageMap) { + if (usage.hasStoredCost) { + // 使用请求时已存储的费用(精确) + totalCost += usage.ratedCostMicro / 1000000 + } else { + // Legacy fallback:旧数据没有存储费用,从 token 重算 + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (usage.ephemeral5mTokens > 0 || usage.ephemeral1hTokens > 0) { + usageData.cache_creation = { + ephemeral_5m_input_tokens: usage.ephemeral5mTokens, + ephemeral_1h_input_tokens: usage.ephemeral1hTokens + } + } + + const costResult = CostCalculator.calculateCost(usageData, model) + totalCost += costResult.costs.total + } + } + + // 如果没有模型级别的详细数据,回退到总体数据计算 + if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) { + const usage = fullKeyData.usage.total + const costUsage = { + input_tokens: usage.inputTokens || 0, + output_tokens: usage.outputTokens || 0, + cache_creation_input_tokens: usage.cacheCreateTokens || 0, + cache_read_input_tokens: usage.cacheReadTokens || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (usage.ephemeral5mTokens > 0 || usage.ephemeral1hTokens > 0) { + costUsage.cache_creation = { + ephemeral_5m_input_tokens: usage.ephemeral5mTokens, + ephemeral_1h_input_tokens: usage.ephemeral1hTokens + } + } + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022') + totalCost = costResult.costs.total + } + + formattedCost = CostCalculator.formatCost(totalCost) + } + } catch (error) { + logger.warn(`Failed to calculate cost for key ${keyId}:`, error) + // 回退到简单计算 + if (fullKeyData.usage?.total?.allTokens > 0) { + const usage = fullKeyData.usage.total + const costUsage = { + input_tokens: usage.inputTokens || 0, + output_tokens: usage.outputTokens || 0, + cache_creation_input_tokens: usage.cacheCreateTokens || 0, + cache_read_input_tokens: usage.cacheReadTokens || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (usage.ephemeral5mTokens > 0 || usage.ephemeral1hTokens > 0) { + costUsage.cache_creation = { + ephemeral_5m_input_tokens: usage.ephemeral5mTokens, + ephemeral_1h_input_tokens: usage.ephemeral1hTokens + } + } + + const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022') + totalCost = costResult.costs.total + formattedCost = costResult.formatted.total + } + } + + // 获取当前使用量 + let currentWindowRequests = 0 + let currentWindowTokens = 0 + let currentWindowCost = 0 // 新增:当前窗口费用 + let currentDailyCost = 0 + let windowStartTime = null + let windowEndTime = null + let windowRemainingSeconds = null + + try { + // 获取当前时间窗口的请求次数、Token使用量和费用 + if (fullKeyData.rateLimitWindow > 0) { + const client = redis.getClientSafe() + const requestCountKey = `rate_limit:requests:${keyId}` + const tokenCountKey = `rate_limit:tokens:${keyId}` + const costCountKey = `rate_limit:cost:${keyId}` // 新增:费用计数key + const windowStartKey = `rate_limit:window_start:${keyId}` + + currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') + currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') + currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:获取当前窗口费用 + + // 获取窗口开始时间和计算剩余时间 + const windowStart = await client.get(windowStartKey) + if (windowStart) { + const now = Date.now() + windowStartTime = parseInt(windowStart) + const windowDuration = fullKeyData.rateLimitWindow * 60 * 1000 // 转换为毫秒 + windowEndTime = windowStartTime + windowDuration + + // 如果窗口还有效 + if (now < windowEndTime) { + windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000)) + } else { + // 窗口已过期,下次请求会重置 + windowStartTime = null + windowEndTime = null + windowRemainingSeconds = 0 + // 重置计数为0,因为窗口已过期 + currentWindowRequests = 0 + currentWindowTokens = 0 + currentWindowCost = 0 // 新增:重置窗口费用 + } + } + } + + // 获取当日费用 + currentDailyCost = (await redis.getDailyCost(keyId)) || 0 + } catch (error) { + logger.warn(`Failed to get current usage for key ${keyId}:`, error) + } + + const boundAccountDetails = {} + + const accountDetailTasks = [] + + if (fullKeyData.claudeAccountId) { + accountDetailTasks.push( + (async () => { + try { + const overview = await claudeAccountService.getAccountOverview( + fullKeyData.claudeAccountId + ) + + if (overview && overview.accountType === 'dedicated') { + boundAccountDetails.claude = overview + } + } catch (error) { + logger.warn(`⚠️ Failed to load Claude account overview for key ${keyId}:`, error) + } + })() + ) + } + + if (fullKeyData.openaiAccountId) { + accountDetailTasks.push( + (async () => { + try { + const overview = await openaiAccountService.getAccountOverview( + fullKeyData.openaiAccountId + ) + + if (overview && overview.accountType === 'dedicated') { + boundAccountDetails.openai = overview + } + } catch (error) { + logger.warn(`⚠️ Failed to load OpenAI account overview for key ${keyId}:`, error) + } + })() + ) + } + + if (accountDetailTasks.length > 0) { + await Promise.allSettled(accountDetailTasks) + } + + // 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息) + const responseData = { + id: keyId, + name: fullKeyData.name, + description: fullKeyData.description || keyData.description || '', + isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的 + createdAt: fullKeyData.createdAt || keyData.createdAt, + expiresAt: fullKeyData.expiresAt || keyData.expiresAt, + // 添加激活相关字段 + expirationMode: fullKeyData.expirationMode || 'fixed', + isActivated: fullKeyData.isActivated === true || fullKeyData.isActivated === 'true', + activationDays: parseInt(fullKeyData.activationDays || 0), + activatedAt: fullKeyData.activatedAt || null, + permissions: fullKeyData.permissions, + + // 使用统计(使用验证结果中的完整数据) + usage: { + total: { + ...(fullKeyData.usage?.total || { + requests: 0, + tokens: 0, + allTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0 + }), + cost: totalCost, + formattedCost + } + }, + + // 限制信息(显示配置和当前使用量) + limits: { + tokenLimit: fullKeyData.tokenLimit || 0, + concurrencyLimit: fullKeyData.concurrencyLimit || 0, + rateLimitWindow: fullKeyData.rateLimitWindow || 0, + rateLimitRequests: fullKeyData.rateLimitRequests || 0, + rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制 + dailyCostLimit: fullKeyData.dailyCostLimit || 0, + totalCostLimit: fullKeyData.totalCostLimit || 0, + weeklyOpusCostLimit: parseFloat(fullKeyData.weeklyOpusCostLimit) || 0, // Opus 周费用限制 + weeklyResetDay: parseInt(fullKeyData.weeklyResetDay) || 1, // 周费用重置日 (1-7) + weeklyResetHour: parseInt(fullKeyData.weeklyResetHour) || 0, // 周费用重置时 (0-23) + // 当前使用量 + currentWindowRequests, + currentWindowTokens, + currentWindowCost, // 新增:当前窗口费用 + currentDailyCost, + currentTotalCost: totalCost, + weeklyOpusCost: + (await redis.getWeeklyOpusCost( + keyId, + parseInt(fullKeyData.weeklyResetDay) || 1, + parseInt(fullKeyData.weeklyResetHour) || 0 + )) || 0, // 当前 Opus 周费用 + // 时间窗口信息 + windowStartTime, + windowEndTime, + windowRemainingSeconds + }, + + // 绑定的账户信息(只显示ID,不显示敏感信息) + accounts: { + claudeAccountId: + fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== '' + ? fullKeyData.claudeAccountId + : null, + geminiAccountId: + fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== '' + ? fullKeyData.geminiAccountId + : null, + openaiAccountId: + fullKeyData.openaiAccountId && fullKeyData.openaiAccountId !== '' + ? fullKeyData.openaiAccountId + : null, + details: Object.keys(boundAccountDetails).length > 0 ? boundAccountDetails : null + }, + + // 模型和客户端限制信息 + restrictions: { + enableModelRestriction: fullKeyData.enableModelRestriction || false, + restrictedModels: fullKeyData.restrictedModels || [], + enableClientRestriction: fullKeyData.enableClientRestriction || false, + allowedClients: fullKeyData.allowedClients || [] + }, + + // Key 级别的服务倍率 + serviceRates: (() => { + try { + return fullKeyData.serviceRates + ? typeof fullKeyData.serviceRates === 'string' + ? JSON.parse(fullKeyData.serviceRates) + : fullKeyData.serviceRates + : {} + } catch (e) { + return {} + } + })() + } + + return res.json({ + success: true, + data: responseData + }) + } catch (error) { + logger.error('❌ Failed to process user stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve API key statistics' + }) + } +}) + +// 📊 批量查询统计数据接口 +router.post('/api/batch-stats', async (req, res) => { + try { + const { apiIds } = req.body + + // 验证输入 + if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'API IDs array is required' + }) + } + + // 限制最多查询 30 个 + if (apiIds.length > 30) { + return res.status(400).json({ + error: 'Too many keys', + message: 'Maximum 30 API keys can be queried at once' + }) + } + + // 验证所有 ID 格式 + const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i + const invalidIds = apiIds.filter((id) => !uuidRegex.test(id)) + if (invalidIds.length > 0) { + return res.status(400).json({ + error: 'Invalid API ID format', + message: `Invalid API IDs: ${invalidIds.join(', ')}` + }) + } + + const individualStats = [] + const aggregated = { + totalKeys: apiIds.length, + activeKeys: 0, + usage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }, + dailyUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + }, + monthlyUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + } + } + + // 并行查询所有 API Key 数据(复用单key查询逻辑) + const results = await Promise.allSettled( + apiIds.map(async (apiId) => { + const keyData = await redis.getApiKey(apiId) + + if (!keyData || Object.keys(keyData).length === 0) { + return { error: 'Not found', apiId } + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return { error: 'Disabled', apiId } + } + + // 检查是否过期 + if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { + return { error: 'Expired', apiId } + } + + // 复用单key查询的逻辑:获取使用统计 + const usage = await redis.getUsageStats(apiId) + + // 获取费用统计(与单key查询一致) + const costStats = await redis.getCostStats(apiId) + + return { + apiId, + name: keyData.name, + description: keyData.description || '', + isActive: true, + createdAt: keyData.createdAt, + usage: usage.total || {}, + dailyStats: { + ...usage.daily, + cost: costStats.daily + }, + monthlyStats: { + ...usage.monthly, + cost: costStats.monthly + }, + totalCost: costStats.total, + serviceRates: (() => { + try { + return keyData.serviceRates + ? typeof keyData.serviceRates === 'string' + ? JSON.parse(keyData.serviceRates) + : keyData.serviceRates + : {} + } catch (e) { + return {} + } + })() + } + }) + ) + + // 处理结果并聚合 + results.forEach((result) => { + if (result.status === 'fulfilled' && result.value && !result.value.error) { + const stats = result.value + aggregated.activeKeys++ + + // 聚合总使用量 + if (stats.usage) { + aggregated.usage.requests += stats.usage.requests || 0 + aggregated.usage.inputTokens += stats.usage.inputTokens || 0 + aggregated.usage.outputTokens += stats.usage.outputTokens || 0 + aggregated.usage.cacheCreateTokens += stats.usage.cacheCreateTokens || 0 + aggregated.usage.cacheReadTokens += stats.usage.cacheReadTokens || 0 + aggregated.usage.allTokens += stats.usage.allTokens || 0 + } + + // 聚合总费用 + aggregated.usage.cost += stats.totalCost || 0 + + // 聚合今日使用量 + aggregated.dailyUsage.requests += stats.dailyStats.requests || 0 + aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0 + aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0 + aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0 + aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0 + aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0 + aggregated.dailyUsage.cost += stats.dailyStats.cost || 0 + + // 聚合本月使用量 + aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0 + aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0 + aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0 + aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0 + aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0 + aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0 + aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0 + + // 添加到个体统计 + individualStats.push({ + apiId: stats.apiId, + name: stats.name, + isActive: true, + usage: stats.usage, + dailyUsage: { + ...stats.dailyStats, + formattedCost: CostCalculator.formatCost(stats.dailyStats.cost || 0) + }, + monthlyUsage: { + ...stats.monthlyStats, + formattedCost: CostCalculator.formatCost(stats.monthlyStats.cost || 0) + } + }) + } + }) + + // 格式化费用显示 + aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost) + aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost) + aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost) + + logger.api(`📊 Batch stats query for ${apiIds.length} keys from ${req.ip || 'unknown'}`) + + return res.json({ + success: true, + data: { + aggregated, + individual: individualStats + } + }) + } catch (error) { + logger.error('❌ Failed to process batch stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve batch statistics' + }) + } +}) + +// 📊 批量模型统计查询接口 +router.post('/api/batch-model-stats', async (req, res) => { + try { + const { apiIds, period = 'daily' } = req.body + + // 验证输入 + if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) { + return res.status(400).json({ + error: 'Invalid input', + message: 'API IDs array is required' + }) + } + + // 限制最多查询 30 个 + if (apiIds.length > 30) { + return res.status(400).json({ + error: 'Too many keys', + message: 'Maximum 30 API keys can be queried at once' + }) + } + + const _client = redis.getClientSafe() + const tzDate = redis.getDateInTimezone() + const today = redis.getDateStringInTimezone() + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` + + const modelUsageMap = new Map() + + // 并行查询所有 API Key 的模型统计 + await Promise.all( + apiIds.map(async (apiId) => { + const pattern = + period === 'daily' + ? `usage:${apiId}:model:daily:*:${today}` + : `usage:${apiId}:model:monthly:*:${currentMonth}` + + const results = await redis.scanAndGetAllChunked(pattern) + + for (const { key, data } of results) { + const match = key.match( + period === 'daily' + ? /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + : /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + ) + + if (!match) { + continue + } + + const model = match[1] + + if (data && Object.keys(data).length > 0) { + if (!modelUsageMap.has(model)) { + modelUsageMap.set(model, { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + ephemeral5mTokens: 0, + ephemeral1hTokens: 0, + allTokens: 0, + realCostMicro: 0, + ratedCostMicro: 0, + hasStoredCost: false + }) + } + + const modelUsage = modelUsageMap.get(model) + modelUsage.requests += parseInt(data.requests) || 0 + modelUsage.inputTokens += parseInt(data.inputTokens) || 0 + modelUsage.outputTokens += parseInt(data.outputTokens) || 0 + modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 + modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 + modelUsage.ephemeral5mTokens += parseInt(data.ephemeral5mTokens) || 0 + modelUsage.ephemeral1hTokens += parseInt(data.ephemeral1hTokens) || 0 + modelUsage.allTokens += parseInt(data.allTokens) || 0 + modelUsage.realCostMicro += parseInt(data.realCostMicro) || 0 + modelUsage.ratedCostMicro += parseInt(data.ratedCostMicro) || 0 + // 检查 Redis 数据是否包含成本字段 + if ('realCostMicro' in data || 'ratedCostMicro' in data) { + modelUsage.hasStoredCost = true + } + } + } + }) + ) + + // 转换为数组并处理费用 + const modelStats = [] + for (const [model, usage] of modelUsageMap) { + const usageData = { + input_tokens: usage.inputTokens, + output_tokens: usage.outputTokens, + cache_creation_input_tokens: usage.cacheCreateTokens, + cache_read_input_tokens: usage.cacheReadTokens + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (usage.ephemeral5mTokens > 0 || usage.ephemeral1hTokens > 0) { + usageData.cache_creation = { + ephemeral_5m_input_tokens: usage.ephemeral5mTokens, + ephemeral_1h_input_tokens: usage.ephemeral1hTokens + } + } + + // 优先使用存储的费用,否则回退到重新计算 + const { hasStoredCost } = usage + const costData = CostCalculator.calculateCost(usageData, model) + + // 如果有存储的费用,覆盖计算的费用 + if (hasStoredCost) { + costData.costs.real = (usage.realCostMicro || 0) / 1000000 + costData.costs.rated = (usage.ratedCostMicro || 0) / 1000000 + costData.costs.total = costData.costs.real // 保持兼容 + costData.formatted.total = `$${costData.costs.real.toFixed(6)}` + } + + modelStats.push({ + model, + requests: usage.requests, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheCreateTokens: usage.cacheCreateTokens, + cacheReadTokens: usage.cacheReadTokens, + allTokens: usage.allTokens, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing, + isLegacy: !hasStoredCost + }) + } + + // 按总 token 数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens) + + logger.api(`📊 Batch model stats query for ${apiIds.length} keys, period: ${period}`) + + return res.json({ + success: true, + data: modelStats, + period + }) + } catch (error) { + logger.error('❌ Failed to process batch model stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve batch model statistics' + }) + } +}) + +// maxTokens 白名单 +const ALLOWED_MAX_TOKENS = [100, 500, 1000, 2000, 4096] +const sanitizeMaxTokens = (value) => + ALLOWED_MAX_TOKENS.includes(Number(value)) ? Number(value) : 1000 + +// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务 +router.post('/api-key/test', async (req, res) => { + const config = require('../../config/config') + const { sendStreamTestRequest } = require('../utils/testPayloadHelper') + + try { + const { apiKey, model = 'claude-sonnet-4-5-20250929', prompt = 'hi' } = req.body + const maxTokens = sanitizeMaxTokens(req.body.maxTokens) + + if (!apiKey) { + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }) + } + + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + const validation = await apiKeyService.validateApiKeyForStats(apiKey) + if (!validation.valid) { + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + logger.api(`🧪 API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`) + + const port = config.server.port || 3000 + const apiUrl = `http://127.0.0.1:${port}/api/v1/messages?beta=true` + + await sendStreamTestRequest({ + apiUrl, + authorization: apiKey, + responseStream: res, + payload: createClaudeTestPayload(model, { stream: true, prompt, maxTokens }), + timeout: 60000, + extraHeaders: { + 'x-api-key': apiKey, + 'x-app': 'claude-code', + 'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14' + }, + sanitize: false + }) + } catch (error) { + logger.error('❌ API Key test failed:', error) + + const errorMsg = error.message || 'An unexpected error occurred' + if (!res.headersSent) { + return res.status(500).json({ + error: 'Test failed', + message: errorMsg + }) + } + + res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`) + res.end() + } +}) + +// 🧪 Gemini API Key 端点测试接口 +router.post('/api-key/test-gemini', async (req, res) => { + const config = require('../../config/config') + const { createGeminiTestPayload } = require('../utils/testPayloadHelper') + + try { + const { apiKey, model = 'gemini-2.5-pro', prompt = 'hi' } = req.body + const maxTokens = sanitizeMaxTokens(req.body.maxTokens) + + if (!apiKey) { + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }) + } + + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + const validation = await apiKeyService.validateApiKeyForStats(apiKey) + if (!validation.valid) { + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + // 检查 Gemini 权限 + if (!apiKeyService.hasPermission(validation.keyData.permissions, 'gemini')) { + return res.status(403).json({ + error: 'Permission denied', + message: 'This API key does not have Gemini permission' + }) + } + + logger.api( + `🧪 Gemini API Key test started for: ${validation.keyData.name} (${validation.keyData.id})` + ) + + const port = config.server.port || 3000 + const apiUrl = `http://127.0.0.1:${port}/gemini/v1/models/${model}:streamGenerateContent?alt=sse` + + // 设置 SSE 响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + + res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`) + + const axios = require('axios') + const payload = createGeminiTestPayload(model, { prompt, maxTokens }) + + try { + const response = await axios.post(apiUrl, payload, { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey + }, + timeout: 60000, + responseType: 'stream', + validateStatus: () => true + }) + + if (response.status !== 200) { + const chunks = [] + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', () => { + const errorData = Buffer.concat(chunks).toString() + let errorMsg = `API Error: ${response.status}` + try { + const json = JSON.parse(errorData) + errorMsg = extractErrorMessage(json, errorMsg) + } catch { + if (errorData.length < 200) { + errorMsg = errorData || errorMsg + } + } + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: sanitizeErrorMsg(errorMsg) })}\n\n` + ) + res.end() + }) + return + } + + let buffer = '' + response.data.on('data', (chunk) => { + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.startsWith('data:')) { + continue + } + const jsonStr = line.substring(5).trim() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonStr) + // Gemini 格式: candidates[0].content.parts[0].text + const text = data.candidates?.[0]?.content?.parts?.[0]?.text + if (text) { + res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`) + } + } catch { + // ignore + } + } + }) + + response.data.on('end', () => { + res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`) + res.end() + }) + + response.data.on('error', (err) => { + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n` + ) + res.end() + }) + } catch (axiosError) { + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n` + ) + res.end() + } + } catch (error) { + logger.error('❌ Gemini API Key test failed:', error) + + if (!res.headersSent) { + return res.status(500).json({ + error: 'Test failed', + message: getSafeMessage(error) + }) + } + + res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`) + res.end() + } +}) + +// 🧪 OpenAI/Codex API Key 端点测试接口 +router.post('/api-key/test-openai', async (req, res) => { + const config = require('../../config/config') + const { createOpenAITestPayload } = require('../utils/testPayloadHelper') + + try { + const { apiKey, model = 'gpt-5', prompt = 'hi' } = req.body + const maxTokens = sanitizeMaxTokens(req.body.maxTokens) + + if (!apiKey) { + return res.status(400).json({ + error: 'API Key is required', + message: 'Please provide your API Key' + }) + } + + if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) { + return res.status(400).json({ + error: 'Invalid API key format', + message: 'API key format is invalid' + }) + } + + const validation = await apiKeyService.validateApiKeyForStats(apiKey) + if (!validation.valid) { + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + // 检查 OpenAI 权限 + if (!apiKeyService.hasPermission(validation.keyData.permissions, 'openai')) { + return res.status(403).json({ + error: 'Permission denied', + message: 'This API key does not have OpenAI permission' + }) + } + + logger.api( + `🧪 OpenAI API Key test started for: ${validation.keyData.name} (${validation.keyData.id})` + ) + + const port = config.server.port || 3000 + const apiUrl = `http://127.0.0.1:${port}/openai/responses` + + // 设置 SSE 响应头 + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + + res.write(`data: ${JSON.stringify({ type: 'test_start', message: 'Test started' })}\n\n`) + + const axios = require('axios') + const payload = createOpenAITestPayload(model, { prompt, maxTokens }) + + try { + const response = await axios.post(apiUrl, payload, { + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'User-Agent': 'codex_cli_rs/1.0.0' + }, + timeout: 60000, + responseType: 'stream', + validateStatus: () => true + }) + + if (response.status !== 200) { + const chunks = [] + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', () => { + const errorData = Buffer.concat(chunks).toString() + let errorMsg = `API Error: ${response.status}` + try { + const json = JSON.parse(errorData) + errorMsg = extractErrorMessage(json, errorMsg) + } catch { + if (errorData.length < 200) { + errorMsg = errorData || errorMsg + } + } + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: sanitizeErrorMsg(errorMsg) })}\n\n` + ) + res.end() + }) + return + } + + let buffer = '' + response.data.on('data', (chunk) => { + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.startsWith('data:')) { + continue + } + const jsonStr = line.substring(5).trim() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonStr) + // OpenAI Responses 格式: output[].content[].text 或 delta + if (data.type === 'response.output_text.delta' && data.delta) { + res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta })}\n\n`) + } else if (data.type === 'response.content_part.delta' && data.delta?.text) { + res.write(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}\n\n`) + } + } catch { + // ignore + } + } + }) + + response.data.on('end', () => { + res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`) + res.end() + }) + + response.data.on('error', (err) => { + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(err) })}\n\n` + ) + res.end() + }) + } catch (axiosError) { + res.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: getSafeMessage(axiosError) })}\n\n` + ) + res.end() + } + } catch (error) { + logger.error('❌ OpenAI API Key test failed:', error) + + if (!res.headersSent) { + return res.status(500).json({ + error: 'Test failed', + message: getSafeMessage(error) + }) + } + + res.write(`data: ${JSON.stringify({ type: 'error', error: getSafeMessage(error) })}\n\n`) + res.end() + } +}) + +// 📊 用户模型统计查询接口 - 安全的自查询接口 +router.post('/api/user-model-stats', async (req, res) => { + try { + const { apiKey, apiId, period = 'monthly' } = req.body + + let keyData + let keyId + + if (apiId) { + // 通过 apiId 查询 + if ( + typeof apiId !== 'string' || + !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) + ) { + return res.status(400).json({ + error: 'Invalid API ID format', + message: 'API ID must be a valid UUID' + }) + } + + // 直接通过 ID 获取 API Key 数据 + keyData = await redis.getApiKey(apiId) + + if (!keyData || Object.keys(keyData).length === 0) { + logger.security(`API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`) + return res.status(404).json({ + error: 'API key not found', + message: 'The specified API key does not exist' + }) + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + const keyName = keyData.name || 'Unknown' + return res.status(403).json({ + error: 'API key is disabled', + message: `API Key "${keyName}" 已被禁用`, + keyName + }) + } + + keyId = apiId + + // 获取使用统计 + const usage = await redis.getUsageStats(keyId) + keyData.usage = { total: usage.total } + } else if (apiKey) { + // 通过 apiKey 查询(保持向后兼容) + // 验证API Key + const validation = await apiKeyService.validateApiKey(apiKey) + + if (!validation.valid) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.security( + `🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}` + ) + return res.status(401).json({ + error: 'Invalid API key', + message: validation.error + }) + } + + const { keyData: validatedKeyData } = validation + keyData = validatedKeyData + keyId = keyData.id + } else { + logger.security( + `🔒 Missing API key or ID in user model stats query from ${req.ip || 'unknown'}` + ) + return res.status(400).json({ + error: 'API Key or ID is required', + message: 'Please provide your API Key or API ID' + }) + } + + logger.api( + `📊 User model stats query from key: ${keyData.name} (${keyId}) for period: ${period}` + ) + + // 重用管理后台的模型统计逻辑,但只返回该API Key的数据 + const _client = redis.getClientSafe() + // 使用与管理页面相同的时区处理逻辑 + const tzDate = redis.getDateInTimezone() + const today = redis.getDateStringInTimezone() + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}` + + let pattern + let matchRegex + if (period === 'daily') { + pattern = `usage:${keyId}:model:daily:*:${today}` + matchRegex = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ + } else if (period === 'alltime') { + pattern = `usage:${keyId}:model:alltime:*` + matchRegex = /usage:.+:model:alltime:(.+)$/ + } else { + // monthly + pattern = `usage:${keyId}:model:monthly:*:${currentMonth}` + matchRegex = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/ + } + + const results = await redis.scanAndGetAllChunked(pattern) + const modelStats = [] + + for (const { key, data } of results) { + const match = key.match(matchRegex) + + if (!match) { + continue + } + + const model = match[1] + + if (data && Object.keys(data).length > 0) { + const ephemeral5m = parseInt(data.ephemeral5mTokens) || 0 + const ephemeral1h = parseInt(data.ephemeral1hTokens) || 0 + const usage = { + input_tokens: parseInt(data.inputTokens) || 0, + output_tokens: parseInt(data.outputTokens) || 0, + cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0 + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + if (ephemeral5m > 0 || ephemeral1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5m, + ephemeral_1h_input_tokens: ephemeral1h + } + } + + // 优先使用存储的费用,否则回退到重新计算 + // 检查字段是否存在(而非 > 0),以支持真正的零成本场景 + const realCostMicro = parseInt(data.realCostMicro) || 0 + const ratedCostMicro = parseInt(data.ratedCostMicro) || 0 + const hasStoredCost = 'realCostMicro' in data || 'ratedCostMicro' in data + const costData = CostCalculator.calculateCost(usage, model) + + // 如果有存储的费用,覆盖计算的费用 + if (hasStoredCost) { + costData.costs.real = realCostMicro / 1000000 + costData.costs.rated = ratedCostMicro / 1000000 + costData.costs.total = costData.costs.real + costData.formatted.total = `$${costData.costs.real.toFixed(6)}` + } + + // alltime 键不存储 allTokens,需要计算 + const allTokens = + period === 'alltime' + ? usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + : parseInt(data.allTokens) || 0 + + modelStats.push({ + model, + requests: parseInt(data.requests) || 0, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + allTokens, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing, + isLegacy: !hasStoredCost + }) + } + } + + // 如果没有详细的模型数据,不显示历史数据以避免混淆 + // 只有在查询特定时间段时返回空数组,表示该时间段确实没有数据 + if (modelStats.length === 0) { + logger.info(`📊 No model stats found for key ${keyId} in period ${period}`) + } + + // 按总token数降序排列 + modelStats.sort((a, b) => b.allTokens - a.allTokens) + + return res.json({ + success: true, + data: modelStats, + period + }) + } catch (error) { + logger.error('❌ Failed to process user model stats query:', error) + return res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve model statistics' + }) + } +}) + +// 📊 获取服务倍率配置(公开接口) +router.get('/service-rates', async (req, res) => { + try { + const rates = await serviceRatesService.getRates() + res.json({ + success: true, + data: rates + }) + } catch (error) { + logger.error('❌ Failed to get service rates:', error) + res.status(500).json({ + error: 'Internal server error', + message: 'Failed to retrieve service rates' + }) + } +}) + +// 🎫 公开的额度卡兑换接口(通过 apiId 验证身份) +router.post('/api/redeem-card', async (req, res) => { + const quotaCardService = require('../services/quotaCardService') + + try { + const { apiId, code } = req.body + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + const hour = new Date().toISOString().slice(0, 13) + + // 防暴力破解:检查失败锁定 + const failKey = `redeem_card:fail:${clientIP}` + const failCount = parseInt((await redis.client.get(failKey)) || '0') + if (failCount >= 5) { + logger.security(`🔒 Card redemption locked for IP: ${clientIP}`) + return res.status(403).json({ + success: false, + error: '失败次数过多,请1小时后再试' + }) + } + + // 防暴力破解:检查 IP 速率限制 + const ipKey = `redeem_card:ip:${clientIP}:${hour}` + const ipCount = await redis.client.incr(ipKey) + await redis.client.expire(ipKey, 3600) + if (ipCount > 10) { + logger.security(`🚨 Card redemption rate limit for IP: ${clientIP}`) + return res.status(429).json({ + success: false, + error: '请求过于频繁,请稍后再试' + }) + } + + if (!apiId || !code) { + return res.status(400).json({ + success: false, + error: '请输入卡号' + }) + } + + // 验证 apiId 格式 + if ( + typeof apiId !== 'string' || + !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) + ) { + return res.status(400).json({ + success: false, + error: 'API ID 格式无效' + }) + } + + // 验证 API Key 存在且有效 + const keyData = await redis.getApiKey(apiId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } + + if (keyData.isActive !== 'true') { + return res.status(403).json({ + success: false, + error: 'API Key 已禁用' + }) + } + + // 调用兑换服务 + const result = await quotaCardService.redeemCard(code, apiId, null, keyData.name || 'API Stats') + + // 成功时清除失败计数(静默处理,不影响成功响应) + redis.client.del(failKey).catch(() => {}) + + logger.api(`🎫 Card redeemed via API Stats: ${code} -> ${apiId}`) + + res.json({ + success: true, + data: result + }) + } catch (error) { + // 失败时增加失败计数(静默处理,不影响错误响应) + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + const failKey = `redeem_card:fail:${clientIP}` + redis.client + .incr(failKey) + .then(() => redis.client.expire(failKey, 3600)) + .catch(() => {}) + + logger.error('❌ Failed to redeem card:', error) + res.status(400).json({ + success: false, + error: error.message + }) + } +}) + +// 📋 公开的兑换记录查询接口(通过 apiId 验证身份) +router.get('/api/redemption-history', async (req, res) => { + const quotaCardService = require('../services/quotaCardService') + + try { + const { apiId, limit = 50, offset = 0 } = req.query + + if (!apiId) { + return res.status(400).json({ + success: false, + error: '缺少 API ID' + }) + } + + // 验证 apiId 格式 + if ( + typeof apiId !== 'string' || + !apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i) + ) { + return res.status(400).json({ + success: false, + error: 'API ID 格式无效' + }) + } + + // 验证 API Key 存在 + const keyData = await redis.getApiKey(apiId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ + success: false, + error: 'API Key 不存在' + }) + } + + // 获取该 API Key 的兑换记录 + const result = await quotaCardService.getRedemptions({ + apiKeyId: apiId, + limit: parseInt(limit), + offset: parseInt(offset) + }) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Failed to get redemption history:', error) + res.status(500).json({ + success: false, + error: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/azureOpenaiRoutes.js b/src/routes/azureOpenaiRoutes.js new file mode 100644 index 0000000..9cf1431 --- /dev/null +++ b/src/routes/azureOpenaiRoutes.js @@ -0,0 +1,542 @@ +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const azureOpenaiAccountService = require('../services/account/azureOpenaiAccountService') +const azureOpenaiRelayService = require('../services/relay/azureOpenaiRelayService') +const apiKeyService = require('../services/apiKeyService') +const crypto = require('crypto') +const upstreamErrorHelper = require('../utils/upstreamErrorHelper') +const { createRequestDetailMeta } = require('../utils/requestDetailHelper') + +// 支持的模型列表 - 基于真实的 Azure OpenAI 模型 +const ALLOWED_MODELS = { + CHAT_MODELS: [ + 'gpt-4', + 'gpt-4-turbo', + 'gpt-4o', + 'gpt-4o-mini', + 'gpt-5', + 'gpt-5-mini', + 'gpt-35-turbo', + 'gpt-35-turbo-16k', + 'codex-mini' + ], + EMBEDDING_MODELS: ['text-embedding-ada-002', 'text-embedding-3-small', 'text-embedding-3-large'] +} + +const ALL_ALLOWED_MODELS = [...ALLOWED_MODELS.CHAT_MODELS, ...ALLOWED_MODELS.EMBEDDING_MODELS] + +// Azure OpenAI 稳定 API 版本 +// const AZURE_API_VERSION = '2024-02-01' // 当前未使用,保留以备后用 + +// 原子使用统计报告器 +class AtomicUsageReporter { + constructor() { + this.reportedUsage = new Set() + this.pendingReports = new Map() + } + + async reportOnce(requestId, usageData, apiKeyId, modelToRecord, accountId, requestMeta = null) { + if (this.reportedUsage.has(requestId)) { + logger.debug(`Usage already reported for request: ${requestId}`) + return false + } + + // 防止并发重复报告 + if (this.pendingReports.has(requestId)) { + return this.pendingReports.get(requestId) + } + + const reportPromise = this._performReport( + requestId, + usageData, + apiKeyId, + modelToRecord, + accountId, + requestMeta + ) + this.pendingReports.set(requestId, reportPromise) + + try { + const result = await reportPromise + this.reportedUsage.add(requestId) + return result + } finally { + this.pendingReports.delete(requestId) + // 清理过期的已报告记录 + setTimeout(() => this.reportedUsage.delete(requestId), 60 * 1000) // 1分钟后清理 + } + } + + async _performReport( + requestId, + usageData, + apiKeyId, + modelToRecord, + accountId, + requestMeta = null + ) { + try { + const inputTokens = usageData.prompt_tokens || usageData.input_tokens || 0 + const outputTokens = usageData.completion_tokens || usageData.output_tokens || 0 + const cacheCreateTokens = + usageData.prompt_tokens_details?.cache_creation_tokens || + usageData.input_tokens_details?.cache_creation_tokens || + 0 + const cacheReadTokens = + usageData.prompt_tokens_details?.cached_tokens || + usageData.input_tokens_details?.cached_tokens || + 0 + + await apiKeyService.recordUsage( + apiKeyId, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + modelToRecord, + accountId, + 'azure-openai', + null, + requestMeta + ) + + // 同步更新 Azure 账户的 lastUsedAt 和累计使用量 + try { + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + if (accountId) { + await azureOpenaiAccountService.updateAccountUsage(accountId, totalTokens) + } + } catch (acctErr) { + logger.warn(`Failed to update Azure account usage for ${accountId}: ${acctErr.message}`) + } + + logger.info( + `📊 Azure OpenAI Usage recorded for ${requestId}: ` + + `model=${modelToRecord}, ` + + `input=${inputTokens}, output=${outputTokens}, ` + + `cache_create=${cacheCreateTokens}, cache_read=${cacheReadTokens}` + ) + return true + } catch (error) { + logger.error('Failed to report Azure OpenAI usage:', error) + return false + } + } +} + +const usageReporter = new AtomicUsageReporter() + +// 健康检查 +router.get('/health', (req, res) => { + res.status(200).json({ + status: 'healthy', + service: 'azure-openai-relay', + timestamp: new Date().toISOString() + }) +}) + +// 获取可用模型列表(兼容 OpenAI API) +router.get('/models', authenticateApiKey, async (req, res) => { + try { + const models = ALL_ALLOWED_MODELS.map((model) => ({ + id: `azure/${model}`, + object: 'model', + created: Date.now(), + owned_by: 'azure-openai' + })) + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('Error fetching Azure OpenAI models:', error) + res.status(500).json({ error: { message: 'Failed to fetch models' } }) + } +}) + +// 处理聊天完成请求 +router.post('/chat/completions', authenticateApiKey, async (req, res) => { + const requestId = `azure_req_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` + const sessionId = req.sessionId || req.headers['x-session-id'] || null + + logger.info(`🚀 Azure OpenAI Chat Request ${requestId}`, { + apiKeyId: req.apiKey?.id, + sessionId, + model: req.body.model, + stream: req.body.stream || false, + messages: req.body.messages?.length || 0 + }) + + try { + // 获取绑定的 Azure OpenAI 账户 + let account = null + if (req.apiKey?.azureOpenaiAccountId) { + account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) + if (account) { + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + account.id, + 'azure-openai' + ) + if (isTempUnavailable) { + logger.warn(`⏱️ Bound Azure OpenAI account temporarily unavailable, falling back to pool`) + account = null + } + } + if (!account) { + logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) + } + } + + // 如果没有绑定账户或账户不可用,选择一个可用账户 + if (!account || account.isActive !== 'true') { + account = await azureOpenaiAccountService.selectAvailableAccount(sessionId) + } + + // 发送请求到 Azure OpenAI + const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({ + account, + requestBody: req.body, + headers: req.headers, + isStream: req.body.stream || false, + endpoint: 'chat/completions' + }) + + // 检查上游响应状态码(仅对认证/限流/服务端错误暂停,不对 400/404 等客户端错误暂停) + const azureAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + const shouldPause = + account?.id && + !azureAutoProtectionDisabled && + (response.status === 401 || + response.status === 403 || + response.status === 429 || + response.status >= 500) + if (shouldPause) { + const customTtl = + response.status === 429 ? upstreamErrorHelper.parseRetryAfter(response.headers) : null + await upstreamErrorHelper + .markTempUnavailable(account.id, 'azure-openai', response.status, customTtl) + .catch(() => {}) + } + + // 处理流式响应 + if (req.body.stream) { + await azureOpenaiRelayService.handleStreamResponse(response, res, { + onEnd: async ({ usageData, actualModel }) => { + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: true, + statusCode: res.statusCode + }) + ) + } + }, + onError: (error) => { + logger.error(`Stream error for request ${requestId}:`, error) + } + }) + } else { + // 处理非流式响应 + const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse( + response, + res + ) + + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: false, + statusCode: response.status + }) + ) + } + } + } catch (error) { + logger.error(`Azure OpenAI request failed ${requestId}:`, error) + + if (!res.headersSent) { + const statusCode = error.response?.status || 500 + const errorMessage = + error.response?.data?.error?.message || error.message || 'Internal server error' + + res.status(statusCode).json({ + error: { + message: errorMessage, + type: 'azure_openai_error', + code: error.code || 'unknown' + } + }) + } + } +}) + +// 处理响应请求 (gpt-5, gpt-5-mini, codex-mini models) +router.post('/responses', authenticateApiKey, async (req, res) => { + const requestId = `azure_resp_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` + const sessionId = req.sessionId || req.headers['x-session-id'] || null + + logger.info(`🚀 Azure OpenAI Responses Request ${requestId}`, { + apiKeyId: req.apiKey?.id, + sessionId, + model: req.body.model, + stream: req.body.stream || false, + messages: req.body.messages?.length || 0 + }) + + try { + // 获取绑定的 Azure OpenAI 账户 + let account = null + if (req.apiKey?.azureOpenaiAccountId) { + account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) + if (account) { + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + account.id, + 'azure-openai' + ) + if (isTempUnavailable) { + logger.warn(`⏱️ Bound Azure OpenAI account temporarily unavailable, falling back to pool`) + account = null + } + } + if (!account) { + logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) + } + } + + // 如果没有绑定账户或账户不可用,选择一个可用账户 + if (!account || account.isActive !== 'true') { + account = await azureOpenaiAccountService.selectAvailableAccount(sessionId) + } + + // 发送请求到 Azure OpenAI + const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({ + account, + requestBody: req.body, + headers: req.headers, + isStream: req.body.stream || false, + endpoint: 'responses' + }) + + // 检查上游响应状态码(仅对认证/限流/服务端错误暂停,不对 400/404 等客户端错误暂停) + const azureAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + const shouldPause = + account?.id && + !azureAutoProtectionDisabled && + (response.status === 401 || + response.status === 403 || + response.status === 429 || + response.status >= 500) + if (shouldPause) { + const customTtl = + response.status === 429 ? upstreamErrorHelper.parseRetryAfter(response.headers) : null + await upstreamErrorHelper + .markTempUnavailable(account.id, 'azure-openai', response.status, customTtl) + .catch(() => {}) + } + + // 处理流式响应 + if (req.body.stream) { + await azureOpenaiRelayService.handleStreamResponse(response, res, { + onEnd: async ({ usageData, actualModel }) => { + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: true, + statusCode: res.statusCode + }) + ) + } + }, + onError: (error) => { + logger.error(`Stream error for request ${requestId}:`, error) + } + }) + } else { + // 处理非流式响应 + const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse( + response, + res + ) + + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: false, + statusCode: response.status + }) + ) + } + } + } catch (error) { + logger.error(`Azure OpenAI responses request failed ${requestId}:`, error) + + if (!res.headersSent) { + const statusCode = error.response?.status || 500 + const errorMessage = + error.response?.data?.error?.message || error.message || 'Internal server error' + + res.status(statusCode).json({ + error: { + message: errorMessage, + type: 'azure_openai_error', + code: error.code || 'unknown' + } + }) + } + } +}) + +// 处理嵌入请求 +router.post('/embeddings', authenticateApiKey, async (req, res) => { + const requestId = `azure_embed_${Date.now()}_${crypto.randomBytes(8).toString('hex')}` + const sessionId = req.sessionId || req.headers['x-session-id'] || null + + logger.info(`🚀 Azure OpenAI Embeddings Request ${requestId}`, { + apiKeyId: req.apiKey?.id, + sessionId, + model: req.body.model, + input: Array.isArray(req.body.input) ? req.body.input.length : 1 + }) + + try { + // 获取绑定的 Azure OpenAI 账户 + let account = null + if (req.apiKey?.azureOpenaiAccountId) { + account = await azureOpenaiAccountService.getAccount(req.apiKey.azureOpenaiAccountId) + if (account) { + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + account.id, + 'azure-openai' + ) + if (isTempUnavailable) { + logger.warn(`⏱️ Bound Azure OpenAI account temporarily unavailable, falling back to pool`) + account = null + } + } + if (!account) { + logger.warn(`Bound Azure OpenAI account not found: ${req.apiKey.azureOpenaiAccountId}`) + } + } + + // 如果没有绑定账户或账户不可用,选择一个可用账户 + if (!account || account.isActive !== 'true') { + account = await azureOpenaiAccountService.selectAvailableAccount(sessionId) + } + + // 发送请求到 Azure OpenAI + const response = await azureOpenaiRelayService.handleAzureOpenAIRequest({ + account, + requestBody: req.body, + headers: req.headers, + isStream: false, + endpoint: 'embeddings' + }) + + // 检查上游响应状态码(仅对认证/限流/服务端错误暂停,不对 400/404 等客户端错误暂停) + const azureAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + const shouldPause = + account?.id && + !azureAutoProtectionDisabled && + (response.status === 401 || + response.status === 403 || + response.status === 429 || + response.status >= 500) + if (shouldPause) { + const customTtl = + response.status === 429 ? upstreamErrorHelper.parseRetryAfter(response.headers) : null + await upstreamErrorHelper + .markTempUnavailable(account.id, 'azure-openai', response.status, customTtl) + .catch(() => {}) + } + + // 处理响应 + const { usageData, actualModel } = azureOpenaiRelayService.handleNonStreamResponse( + response, + res + ) + + if (usageData) { + const modelToRecord = actualModel || req.body.model || 'unknown' + await usageReporter.reportOnce( + requestId, + usageData, + req.apiKey.id, + modelToRecord, + account.id, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: false, + statusCode: response.status + }) + ) + } + } catch (error) { + logger.error(`Azure OpenAI embeddings request failed ${requestId}:`, error) + + if (!res.headersSent) { + const statusCode = error.response?.status || 500 + const errorMessage = + error.response?.data?.error?.message || error.message || 'Internal server error' + + res.status(statusCode).json({ + error: { + message: errorMessage, + type: 'azure_openai_error', + code: error.code || 'unknown' + } + }) + } + } +}) + +// 获取使用统计 +router.get('/usage', authenticateApiKey, async (req, res) => { + try { + const { start_date, end_date } = req.query + const usage = await apiKeyService.getUsageStats(req.apiKey.id, start_date, end_date) + + res.json({ + object: 'usage', + data: usage + }) + } catch (error) { + logger.error('Error fetching Azure OpenAI usage:', error) + res.status(500).json({ error: { message: 'Failed to fetch usage data' } }) + } +}) + +module.exports = router diff --git a/src/routes/droidRoutes.js b/src/routes/droidRoutes.js new file mode 100644 index 0000000..7e471ce --- /dev/null +++ b/src/routes/droidRoutes.js @@ -0,0 +1,196 @@ +const crypto = require('crypto') +const express = require('express') +const { authenticateApiKey } = require('../middleware/auth') +const droidRelayService = require('../services/relay/droidRelayService') +const sessionHelper = require('../utils/sessionHelper') +const logger = require('../utils/logger') +const apiKeyService = require('../services/apiKeyService') + +const router = express.Router() + +function hasDroidPermission(apiKeyData) { + return apiKeyService.hasPermission(apiKeyData?.permissions, 'droid') +} + +/** + * Droid API 转发路由 + * + * 支持的 Factory.ai 端点: + * - /droid/claude - Anthropic (Claude) Messages API + * - /droid/openai - OpenAI Responses API + * - /droid/comm - OpenAI Chat Completions API + */ + +// Claude (Anthropic) 端点 - /v1/messages +router.post('/claude/v1/messages', authenticateApiKey, async (req, res) => { + try { + const sessionHash = sessionHelper.generateSessionHash(req.body) + + if (!hasDroidPermission(req.apiKey)) { + logger.security( + `🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'permission_denied', + message: '此 API Key 未启用 Droid 权限' + }) + } + + const result = await droidRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + { endpointType: 'anthropic', sessionHash } + ) + + // 如果是流式响应,已经在 relayService 中处理了 + if (result.streaming) { + return + } + + // 非流式响应 + res.status(result.statusCode).set(result.headers).send(result.body) + } catch (error) { + logger.error('Droid Claude relay error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +// Comm 端点 - /v1/chat/completions(OpenAI Chat Completions 格式) +router.post('/comm/v1/chat/completions', authenticateApiKey, async (req, res) => { + try { + const sessionId = + req.headers['session_id'] || + req.headers['x-session-id'] || + req.body?.session_id || + req.body?.conversation_id || + null + + const sessionHash = sessionId + ? crypto.createHash('sha256').update(String(sessionId)).digest('hex') + : null + + if (!hasDroidPermission(req.apiKey)) { + logger.security( + `🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'permission_denied', + message: '此 API Key 未启用 Droid 权限' + }) + } + + const result = await droidRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + { endpointType: 'comm', sessionHash } + ) + + if (result.streaming) { + return + } + + res.status(result.statusCode).set(result.headers).send(result.body) + } catch (error) { + logger.error('Droid Comm relay error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +// OpenAI 端点 - /v1/responses +router.post(['/openai/v1/responses', '/openai/responses'], authenticateApiKey, async (req, res) => { + try { + const sessionId = + req.headers['session_id'] || + req.headers['x-session-id'] || + req.body?.session_id || + req.body?.conversation_id || + null + + const sessionHash = sessionId + ? crypto.createHash('sha256').update(String(sessionId)).digest('hex') + : null + + if (!hasDroidPermission(req.apiKey)) { + logger.security( + `🚫 API Key ${req.apiKey?.id || 'unknown'} 缺少 Droid 权限,拒绝访问 ${req.originalUrl}` + ) + return res.status(403).json({ + error: 'permission_denied', + message: '此 API Key 未启用 Droid 权限' + }) + } + + const result = await droidRelayService.relayRequest( + req.body, + req.apiKey, + req, + res, + req.headers, + { endpointType: 'openai', sessionHash } + ) + + if (result.streaming) { + return + } + + res.status(result.statusCode).set(result.headers).send(result.body) + } catch (error) { + logger.error('Droid OpenAI relay error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +// 模型列表端点(兼容性) +router.get('/*/v1/models', authenticateApiKey, async (req, res) => { + try { + // 返回可用的模型列表 + const models = [ + { + id: 'claude-opus-4-1-20250805', + object: 'model', + created: Date.now(), + owned_by: 'anthropic' + }, + { + id: 'claude-sonnet-4-5-20250929', + object: 'model', + created: Date.now(), + owned_by: 'anthropic' + }, + { + id: 'gpt-5-2025-08-07', + object: 'model', + created: Date.now(), + owned_by: 'openai' + } + ] + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('Droid models list error:', error) + res.status(500).json({ + error: 'internal_server_error', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/geminiRoutes.js b/src/routes/geminiRoutes.js new file mode 100644 index 0000000..8fdbcd4 --- /dev/null +++ b/src/routes/geminiRoutes.js @@ -0,0 +1,115 @@ +/** + * Gemini API 路由模块(精简版) + * + * 该模块只包含 geminiRoutes 独有的路由: + * - /messages - OpenAI 兼容格式消息处理 + * - /models - 模型列表 + * - /usage - 使用统计 + * - /key-info - API Key 信息 + * - /v1internal:listExperiments - 实验列表 + * - /v1beta/models/:modelName:listExperiments - 带模型参数的实验列表 + * + * 其他标准 Gemini API 路由由 standardGeminiRoutes.js 处理。 + * 所有处理函数都从 geminiHandlers.js 导入,以避免代码重复。 + */ + +const express = require('express') +const router = express.Router() +const { authenticateApiKey } = require('../middleware/auth') + +// 从 handlers/geminiHandlers.js 导入所有处理函数 +const { + handleMessages, + handleModels, + handleUsage, + handleKeyInfo, + handleSimpleEndpoint, + // 以下函数需要导出供其他模块使用(如 unified.js) + handleGenerateContent, + handleStreamGenerateContent, + handleLoadCodeAssist, + handleOnboardUser, + handleRetrieveUserQuota, + handleCountTokens, + handleStandardGenerateContent, + handleStandardStreamGenerateContent, + ensureGeminiPermissionMiddleware +} = require('../handlers/geminiHandlers') + +// ============================================================================ +// OpenAI 兼容格式路由 +// ============================================================================ + +/** + * POST /messages + * OpenAI 兼容格式的消息处理端点 + */ +router.post('/messages', authenticateApiKey, handleMessages) + +// ============================================================================ +// 模型和信息路由 +// ============================================================================ + +/** + * GET /models + * 获取可用模型列表 + */ +router.get('/models', authenticateApiKey, handleModels) + +/** + * GET /usage + * 获取使用情况统计 + */ +router.get('/usage', authenticateApiKey, handleUsage) + +/** + * GET /key-info + * 获取 API Key 信息 + */ +router.get('/key-info', authenticateApiKey, handleKeyInfo) + +// ============================================================================ +// v1internal 独有路由 +// ============================================================================ + +/** + * POST /v1internal:listExperiments + * 列出实验(只有 geminiRoutes 定义此路由) + */ +router.post( + '/v1internal\\:listExperiments', + authenticateApiKey, + handleSimpleEndpoint('listExperiments') +) + +/** + * POST /v1internal:retrieveUserQuota + * 获取用户配额信息(Gemini CLI 0.22.2+ 需要) + */ +router.post('/v1internal\\:retrieveUserQuota', authenticateApiKey, handleRetrieveUserQuota) + +/** + * POST /v1beta/models/:modelName:listExperiments + * 带模型参数的实验列表(只有 geminiRoutes 定义此路由) + */ +router.post( + '/v1beta/models/:modelName\\:listExperiments', + authenticateApiKey, + handleSimpleEndpoint('listExperiments') +) + +// ============================================================================ +// 导出 +// ============================================================================ + +module.exports = router + +// 导出处理函数供其他模块使用(如 unified.js、standardGeminiRoutes.js) +module.exports.handleLoadCodeAssist = handleLoadCodeAssist +module.exports.handleOnboardUser = handleOnboardUser +module.exports.handleCountTokens = handleCountTokens +module.exports.handleGenerateContent = handleGenerateContent +module.exports.handleStreamGenerateContent = handleStreamGenerateContent +module.exports.handleStandardGenerateContent = handleStandardGenerateContent +module.exports.handleStandardStreamGenerateContent = handleStandardStreamGenerateContent +module.exports.ensureGeminiPermissionMiddleware = ensureGeminiPermissionMiddleware diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js new file mode 100644 index 0000000..89fba4b --- /dev/null +++ b/src/routes/openaiClaudeRoutes.js @@ -0,0 +1,606 @@ +/** + * OpenAI 兼容的 Claude API 路由 + * 提供 OpenAI 格式的 API 接口,内部转发到 Claude + */ + +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const claudeRelayService = require('../services/relay/claudeRelayService') +const claudeConsoleRelayService = require('../services/relay/claudeConsoleRelayService') +const openaiToClaude = require('../services/openaiToClaude') +const apiKeyService = require('../services/apiKeyService') +const unifiedClaudeScheduler = require('../services/scheduler/unifiedClaudeScheduler') +const claudeCodeHeadersService = require('../services/claudeCodeHeadersService') +const { getSafeMessage } = require('../utils/errorSanitizer') +const sessionHelper = require('../utils/sessionHelper') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const pricingService = require('../services/pricingService') +const { getEffectiveModel } = require('../utils/modelHelper') +const { createRequestDetailMeta } = require('../utils/requestDetailHelper') + +// 🔧 辅助函数:检查 API Key 权限 +function checkPermissions(apiKeyData, requiredPermission = 'claude') { + return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission) +} + +function queueRateLimitUpdate( + rateLimitInfo, + usageSummary, + model, + context = '', + keyId = null, + accountType = null, + preCalculatedCost = null +) { + if (!rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + updateRateLimitCounters(rateLimitInfo, usageSummary, model, keyId, accountType, preCalculatedCost) + .then(({ totalTokens, totalCost }) => { + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + }) + .catch((error) => { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + }) +} + +// 📋 OpenAI 兼容的模型列表端点 +router.get('/v1/models', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey + + // 检查权限 + if (!checkPermissions(apiKeyData, 'claude')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Claude', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // Claude 模型列表 - 只返回 opus-4 和 sonnet-4 + let models = [ + { + id: 'claude-opus-4-20250514', + object: 'model', + created: 1736726400, // 2025-01-13 + owned_by: 'anthropic' + }, + { + id: 'claude-sonnet-4-20250514', + object: 'model', + created: 1736726400, // 2025-01-13 + owned_by: 'anthropic' + } + ] + + // 如果启用了模型限制,视为黑名单:过滤掉受限模型 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { + models = models.filter((model) => !apiKeyData.restrictedModels.includes(model.id)) + } + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('❌ Failed to get OpenAI-Claude models:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve models', + type: 'server_error', + code: 'internal_error' + } + }) + } + return undefined +}) + +// 📄 OpenAI 兼容的模型详情端点 +router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey + const modelId = req.params.model + + // 检查权限 + if (!checkPermissions(apiKeyData, 'claude')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Claude', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 模型限制(黑名单):命中则直接拒绝 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { + if (apiKeyData.restrictedModels.includes(modelId)) { + return res.status(404).json({ + error: { + message: `Model '${modelId}' not found`, + type: 'invalid_request_error', + code: 'model_not_found' + } + }) + } + } + + // 从 model_pricing.json 获取模型信息 + const modelData = pricingService.getModelPricing(modelId) + + // 构建标准 OpenAI 格式的模型响应 + let modelInfo + + if (modelData) { + // 如果在 pricing 文件中找到了模型 + modelInfo = { + id: modelId, + object: 'model', + created: 1736726400, // 2025-01-13 + owned_by: 'anthropic', + permission: [], + root: modelId, + parent: null + } + } else { + // 如果没找到,返回默认信息(但仍保持正确格式) + modelInfo = { + id: modelId, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'anthropic', + permission: [], + root: modelId, + parent: null + } + } + + res.json(modelInfo) + } catch (error) { + logger.error('❌ Failed to get model details:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve model details', + type: 'server_error', + code: 'internal_error' + } + }) + } + return undefined +}) + +// 🔧 处理聊天完成请求的核心函数 +async function handleChatCompletion(req, res, apiKeyData) { + const startTime = Date.now() + let abortController = null + + try { + // 检查权限 + if (!checkPermissions(apiKeyData, 'claude')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Claude', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 记录原始请求 + logger.debug('📥 Received OpenAI format request:', { + model: req.body.model, + messageCount: req.body.messages?.length, + stream: req.body.stream, + maxTokens: req.body.max_tokens + }) + + // 转换 OpenAI 请求为 Claude 格式 + const claudeRequest = openaiToClaude.convertRequest(req.body) + + // 模型限制(黑名单):命中受限模型则拒绝 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels?.length > 0) { + const effectiveModel = getEffectiveModel(claudeRequest.model || '') + if (apiKeyData.restrictedModels.includes(effectiveModel)) { + return res.status(403).json({ + error: { + message: `Model ${req.body.model} is not allowed for this API key`, + type: 'invalid_request_error', + code: 'model_not_allowed' + } + }) + } + } + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(claudeRequest) + + // 选择可用的Claude账户 + let accountSelection + try { + accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + claudeRequest.model + ) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = claudeRelayService._buildStandardRateLimitMessage(error.rateLimitEndAt) + return res.status(403).json({ + error: 'upstream_rate_limited', + message: limitMessage + }) + } + throw error + } + const { accountId, accountType } = accountSelection + + // 获取该账号存储的 Claude Code headers + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) + + logger.debug(`📋 Using Claude Code headers for account ${accountId}:`, { + userAgent: claudeCodeHeaders['user-agent'] + }) + + // 处理流式请求 + if (claudeRequest.stream) { + logger.info(`🌊 Processing OpenAI stream request for model: ${req.body.model}`) + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + // 创建中止控制器 + abortController = new AbortController() + + // 处理客户端断开 + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info('🔌 Client disconnected, aborting Claude request') + abortController.abort() + } + }) + + // 使用转换后的响应流 (根据账户类型选择转发服务) + // 创建 usage 回调函数 + const usageCallback = (usage) => { + // 记录使用统计 + if (usage && usage.input_tokens !== undefined && usage.output_tokens !== undefined) { + const model = usage.model || claudeRequest.model + const cacheCreateTokens = + (usage.cache_creation && typeof usage.cache_creation === 'object' + ? (usage.cache_creation.ephemeral_5m_input_tokens || 0) + + (usage.cache_creation.ephemeral_1h_input_tokens || 0) + : usage.cache_creation_input_tokens || 0) || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + const usageWithRequestMeta = { ...usage } + const requestBetaHeader = + req.headers['anthropic-beta'] || + req.headers['Anthropic-Beta'] || + req.headers['ANTHROPIC-BETA'] + if (requestBetaHeader) { + usageWithRequestMeta.request_anthropic_beta = requestBetaHeader + } + if (typeof claudeRequest?.speed === 'string' && claudeRequest.speed.trim()) { + usageWithRequestMeta.request_speed = claudeRequest.speed.trim().toLowerCase() + } + + // 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据 + apiKeyService + .recordUsageWithDetails( + apiKeyData.id, + usageWithRequestMeta, // 传递 usage + 请求模式元信息(beta/speed) + model, + accountId, + accountType, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: true, + statusCode: res.statusCode + }) + ) + .then((costs) => { + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens, + cacheReadTokens + }, + model, + `openai-${accountType}-stream`, + req.apiKey?.id, + accountType, + costs + ) + }) + .catch((error) => { + logger.error('❌ Failed to record usage:', error) + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens, + cacheReadTokens + }, + model, + `openai-${accountType}-stream`, + req.apiKey?.id, + accountType + ) + }) + } + } + + // 创建流转换器 + const sessionId = `chatcmpl-${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}` + const streamTransformer = (chunk) => + openaiToClaude.convertStreamChunk(chunk, req.body.model, sessionId) + + // 根据账户类型选择转发服务 + if (accountType === 'claude-console') { + // Claude Console 账户使用 Console 转发服务 + await claudeConsoleRelayService.relayStreamRequestWithUsageCapture( + claudeRequest, + apiKeyData, + res, + claudeCodeHeaders, + usageCallback, + accountId, + streamTransformer + ) + } else { + // Claude Official 账户使用标准转发服务 + await claudeRelayService.relayStreamRequestWithUsageCapture( + claudeRequest, + apiKeyData, + res, + claudeCodeHeaders, + usageCallback, + streamTransformer, + { + betaHeader: + 'oauth-2025-04-20,claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14' + } + ) + } + } else { + // 非流式请求 + logger.info(`📄 Processing OpenAI non-stream request for model: ${req.body.model}`) + + // 根据账户类型选择转发服务 + let claudeResponse + if (accountType === 'claude-console') { + // Claude Console 账户使用 Console 转发服务 + claudeResponse = await claudeConsoleRelayService.relayRequest( + claudeRequest, + apiKeyData, + req, + res, + claudeCodeHeaders, + accountId + ) + } else { + // Claude Official 账户使用标准转发服务 + claudeResponse = await claudeRelayService.relayRequest( + claudeRequest, + apiKeyData, + req, + res, + claudeCodeHeaders, + { betaHeader: 'oauth-2025-04-20' } + ) + } + + // 解析 Claude 响应 + let claudeData + try { + claudeData = JSON.parse(claudeResponse.body) + } catch (error) { + logger.error('❌ Failed to parse Claude response:', error) + return res.status(502).json({ + error: { + message: 'Invalid response from Claude API', + type: 'api_error', + code: 'invalid_response' + } + }) + } + + // 处理错误响应 + if (claudeResponse.statusCode >= 400) { + return res.status(claudeResponse.statusCode).json({ + error: { + message: claudeData.error?.message || 'Claude API error', + type: claudeData.error?.type || 'api_error', + code: claudeData.error?.code || 'unknown_error' + } + }) + } + + // 转换为 OpenAI 格式 + const openaiResponse = openaiToClaude.convertResponse(claudeData, req.body.model) + + // 记录使用统计 + if (claudeData.usage) { + const { usage } = claudeData + const cacheCreateTokens = + (usage.cache_creation && typeof usage.cache_creation === 'object' + ? (usage.cache_creation.ephemeral_5m_input_tokens || 0) + + (usage.cache_creation.ephemeral_1h_input_tokens || 0) + : usage.cache_creation_input_tokens || 0) || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + const usageWithRequestMeta = { ...usage } + const requestBetaHeader = + req.headers['anthropic-beta'] || + req.headers['Anthropic-Beta'] || + req.headers['ANTHROPIC-BETA'] + if (requestBetaHeader) { + usageWithRequestMeta.request_anthropic_beta = requestBetaHeader + } + if (typeof claudeRequest?.speed === 'string' && claudeRequest.speed.trim()) { + usageWithRequestMeta.request_speed = claudeRequest.speed.trim().toLowerCase() + } + // 使用新的 recordUsageWithDetails 方法来支持详细的缓存数据 + apiKeyService + .recordUsageWithDetails( + apiKeyData.id, + usageWithRequestMeta, // 传递 usage + 请求模式元信息(beta/speed) + claudeRequest.model, + accountId, + accountType, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: false, + statusCode: res.statusCode + }) + ) + .then((costs) => { + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens, + cacheReadTokens + }, + claudeRequest.model, + `openai-${accountType}-non-stream`, + req.apiKey?.id, + accountType, + costs + ) + }) + .catch((error) => { + logger.error('❌ Failed to record usage:', error) + queueRateLimitUpdate( + req.rateLimitInfo, + { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens, + cacheReadTokens + }, + claudeRequest.model, + `openai-${accountType}-non-stream`, + req.apiKey?.id, + accountType + ) + }) + } + + // 返回 OpenAI 格式响应 + res.json(openaiResponse) + } + + const duration = Date.now() - startTime + logger.info(`✅ OpenAI-Claude request completed in ${duration}ms`) + } catch (error) { + // 客户端主动断开连接是正常情况,使用 INFO 级别 + if (error.message === 'Client disconnected') { + logger.info('🔌 OpenAI-Claude stream ended: Client disconnected') + } else { + logger.error('❌ OpenAI-Claude request error:', error) + } + + // 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT + if (!res.headersSent) { + // 客户端断开使用 499 状态码 (Client Closed Request) + if (error.message === 'Client disconnected') { + res.status(499).end() + } else { + const status = error.status || 500 + res.status(status).json({ + error: { + message: getSafeMessage(error), + type: 'server_error', + code: 'internal_error' + } + }) + } + } + } finally { + // 清理资源 + if (abortController) { + abortController = null + } + } + return undefined +} + +// 🚀 OpenAI 兼容的聊天完成端点 +router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { + await handleChatCompletion(req, res, req.apiKey) +}) + +// 🔧 OpenAI 兼容的 completions 端点(传统格式,转换为 chat 格式) +router.post('/v1/completions', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey + + // 验证必需参数 + if (!req.body.prompt) { + return res.status(400).json({ + error: { + message: 'Prompt is required', + type: 'invalid_request_error', + code: 'invalid_request' + } + }) + } + + // 将传统 completions 格式转换为 chat 格式 + const originalBody = req.body + req.body = { + model: originalBody.model, + messages: [ + { + role: 'user', + content: originalBody.prompt + } + ], + max_tokens: originalBody.max_tokens, + temperature: originalBody.temperature, + top_p: originalBody.top_p, + stream: originalBody.stream, + stop: originalBody.stop, + n: originalBody.n || 1, + presence_penalty: originalBody.presence_penalty, + frequency_penalty: originalBody.frequency_penalty, + logit_bias: originalBody.logit_bias, + user: originalBody.user + } + + // 使用共享的处理函数 + await handleChatCompletion(req, res, apiKeyData) + } catch (error) { + logger.error('❌ OpenAI completions error:', error) + res.status(500).json({ + error: { + message: 'Failed to process completion request', + type: 'server_error', + code: 'internal_error' + } + }) + } + return undefined +}) + +module.exports = router +module.exports.handleChatCompletion = handleChatCompletion diff --git a/src/routes/openaiGeminiRoutes.js b/src/routes/openaiGeminiRoutes.js new file mode 100644 index 0000000..c019375 --- /dev/null +++ b/src/routes/openaiGeminiRoutes.js @@ -0,0 +1,868 @@ +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const { authenticateApiKey } = require('../middleware/auth') +const geminiAccountService = require('../services/account/geminiAccountService') +const unifiedGeminiScheduler = require('../services/scheduler/unifiedGeminiScheduler') +const { getAvailableModels } = require('../services/relay/geminiRelayService') +const crypto = require('crypto') +const apiKeyService = require('../services/apiKeyService') +const { createRequestDetailMeta } = require('../utils/requestDetailHelper') + +// 生成会话哈希 +function generateSessionHash(req) { + const authSource = + req.headers['authorization'] || req.headers['x-api-key'] || req.headers['x-goog-api-key'] + + const sessionData = [req.headers['user-agent'], req.ip, authSource?.substring(0, 20)] + .filter(Boolean) + .join(':') + + return crypto.createHash('sha256').update(sessionData).digest('hex') +} + +function ensureAntigravityProjectId(account) { + if (account.projectId) { + return account.projectId + } + if (account.tempProjectId) { + return account.tempProjectId + } + return `ag-${crypto.randomBytes(8).toString('hex')}` +} + +// 检查 API Key 权限 +function checkPermissions(apiKeyData, requiredPermission = 'gemini') { + return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission) +} + +// 转换 OpenAI 消息格式到 Gemini 格式 +function convertMessagesToGemini(messages) { + const contents = [] + let systemInstruction = '' + + // 辅助函数:提取文本内容 + function extractTextContent(content) { + // 处理 null 或 undefined + if (content === null || content === undefined) { + return '' + } + + // 处理字符串 + if (typeof content === 'string') { + return content + } + + // 处理数组格式的内容 + if (Array.isArray(content)) { + return content + .map((item) => { + if (item === null || item === undefined) { + return '' + } + if (typeof item === 'string') { + return item + } + if (typeof item === 'object') { + // 处理 {type: 'text', text: '...'} 格式 + if (item.type === 'text' && item.text) { + return item.text + } + // 处理 {text: '...'} 格式 + if (item.text) { + return item.text + } + // 处理嵌套的对象或数组 + if (item.content) { + return extractTextContent(item.content) + } + } + return '' + }) + .join('') + } + + // 处理对象格式的内容 + if (typeof content === 'object') { + // 处理 {text: '...'} 格式 + if (content.text) { + return content.text + } + // 处理 {content: '...'} 格式 + if (content.content) { + return extractTextContent(content.content) + } + // 处理 {parts: [{text: '...'}]} 格式 + if (content.parts && Array.isArray(content.parts)) { + return content.parts + .map((part) => { + if (part && part.text) { + return part.text + } + return '' + }) + .join('') + } + } + + // 最后的后备选项:只有在内容确实不为空且有意义时才转换为字符串 + if ( + content !== undefined && + content !== null && + content !== '' && + typeof content !== 'object' + ) { + return String(content) + } + + return '' + } + + for (const message of messages) { + const textContent = extractTextContent(message.content) + + if (message.role === 'system') { + systemInstruction += (systemInstruction ? '\n\n' : '') + textContent + } else if (message.role === 'user') { + contents.push({ + role: 'user', + parts: [{ text: textContent }] + }) + } else if (message.role === 'assistant') { + contents.push({ + role: 'model', + parts: [{ text: textContent }] + }) + } + } + + return { contents, systemInstruction } +} + +// 转换 Gemini 响应到 OpenAI 格式 +function convertGeminiResponseToOpenAI(geminiResponse, model, stream = false) { + if (stream) { + // 处理流式响应 - 原样返回 SSE 数据 + return geminiResponse + } else { + // 非流式响应转换 + // 处理嵌套的 response 结构 + const actualResponse = geminiResponse.response || geminiResponse + + if (actualResponse.candidates && actualResponse.candidates.length > 0) { + const candidate = actualResponse.candidates[0] + const content = candidate.content?.parts?.[0]?.text || '' + const finishReason = candidate.finishReason?.toLowerCase() || 'stop' + + // 计算 token 使用量 + const usage = actualResponse.usageMetadata || { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + + return { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content + }, + finish_reason: finishReason + } + ], + usage: { + prompt_tokens: usage.promptTokenCount, + completion_tokens: usage.candidatesTokenCount, + total_tokens: usage.totalTokenCount + } + } + } else { + throw new Error('No response from Gemini') + } + } +} + +// OpenAI 兼容的聊天完成端点 +router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { + const startTime = Date.now() + let abortController = null + let account = null // Declare account outside try block for error handling + let accountSelection = null // Declare accountSelection for error handling + let sessionHash = null // Declare sessionHash for error handling + + try { + const apiKeyData = req.apiKey + + // 检查权限 + if (!checkPermissions(apiKeyData, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + // 处理请求体结构 - 支持多种格式 + let requestBody = req.body + + // 如果请求体被包装在 body 字段中,解包它 + if (req.body.body && typeof req.body.body === 'object') { + requestBody = req.body.body + } + + // 从 URL 路径中提取模型信息(如果存在) + let urlModel = null + const urlPath = req.body?.config?.url || req.originalUrl || req.url + const modelMatch = urlPath.match(/\/([^/]+):(?:stream)?[Gg]enerateContent/) + if (modelMatch) { + urlModel = modelMatch[1] + logger.debug(`Extracted model from URL: ${urlModel}`) + } + + // 提取请求参数 + const { + messages: requestMessages, + contents: requestContents, + model: bodyModel = 'gemini-2.0-flash-exp', + temperature = 0.7, + max_tokens = 4096, + stream = false + } = requestBody + + // 检查URL中是否包含stream标识 + const isStreamFromUrl = urlPath && urlPath.includes('streamGenerateContent') + const actualStream = stream || isStreamFromUrl + + // 优先使用 URL 中的模型,其次是请求体中的模型 + const model = urlModel || bodyModel + + // 支持两种格式: OpenAI 的 messages 或 Gemini 的 contents + let messages = requestMessages + if (requestContents && Array.isArray(requestContents)) { + messages = requestContents + } + + // 验证必需参数 + if (!messages || !Array.isArray(messages) || messages.length === 0) { + return res.status(400).json({ + error: { + message: 'Messages array is required', + type: 'invalid_request_error', + code: 'invalid_request' + } + }) + } + + // 检查模型限制 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) { + if (!apiKeyData.restrictedModels.includes(model)) { + return res.status(403).json({ + error: { + message: `Model ${model} is not allowed for this API key`, + type: 'invalid_request_error', + code: 'model_not_allowed' + } + }) + } + } + + // 转换消息格式 + const { contents: geminiContents, systemInstruction } = convertMessagesToGemini(messages) + + // 构建 Gemini 请求体 + const geminiRequestBody = { + contents: geminiContents, + generationConfig: { + temperature, + maxOutputTokens: max_tokens, + candidateCount: 1 + } + } + + if (systemInstruction) { + geminiRequestBody.systemInstruction = { parts: [{ text: systemInstruction }] } + } + + // 生成会话哈希用于粘性会话 + sessionHash = generateSessionHash(req) + + // 选择可用的 Gemini 账户 + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + model + ) + account = await geminiAccountService.getAccount(accountSelection.accountId) + } catch (error) { + logger.error('Failed to select Gemini account:', error) + account = null + } + + if (!account) { + return res.status(503).json({ + error: { + message: 'No available Gemini accounts', + type: 'service_unavailable', + code: 'service_unavailable' + } + }) + } + + logger.info(`Using Gemini account: ${account.id} for API key: ${apiKeyData.id}`) + + // 标记账户被使用 + await geminiAccountService.markAccountUsed(account.id) + + // 解析账户的代理配置 + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + // 创建中止控制器 + abortController = new AbortController() + + // 处理客户端断开连接 + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info('Client disconnected, aborting Gemini request') + abortController.abort() + } + }) + + // 获取OAuth客户端 + const client = await geminiAccountService.getOauthClient( + account.accessToken, + account.refreshToken, + proxyConfig, + account.oauthProvider + ) + if (actualStream) { + // 流式响应 + const oauthProvider = account.oauthProvider || 'gemini-cli' + let { projectId } = account + + if (oauthProvider === 'antigravity') { + projectId = ensureAntigravityProjectId(account) + if (!account.projectId && account.tempProjectId !== projectId) { + await geminiAccountService.updateTempProjectId(account.id, projectId) + account.tempProjectId = projectId + } + } + + logger.info('StreamGenerateContent request', { + model, + projectId, + apiKeyId: apiKeyData.id + }) + + const streamResponse = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentStreamAntigravity( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, + apiKeyData.id, // 使用 API Key ID 作为 session ID + abortController.signal, // 传递中止信号 + proxyConfig // 传递代理配置 + ) + : await geminiAccountService.generateContentStream( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, // 使用有权限的项目ID + apiKeyData.id, // 使用 API Key ID 作为 session ID + abortController.signal, // 传递中止信号 + proxyConfig // 传递代理配置 + ) + + // 设置流式响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + // 处理流式响应,转换为 OpenAI 格式 + let buffer = '' + + // 发送初始的空消息,符合 OpenAI 流式格式 + const initialChunk = { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { role: 'assistant' }, + finish_reason: null + } + ] + } + res.write(`data: ${JSON.stringify(initialChunk)}\n\n`) + + // 用于收集usage数据 + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + let usageReported = false // 修复:改为 let 以便后续修改 + + streamResponse.on('data', (chunk) => { + try { + const chunkStr = chunk.toString() + + if (!chunkStr.trim()) { + return + } + + buffer += chunkStr + const lines = buffer.split('\n') + buffer = lines.pop() || '' // 保留最后一个不完整的行 + + for (const line of lines) { + if (!line.trim()) { + continue + } + + // 处理 SSE 格式 + let jsonData = line + if (line.startsWith('data: ')) { + jsonData = line.substring(6).trim() + } + + if (!jsonData || jsonData === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonData) + + // 捕获usage数据 + if (data.response?.usageMetadata) { + totalUsage = data.response.usageMetadata + logger.debug('📊 Captured Gemini usage data:', totalUsage) + } + + // 转换为 OpenAI 流式格式 + if (data.response?.candidates && data.response.candidates.length > 0) { + const candidate = data.response.candidates[0] + const content = candidate.content?.parts?.[0]?.text || '' + const { finishReason } = candidate + + // 只有当有内容或者是结束标记时才发送数据 + if (content || finishReason === 'STOP') { + const openaiChunk = { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: content ? { content } : {}, + finish_reason: finishReason === 'STOP' ? 'stop' : null + } + ] + } + + res.write(`data: ${JSON.stringify(openaiChunk)}\n\n`) + + // 如果结束了,添加 usage 信息并发送最终的 [DONE] + if (finishReason === 'STOP') { + // 如果有 usage 数据,添加到最后一个 chunk + if (data.response.usageMetadata) { + const usageChunk = { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop' + } + ], + usage: { + prompt_tokens: data.response.usageMetadata.promptTokenCount || 0, + completion_tokens: data.response.usageMetadata.candidatesTokenCount || 0, + total_tokens: data.response.usageMetadata.totalTokenCount || 0 + } + } + res.write(`data: ${JSON.stringify(usageChunk)}\n\n`) + } + res.write('data: [DONE]\n\n') + } + } + } + } catch (e) { + logger.debug('Error parsing JSON line:', e.message) + } + } + } catch (error) { + logger.error('Stream processing error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: error.message || 'Stream error', + type: 'api_error' + } + }) + } + } + }) + + streamResponse.on('end', async () => { + logger.info('Stream completed successfully') + + // 记录使用统计 + if (!usageReported && totalUsage.totalTokenCount > 0) { + try { + await apiKeyService.recordUsage( + apiKeyData.id, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + account.id, + 'gemini', + null, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: true, + statusCode: res.statusCode + }) + ) + logger.info( + `📊 Recorded Gemini stream usage - Input: ${totalUsage.promptTokenCount}, Output: ${totalUsage.candidatesTokenCount}, Total: ${totalUsage.totalTokenCount}` + ) + + // 修复:标记 usage 已上报,避免重复上报 + usageReported = true + } catch (error) { + logger.error('Failed to record Gemini usage:', error) + } + } + + if (!res.headersSent) { + res.write('data: [DONE]\n\n') + } + res.end() + }) + + streamResponse.on('error', (error) => { + logger.error('Stream error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: error.message || 'Stream error', + type: 'api_error' + } + }) + } else { + // 如果已经开始发送流数据,发送错误事件 + // 修复:使用 JSON.stringify 避免字符串插值导致的格式错误 + if (!res.destroyed) { + try { + res.write( + `data: ${JSON.stringify({ + error: { + message: error.message || 'Stream error', + type: 'stream_error', + code: error.code + } + })}\n\n` + ) + res.write('data: [DONE]\n\n') + } catch (writeError) { + logger.error('Error sending error event:', writeError) + } + } + res.end() + } + }) + } else { + // 非流式响应 + const oauthProvider = account.oauthProvider || 'gemini-cli' + let { projectId } = account + + if (oauthProvider === 'antigravity') { + projectId = ensureAntigravityProjectId(account) + if (!account.projectId && account.tempProjectId !== projectId) { + await geminiAccountService.updateTempProjectId(account.id, projectId) + account.tempProjectId = projectId + } + } + + logger.info('GenerateContent request', { + model, + projectId, + apiKeyId: apiKeyData.id + }) + + const response = + oauthProvider === 'antigravity' + ? await geminiAccountService.generateContentAntigravity( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, + apiKeyData.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 + ) + : await geminiAccountService.generateContent( + client, + { model, request: geminiRequestBody }, + null, // user_prompt_id + projectId, // 使用有权限的项目ID + apiKeyData.id, // 使用 API Key ID 作为 session ID + proxyConfig // 传递代理配置 + ) + + // 转换为 OpenAI 格式并返回 + const openaiResponse = convertGeminiResponseToOpenAI(response, model, false) + + // 记录使用统计 + if (openaiResponse.usage) { + try { + await apiKeyService.recordUsage( + apiKeyData.id, + openaiResponse.usage.prompt_tokens || 0, + openaiResponse.usage.completion_tokens || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + account.id, + 'gemini', + null, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: false, + statusCode: res.statusCode || 200 + }) + ) + logger.info( + `📊 Recorded Gemini usage - Input: ${openaiResponse.usage.prompt_tokens}, Output: ${openaiResponse.usage.completion_tokens}, Total: ${openaiResponse.usage.total_tokens}` + ) + } catch (error) { + logger.error('Failed to record Gemini usage:', error) + } + } + + res.json(openaiResponse) + } + + const duration = Date.now() - startTime + logger.info(`OpenAI-Gemini request completed in ${duration}ms`) + } catch (error) { + const statusForLog = error?.status || error?.response?.status + logger.error('OpenAI-Gemini request error', { + message: error?.message, + status: statusForLog, + code: error?.code, + requestUrl: error?.config?.url, + requestMethod: error?.config?.method, + upstreamTraceId: error?.response?.headers?.['x-cloudaicompanion-trace-id'] + }) + + // 处理速率限制 + if (error.status === 429) { + if (req.apiKey && account && accountSelection) { + await unifiedGeminiScheduler.markAccountRateLimited(account.id, 'gemini', sessionHash) + } + } + + // 检查响应是否已发送(流式响应场景),避免 ERR_HTTP_HEADERS_SENT + if (!res.headersSent) { + // 客户端断开使用 499 状态码 (Client Closed Request) + if (error.message === 'Client disconnected') { + res.status(499).end() + } else { + // 返回 OpenAI 格式的错误响应 + const status = error.status || 500 + const errorResponse = { + error: error.error || { + message: error.message || 'Internal server error', + type: 'server_error', + code: 'internal_error' + } + } + res.status(status).json(errorResponse) + } + } + } finally { + // 清理资源 + if (abortController) { + abortController = null + } + } + return undefined +}) + +// 获取可用模型列表的共享处理器 +async function handleGetModels(req, res) { + try { + const apiKeyData = req.apiKey + + // 检查权限 + if (!checkPermissions(apiKeyData, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 选择账户获取模型列表 + let account = null + try { + const accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + apiKeyData, + null, + null + ) + account = await geminiAccountService.getAccount(accountSelection.accountId) + } catch (error) { + logger.warn('Failed to select Gemini account for models endpoint:', error) + } + + let models = [] + + if (account) { + // 获取实际的模型列表(失败时回退到默认列表,避免影响 /v1/models 可用性) + try { + const oauthProvider = account.oauthProvider || 'gemini-cli' + models = + oauthProvider === 'antigravity' + ? await geminiAccountService.fetchAvailableModelsAntigravity( + account.accessToken, + account.proxy, + account.refreshToken + ) + : await getAvailableModels(account.accessToken, account.proxy) + } catch (error) { + logger.warn('Failed to get Gemini models list from upstream, fallback to default:', error) + models = [] + } + } else { + // 返回默认模型列表 + models = [ + { + id: 'gemini-2.0-flash-exp', + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'google' + } + ] + } + + if (!models || models.length === 0) { + models = [ + { + id: 'gemini-2.0-flash-exp', + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'google' + } + ] + } + + // 如果启用了模型限制,过滤模型列表 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) { + models = models.filter((model) => apiKeyData.restrictedModels.includes(model.id)) + } + + res.json({ + object: 'list', + data: models + }) + } catch (error) { + logger.error('Failed to get OpenAI-Gemini models:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve models', + type: 'server_error', + code: 'internal_error' + } + }) + } +} + +// OpenAI 兼容的模型列表端点 (带 v1 版) +router.get('/v1/models', authenticateApiKey, handleGetModels) + +// OpenAI 兼容的模型列表端点 (根路径版,方便第三方加载) +router.get('/models', authenticateApiKey, handleGetModels) + +// OpenAI 兼容的模型详情端点 +router.get('/v1/models/:model', authenticateApiKey, async (req, res) => { + try { + const apiKeyData = req.apiKey + const modelId = req.params.model + + // 检查权限 + if (!checkPermissions(apiKeyData, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 检查模型限制 + if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels.length > 0) { + if (!apiKeyData.restrictedModels.includes(modelId)) { + return res.status(404).json({ + error: { + message: `Model '${modelId}' not found`, + type: 'invalid_request_error', + code: 'model_not_found' + } + }) + } + } + + // 返回模型信息 + res.json({ + id: modelId, + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'google', + permission: [], + root: modelId, + parent: null + }) + } catch (error) { + logger.error('Failed to get model details:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve model details', + type: 'server_error', + code: 'internal_error' + } + }) + } + return undefined +}) + +module.exports = router diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js new file mode 100644 index 0000000..abdd374 --- /dev/null +++ b/src/routes/openaiRoutes.js @@ -0,0 +1,1040 @@ +const express = require('express') +const axios = require('axios') +const router = express.Router() +const logger = require('../utils/logger') +const config = require('../../config/config') +const { authenticateApiKey } = require('../middleware/auth') +const unifiedOpenAIScheduler = require('../services/scheduler/unifiedOpenAIScheduler') +const openaiAccountService = require('../services/account/openaiAccountService') +const openaiResponsesAccountService = require('../services/account/openaiResponsesAccountService') +const openaiResponsesRelayService = require('../services/relay/openaiResponsesRelayService') +const apiKeyService = require('../services/apiKeyService') +const redis = require('../models/redis') +const crypto = require('crypto') +const ProxyHelper = require('../utils/proxyHelper') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const { IncrementalSSEParser } = require('../utils/sseParser') +const { getSafeMessage } = require('../utils/errorSanitizer') +const { + createRequestDetailMeta, + extractOpenAICacheReadTokens +} = require('../utils/requestDetailHelper') +const requestBodyRuleService = require('../services/requestBodyRuleService') + +// Codex CLI 系统提示词(非 Codex CLI 客户端请求时注入,统一端点也使用) +const CODEX_CLI_INSTRUCTIONS = + "You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.\n\n## General\n\n- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)\n\n## Editing constraints\n\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\n- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \"Assigns the value to the variable\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).\n- You may be in a dirty git worktree.\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\n * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\n * If the changes are in unrelated files, just ignore them and don't revert them.\n- Do not amend a commit unless explicitly requested to do so.\n- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\n\n## Plan tool\n\nWhen using the planning tool:\n- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).\n- Do not make single-step plans.\n- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.\n\n## Codex CLI harness, sandboxing, and approvals\n\nThe Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.\n\nFilesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:\n- **read-only**: The sandbox only permits reading files.\n- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.\n- **danger-full-access**: No filesystem sandboxing - all commands are permitted.\n\nNetwork sandboxing defines whether network can be accessed without approval. Options for `network_access` are:\n- **restricted**: Requires approval\n- **enabled**: No approval needed\n\nApprovals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are\n- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe \"read\" commands.\n- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.\n- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)\n- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.\n\nWhen you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:\n- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)\n- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.\n- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)\n- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `with_escalated_permissions` and `justification` parameters - do not message the user before requesting approval for the command.\n- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for\n- (for all of these, you should weigh alternative paths that do not require approval)\n\nWhen `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.\n\nYou will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.\n\nAlthough they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to \"never\", in which case never ask for approvals.\n\nWhen requesting approval to execute a command that will require escalated privileges:\n - Provide the `with_escalated_permissions` parameter with the boolean value true\n - Include a short, 1 sentence explanation for why you need to enable `with_escalated_permissions` in the justification parameter\n\n## Special user requests\n\n- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\n- If the user asks for a \"review\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\n\n## Frontend tasks\nWhen doing frontend design tasks, avoid collapsing into \"AI slop\" or safe, average-looking layouts.\nAim for interfaces that feel intentional, bold, and a bit surprising.\n- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).\n- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.\n- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.\n- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.\n- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.\n- Ensure the page loads properly on both desktop and mobile\n\nException: If working within an existing website or design system, preserve the established patterns, structure, and visual language.\n\n## Presenting your work and final message\n\nYou are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.\n\n- Default: be very concise; friendly coding teammate tone.\n- Ask only when needed; suggest ideas; mirror the user's style.\n- For substantial work, summarize clearly; follow final‑answer formatting.\n- Skip heavy formatting for simple confirmations.\n- Don't dump large files you've written; reference paths only.\n- No \"save/copy this file\" - User is on the same machine.\n- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.\n- For code changes:\n * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with \"summary\", just jump right in.\n * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.\n * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.\n- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.\n\n### Final answer structure and style guidelines\n\n- Plain text; CLI handles styling. Use structure only when it helps scanability.\n- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.\n- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.\n- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.\n- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.\n- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.\n- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no \"above/below\"; parallel wording.\n- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.\n- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.\n- File References: When referencing files in your response follow the below rules:\n * Use inline code to make file paths clickable.\n * Each reference should have a stand alone path. Even if it's the same file.\n * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.\n * Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\n * Do not use URIs like file://, vscode://, or https://.\n * Do not provide range of lines\n * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\n" + +// 创建代理 Agent(使用统一的代理工具) +function createProxyAgent(proxy) { + return ProxyHelper.createProxyAgent(proxy) +} + +// 检查 API Key 是否具备 OpenAI 权限 +function checkOpenAIPermissions(apiKeyData) { + return apiKeyService.hasPermission(apiKeyData?.permissions, 'openai') +} + +function normalizeHeaders(headers = {}) { + if (!headers || typeof headers !== 'object') { + return {} + } + const normalized = {} + for (const [key, value] of Object.entries(headers)) { + if (!key) { + continue + } + normalized[key.toLowerCase()] = Array.isArray(value) ? value[0] : value + } + return normalized +} + +function toNumberSafe(value) { + if (value === undefined || value === null || value === '') { + return null + } + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +function extractCodexUsageHeaders(headers) { + const normalized = normalizeHeaders(headers) + if (!normalized || Object.keys(normalized).length === 0) { + return null + } + + const snapshot = { + primaryUsedPercent: toNumberSafe(normalized['x-codex-primary-used-percent']), + primaryResetAfterSeconds: toNumberSafe(normalized['x-codex-primary-reset-after-seconds']), + primaryWindowMinutes: toNumberSafe(normalized['x-codex-primary-window-minutes']), + secondaryUsedPercent: toNumberSafe(normalized['x-codex-secondary-used-percent']), + secondaryResetAfterSeconds: toNumberSafe(normalized['x-codex-secondary-reset-after-seconds']), + secondaryWindowMinutes: toNumberSafe(normalized['x-codex-secondary-window-minutes']), + primaryOverSecondaryPercent: toNumberSafe( + normalized['x-codex-primary-over-secondary-limit-percent'] + ) + } + + const hasData = Object.values(snapshot).some((value) => value !== null) + return hasData ? snapshot : null +} + +function isCompactResponsesRoute(req) { + return ( + req.path === '/responses/compact' || + req.path === '/v1/responses/compact' || + (req.originalUrl && req.originalUrl.includes('/responses/compact')) + ) +} + +function isStandardResponsesRoute(req) { + if (req._fromUnifiedEndpoint) { + return false + } + + return req.path === '/responses' || req.path === '/v1/responses' +} + +function getCodexCompatibleModel(requestedModel = null) { + const isCodexModel = + typeof requestedModel === 'string' && requestedModel.toLowerCase().includes('codex') + + if (requestedModel && requestedModel.startsWith('gpt-5-') && !isCodexModel) { + return 'gpt-5' + } + + return requestedModel +} + +function normalizeGpt5ModelForCodex(body = {}) { + const requestedModel = body?.model || null + const compatibleModel = getCodexCompatibleModel(requestedModel) + + if (compatibleModel !== requestedModel) { + logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`) + body.model = compatibleModel + } + + return compatibleModel +} + +function applyCodexCliAdaptation(body = {}) { + const fieldsToRemove = [ + 'temperature', + 'top_p', + 'max_output_tokens', + 'user', + 'text_formatting', + 'truncation', + 'text', + 'service_tier', + 'prompt_cache_retention', + 'safety_identifier' + ] + + fieldsToRemove.forEach((field) => { + delete body[field] + }) + + body.instructions = CODEX_CLI_INSTRUCTIONS +} + +async function applyRateLimitTracking( + req, + usageSummary, + model, + context = '', + accountType = null, + preCalculatedCost = null +) { + if (!req.rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + req.rateLimitInfo, + usageSummary, + model, + req.apiKey?.id, + accountType, + preCalculatedCost + ) + + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + } catch (error) { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + } +} + +// 使用统一调度器选择 OpenAI 账户 +async function getOpenAIAuthToken(apiKeyData, sessionId = null, requestedModel = null) { + try { + // 生成会话哈希(如果有会话ID) + const sessionHash = sessionId + ? crypto.createHash('sha256').update(sessionId).digest('hex') + : null + + // 使用统一调度器选择账户 + const result = await unifiedOpenAIScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + requestedModel + ) + + if (!result || !result.accountId) { + const error = new Error('No available OpenAI account found') + error.statusCode = 402 // Payment Required - 资源耗尽 + throw error + } + + // 根据账户类型获取账户详情 + let account, + accessToken, + proxy = null + + if (result.accountType === 'openai-responses') { + // 处理 OpenAI-Responses 账户 + account = await openaiResponsesAccountService.getAccount(result.accountId) + if (!account || !account.apiKey) { + const error = new Error(`OpenAI-Responses account ${result.accountId} has no valid apiKey`) + error.statusCode = 403 // Forbidden - 账户配置错误 + throw error + } + + // OpenAI-Responses 账户不需要 accessToken,直接返回账户信息 + accessToken = null // OpenAI-Responses 使用账户内的 apiKey + + // 解析代理配置 + if (account.proxy) { + try { + proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + logger.info(`Selected OpenAI-Responses account: ${account.name} (${result.accountId})`) + } else { + // 处理普通 OpenAI 账户 + account = await openaiAccountService.getAccount(result.accountId) + if (!account || !account.accessToken) { + const error = new Error(`OpenAI account ${result.accountId} has no valid accessToken`) + error.statusCode = 403 // Forbidden - 账户配置错误 + throw error + } + + // 检查 token 是否过期并自动刷新(双重保护) + if (openaiAccountService.isTokenExpired(account)) { + if (account.refreshToken) { + logger.info(`🔄 Token expired, auto-refreshing for account ${account.name} (fallback)`) + try { + await openaiAccountService.refreshAccountToken(result.accountId) + // 重新获取更新后的账户 + account = await openaiAccountService.getAccount(result.accountId) + logger.info(`✅ Token refreshed successfully in route handler`) + } catch (refreshError) { + logger.error(`Failed to refresh token for ${account.name}:`, refreshError) + const error = new Error(`Token expired and refresh failed: ${refreshError.message}`) + error.statusCode = 403 // Forbidden - 认证失败 + throw error + } + } else { + const error = new Error( + `Token expired and no refresh token available for account ${account.name}` + ) + error.statusCode = 403 // Forbidden - 认证失败 + throw error + } + } + + // 解密 accessToken(account.accessToken 是加密的) + accessToken = openaiAccountService.decrypt(account.accessToken) + if (!accessToken) { + const error = new Error('Failed to decrypt OpenAI accessToken') + error.statusCode = 403 // Forbidden - 配置/权限错误 + throw error + } + + // 解析代理配置 + if (account.proxy) { + try { + proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + logger.info(`Selected OpenAI account: ${account.name} (${result.accountId})`) + } + + return { + accessToken, + accountId: result.accountId, + accountName: account.name, + accountType: result.accountType, + proxy, + account + } + } catch (error) { + logger.error('Failed to get OpenAI auth token:', error) + throw error + } +} + +// 主处理函数,供两个路由共享 +const handleResponses = async (req, res) => { + let upstream = null + let accountId = null + let accountType = 'openai' + let sessionHash = null + let account = null + let proxy = null + let accessToken = null + + try { + // 从中间件获取 API Key 数据 + const apiKeyData = req.apiKey || {} + + if (!checkOpenAIPermissions(apiKeyData)) { + logger.security( + `🚫 API Key ${apiKeyData.id || 'unknown'} 缺少 OpenAI 权限,拒绝访问 ${req.originalUrl}` + ) + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access OpenAI', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 判断是否为 Codex CLI 的请求(基于 User-Agent) + // 支持: codex_vscode, codex_cli_rs, codex_exec (非交互式/脚本模式) + const userAgent = req.headers['user-agent'] || '' + const codexCliPattern = /^(codex_vscode|codex_cli_rs|codex_exec)\/[\d.]+/i + const isCodexCLI = codexCliPattern.test(userAgent) + + const standardResponsesRoute = isStandardResponsesRoute(req) + const compactRoute = isCompactResponsesRoute(req) + const shouldUseToggleControlledFlow = standardResponsesRoute && !compactRoute + + if (shouldUseToggleControlledFlow) { + const shouldApplyCodexAdaptation = + apiKeyData.enableOpenAIResponsesCodexAdaptation === true && !isCodexCLI + const shouldApplyPayloadRules = apiKeyData.enableOpenAIResponsesPayloadRules === true + + if (shouldApplyCodexAdaptation) { + normalizeGpt5ModelForCodex(req.body) + applyCodexCliAdaptation(req.body) + logger.info('📝 Standard Responses request applied Codex CLI adaptation') + } else if (isCodexCLI) { + logger.info('✅ Codex CLI request detected, forwarding current payload') + } else { + logger.info('📦 Standard Responses request is passing through without Codex adaptation') + } + + if (shouldApplyPayloadRules) { + req.body = requestBodyRuleService.applyRules( + req.body, + apiKeyData.openaiResponsesPayloadRules + ) + logger.info('🧩 Standard Responses request applied API key payload rules') + } + } else { + normalizeGpt5ModelForCodex(req.body) + + if (!isCodexCLI && !req._fromUnifiedEndpoint) { + applyCodexCliAdaptation(req.body) + logger.info('📝 Non-Codex CLI request detected, applying Codex CLI adaptation') + } else { + logger.info('✅ Codex CLI request detected, forwarding as-is') + } + } + + // 从最终请求体中提取 service_tier,用于后续费用计算 + req._serviceTier = req.body?.service_tier || null + + // 从最终请求体中提取模型、会话 ID 和流式标志 + // NOTE: For some clients, prompt_cache_key is the only stable per-session key. + const sessionId = + req.headers['session_id'] || + req.headers['x-session-id'] || + req.body?.session_id || + req.body?.conversation_id || + req.body?.prompt_cache_key || + null + + sessionHash = sessionId ? crypto.createHash('sha256').update(sessionId).digest('hex') : null + + const requestedModel = req.body?.model || null + const schedulerModel = getCodexCompatibleModel(requestedModel) + const isStream = req.body?.stream !== false // 默认为流式(兼容现有行为) + + if (schedulerModel !== requestedModel) { + logger.info( + `🧭 Using Codex-compatible model ${schedulerModel} for account selection (requested: ${requestedModel})` + ) + } + + // 使用调度器选择账户 + ;({ accessToken, accountId, accountType, proxy, account } = await getOpenAIAuthToken( + apiKeyData, + sessionId, + schedulerModel + )) + + // 如果是 OpenAI-Responses 账户,使用专门的中继服务处理 + if (accountType === 'openai-responses') { + logger.info(`🔀 Using OpenAI-Responses relay service for account: ${account.name}`) + return await openaiResponsesRelayService.handleRequest(req, res, account, apiKeyData) + } + + if (schedulerModel !== requestedModel) { + logger.info( + `📝 Standard Responses request normalized model ${requestedModel} -> ${schedulerModel} for OpenAI Codex backend` + ) + req.body.model = schedulerModel + } + + const upstreamRequestedModel = req.body?.model || requestedModel + + // 基于白名单构造上游所需的请求头,确保键为小写且值受控 + const incoming = req.headers || {} + + const allowedKeys = ['version', 'openai-beta', 'session_id'] + + const headers = {} + for (const key of allowedKeys) { + if (incoming[key] !== undefined) { + headers[key] = incoming[key] + } + } + + // 覆盖或新增必要头部 + headers['authorization'] = `Bearer ${accessToken}` + headers['chatgpt-account-id'] = account.accountId || account.chatgptUserId || accountId + headers['host'] = 'chatgpt.com' + headers['accept'] = isStream ? 'text/event-stream' : 'application/json' + headers['content-type'] = 'application/json' + if (!compactRoute) { + req.body['store'] = false + } else if (req.body && Object.prototype.hasOwnProperty.call(req.body, 'store')) { + delete req.body['store'] + } + + // 创建代理 agent + const proxyAgent = createProxyAgent(proxy) + + // 配置请求选项 + const axiosConfig = { + headers, + timeout: config.requestTimeout || 600000, + validateStatus: () => true + } + + // 如果有代理,添加代理配置 + if (proxyAgent) { + axiosConfig.httpAgent = proxyAgent + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info(`🌐 Using proxy for OpenAI request: ${ProxyHelper.getProxyDescription(proxy)}`) + } else { + logger.debug('🌐 No proxy configured for OpenAI request') + } + + const codexEndpoint = compactRoute + ? 'https://chatgpt.com/backend-api/codex/responses/compact' + : 'https://chatgpt.com/backend-api/codex/responses' + + // 根据 stream 参数决定请求类型 + if (isStream) { + // 流式请求 + upstream = await axios.post(codexEndpoint, req.body, { + ...axiosConfig, + responseType: 'stream' + }) + } else { + // 非流式请求 + upstream = await axios.post(codexEndpoint, req.body, axiosConfig) + } + + const codexUsageSnapshot = extractCodexUsageHeaders(upstream.headers) + if (codexUsageSnapshot) { + try { + await openaiAccountService.updateCodexUsageSnapshot(accountId, codexUsageSnapshot) + } catch (codexError) { + logger.error('⚠️ 更新 Codex 使用统计失败:', codexError) + } + } + + // 处理 429 限流错误 + if (upstream.status === 429) { + logger.warn(`🚫 Rate limit detected for OpenAI account ${accountId} (Codex API)`) + + // 解析响应体中的限流信息 + let resetsInSeconds = null + let errorData = null + + try { + // 对于429错误,无论是否是流式请求,响应都会是完整的JSON错误对象 + if (isStream && upstream.data) { + // 流式响应需要先收集数据 + const chunks = [] + await new Promise((resolve, reject) => { + upstream.data.on('data', (chunk) => chunks.push(chunk)) + upstream.data.on('end', resolve) + upstream.data.on('error', reject) + // 设置超时防止无限等待 + setTimeout(resolve, 5000) + }) + + const fullResponse = Buffer.concat(chunks).toString() + try { + errorData = JSON.parse(fullResponse) + } catch (e) { + logger.error('Failed to parse 429 error response:', e) + logger.debug('Raw response:', fullResponse) + } + } else { + // 非流式响应直接使用data + errorData = upstream.data + } + + // 提取重置时间 + if (errorData && errorData.error && errorData.error.resets_in_seconds) { + resetsInSeconds = errorData.error.resets_in_seconds + logger.info( + `🕐 Codex rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)` + ) + } else { + logger.warn( + '⚠️ Could not extract resets_in_seconds from 429 response, using default 60 minutes' + ) + } + } catch (e) { + logger.error('⚠️ Failed to parse rate limit error:', e) + } + + // 标记账户为限流状态 + await unifiedOpenAIScheduler.markAccountRateLimited( + accountId, + 'openai', + sessionHash, + resetsInSeconds + ) + + // 返回错误响应给客户端 + const errorResponse = errorData || { + error: { + type: 'usage_limit_reached', + message: 'The usage limit has been reached', + resets_in_seconds: resetsInSeconds + } + } + + if (isStream) { + // 流式响应也需要设置正确的状态码 + res.status(429) + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.write(`data: ${JSON.stringify(errorResponse)}\n\n`) + res.end() + } else { + res.status(429).json(errorResponse) + } + + return + } else if (upstream.status === 401 || upstream.status === 402) { + const unauthorizedStatus = upstream.status + const statusDescription = unauthorizedStatus === 401 ? 'Unauthorized' : 'Payment required' + logger.warn( + `🔐 ${statusDescription} error detected for OpenAI account ${accountId} (Codex API)` + ) + + let errorData = null + + try { + if (isStream && upstream.data && typeof upstream.data.on === 'function') { + const chunks = [] + await new Promise((resolve, reject) => { + upstream.data.on('data', (chunk) => chunks.push(chunk)) + upstream.data.on('end', resolve) + upstream.data.on('error', reject) + setTimeout(resolve, 5000) + }) + + const fullResponse = Buffer.concat(chunks).toString() + try { + errorData = JSON.parse(fullResponse) + } catch (parseError) { + logger.error(`Failed to parse ${unauthorizedStatus} error response:`, parseError) + logger.debug(`Raw ${unauthorizedStatus} response:`, fullResponse) + errorData = { error: { message: fullResponse || 'Unauthorized' } } + } + } else { + errorData = upstream.data + } + } catch (parseError) { + logger.error(`⚠️ Failed to handle ${unauthorizedStatus} error response:`, parseError) + } + + const statusLabel = unauthorizedStatus === 401 ? '401错误' : '402错误' + const extraHint = unauthorizedStatus === 402 ? ',可能欠费' : '' + let reason = `OpenAI账号认证失败(${statusLabel}${extraHint})` + if (errorData) { + const messageCandidate = + errorData.error && + typeof errorData.error.message === 'string' && + errorData.error.message.trim() + ? errorData.error.message.trim() + : typeof errorData.message === 'string' && errorData.message.trim() + ? errorData.message.trim() + : null + if (messageCandidate) { + reason = `OpenAI账号认证失败(${statusLabel}${extraHint}):${messageCandidate}` + } + } + + try { + await unifiedOpenAIScheduler.markAccountUnauthorized( + accountId, + 'openai', + sessionHash, + reason + ) + } catch (markError) { + logger.error( + `❌ Failed to mark OpenAI account unauthorized after ${unauthorizedStatus}:`, + markError + ) + } + + let errorResponse = errorData + if (!errorResponse || typeof errorResponse !== 'object' || Buffer.isBuffer(errorResponse)) { + const fallbackMessage = + typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized' + errorResponse = { + error: { + message: fallbackMessage, + type: 'unauthorized', + code: 'unauthorized' + } + } + } + + res.status(unauthorizedStatus).json(errorResponse) + return + } else if (upstream.status === 200 || upstream.status === 201) { + // 请求成功,检查并移除限流状态 + const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId) + if (isRateLimited) { + logger.info( + `✅ Removing rate limit for OpenAI account ${accountId} after successful request` + ) + await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai') + } + } + + res.status(upstream.status) + + if (isStream) { + // 流式响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + } else { + // 非流式响应头 + res.setHeader('Content-Type', 'application/json') + } + + // 透传关键诊断头,避免传递不安全或与传输相关的头 + const passThroughHeaderKeys = ['openai-version', 'x-request-id', 'openai-processing-ms'] + for (const key of passThroughHeaderKeys) { + const val = upstream.headers?.[key] + if (val !== undefined) { + res.setHeader(key, val) + } + } + + if (isStream) { + // 立即刷新响应头,开始 SSE + if (typeof res.flushHeaders === 'function') { + res.flushHeaders() + } + } + + // 处理响应并捕获 usage 数据和真实的 model + let usageData = null + let actualModel = null + let usageReported = false + let rateLimitDetected = false + let rateLimitResetsInSeconds = null + + if (!isStream) { + // 非流式响应处理 + try { + logger.info(`📄 Processing OpenAI non-stream response for model: ${upstreamRequestedModel}`) + + // 直接获取完整响应 + const responseData = upstream.data + + // 从响应中获取实际的 model 和 usage + actualModel = responseData.model || upstreamRequestedModel || 'gpt-4' + usageData = responseData.usage + + logger.debug(`📊 Non-stream response - Model: ${actualModel}, Usage:`, usageData) + + // 记录使用统计 + if (usageData) { + const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 + const cacheReadTokens = extractOpenAICacheReadTokens(usageData) + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) + + const nonStreamCosts = await apiKeyService.recordUsage( + apiKeyData.id, + actualInputTokens, // 传递实际输入(不含缓存) + outputTokens, + 0, // OpenAI没有cache_creation_tokens + cacheReadTokens, + actualModel, + accountId, + 'openai', + req._serviceTier, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: false, + statusCode: upstream.status + }) + ) + + logger.info( + `📊 Recorded OpenAI non-stream usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), Output: ${outputTokens}, Total: ${usageData.total_tokens || totalInputTokens + outputTokens}, Model: ${actualModel}` + ) + + await applyRateLimitTracking( + req, + { + inputTokens: actualInputTokens, + outputTokens, + cacheCreateTokens: 0, + cacheReadTokens + }, + actualModel, + 'openai-non-stream', + 'openai', + nonStreamCosts + ) + } + + // 返回响应 + res.json(responseData) + return + } catch (error) { + logger.error('Failed to process non-stream response:', error) + if (!res.headersSent) { + res.status(500).json({ error: { message: 'Failed to process response' } }) + } + return + } + } + + // 使用增量 SSE 解析器 + const sseParser = new IncrementalSSEParser() + + // 处理解析出的事件 + const processSSEEvent = (eventData) => { + // 检查是否是 response.completed 事件 + if (eventData.type === 'response.completed' && eventData.response) { + // 从响应中获取真实的 model + if (eventData.response.model) { + actualModel = eventData.response.model + logger.debug(`📊 Captured actual model: ${actualModel}`) + } + + // 获取 usage 数据 + if (eventData.response.usage) { + usageData = eventData.response.usage + logger.debug('📊 Captured OpenAI usage data:', usageData) + } + } + + // 检查是否有限流错误 + if (eventData.error && eventData.error.type === 'usage_limit_reached') { + rateLimitDetected = true + if (eventData.error.resets_in_seconds) { + rateLimitResetsInSeconds = eventData.error.resets_in_seconds + logger.warn( + `🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds` + ) + } + } + } + + upstream.data.on('data', (chunk) => { + try { + // 转发数据给客户端 + if (!res.destroyed) { + res.write(chunk) + } + + // 使用增量解析器处理数据 + const events = sseParser.feed(chunk.toString()) + for (const event of events) { + if (event.type === 'data' && event.data) { + processSSEEvent(event.data) + } + } + } catch (error) { + logger.error('Error processing OpenAI stream chunk:', error) + } + }) + + upstream.data.on('end', async () => { + // 处理剩余的 buffer + const remaining = sseParser.getRemaining() + if (remaining.trim()) { + const events = sseParser.feed('\n\n') // 强制刷新剩余内容 + for (const event of events) { + if (event.type === 'data' && event.data) { + processSSEEvent(event.data) + } + } + } + + // 记录使用统计 + if (!usageReported && usageData) { + try { + const totalInputTokens = usageData.input_tokens || 0 + const outputTokens = usageData.output_tokens || 0 + const cacheReadTokens = extractOpenAICacheReadTokens(usageData) + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) + + // 使用响应中的真实 model,如果没有则使用请求中的 model,最后回退到默认值 + const modelToRecord = actualModel || upstreamRequestedModel || 'gpt-4' + + const streamCosts = await apiKeyService.recordUsage( + apiKeyData.id, + actualInputTokens, // 传递实际输入(不含缓存) + outputTokens, + 0, // OpenAI没有cache_creation_tokens + cacheReadTokens, + modelToRecord, + accountId, + 'openai', + req._serviceTier, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: true, + statusCode: res.statusCode + }) + ) + + logger.info( + `📊 Recorded OpenAI usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), Output: ${outputTokens}, Total: ${usageData.total_tokens || totalInputTokens + outputTokens}, Model: ${modelToRecord} (actual: ${actualModel}, requested: ${upstreamRequestedModel})` + ) + usageReported = true + + await applyRateLimitTracking( + req, + { + inputTokens: actualInputTokens, + outputTokens, + cacheCreateTokens: 0, + cacheReadTokens + }, + modelToRecord, + 'openai-stream', + 'openai', + streamCosts + ) + } catch (error) { + logger.error('Failed to record OpenAI usage:', error) + } + } + + // 如果在流式响应中检测到限流 + if (rateLimitDetected) { + logger.warn(`🚫 Processing rate limit for OpenAI account ${accountId} from stream`) + await unifiedOpenAIScheduler.markAccountRateLimited( + accountId, + 'openai', + sessionHash, + rateLimitResetsInSeconds + ) + } else if (upstream.status === 200) { + // 流式请求成功,检查并移除限流状态 + const isRateLimited = await unifiedOpenAIScheduler.isAccountRateLimited(accountId) + if (isRateLimited) { + logger.info( + `✅ Removing rate limit for OpenAI account ${accountId} after successful stream` + ) + await unifiedOpenAIScheduler.removeAccountRateLimit(accountId, 'openai') + } + } + + res.end() + }) + + upstream.data.on('error', (err) => { + logger.error('Upstream stream error:', err) + if (!res.headersSent) { + res.status(502).json({ error: { message: 'Upstream stream error' } }) + } else { + res.end() + } + }) + + // 客户端断开时清理上游流 + const cleanup = () => { + try { + upstream.data?.unpipe?.(res) + upstream.data?.destroy?.() + } catch (_) { + // + } + } + req.on('close', cleanup) + req.on('aborted', cleanup) + } catch (error) { + logger.error('Proxy to ChatGPT codex/responses failed:', error) + // 优先使用主动设置的 statusCode,然后是上游响应的状态码,最后默认 500 + const status = error.statusCode || error.response?.status || 500 + + if ((status === 401 || status === 402) && accountId) { + const statusLabel = status === 401 ? '401错误' : '402错误' + const extraHint = status === 402 ? ',可能欠费' : '' + let reason = `OpenAI账号认证失败(${statusLabel}${extraHint})` + const errorData = error.response?.data + if (errorData) { + if (typeof errorData === 'string' && errorData.trim()) { + reason = `OpenAI账号认证失败(${statusLabel}${extraHint}):${errorData.trim()}` + } else if ( + errorData.error && + typeof errorData.error.message === 'string' && + errorData.error.message.trim() + ) { + reason = `OpenAI账号认证失败(${statusLabel}${extraHint}):${errorData.error.message.trim()}` + } else if (typeof errorData.message === 'string' && errorData.message.trim()) { + reason = `OpenAI账号认证失败(${statusLabel}${extraHint}):${errorData.message.trim()}` + } + } else if (error.message) { + reason = `OpenAI账号认证失败(${statusLabel}${extraHint}):${error.message}` + } + + try { + await unifiedOpenAIScheduler.markAccountUnauthorized( + accountId, + accountType || 'openai', + sessionHash, + reason + ) + } catch (markError) { + logger.error('❌ Failed to mark OpenAI account unauthorized in catch handler:', markError) + } + } + + let responsePayload = error.response?.data + if (!responsePayload) { + responsePayload = { error: { message: getSafeMessage(error) } } + } else if (typeof responsePayload === 'string') { + responsePayload = { error: { message: getSafeMessage(responsePayload) } } + } else if (typeof responsePayload === 'object' && !responsePayload.error) { + responsePayload = { + error: { message: getSafeMessage(responsePayload.message || error) } + } + } else if (responsePayload.error?.message) { + responsePayload.error.message = getSafeMessage(responsePayload.error.message) + } + + if (!res.headersSent) { + res.status(status).json(responsePayload) + } + } +} + +// 注册两个路由路径,都使用相同的处理函数 +router.post('/responses', authenticateApiKey, handleResponses) +router.post('/v1/responses', authenticateApiKey, handleResponses) +router.post('/responses/compact', authenticateApiKey, handleResponses) +router.post('/v1/responses/compact', authenticateApiKey, handleResponses) + +// 使用情况统计端点 +router.get('/usage', authenticateApiKey, async (req, res) => { + try { + const keyData = req.apiKey + // 按需查询 usage 数据 + const usage = await redis.getUsageStats(keyData.id) + + res.json({ + object: 'usage', + total_tokens: usage?.total?.tokens || 0, + total_requests: usage?.total?.requests || 0, + daily_tokens: usage?.daily?.tokens || 0, + daily_requests: usage?.daily?.requests || 0, + monthly_tokens: usage?.monthly?.tokens || 0, + monthly_requests: usage?.monthly?.requests || 0 + }) + } catch (error) { + logger.error('Failed to get usage stats:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve usage statistics', + type: 'api_error' + } + }) + } +}) + +// API Key 信息端点 +router.get('/key-info', authenticateApiKey, async (req, res) => { + try { + const keyData = req.apiKey + // 按需查询 usage 数据(仅 key-info 端点需要) + const usage = await redis.getUsageStats(keyData.id) + const tokensUsed = usage?.total?.tokens || 0 + res.json({ + id: keyData.id, + name: keyData.name, + description: keyData.description, + permissions: keyData.permissions, + token_limit: keyData.tokenLimit, + tokens_used: tokensUsed, + tokens_remaining: + keyData.tokenLimit > 0 ? Math.max(0, keyData.tokenLimit - tokensUsed) : null, + rate_limit: { + window: keyData.rateLimitWindow, + requests: keyData.rateLimitRequests + }, + usage: { + total: usage?.total || {}, + daily: usage?.daily || {}, + monthly: usage?.monthly || {} + } + }) + } catch (error) { + logger.error('Failed to get key info:', error) + res.status(500).json({ + error: { + message: 'Failed to retrieve API key information', + type: 'api_error' + } + }) + } +}) + +module.exports = router +module.exports.handleResponses = handleResponses +module.exports.CODEX_CLI_INSTRUCTIONS = CODEX_CLI_INSTRUCTIONS diff --git a/src/routes/standardGeminiRoutes.js b/src/routes/standardGeminiRoutes.js new file mode 100644 index 0000000..fefe611 --- /dev/null +++ b/src/routes/standardGeminiRoutes.js @@ -0,0 +1,264 @@ +/** + * 标准 Gemini API 路由模块 + * + * 该模块处理标准 Gemini API 格式的请求: + * - v1beta/models/:modelName:generateContent + * - v1beta/models/:modelName:streamGenerateContent + * - v1beta/models/:modelName:countTokens + * - v1beta/models/:modelName:loadCodeAssist + * - v1beta/models/:modelName:onboardUser + * - v1/models/:modelName:* (同上) + * - v1internal:* (内部格式) + * - v1beta/models, v1/models (模型列表) + * - v1beta/models/:modelName, v1/models/:modelName (模型详情) + * + * 所有处理函数都从 geminiHandlers.js 导入,以避免代码重复。 + */ + +const express = require('express') +const router = express.Router() +const { authenticateApiKey } = require('../middleware/auth') +const logger = require('../utils/logger') + +// 从 handlers/geminiHandlers.js 导入所有处理函数 +const { + ensureGeminiPermissionMiddleware, + handleLoadCodeAssist, + handleOnboardUser, + handleCountTokens, + handleGenerateContent, + handleStreamGenerateContent, + handleStandardGenerateContent, + handleStandardStreamGenerateContent, + handleModels, + handleModelDetails +} = require('../handlers/geminiHandlers') + +// ============================================================================ +// v1beta 版本的标准路由 - 支持动态模型名称 +// ============================================================================ + +/** + * POST /v1beta/models/:modelName:loadCodeAssist + */ +router.post( + '/v1beta/models/:modelName\\:loadCodeAssist', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`) + handleLoadCodeAssist(req, res, next) + } +) + +/** + * POST /v1beta/models/:modelName:onboardUser + */ +router.post( + '/v1beta/models/:modelName\\:onboardUser', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`) + handleOnboardUser(req, res, next) + } +) + +/** + * POST /v1beta/models/:modelName:countTokens + */ +router.post( + '/v1beta/models/:modelName\\:countTokens', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request: ${req.method} ${req.originalUrl}`) + handleCountTokens(req, res, next) + } +) + +/** + * POST /v1beta/models/:modelName:generateContent + * 使用专门的标准 API 处理函数(支持 OAuth 和 API 账户) + */ +router.post( + '/v1beta/models/:modelName\\:generateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + handleStandardGenerateContent +) + +/** + * POST /v1beta/models/:modelName:streamGenerateContent + * 使用专门的标准 API 流式处理函数(支持 OAuth 和 API 账户) + */ +router.post( + '/v1beta/models/:modelName\\:streamGenerateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + handleStandardStreamGenerateContent +) + +// ============================================================================ +// v1 版本的标准路由(为了完整性,虽然 Gemini 主要使用 v1beta) +// ============================================================================ + +/** + * POST /v1/models/:modelName:generateContent + */ +router.post( + '/v1/models/:modelName\\:generateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + handleStandardGenerateContent +) + +/** + * POST /v1/models/:modelName:streamGenerateContent + */ +router.post( + '/v1/models/:modelName\\:streamGenerateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + handleStandardStreamGenerateContent +) + +/** + * POST /v1/models/:modelName:countTokens + */ +router.post( + '/v1/models/:modelName\\:countTokens', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1): ${req.method} ${req.originalUrl}`) + handleCountTokens(req, res, next) + } +) + +// ============================================================================ +// v1internal 版本的标准路由(这些使用内部格式的处理函数) +// ============================================================================ + +/** + * POST /v1internal:loadCodeAssist + */ +router.post( + '/v1internal\\:loadCodeAssist', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`) + handleLoadCodeAssist(req, res, next) + } +) + +/** + * POST /v1internal:onboardUser + */ +router.post( + '/v1internal\\:onboardUser', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`) + handleOnboardUser(req, res, next) + } +) + +/** + * POST /v1internal:countTokens + */ +router.post( + '/v1internal\\:countTokens', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`) + handleCountTokens(req, res, next) + } +) + +/** + * POST /v1internal:generateContent + * v1internal 格式使用内部格式的处理函数 + */ +router.post( + '/v1internal\\:generateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`) + handleGenerateContent(req, res, next) + } +) + +/** + * POST /v1internal:streamGenerateContent + * v1internal 格式使用内部格式的处理函数 + */ +router.post( + '/v1internal\\:streamGenerateContent', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + (req, res, next) => { + logger.info(`Standard Gemini API request (v1internal): ${req.method} ${req.originalUrl}`) + handleStreamGenerateContent(req, res, next) + } +) + +// ============================================================================ +// 模型列表端点 +// ============================================================================ + +/** + * GET /v1beta/models + * 获取模型列表(v1beta 版本) + */ +router.get('/v1beta/models', authenticateApiKey, ensureGeminiPermissionMiddleware, (req, res) => { + logger.info('Standard Gemini API models request (v1beta)') + handleModels(req, res) +}) + +/** + * GET /v1/models + * 获取模型列表(v1 版本) + */ +router.get('/v1/models', authenticateApiKey, ensureGeminiPermissionMiddleware, (req, res) => { + logger.info('Standard Gemini API models request (v1)') + handleModels(req, res) +}) + +// ============================================================================ +// 模型详情端点 +// ============================================================================ + +/** + * GET /v1beta/models/:modelName + * 获取模型详情(v1beta 版本) + */ +router.get( + '/v1beta/models/:modelName', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + handleModelDetails +) + +/** + * GET /v1/models/:modelName + * 获取模型详情(v1 版本) + */ +router.get( + '/v1/models/:modelName', + authenticateApiKey, + ensureGeminiPermissionMiddleware, + handleModelDetails +) + +// ============================================================================ +// 初始化日志 +// ============================================================================ + +logger.info('Standard Gemini API routes initialized') + +module.exports = router diff --git a/src/routes/unified.js b/src/routes/unified.js new file mode 100644 index 0000000..5c2b793 --- /dev/null +++ b/src/routes/unified.js @@ -0,0 +1,702 @@ +const express = require('express') +const { authenticateApiKey } = require('../middleware/auth') +const logger = require('../utils/logger') +const { handleChatCompletion } = require('./openaiClaudeRoutes') +// 从 handlers/geminiHandlers.js 导入 standard 处理函数(支持 OAuth + API Key 双账户类型) +const { + handleStandardGenerateContent: geminiHandleGenerateContent, + handleStandardStreamGenerateContent: geminiHandleStreamGenerateContent +} = require('../handlers/geminiHandlers') +const openaiRoutes = require('./openaiRoutes') +const { CODEX_CLI_INSTRUCTIONS } = require('./openaiRoutes') +const apiKeyService = require('../services/apiKeyService') +const GeminiToOpenAIConverter = require('../services/geminiToOpenAI') +const CodexToOpenAIConverter = require('../services/codexToOpenAI') + +const router = express.Router() + +// 🔍 根据模型名称检测后端类型 +function detectBackendFromModel(modelName) { + if (!modelName) { + return 'claude' // 默认 Claude + } + + const model = modelName.toLowerCase() + + // Claude 模型 + if (model.startsWith('claude-')) { + return 'claude' + } + + // Gemini 模型 + if (model.startsWith('gemini-')) { + return 'gemini' + } + + // OpenAI 模型 + if (model.startsWith('gpt-')) { + return 'openai' + } + + // 默认使用 Claude + return 'claude' +} + +// 🚀 智能后端路由处理器 +async function routeToBackend(req, res, requestedModel) { + const backend = detectBackendFromModel(requestedModel) + + logger.info(`🔀 Routing request - Model: ${requestedModel}, Backend: ${backend}`) + + // 检查权限 + const { permissions } = req.apiKey + + if (backend === 'claude') { + // Claude 后端:通过 OpenAI 兼容层 + if (!apiKeyService.hasPermission(permissions, 'claude')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Claude', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + await handleChatCompletion(req, res, req.apiKey) + } else if (backend === 'openai') { + // OpenAI 后端 + if (!apiKeyService.hasPermission(permissions, 'openai')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access OpenAI', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + // 响应格式拦截:Codex/Responses → OpenAI Chat Completions + const codexConverter = new CodexToOpenAIConverter() + const originalJson = res.json.bind(res) + + // 流式:patch res.write/res.end 拦截 SSE 事件 + // 与 openaiRoutes 保持一致:stream 缺省时视为流式(stream !== false) + if (req.body.stream !== false) { + const streamState = codexConverter.createStreamState() + const sseBuffer = { data: '' } + const originalWrite = res.write.bind(res) + const originalEnd = res.end.bind(res) + + res.write = function (chunk, encoding, callback) { + if (res.statusCode >= 400) { + return originalWrite(chunk, encoding, callback) + } + + const str = (typeof chunk === 'string' ? chunk : chunk.toString()).replace(/\r\n/g, '\n') + sseBuffer.data += str + + let idx + while ((idx = sseBuffer.data.indexOf('\n\n')) !== -1) { + const event = sseBuffer.data.slice(0, idx) + sseBuffer.data = sseBuffer.data.slice(idx + 2) + + if (!event.trim()) { + continue + } + + const lines = event.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6) + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + + try { + const eventData = JSON.parse(jsonStr) + if (eventData.error) { + originalWrite(`data: ${jsonStr}\n\n`) + continue + } + const converted = codexConverter.convertStreamChunk( + eventData, + requestedModel, + streamState + ) + for (const c of converted) { + originalWrite(c) + } + } catch (e) { + originalWrite(`data: ${jsonStr}\n\n`) + } + } + } + } + + if (typeof callback === 'function') { + callback() + } + return true + } + + res.end = function (chunk, encoding, callback) { + if (res.statusCode < 400) { + // 处理 res.end(chunk) 传入的最后一块数据 + if (chunk) { + const str = (typeof chunk === 'string' ? chunk : chunk.toString()).replace( + /\r\n/g, + '\n' + ) + sseBuffer.data += str + chunk = undefined + } + + if (sseBuffer.data.trim()) { + const remaining = `${sseBuffer.data}\n\n` + sseBuffer.data = '' + + const lines = remaining.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6) + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + try { + const eventData = JSON.parse(jsonStr) + if (eventData.error) { + originalWrite(`data: ${jsonStr}\n\n`) + } else { + const converted = codexConverter.convertStreamChunk( + eventData, + requestedModel, + streamState + ) + for (const c of converted) { + originalWrite(c) + } + } + } catch (e) { + originalWrite(`data: ${jsonStr}\n\n`) + } + } + } + } + + originalWrite('data: [DONE]\n\n') + } + return originalEnd(chunk, encoding, callback) + } + } + + // 非流式:patch res.json 拦截 JSON 响应 + // chatgpt.com 后端返回 { type: "response.completed", response: {...} } + // api.openai.com 后端返回标准 Response 对象 { object: "response", status, output, ... } + res.json = function (data) { + if (res.statusCode >= 400) { + return originalJson(data) + } + if (data && (data.type === 'response.completed' || data.object === 'response')) { + try { + return originalJson(codexConverter.convertResponse(data, requestedModel)) + } catch (e) { + logger.debug('Codex response conversion failed, passing through:', e.message) + return originalJson(data) + } + } + return originalJson(data) + } + + // 输入转换:Chat Completions → Responses API 格式 + req.body = codexConverter.buildRequestFromOpenAI(req.body) + // 注入 Codex CLI 系统提示词(与 handleResponses 非 Codex CLI 适配一致) + req.body.instructions = CODEX_CLI_INSTRUCTIONS + req._fromUnifiedEndpoint = true + // 修正请求路径:body 已转为 Responses 格式,路径需与之匹配 + // Express req.path 是只读 getter(派生自 req.url),需改 req.url + req.url = '/v1/responses' + + return await openaiRoutes.handleResponses(req, res) + } else if (backend === 'gemini') { + // Gemini 后端 + if (!apiKeyService.hasPermission(permissions, 'gemini')) { + return res.status(403).json({ + error: { + message: 'This API key does not have permission to access Gemini', + type: 'permission_denied', + code: 'permission_denied' + } + }) + } + + // 将 OpenAI Chat Completions 参数转换为 Gemini 原生格式 + // standard 处理器从 req.body 根层解构 contents/generationConfig 等字段 + const geminiRequest = buildGeminiRequestFromOpenAI(req.body) + + // standard 处理器从 req.params.modelName 获取模型名 + req.params = req.params || {} + req.params.modelName = requestedModel + + // 平铺到 req.body 根层(保留 messages/stream 等原始字段给 sessionHelper 计算 hash) + req.body.contents = geminiRequest.contents + req.body.generationConfig = geminiRequest.generationConfig || {} + req.body.safetySettings = geminiRequest.safetySettings + // standard 处理器读取 camelCase: systemInstruction + if (geminiRequest.system_instruction) { + req.body.systemInstruction = geminiRequest.system_instruction + } + if (geminiRequest.tools) { + req.body.tools = geminiRequest.tools + } + if (geminiRequest.toolConfig) { + req.body.toolConfig = geminiRequest.toolConfig + } + + if (req.body.stream) { + // 响应格式拦截:Gemini SSE → OpenAI Chat Completions chunk + const geminiConverter = new GeminiToOpenAIConverter() + const geminiStreamState = geminiConverter.createStreamState() + const geminiOriginalWrite = res.write.bind(res) + const geminiOriginalEnd = res.end.bind(res) + + res.write = function (chunk, encoding, callback) { + if (res.statusCode >= 400) { + return geminiOriginalWrite(chunk, encoding, callback) + } + + const converted = geminiConverter.convertStreamChunk( + chunk, + requestedModel, + geminiStreamState + ) + if (converted) { + return geminiOriginalWrite(converted, encoding, callback) + } + if (typeof callback === 'function') { + callback() + } + return true + } + + res.end = function (chunk, encoding, callback) { + if (res.statusCode < 400) { + // 处理 res.end(chunk) 传入的最后一块数据 + if (chunk) { + const converted = geminiConverter.convertStreamChunk( + chunk, + requestedModel, + geminiStreamState + ) + if (converted) { + geminiOriginalWrite(converted) + } + chunk = undefined + } + // 刷新 converter 内部 buffer 中的残留数据 + if (geminiStreamState.buffer.trim()) { + const remaining = geminiConverter.convertStreamChunk( + '\n\n', + requestedModel, + geminiStreamState + ) + if (remaining) { + geminiOriginalWrite(remaining) + } + } + geminiOriginalWrite('data: [DONE]\n\n') + } + return geminiOriginalEnd(chunk, encoding, callback) + } + + return await geminiHandleStreamGenerateContent(req, res) + } else { + // 响应格式拦截:Gemini JSON → OpenAI chat.completion + const geminiConverter = new GeminiToOpenAIConverter() + const geminiOriginalJson = res.json.bind(res) + + res.json = function (data) { + if (res.statusCode >= 400) { + return geminiOriginalJson(data) + } + if (data && (data.candidates || data.response?.candidates)) { + return geminiOriginalJson(geminiConverter.convertResponse(data, requestedModel)) + } + return geminiOriginalJson(data) + } + + return await geminiHandleGenerateContent(req, res) + } + } else { + return res.status(500).json({ + error: { + message: `Unsupported backend: ${backend}`, + type: 'server_error', + code: 'unsupported_backend' + } + }) + } +} + +// 🔄 OpenAI 兼容的 chat/completions 端点(智能后端路由) +router.post('/v1/chat/completions', authenticateApiKey, async (req, res) => { + try { + // 验证必需参数 + if (!req.body.messages || !Array.isArray(req.body.messages) || req.body.messages.length === 0) { + return res.status(400).json({ + error: { + message: 'Messages array is required and cannot be empty', + type: 'invalid_request_error', + code: 'invalid_request' + } + }) + } + + const requestedModel = req.body.model || 'claude-3-5-sonnet-20241022' + req.body.model = requestedModel // 确保模型已设置 + + // 使用统一的后端路由处理器 + await routeToBackend(req, res, requestedModel) + } catch (error) { + logger.error('❌ OpenAI chat/completions error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: 'Internal server error', + type: 'server_error', + code: 'internal_error' + } + }) + } + } +}) + +// 🔄 OpenAI 兼容的 completions 端点(传统格式,智能后端路由) +router.post('/v1/completions', authenticateApiKey, async (req, res) => { + try { + // 验证必需参数 + if (!req.body.prompt) { + return res.status(400).json({ + error: { + message: 'Prompt is required', + type: 'invalid_request_error', + code: 'invalid_request' + } + }) + } + + // 将传统 completions 格式转换为 chat 格式 + const originalBody = req.body + const requestedModel = originalBody.model || 'claude-3-5-sonnet-20241022' + + req.body = { + model: requestedModel, + messages: [ + { + role: 'user', + content: originalBody.prompt + } + ], + max_tokens: originalBody.max_tokens, + temperature: originalBody.temperature, + top_p: originalBody.top_p, + stream: originalBody.stream, + stop: originalBody.stop, + n: originalBody.n || 1, + presence_penalty: originalBody.presence_penalty, + frequency_penalty: originalBody.frequency_penalty, + logit_bias: originalBody.logit_bias, + user: originalBody.user + } + + // 使用统一的后端路由处理器 + await routeToBackend(req, res, requestedModel) + } catch (error) { + logger.error('❌ OpenAI completions error:', error) + if (!res.headersSent) { + res.status(500).json({ + error: { + message: 'Failed to process completion request', + type: 'server_error', + code: 'internal_error' + } + }) + } + } +}) + +// --- OpenAI Chat Completions → Gemini 原生请求转换(OpenAI → Gemini 格式映射) --- + +function buildGeminiRequestFromOpenAI(body) { + const request = {} + const generationConfig = {} + const messages = body.messages || [] + + // 第一遍:收集 assistant tool_calls 的 id→name 映射(用于 tool response 关联) + const toolCallNames = Object.create(null) + for (const msg of messages) { + if (msg.role === 'assistant' && msg.tool_calls) { + for (const tc of msg.tool_calls) { + if (tc.id && tc.function?.name) { + toolCallNames[tc.id] = tc.function.name + } + } + } + } + + // 第二遍:构建 contents + system_instruction + const systemParts = [] + const contents = [] + + for (const msg of messages) { + if (msg.role === 'system' || msg.role === 'developer') { + const text = extractTextContent(msg.content) + if (text) { + systemParts.push({ text }) + } + } else if (msg.role === 'user') { + const parts = buildContentParts(msg.content) + if (parts.length > 0) { + contents.push({ role: 'user', parts }) + } + } else if (msg.role === 'assistant') { + // 格式映射: assistant 内容保留 text + image(多模态) + const parts = buildContentParts(msg.content) + // tool_calls → functionCall parts + if (msg.tool_calls) { + for (const tc of msg.tool_calls) { + if (tc.function) { + let args + try { + args = JSON.parse(tc.function.arguments || '{}') + } catch { + // parse 失败时尝试保留原始内容 + args = tc.function.arguments ? { _raw: tc.function.arguments } : {} + } + parts.push({ + functionCall: { name: tc.function.name, args } + }) + } + } + } + if (parts.length > 0) { + contents.push({ role: 'model', parts }) + } + } else if (msg.role === 'tool') { + // tool response → functionResponse(Gemini 用 user role) + const name = toolCallNames[msg.tool_call_id] || msg.name || 'unknown' + let responseContent + try { + responseContent = + typeof msg.content === 'string' ? JSON.parse(msg.content) : msg.content || {} + } catch { + responseContent = { result: msg.content } + } + contents.push({ + role: 'user', + parts: [{ functionResponse: { name, response: responseContent } }] + }) + } + } + + if (systemParts.length > 0) { + if (contents.length === 0) { + // Gemini 格式:只有 system 消息时,将其作为 user content(避免 Gemini 拒绝空 contents) + contents.push({ role: 'user', parts: systemParts }) + } else { + request.system_instruction = { parts: systemParts } + } + } + request.contents = contents + + // Generation config + if (body.temperature !== undefined) { + generationConfig.temperature = body.temperature + } + const maxTokens = body.max_completion_tokens || body.max_tokens + if (maxTokens !== undefined) { + generationConfig.maxOutputTokens = maxTokens + } + if (body.top_p !== undefined) { + generationConfig.topP = body.top_p + } + if (body.top_k !== undefined) { + generationConfig.topK = body.top_k + } + if (body.n !== undefined && body.n > 1) { + generationConfig.candidateCount = body.n + } + if (body.stop) { + generationConfig.stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop] + } + + // modalities → responseModalities(text→TEXT, image→IMAGE, audio→AUDIO) + if (body.modalities && Array.isArray(body.modalities)) { + const modalityMap = { text: 'TEXT', image: 'IMAGE', audio: 'AUDIO' } + const mapped = body.modalities.map((m) => modalityMap[m.toLowerCase()]).filter(Boolean) + if (mapped.length > 0) { + generationConfig.responseModalities = mapped + } + } + + // image_config → imageConfig(Gemini 格式:aspect_ratio→aspectRatio, image_size→imageSize) + if (body.image_config) { + const imageConfig = {} + if (body.image_config.aspect_ratio) { + imageConfig.aspectRatio = body.image_config.aspect_ratio + } + if (body.image_config.image_size) { + imageConfig.imageSize = body.image_config.image_size + } + if (Object.keys(imageConfig).length > 0) { + generationConfig.imageConfig = imageConfig + } + } + + // reasoning_effort → thinkingConfig(Gemini 格式) + if (body.reasoning_effort) { + const effort = body.reasoning_effort.toLowerCase() + if (effort === 'none') { + generationConfig.thinkingConfig = { thinkingLevel: 'none', includeThoughts: false } + } else if (effort === 'auto') { + // 格式映射: auto → thinkingBudget:-1 (让模型自行决定) + generationConfig.thinkingConfig = { thinkingBudget: -1, includeThoughts: true } + } else { + generationConfig.thinkingConfig = { thinkingLevel: effort, includeThoughts: true } + } + } + + // response_format → responseMimeType / responseSchema + if (body.response_format) { + if (body.response_format.type === 'json_object') { + generationConfig.responseMimeType = 'application/json' + } else if ( + body.response_format.type === 'json_schema' && + body.response_format.json_schema?.schema + ) { + generationConfig.responseMimeType = 'application/json' + generationConfig.responseSchema = body.response_format.json_schema.schema + } + } + + if (Object.keys(generationConfig).length > 0) { + request.generationConfig = generationConfig + } + + // Tools: OpenAI function → Gemini functionDeclarations(OpenAI → Gemini 格式映射) + if (body.tools && body.tools.length > 0) { + const functionDeclarations = [] + const extraTools = [] + for (const tool of body.tools) { + if (tool.type === 'function' && tool.function) { + const decl = { + name: tool.function.name, + description: tool.function.description || '' + } + if (tool.function.parameters) { + // 格式映射: parameters → parametersJsonSchema, 删除 strict + const schema = { ...tool.function.parameters } + delete schema.strict + decl.parametersJsonSchema = schema + } else { + decl.parametersJsonSchema = { type: 'object', properties: {} } + } + functionDeclarations.push(decl) + } else if ( + tool.type === 'google_search' || + tool.type === 'code_execution' || + tool.type === 'url_context' + ) { + // 非 function 工具透传,snake_case → camelCase(Gemini 原生格式) + const typeMap = { + google_search: 'googleSearch', + code_execution: 'codeExecution', + url_context: 'urlContext' + } + const geminiType = typeMap[tool.type] + extraTools.push({ [geminiType]: tool[tool.type] || {} }) + } + } + const toolsArray = [] + if (functionDeclarations.length > 0) { + toolsArray.push({ functionDeclarations }) + } + toolsArray.push(...extraTools) + if (toolsArray.length > 0) { + request.tools = toolsArray + } + } + + // tool_choice → toolConfig.functionCallingConfig + if (body.tool_choice) { + if (body.tool_choice === 'none') { + request.toolConfig = { functionCallingConfig: { mode: 'NONE' } } + } else if (body.tool_choice === 'auto') { + request.toolConfig = { functionCallingConfig: { mode: 'AUTO' } } + } else if (body.tool_choice === 'required') { + request.toolConfig = { functionCallingConfig: { mode: 'ANY' } } + } else if (typeof body.tool_choice === 'object' && body.tool_choice.function?.name) { + request.toolConfig = { + functionCallingConfig: { + mode: 'ANY', + allowedFunctionNames: [body.tool_choice.function.name] + } + } + } + } + + // 默认安全设置(Gemini 格式:最大化允许,避免不必要的内容拦截) + if (!request.safetySettings) { + request.safetySettings = [ + { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'OFF' }, + { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'OFF' }, + { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'OFF' }, + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'OFF' }, + { category: 'HARM_CATEGORY_CIVIC_INTEGRITY', threshold: 'BLOCK_NONE' } + ] + } + + return request +} + +function extractTextContent(content) { + if (typeof content === 'string') { + return content + } + if (Array.isArray(content)) { + return content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('') + } + return '' +} + +function buildContentParts(content) { + if (typeof content === 'string') { + return [{ text: content }] + } + if (Array.isArray(content)) { + const parts = [] + for (const item of content) { + if (item.type === 'text') { + parts.push({ text: item.text }) + } else if (item.type === 'image_url' && item.image_url?.url) { + const { url } = item.image_url + if (url.startsWith('data:')) { + const match = url.match(/^data:([^;]+);base64,(.+)$/) + if (match) { + parts.push({ inlineData: { mimeType: match[1], data: match[2] } }) + } + } + } + } + return parts + } + if (!content) { + return [] + } + return [{ text: String(content) }] +} + +module.exports = router +module.exports.detectBackendFromModel = detectBackendFromModel +module.exports.routeToBackend = routeToBackend diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js new file mode 100644 index 0000000..55cbcc4 --- /dev/null +++ b/src/routes/userRoutes.js @@ -0,0 +1,926 @@ +const express = require('express') +const router = express.Router() +const ldapService = require('../services/ldapService') +const userService = require('../services/userService') +const apiKeyService = require('../services/apiKeyService') +const logger = require('../utils/logger') +const config = require('../../config/config') +const inputValidator = require('../utils/inputValidator') +const { RateLimiterRedis } = require('rate-limiter-flexible') +const redis = require('../models/redis') +const { authenticateUser, authenticateUserOrAdmin, requireAdmin } = require('../middleware/auth') + +// 🚦 配置登录速率限制 +// 只基于IP地址限制,避免攻击者恶意锁定特定账户 + +// 延迟初始化速率限制器,确保 Redis 已连接 +let ipRateLimiter = null +let strictIpRateLimiter = null + +// 初始化速率限制器函数 +function initRateLimiters() { + if (!ipRateLimiter) { + try { + const redisClient = redis.getClientSafe() + + // IP地址速率限制 - 正常限制 + ipRateLimiter = new RateLimiterRedis({ + storeClient: redisClient, + keyPrefix: 'login_ip_limiter', + points: 30, // 每个IP允许30次尝试 + duration: 900, // 15分钟窗口期 + blockDuration: 900 // 超限后封禁15分钟 + }) + + // IP地址速率限制 - 严格限制(用于检测暴力破解) + strictIpRateLimiter = new RateLimiterRedis({ + storeClient: redisClient, + keyPrefix: 'login_ip_strict', + points: 100, // 每个IP允许100次尝试 + duration: 3600, // 1小时窗口期 + blockDuration: 3600 // 超限后封禁1小时 + }) + } catch (error) { + logger.error('❌ 初始化速率限制器失败:', error) + // 速率限制器初始化失败时继续运行,但记录错误 + } + } + return { ipRateLimiter, strictIpRateLimiter } +} + +// 🔐 用户登录端点 +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body + const clientIp = req.ip || req.connection.remoteAddress || 'unknown' + + // 初始化速率限制器(如果尚未初始化) + const limiters = initRateLimiters() + + // 检查IP速率限制 - 基础限制 + if (limiters.ipRateLimiter) { + try { + await limiters.ipRateLimiter.consume(clientIp) + } catch (rateLimiterRes) { + const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 900 + logger.security(`🚫 Login rate limit exceeded for IP: ${clientIp}`) + res.set('Retry-After', String(retryAfter)) + return res.status(429).json({ + error: 'Too many requests', + message: `Too many login attempts from this IP. Please try again later.` + }) + } + } + + // 检查IP速率限制 - 严格限制(防止暴力破解) + if (limiters.strictIpRateLimiter) { + try { + await limiters.strictIpRateLimiter.consume(clientIp) + } catch (rateLimiterRes) { + const retryAfter = Math.round(rateLimiterRes.msBeforeNext / 1000) || 3600 + logger.security(`🚫 Strict rate limit exceeded for IP: ${clientIp} - possible brute force`) + res.set('Retry-After', String(retryAfter)) + return res.status(429).json({ + error: 'Too many requests', + message: 'Too many login attempts detected. Access temporarily blocked.' + }) + } + } + + if (!username || !password) { + return res.status(400).json({ + error: 'Missing credentials', + message: 'Username and password are required' + }) + } + + // 验证输入格式 + let validatedUsername + try { + validatedUsername = inputValidator.validateUsername(username) + inputValidator.validatePassword(password) + } catch (validationError) { + return res.status(400).json({ + error: 'Invalid input', + message: validationError.message + }) + } + + // 检查用户管理是否启用 + if (!config.userManagement.enabled) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'User management is not enabled' + }) + } + + // 检查LDAP是否启用 + if (!config.ldap || !config.ldap.enabled) { + return res.status(503).json({ + error: 'Service unavailable', + message: 'LDAP authentication is not enabled' + }) + } + + // 尝试LDAP认证 + const authResult = await ldapService.authenticateUserCredentials(validatedUsername, password) + + if (!authResult.success) { + // 登录失败 + logger.info(`🚫 Failed login attempt for user: ${validatedUsername} from IP: ${clientIp}`) + return res.status(401).json({ + error: 'Authentication failed', + message: authResult.message + }) + } + + // 登录成功 + logger.info(`✅ User login successful: ${validatedUsername} from IP: ${clientIp}`) + + res.json({ + success: true, + message: 'Login successful', + user: { + id: authResult.user.id, + username: authResult.user.username, + email: authResult.user.email, + displayName: authResult.user.displayName, + firstName: authResult.user.firstName, + lastName: authResult.user.lastName, + role: authResult.user.role + }, + sessionToken: authResult.sessionToken + }) + } catch (error) { + logger.error('❌ User login error:', error) + res.status(500).json({ + error: 'Login error', + message: 'Internal server error during login' + }) + } +}) + +// 🚪 用户登出端点 +router.post('/logout', authenticateUser, async (req, res) => { + try { + await userService.invalidateUserSession(req.user.sessionToken) + + logger.info(`👋 User logout: ${req.user.username}`) + + res.json({ + success: true, + message: 'Logout successful' + }) + } catch (error) { + logger.error('❌ User logout error:', error) + res.status(500).json({ + error: 'Logout error', + message: 'Internal server error during logout' + }) + } +}) + +// 👤 获取当前用户信息 +router.get('/profile', authenticateUser, async (req, res) => { + try { + const user = await userService.getUserById(req.user.id) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User profile not found' + }) + } + + res.json({ + success: true, + user: { + id: user.id, + username: user.username, + email: user.email, + displayName: user.displayName, + firstName: user.firstName, + lastName: user.lastName, + role: user.role, + isActive: user.isActive, + createdAt: user.createdAt, + lastLoginAt: user.lastLoginAt, + apiKeyCount: user.apiKeyCount, + totalUsage: user.totalUsage + }, + config: { + maxApiKeysPerUser: config.userManagement.maxApiKeysPerUser, + allowUserDeleteApiKeys: config.userManagement.allowUserDeleteApiKeys + } + }) + } catch (error) { + logger.error('❌ Get user profile error:', error) + res.status(500).json({ + error: 'Profile error', + message: 'Failed to retrieve user profile' + }) + } +}) + +// 🔑 获取用户的API Keys +router.get('/api-keys', authenticateUser, async (req, res) => { + try { + const { includeDeleted = 'false' } = req.query + const apiKeys = await apiKeyService.getUserApiKeys(req.user.id, includeDeleted === 'true') + + // 移除敏感信息并格式化usage数据 + const safeApiKeys = apiKeys.map((key) => { + // Flatten usage structure for frontend compatibility + let flatUsage = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + + if (key.usage && key.usage.total) { + flatUsage = { + requests: key.usage.total.requests || 0, + inputTokens: key.usage.total.inputTokens || 0, + outputTokens: key.usage.total.outputTokens || 0, + totalCost: key.totalCost || 0 + } + } + + return { + id: key.id, + name: key.name, + description: key.description, + tokenLimit: key.tokenLimit, + isActive: key.isActive, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + expiresAt: key.expiresAt, + usage: flatUsage, + dailyCost: key.dailyCost, + dailyCostLimit: key.dailyCostLimit, + totalCost: key.totalCost, + totalCostLimit: key.totalCostLimit, + // 不返回实际的key值,只返回前缀和后几位 + keyPreview: key.key + ? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}` + : null, + // Include deletion fields for deleted keys + isDeleted: key.isDeleted, + deletedAt: key.deletedAt, + deletedBy: key.deletedBy, + deletedByType: key.deletedByType + } + }) + + res.json({ + success: true, + apiKeys: safeApiKeys, + total: safeApiKeys.length + }) + } catch (error) { + logger.error('❌ Get user API keys error:', error) + res.status(500).json({ + error: 'API Keys error', + message: 'Failed to retrieve API keys' + }) + } +}) + +// 🔑 创建新的API Key +router.post('/api-keys', authenticateUser, async (req, res) => { + try { + const { name, description, tokenLimit, expiresAt, dailyCostLimit, totalCostLimit } = req.body + + if (!name || !name.trim()) { + return res.status(400).json({ + error: 'Missing name', + message: 'API key name is required' + }) + } + + if ( + totalCostLimit !== undefined && + totalCostLimit !== null && + totalCostLimit !== '' && + (Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0) + ) { + return res.status(400).json({ + error: 'Invalid total cost limit', + message: 'Total cost limit must be a non-negative number' + }) + } + + // 检查用户API Key数量限制 + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) + if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) { + return res.status(400).json({ + error: 'API key limit exceeded', + message: `You can only have up to ${config.userManagement.maxApiKeysPerUser} API keys` + }) + } + + // 创建API Key数据 + const apiKeyData = { + name: name.trim(), + description: description?.trim() || '', + userId: req.user.id, + userUsername: req.user.username, + tokenLimit: tokenLimit || null, + expiresAt: expiresAt || null, + dailyCostLimit: dailyCostLimit || null, + totalCostLimit: totalCostLimit || null, + createdBy: 'user', + // 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限 + permissions: 'all' + } + + const newApiKey = await apiKeyService.createApiKey(apiKeyData) + + // 更新用户API Key数量 + await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length + 1) + + logger.info(`🔑 User ${req.user.username} created API key: ${name}`) + + res.status(201).json({ + success: true, + message: 'API key created successfully', + apiKey: { + id: newApiKey.id, + name: newApiKey.name, + description: newApiKey.description, + key: newApiKey.apiKey, // 只在创建时返回完整key + tokenLimit: newApiKey.tokenLimit, + expiresAt: newApiKey.expiresAt, + dailyCostLimit: newApiKey.dailyCostLimit, + totalCostLimit: newApiKey.totalCostLimit, + createdAt: newApiKey.createdAt + } + }) + } catch (error) { + logger.error('❌ Create user API key error:', error) + res.status(500).json({ + error: 'API Key creation error', + message: 'Failed to create API key' + }) + } +}) + +// 🗑️ 删除API Key +router.delete('/api-keys/:keyId', authenticateUser, async (req, res) => { + try { + const { keyId } = req.params + + // 检查是否允许用户删除自己的API Keys + if (!config.userManagement.allowUserDeleteApiKeys) { + return res.status(403).json({ + error: 'Operation not allowed', + message: + 'Users are not allowed to delete their own API keys. Please contact an administrator.' + }) + } + + // 检查API Key是否属于当前用户 + const existingKey = await apiKeyService.getApiKeyById(keyId) + if (!existingKey || existingKey.userId !== req.user.id) { + return res.status(404).json({ + error: 'API key not found', + message: 'API key not found or you do not have permission to access it' + }) + } + + await apiKeyService.deleteApiKey(keyId, req.user.username, 'user') + + // 更新用户API Key数量 + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) + await userService.updateUserApiKeyCount(req.user.id, userApiKeys.length) + + logger.info(`🗑️ User ${req.user.username} deleted API key: ${existingKey.name}`) + + res.json({ + success: true, + message: 'API key deleted successfully' + }) + } catch (error) { + logger.error('❌ Delete user API key error:', error) + res.status(500).json({ + error: 'API Key deletion error', + message: 'Failed to delete API key' + }) + } +}) + +// 📊 获取用户使用统计 +router.get('/usage-stats', authenticateUser, async (req, res) => { + try { + const { period = 'week', model } = req.query + + // 获取用户的API Keys (including deleted ones for complete usage stats) + const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id, true) + const apiKeyIds = userApiKeys.map((key) => key.id) + + if (apiKeyIds.length === 0) { + return res.json({ + success: true, + stats: { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + }) + } + + // 获取使用统计 + const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model }) + + res.json({ + success: true, + stats + }) + } catch (error) { + logger.error('❌ Get user usage stats error:', error) + res.status(500).json({ + error: 'Usage stats error', + message: 'Failed to retrieve usage statistics' + }) + } +}) + +// === 管理员用户管理端点 === + +// 📋 获取用户列表(管理员) +router.get('/', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { page = 1, limit = 20, role, isActive, search } = req.query + + const options = { + page: parseInt(page), + limit: parseInt(limit), + role, + isActive: isActive === 'true' ? true : isActive === 'false' ? false : undefined + } + + const result = await userService.getAllUsers(options) + + // 如果有搜索条件,进行过滤 + let filteredUsers = result.users + if (search) { + const searchLower = search.toLowerCase() + filteredUsers = result.users.filter( + (user) => + user.username.toLowerCase().includes(searchLower) || + user.displayName.toLowerCase().includes(searchLower) || + user.email.toLowerCase().includes(searchLower) + ) + } + + res.json({ + success: true, + users: filteredUsers, + pagination: { + total: result.total, + page: result.page, + limit: result.limit, + totalPages: result.totalPages + } + }) + } catch (error) { + logger.error('❌ Get users list error:', error) + res.status(500).json({ + error: 'Users list error', + message: 'Failed to retrieve users list' + }) + } +}) + +// 👤 获取特定用户信息(管理员) +router.get('/:userId', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + // 获取用户的API Keys(包括已删除的以保留统计数据) + const apiKeys = await apiKeyService.getUserApiKeys(userId, true) + + res.json({ + success: true, + user: { + ...user, + apiKeys: apiKeys.map((key) => { + // Flatten usage structure for frontend compatibility + let flatUsage = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + + if (key.usage && key.usage.total) { + flatUsage = { + requests: key.usage.total.requests || 0, + inputTokens: key.usage.total.inputTokens || 0, + outputTokens: key.usage.total.outputTokens || 0, + totalCost: key.totalCost || 0 + } + } + + return { + id: key.id, + name: key.name, + description: key.description, + isActive: key.isActive, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + usage: flatUsage, + keyPreview: key.key + ? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}` + : null + } + }) + } + }) + } catch (error) { + logger.error('❌ Get user details error:', error) + res.status(500).json({ + error: 'User details error', + message: 'Failed to retrieve user details' + }) + } +}) + +// 🔄 更新用户状态(管理员) +router.patch('/:userId/status', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { isActive } = req.body + + if (typeof isActive !== 'boolean') { + return res.status(400).json({ + error: 'Invalid status', + message: 'isActive must be a boolean value' + }) + } + + const updatedUser = await userService.updateUserStatus(userId, isActive) + + const adminUser = req.admin?.username || req.user?.username + logger.info( + `🔄 Admin ${adminUser} ${isActive ? 'enabled' : 'disabled'} user: ${updatedUser.username}` + ) + + res.json({ + success: true, + message: `User ${isActive ? 'enabled' : 'disabled'} successfully`, + user: { + id: updatedUser.id, + username: updatedUser.username, + isActive: updatedUser.isActive, + updatedAt: updatedUser.updatedAt + } + }) + } catch (error) { + logger.error('❌ Update user status error:', error) + res.status(500).json({ + error: 'Update status error', + message: error.message || 'Failed to update user status' + }) + } +}) + +// 🔄 更新用户角色(管理员) +router.patch('/:userId/role', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { role } = req.body + + const validRoles = ['user', 'admin'] + if (!role || !validRoles.includes(role)) { + return res.status(400).json({ + error: 'Invalid role', + message: `Role must be one of: ${validRoles.join(', ')}` + }) + } + + const updatedUser = await userService.updateUserRole(userId, role) + + const adminUser = req.admin?.username || req.user?.username + logger.info(`🔄 Admin ${adminUser} changed user ${updatedUser.username} role to: ${role}`) + + res.json({ + success: true, + message: `User role updated to ${role} successfully`, + user: { + id: updatedUser.id, + username: updatedUser.username, + role: updatedUser.role, + updatedAt: updatedUser.updatedAt + } + }) + } catch (error) { + logger.error('❌ Update user role error:', error) + res.status(500).json({ + error: 'Update role error', + message: error.message || 'Failed to update user role' + }) + } +}) + +// 🔑 禁用用户的所有API Keys(管理员) +router.post('/:userId/disable-keys', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + const result = await apiKeyService.disableUserApiKeys(userId) + + const adminUser = req.admin?.username || req.user?.username + logger.info(`🔑 Admin ${adminUser} disabled all API keys for user: ${user.username}`) + + res.json({ + success: true, + message: `Disabled ${result.count} API keys for user ${user.username}`, + disabledCount: result.count + }) + } catch (error) { + logger.error('❌ Disable user API keys error:', error) + res.status(500).json({ + error: 'Disable keys error', + message: 'Failed to disable user API keys' + }) + } +}) + +// 📊 获取用户使用统计(管理员) +router.get('/:userId/usage-stats', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const { userId } = req.params + const { period = 'week', model } = req.query + + const user = await userService.getUserById(userId) + if (!user) { + return res.status(404).json({ + error: 'User not found', + message: 'User not found' + }) + } + + // 获取用户的API Keys(包括已删除的以保留统计数据) + const userApiKeys = await apiKeyService.getUserApiKeys(userId, true) + const apiKeyIds = userApiKeys.map((key) => key.id) + + if (apiKeyIds.length === 0) { + return res.json({ + success: true, + user: { + id: user.id, + username: user.username, + displayName: user.displayName + }, + stats: { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + }) + } + + // 获取使用统计 + const stats = await apiKeyService.getAggregatedUsageStats(apiKeyIds, { period, model }) + + res.json({ + success: true, + user: { + id: user.id, + username: user.username, + displayName: user.displayName + }, + stats + }) + } catch (error) { + logger.error('❌ Get user usage stats (admin) error:', error) + res.status(500).json({ + error: 'Usage stats error', + message: 'Failed to retrieve user usage statistics' + }) + } +}) + +// 📊 获取用户管理统计(管理员) +router.get('/stats/overview', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const stats = await userService.getUserStats() + + res.json({ + success: true, + stats + }) + } catch (error) { + logger.error('❌ Get user stats overview error:', error) + res.status(500).json({ + error: 'Stats error', + message: 'Failed to retrieve user statistics' + }) + } +}) + +// 🔧 测试LDAP连接(管理员) +router.get('/admin/ldap-test', authenticateUserOrAdmin, requireAdmin, async (req, res) => { + try { + const testResult = await ldapService.testConnection() + + res.json({ + success: true, + ldapTest: testResult, + config: ldapService.getConfigInfo() + }) + } catch (error) { + logger.error('❌ LDAP test error:', error) + res.status(500).json({ + error: 'LDAP test error', + message: 'Failed to test LDAP connection' + }) + } +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// 额度卡核销相关路由 +// ═══════════════════════════════════════════════════════════════════════════ + +const quotaCardService = require('../services/quotaCardService') + +// 🎫 核销额度卡 +router.post('/redeem-card', authenticateUser, async (req, res) => { + try { + const { code, apiKeyId } = req.body + + if (!code) { + return res.status(400).json({ + error: 'Missing card code', + message: 'Card code is required' + }) + } + + if (!apiKeyId) { + return res.status(400).json({ + error: 'Missing API key ID', + message: 'API key ID is required' + }) + } + + // 验证 API Key 属于当前用户 + const keyData = await redis.getApiKey(apiKeyId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ + error: 'API key not found', + message: 'The specified API key does not exist' + }) + } + + if (keyData.userId !== req.user.id) { + return res.status(403).json({ + error: 'Forbidden', + message: 'You can only redeem cards to your own API keys' + }) + } + + // 执行核销 + const result = await quotaCardService.redeemCard(code, apiKeyId, req.user.id, req.user.username) + + logger.success(`🎫 User ${req.user.username} redeemed card ${code} to key ${apiKeyId}`) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Redeem card error:', error) + res.status(400).json({ + error: 'Redeem failed', + message: error.message + }) + } +}) + +// 📋 获取用户的核销历史 +router.get('/redemption-history', authenticateUser, async (req, res) => { + try { + const { limit = 50, offset = 0 } = req.query + + const result = await quotaCardService.getRedemptions({ + userId: req.user.id, + limit: parseInt(limit), + offset: parseInt(offset) + }) + + res.json({ + success: true, + data: result + }) + } catch (error) { + logger.error('❌ Get redemption history error:', error) + res.status(500).json({ + error: 'Failed to get redemption history', + message: error.message + }) + } +}) + +// 📊 获取用户的额度信息 +router.get('/quota-info', authenticateUser, async (req, res) => { + try { + const { apiKeyId } = req.query + + if (!apiKeyId) { + return res.status(400).json({ + error: 'Missing API key ID', + message: 'API key ID is required' + }) + } + + // 验证 API Key 属于当前用户 + const keyData = await redis.getApiKey(apiKeyId) + if (!keyData || Object.keys(keyData).length === 0) { + return res.status(404).json({ + error: 'API key not found', + message: 'The specified API key does not exist' + }) + } + + if (keyData.userId !== req.user.id) { + return res.status(403).json({ + error: 'Forbidden', + message: 'You can only view your own API key quota' + }) + } + + // 检查是否为聚合 Key + if (keyData.isAggregated !== 'true') { + return res.json({ + success: true, + data: { + isAggregated: false, + message: 'This is a traditional API key, not using quota system' + } + }) + } + + // 解析聚合 Key 数据 + let permissions = [] + let serviceQuotaLimits = {} + let serviceQuotaUsed = {} + + try { + permissions = JSON.parse(keyData.permissions || '[]') + } catch (e) { + permissions = [keyData.permissions] + } + + try { + serviceQuotaLimits = JSON.parse(keyData.serviceQuotaLimits || '{}') + serviceQuotaUsed = JSON.parse(keyData.serviceQuotaUsed || '{}') + } catch (e) { + // 解析失败使用默认值 + } + + res.json({ + success: true, + data: { + isAggregated: true, + quotaLimit: parseFloat(keyData.quotaLimit || 0), + quotaUsed: parseFloat(keyData.quotaUsed || 0), + quotaRemaining: parseFloat(keyData.quotaLimit || 0) - parseFloat(keyData.quotaUsed || 0), + permissions, + serviceQuotaLimits, + serviceQuotaUsed, + expiresAt: keyData.expiresAt + } + }) + } catch (error) { + logger.error('❌ Get quota info error:', error) + res.status(500).json({ + error: 'Failed to get quota info', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/web.js b/src/routes/web.js new file mode 100644 index 0000000..547b986 --- /dev/null +++ b/src/routes/web.js @@ -0,0 +1,381 @@ +const express = require('express') +const bcrypt = require('bcryptjs') +const crypto = require('crypto') +const path = require('path') +const fs = require('fs') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const config = require('../../config/config') + +const router = express.Router() + +// 🏠 服务静态文件 +router.use('/assets', express.static(path.join(__dirname, '../../web/assets'))) + +// 🌐 页面路由重定向到新版 admin-spa +router.get('/', (req, res) => { + res.redirect(301, '/admin-next/api-stats') +}) + +// 🔐 管理员登录 +router.post('/auth/login', async (req, res) => { + try { + const { username, password } = req.body + + if (!username || !password) { + return res.status(400).json({ + error: 'Missing credentials', + message: 'Username and password are required' + }) + } + + // 从Redis获取管理员信息 + let adminData = await redis.getSession('admin_credentials') + + // 如果Redis中没有管理员凭据,尝试从init.json重新加载 + if (!adminData || Object.keys(adminData).length === 0) { + const initFilePath = path.join(__dirname, '../../data/init.json') + + if (fs.existsSync(initFilePath)) { + try { + const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + const saltRounds = 10 + const passwordHash = await bcrypt.hash(initData.adminPassword, saltRounds) + + adminData = { + username: initData.adminUsername, + passwordHash, + createdAt: initData.initializedAt || new Date().toISOString(), + lastLogin: null, + updatedAt: initData.updatedAt || null + } + + // 重新存储到Redis,不设置过期时间 + await redis.getClient().hset('session:admin_credentials', adminData) + + logger.info('✅ Admin credentials reloaded from init.json') + } catch (error) { + logger.error('❌ Failed to reload admin credentials:', error) + return res.status(401).json({ + error: 'Invalid credentials', + message: 'Invalid username or password' + }) + } + } else { + return res.status(401).json({ + error: 'Invalid credentials', + message: 'Invalid username or password' + }) + } + } + + // 验证用户名和密码 + const isValidUsername = adminData.username === username + const isValidPassword = await bcrypt.compare(password, adminData.passwordHash) + + if (!isValidUsername || !isValidPassword) { + logger.security(`Failed login attempt for username: ${username}`) + return res.status(401).json({ + error: 'Invalid credentials', + message: 'Invalid username or password' + }) + } + + // 生成会话token + const sessionId = crypto.randomBytes(32).toString('hex') + + // 存储会话 + const sessionData = { + username: adminData.username, + loginTime: new Date().toISOString(), + lastActivity: new Date().toISOString() + } + + await redis.setSession(sessionId, sessionData, config.security.adminSessionTimeout) + + // 不再更新 Redis 中的最后登录时间,因为 Redis 只是缓存 + // init.json 是唯一真实数据源 + + logger.success(`Admin login successful: ${username}`) + + return res.json({ + success: true, + token: sessionId, + expiresIn: config.security.adminSessionTimeout, + username: adminData.username // 返回真实用户名 + }) + } catch (error) { + logger.error('❌ Login error:', error) + return res.status(500).json({ + error: 'Login failed', + message: 'Internal server error' + }) + } +}) + +// 🚪 管理员登出 +router.post('/auth/logout', async (req, res) => { + try { + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + + if (token) { + await redis.deleteSession(token) + logger.success('🚪 Admin logout successful') + } + + return res.json({ success: true, message: 'Logout successful' }) + } catch (error) { + logger.error('❌ Logout error:', error) + return res.status(500).json({ + error: 'Logout failed', + message: 'Internal server error' + }) + } +}) + +// 🔑 修改账户信息 +router.post('/auth/change-password', async (req, res) => { + try { + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + + if (!token) { + return res.status(401).json({ + error: 'No token provided', + message: 'Authentication required' + }) + } + + const { newUsername, currentPassword, newPassword } = req.body + + if (!currentPassword || !newPassword) { + return res.status(400).json({ + error: 'Missing required fields', + message: 'Current password and new password are required' + }) + } + + // 验证新密码长度 + if (newPassword.length < 8) { + return res.status(400).json({ + error: 'Password too short', + message: 'New password must be at least 8 characters long' + }) + } + + // 获取当前会话 + const sessionData = await redis.getSession(token) + + // 🔒 安全修复:检查空对象 + if (!sessionData || Object.keys(sessionData).length === 0) { + return res.status(401).json({ + error: 'Invalid token', + message: 'Session expired or invalid' + }) + } + + // 🔒 安全修复:验证会话完整性 + if (!sessionData.username || !sessionData.loginTime) { + logger.security( + `🔒 Invalid session structure in /auth/change-password from ${req.ip || 'unknown'}` + ) + await redis.deleteSession(token) + return res.status(401).json({ + error: 'Invalid session', + message: 'Session data corrupted or incomplete' + }) + } + + // 获取当前管理员信息 + const adminData = await redis.getSession('admin_credentials') + if (!adminData) { + return res.status(500).json({ + error: 'Admin data not found', + message: 'Administrator credentials not found' + }) + } + + // 验证当前密码 + const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash) + if (!isValidPassword) { + logger.security(`Invalid current password attempt for user: ${sessionData.username}`) + return res.status(401).json({ + error: 'Invalid current password', + message: 'Current password is incorrect' + }) + } + + // 准备更新的数据 + const updatedUsername = + newUsername && newUsername.trim() ? newUsername.trim() : adminData.username + + // 先更新 init.json(唯一真实数据源) + const initFilePath = path.join(__dirname, '../../data/init.json') + if (!fs.existsSync(initFilePath)) { + return res.status(500).json({ + error: 'Configuration file not found', + message: 'init.json file is missing' + }) + } + + try { + const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8')) + // const oldData = { ...initData }; // 备份旧数据 + + // 更新 init.json + initData.adminUsername = updatedUsername + initData.adminPassword = newPassword // 保存明文密码到init.json + initData.updatedAt = new Date().toISOString() + + // 先写入文件(如果失败则不会影响 Redis) + fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2)) + + // 文件写入成功后,更新 Redis 缓存 + const saltRounds = 10 + const newPasswordHash = await bcrypt.hash(newPassword, saltRounds) + + const updatedAdminData = { + username: updatedUsername, + passwordHash: newPasswordHash, + createdAt: adminData.createdAt, + lastLogin: adminData.lastLogin, + updatedAt: new Date().toISOString() + } + + await redis.setSession('admin_credentials', updatedAdminData) + } catch (fileError) { + logger.error('❌ Failed to update init.json:', fileError) + return res.status(500).json({ + error: 'Update failed', + message: 'Failed to update configuration file' + }) + } + + // 清除当前会话(强制用户重新登录) + await redis.deleteSession(token) + + logger.success(`Admin password changed successfully for user: ${updatedUsername}`) + + return res.json({ + success: true, + message: 'Password changed successfully. Please login again.', + newUsername: updatedUsername + }) + } catch (error) { + logger.error('❌ Change password error:', error) + return res.status(500).json({ + error: 'Change password failed', + message: 'Internal server error' + }) + } +}) + +// 👤 获取当前用户信息 +router.get('/auth/user', async (req, res) => { + try { + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + + if (!token) { + return res.status(401).json({ + error: 'No token provided', + message: 'Authentication required' + }) + } + + // 获取当前会话 + const sessionData = await redis.getSession(token) + + // 🔒 安全修复:检查空对象 + if (!sessionData || Object.keys(sessionData).length === 0) { + return res.status(401).json({ + error: 'Invalid token', + message: 'Session expired or invalid' + }) + } + + // 🔒 安全修复:验证会话完整性 + if (!sessionData.username || !sessionData.loginTime) { + logger.security(`Invalid session structure in /auth/user from ${req.ip || 'unknown'}`) + await redis.deleteSession(token) + return res.status(401).json({ + error: 'Invalid session', + message: 'Session data corrupted or incomplete' + }) + } + + // 获取管理员信息 + const adminData = await redis.getSession('admin_credentials') + if (!adminData) { + return res.status(500).json({ + error: 'Admin data not found', + message: 'Administrator credentials not found' + }) + } + + return res.json({ + success: true, + user: { + username: adminData.username, + loginTime: sessionData.loginTime, + lastActivity: sessionData.lastActivity + } + }) + } catch (error) { + logger.error('❌ Get user info error:', error) + return res.status(500).json({ + error: 'Get user info failed', + message: 'Internal server error' + }) + } +}) + +// 🔄 刷新token +router.post('/auth/refresh', async (req, res) => { + try { + const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken + + if (!token) { + return res.status(401).json({ + error: 'No token provided', + message: 'Authentication required' + }) + } + + const sessionData = await redis.getSession(token) + + // 🔒 安全修复:检查空对象(hgetall 对不存在的 key 返回 {}) + if (!sessionData || Object.keys(sessionData).length === 0) { + return res.status(401).json({ + error: 'Invalid token', + message: 'Session expired or invalid' + }) + } + + // 🔒 安全修复:验证会话完整性(必须有 username 和 loginTime) + if (!sessionData.username || !sessionData.loginTime) { + logger.security(`Invalid session structure detected from ${req.ip || 'unknown'}`) + await redis.deleteSession(token) // 清理无效/伪造的会话 + return res.status(401).json({ + error: 'Invalid session', + message: 'Session data corrupted or incomplete' + }) + } + + // 更新最后活动时间 + sessionData.lastActivity = new Date().toISOString() + await redis.setSession(token, sessionData, config.security.adminSessionTimeout) + + return res.json({ + success: true, + token, + expiresIn: config.security.adminSessionTimeout + }) + } catch (error) { + logger.error('❌ Token refresh error:', error) + return res.status(500).json({ + error: 'Token refresh failed', + message: 'Internal server error' + }) + } +}) + +module.exports = router diff --git a/src/routes/webhook.js b/src/routes/webhook.js new file mode 100644 index 0000000..98cd3d4 --- /dev/null +++ b/src/routes/webhook.js @@ -0,0 +1,439 @@ +const express = require('express') +const router = express.Router() +const logger = require('../utils/logger') +const webhookService = require('../services/webhookService') +const webhookConfigService = require('../services/webhookConfigService') +const { authenticateAdmin } = require('../middleware/auth') +const { getISOStringWithTimezone } = require('../utils/dateHelper') + +// 获取webhook配置 +router.get('/config', authenticateAdmin, async (req, res) => { + try { + const config = await webhookConfigService.getConfig() + res.json({ + success: true, + config + }) + } catch (error) { + logger.error('获取webhook配置失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: '获取webhook配置失败' + }) + } +}) + +// 保存webhook配置 +router.post('/config', authenticateAdmin, async (req, res) => { + try { + const config = await webhookConfigService.saveConfig(req.body) + res.json({ + success: true, + message: 'Webhook配置已保存', + config + }) + } catch (error) { + logger.error('保存webhook配置失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '保存webhook配置失败' + }) + } +}) + +// 添加webhook平台 +router.post('/platforms', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.addPlatform(req.body) + res.json({ + success: true, + message: 'Webhook平台已添加', + platform + }) + } catch (error) { + logger.error('添加webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '添加webhook平台失败' + }) + } +}) + +// 更新webhook平台 +router.put('/platforms/:id', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.updatePlatform(req.params.id, req.body) + res.json({ + success: true, + message: 'Webhook平台已更新', + platform + }) + } catch (error) { + logger.error('更新webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '更新webhook平台失败' + }) + } +}) + +// 删除webhook平台 +router.delete('/platforms/:id', authenticateAdmin, async (req, res) => { + try { + await webhookConfigService.deletePlatform(req.params.id) + res.json({ + success: true, + message: 'Webhook平台已删除' + }) + } catch (error) { + logger.error('删除webhook平台失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '删除webhook平台失败' + }) + } +}) + +// 切换webhook平台启用状态 +router.post('/platforms/:id/toggle', authenticateAdmin, async (req, res) => { + try { + const platform = await webhookConfigService.togglePlatform(req.params.id) + res.json({ + success: true, + message: `Webhook平台已${platform.enabled ? '启用' : '禁用'}`, + platform + }) + } catch (error) { + logger.error('切换webhook平台状态失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: error.message || '切换webhook平台状态失败' + }) + } +}) + +// 测试Webhook连通性 +router.post('/test', authenticateAdmin, async (req, res) => { + try { + const { + url, + type = 'custom', + secret, + enableSign, + deviceKey, + serverUrl, + level, + sound, + group, + // SMTP 相关字段 + host, + port, + secure, + user, + pass, + from, + to, + ignoreTLS, + botToken, + chatId, + apiBaseUrl, + proxyUrl + } = req.body + + // Bark平台特殊处理 + if (type === 'bark') { + if (!deviceKey) { + return res.status(400).json({ + error: 'Missing device key', + message: '请提供Bark设备密钥' + }) + } + + // 验证服务器URL(如果提供) + if (serverUrl) { + try { + new URL(serverUrl) + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid server URL format', + message: '请提供有效的Bark服务器URL' + }) + } + } + + logger.info(`🧪 测试webhook: ${type} - Device Key: ${deviceKey.substring(0, 8)}...`) + } else if (type === 'smtp') { + // SMTP平台验证 + if (!host) { + return res.status(400).json({ + error: 'Missing SMTP host', + message: '请提供SMTP服务器地址' + }) + } + if (!user) { + return res.status(400).json({ + error: 'Missing SMTP user', + message: '请提供SMTP用户名' + }) + } + if (!pass) { + return res.status(400).json({ + error: 'Missing SMTP password', + message: '请提供SMTP密码' + }) + } + if (!to) { + return res.status(400).json({ + error: 'Missing recipient email', + message: '请提供收件人邮箱' + }) + } + + logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`) + } else if (type === 'telegram') { + if (!botToken) { + return res.status(400).json({ + error: 'Missing Telegram bot token', + message: '请提供 Telegram 机器人 Token' + }) + } + if (!chatId) { + return res.status(400).json({ + error: 'Missing Telegram chat id', + message: '请提供 Telegram Chat ID' + }) + } + + if (apiBaseUrl) { + try { + const parsed = new URL(apiBaseUrl) + if (!['http:', 'https:'].includes(parsed.protocol)) { + return res.status(400).json({ + error: 'Invalid Telegram API base url protocol', + message: 'Telegram API 基础地址仅支持 http 或 https' + }) + } + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid Telegram API base url', + message: '请提供有效的 Telegram API 基础地址' + }) + } + } + + if (proxyUrl) { + try { + const parsed = new URL(proxyUrl) + const supportedProtocols = ['http:', 'https:', 'socks4:', 'socks4a:', 'socks5:'] + if (!supportedProtocols.includes(parsed.protocol)) { + return res.status(400).json({ + error: 'Unsupported proxy protocol', + message: 'Telegram 代理仅支持 http/https/socks 协议' + }) + } + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid proxy url', + message: '请提供有效的代理地址' + }) + } + } + + logger.info(`🧪 测试webhook: ${type} - Chat ID: ${chatId}`) + } else { + // 其他平台验证URL + if (!url) { + return res.status(400).json({ + error: 'Missing webhook URL', + message: '请提供webhook URL' + }) + } + + // 验证URL格式 + try { + new URL(url) + } catch (urlError) { + return res.status(400).json({ + error: 'Invalid URL format', + message: '请提供有效的webhook URL' + }) + } + + logger.info(`🧪 测试webhook: ${type} - ${url}`) + } + + // 创建临时平台配置 + const platform = { + type, + url, + secret, + enableSign, + enabled: true, + timeout: 10000 + } + + // 添加Bark特有字段 + if (type === 'bark') { + platform.deviceKey = deviceKey + platform.serverUrl = serverUrl + platform.level = level + platform.sound = sound + platform.group = group + } else if (type === 'smtp') { + // 添加SMTP特有字段 + platform.host = host + platform.port = port || 587 + platform.secure = secure || false + platform.user = user + platform.pass = pass + platform.from = from + platform.to = to + platform.ignoreTLS = ignoreTLS || false + } else if (type === 'telegram') { + platform.botToken = botToken + platform.chatId = chatId + platform.apiBaseUrl = apiBaseUrl + platform.proxyUrl = proxyUrl + } + + const result = await webhookService.testWebhook(platform) + + const identifier = (() => { + if (type === 'bark') { + return `Device: ${deviceKey.substring(0, 8)}...` + } + if (type === 'smtp') { + const recipients = Array.isArray(to) ? to.join(', ') : to + return `${host}:${port || 587} -> ${recipients}` + } + if (type === 'telegram') { + return `Chat ID: ${chatId}` + } + return url + })() + + if (result.success) { + logger.info(`✅ Webhook测试成功: ${identifier}`) + res.json({ + success: true, + message: 'Webhook测试成功', + url: type === 'bark' ? undefined : url, + deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined + }) + } else { + logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`) + res.status(400).json({ + success: false, + message: 'Webhook测试失败', + url: type === 'bark' ? undefined : url, + deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined, + error: result.error + }) + } + } catch (error) { + logger.error('❌ Webhook测试错误:', error) + res.status(500).json({ + error: 'Internal server error', + message: '测试webhook失败' + }) + } +}) + +// 手动触发测试通知 +router.post('/test-notification', authenticateAdmin, async (req, res) => { + try { + const { + type = 'test', + accountId = 'test-account-id', + accountName = '测试账号', + platform = 'claude-oauth', + status = 'test', + errorCode = 'TEST_NOTIFICATION', + reason = '手动测试通知', + message = '这是一条测试通知消息,用于验证 Webhook 通知功能是否正常工作' + } = req.body + + logger.info(`🧪 发送测试通知: ${type}`) + + // 先检查webhook配置 + const config = await webhookConfigService.getConfig() + logger.debug( + `Webhook配置: enabled=${config.enabled}, platforms=${config.platforms?.length || 0}` + ) + if (!config.enabled) { + return res.status(400).json({ + success: false, + message: 'Webhook通知未启用,请先在设置中启用通知功能' + }) + } + + const enabledPlatforms = await webhookConfigService.getEnabledPlatforms() + logger.info(`找到 ${enabledPlatforms.length} 个启用的通知平台`) + + if (enabledPlatforms.length === 0) { + return res.status(400).json({ + success: false, + message: '没有启用的通知平台,请先添加并启用至少一个通知平台' + }) + } + + const testData = { + accountId, + accountName, + platform, + status, + errorCode, + reason, + message, + timestamp: getISOStringWithTimezone(new Date()) + } + + const result = await webhookService.sendNotification(type, testData) + + // 如果没有返回结果,说明可能是配置问题 + if (!result) { + return res.status(400).json({ + success: false, + message: 'Webhook服务未返回结果,请检查配置和日志', + enabledPlatforms: enabledPlatforms.length + }) + } + + // 如果没有成功和失败的记录 + if (result.succeeded === 0 && result.failed === 0) { + return res.status(400).json({ + success: false, + message: '没有发送任何通知,请检查通知类型配置', + result, + enabledPlatforms: enabledPlatforms.length + }) + } + + if (result.failed > 0) { + logger.warn(`⚠️ 测试通知部分失败: ${result.succeeded}成功, ${result.failed}失败`) + return res.json({ + success: true, + message: `测试通知部分成功: ${result.succeeded}个平台成功, ${result.failed}个平台失败`, + data: testData, + result + }) + } + + logger.info(`✅ 测试通知发送成功到 ${result.succeeded} 个平台`) + + res.json({ + success: true, + message: `测试通知已成功发送到 ${result.succeeded} 个平台`, + data: testData, + result + }) + } catch (error) { + logger.error('❌ 发送测试通知失败:', error) + res.status(500).json({ + error: 'Internal server error', + message: `发送测试通知失败: ${error.message}` + }) + } +}) + +module.exports = router diff --git a/src/services/account/accountBalanceService.js b/src/services/account/accountBalanceService.js new file mode 100644 index 0000000..66b0a99 --- /dev/null +++ b/src/services/account/accountBalanceService.js @@ -0,0 +1,799 @@ +const redis = require('../../models/redis') +const balanceScriptService = require('../balanceScriptService') +const logger = require('../../utils/logger') +const CostCalculator = require('../../utils/costCalculator') +const { isBalanceScriptEnabled } = require('../../utils/featureFlags') + +class AccountBalanceService { + constructor(options = {}) { + this.redis = options.redis || redis + this.logger = options.logger || logger + + this.providers = new Map() + + this.CACHE_TTL_SECONDS = 3600 + this.LOCAL_TTL_SECONDS = 300 + + this.LOW_BALANCE_THRESHOLD = 10 + this.HIGH_USAGE_THRESHOLD_PERCENT = 90 + this.DEFAULT_CONCURRENCY = 10 + } + + getSupportedPlatforms() { + return [ + 'claude', + 'claude-console', + 'gemini', + 'gemini-api', + 'openai', + 'openai-responses', + 'azure_openai', + 'bedrock', + 'droid', + 'ccr' + ] + } + + normalizePlatform(platform) { + if (!platform) { + return null + } + + const value = String(platform).trim().toLowerCase() + + // 兼容实施文档与历史命名 + if (value === 'claude-official') { + return 'claude' + } + if (value === 'azure-openai') { + return 'azure_openai' + } + + // 保持前端平台键一致 + return value + } + + registerProvider(platform, provider) { + const normalized = this.normalizePlatform(platform) + if (!normalized) { + throw new Error('registerProvider: 缺少 platform') + } + if (!provider || typeof provider.queryBalance !== 'function') { + throw new Error(`registerProvider: Provider 无效 (${normalized})`) + } + this.providers.set(normalized, provider) + } + + async getAccountBalance(accountId, platform, options = {}) { + const normalizedPlatform = this.normalizePlatform(platform) + const account = await this.getAccount(accountId, normalizedPlatform) + if (!account) { + return null + } + return await this._getAccountBalanceForAccount(account, normalizedPlatform, options) + } + + async refreshAccountBalance(accountId, platform) { + const normalizedPlatform = this.normalizePlatform(platform) + const account = await this.getAccount(accountId, normalizedPlatform) + if (!account) { + return null + } + + return await this._getAccountBalanceForAccount(account, normalizedPlatform, { + queryApi: true, + useCache: false + }) + } + + async getAllAccountsBalance(platform, options = {}) { + const normalizedPlatform = this.normalizePlatform(platform) + const accounts = await this.getAllAccountsByPlatform(normalizedPlatform) + const queryApi = this._parseBoolean(options.queryApi) || false + const useCache = options.useCache !== false + + const results = await this._mapWithConcurrency( + accounts, + this.DEFAULT_CONCURRENCY, + async (acc) => { + try { + const balance = await this._getAccountBalanceForAccount(acc, normalizedPlatform, { + queryApi, + useCache + }) + return { ...balance, name: acc.name || '' } + } catch (error) { + this.logger.error(`批量获取余额失败: ${normalizedPlatform}:${acc?.id}`, error) + return { + success: true, + data: { + accountId: acc?.id, + platform: normalizedPlatform, + balance: null, + quota: null, + statistics: {}, + source: 'local', + lastRefreshAt: new Date().toISOString(), + cacheExpiresAt: null, + status: 'error', + error: error.message || '批量查询失败' + }, + name: acc?.name || '' + } + } + } + ) + + return results + } + + async getBalanceSummary() { + const platforms = this.getSupportedPlatforms() + + const summary = { + totalBalance: 0, + totalCost: 0, + lowBalanceCount: 0, + platforms: {} + } + + for (const platform of platforms) { + const accounts = await this.getAllAccountsByPlatform(platform) + const platformData = { + count: accounts.length, + totalBalance: 0, + totalCost: 0, + lowBalanceCount: 0, + accounts: [] + } + + const balances = await this._mapWithConcurrency( + accounts, + this.DEFAULT_CONCURRENCY, + async (acc) => { + const balance = await this._getAccountBalanceForAccount(acc, platform, { + queryApi: false, + useCache: true + }) + return { ...balance, name: acc.name || '' } + } + ) + + for (const item of balances) { + platformData.accounts.push(item) + + const amount = item?.data?.balance?.amount + const percentage = item?.data?.quota?.percentage + const totalCost = Number(item?.data?.statistics?.totalCost || 0) + + const hasAmount = typeof amount === 'number' && Number.isFinite(amount) + const isLowBalance = hasAmount && amount < this.LOW_BALANCE_THRESHOLD + const isHighUsage = + typeof percentage === 'number' && + Number.isFinite(percentage) && + percentage > this.HIGH_USAGE_THRESHOLD_PERCENT + + if (hasAmount) { + platformData.totalBalance += amount + } + + if (isLowBalance || isHighUsage) { + platformData.lowBalanceCount += 1 + summary.lowBalanceCount += 1 + } + + platformData.totalCost += totalCost + } + + summary.platforms[platform] = platformData + summary.totalBalance += platformData.totalBalance + summary.totalCost += platformData.totalCost + } + + return summary + } + + async clearCache(accountId, platform) { + const normalizedPlatform = this.normalizePlatform(platform) + if (!normalizedPlatform) { + throw new Error('缺少 platform 参数') + } + + await this.redis.deleteAccountBalance(normalizedPlatform, accountId) + this.logger.info(`余额缓存已清除: ${normalizedPlatform}:${accountId}`) + } + + async getAccount(accountId, platform) { + if (!accountId || !platform) { + return null + } + + const serviceMap = { + claude: require('./claudeAccountService'), + 'claude-console': require('./claudeConsoleAccountService'), + gemini: require('./geminiAccountService'), + 'gemini-api': require('./geminiApiAccountService'), + openai: require('./openaiAccountService'), + 'openai-responses': require('./openaiResponsesAccountService'), + azure_openai: require('./azureOpenaiAccountService'), + bedrock: require('./bedrockAccountService'), + droid: require('./droidAccountService'), + ccr: require('./ccrAccountService') + } + + const service = serviceMap[platform] + if (!service || typeof service.getAccount !== 'function') { + return null + } + + const result = await service.getAccount(accountId) + + // 处理不同服务返回格式的差异 + // Bedrock/CCR/Droid 等服务返回 { success, data } 格式 + if (result && typeof result === 'object' && 'success' in result && 'data' in result) { + return result.success ? result.data : null + } + + return result + } + + async getAllAccountsByPlatform(platform) { + if (!platform) { + return [] + } + + const serviceMap = { + claude: require('./claudeAccountService'), + 'claude-console': require('./claudeConsoleAccountService'), + gemini: require('./geminiAccountService'), + 'gemini-api': require('./geminiApiAccountService'), + openai: require('./openaiAccountService'), + 'openai-responses': require('./openaiResponsesAccountService'), + azure_openai: require('./azureOpenaiAccountService'), + bedrock: require('./bedrockAccountService'), + droid: require('./droidAccountService'), + ccr: require('./ccrAccountService') + } + + const service = serviceMap[platform] + if (!service) { + return [] + } + + // Bedrock 特殊:返回 { success, data } + if (platform === 'bedrock' && typeof service.getAllAccounts === 'function') { + const result = await service.getAllAccounts() + return result?.success ? result.data || [] : [] + } + + if (platform === 'openai-responses') { + return await service.getAllAccounts(true) + } + + if (typeof service.getAllAccounts !== 'function') { + return [] + } + + return await service.getAllAccounts() + } + + async _getAccountBalanceForAccount(account, platform, options = {}) { + const queryMode = this._parseQueryMode(options.queryApi) + const useCache = options.useCache !== false + + const accountId = account?.id + if (!accountId) { + // 如果账户缺少 id,返回空响应而不是抛出错误,避免接口报错和UI错误 + this.logger.warn('账户缺少 id,返回空余额数据', { account, platform }) + return this._buildResponse( + { + status: 'error', + errorMessage: '账户数据异常', + balance: null, + currency: 'USD', + quota: null, + statistics: {}, + lastRefreshAt: new Date().toISOString() + }, + 'unknown', + platform, + 'local', + null, + { scriptEnabled: false, scriptConfigured: false } + ) + } + + // 余额脚本配置状态(用于前端控制"刷新余额"按钮) + let scriptConfig = null + let scriptConfigured = false + if (typeof this.redis?.getBalanceScriptConfig === 'function') { + scriptConfig = await this.redis.getBalanceScriptConfig(platform, accountId) + scriptConfigured = !!( + scriptConfig && + scriptConfig.scriptBody && + String(scriptConfig.scriptBody).trim().length > 0 + ) + } + const scriptEnabled = isBalanceScriptEnabled() + const scriptMeta = { scriptEnabled, scriptConfigured } + + const localBalance = await this._getBalanceFromLocal(accountId, platform) + const localStatistics = localBalance.statistics || {} + + const quotaFromLocal = this._buildQuotaFromLocal(account, localStatistics) + + // 安全限制:queryApi=auto 仅用于 Antigravity(gemini + oauthProvider=antigravity)账户 + const effectiveQueryMode = + queryMode === 'auto' && !(platform === 'gemini' && account?.oauthProvider === 'antigravity') + ? 'local' + : queryMode + + // local: 仅本地统计/缓存;auto: 优先缓存,无缓存则尝试远程 Provider(并缓存结果) + if (effectiveQueryMode !== 'api') { + if (useCache) { + const cached = await this.redis.getAccountBalance(platform, accountId) + if (cached && cached.status === 'success') { + return this._buildResponse( + { + status: cached.status, + errorMessage: cached.errorMessage, + balance: quotaFromLocal.balance ?? cached.balance, + currency: quotaFromLocal.currency || cached.currency || 'USD', + quota: quotaFromLocal.quota || cached.quota || null, + statistics: localStatistics, + lastRefreshAt: cached.lastRefreshAt + }, + accountId, + platform, + 'cache', + cached.ttlSeconds, + scriptMeta + ) + } + } + + if (effectiveQueryMode === 'local') { + return this._buildResponse( + { + status: 'success', + errorMessage: null, + balance: quotaFromLocal.balance, + currency: quotaFromLocal.currency || 'USD', + quota: quotaFromLocal.quota, + statistics: localStatistics, + lastRefreshAt: localBalance.lastCalculated + }, + accountId, + platform, + 'local', + null, + scriptMeta + ) + } + } + + // 强制查询:优先脚本(如启用且已配置),否则调用 Provider;失败自动降级到本地统计 + let providerResult + + if (scriptEnabled && scriptConfigured) { + providerResult = await this._getBalanceFromScript(scriptConfig, accountId, platform) + } else { + const provider = this.providers.get(platform) + if (!provider) { + return this._buildResponse( + { + status: 'error', + errorMessage: `不支持的平台: ${platform}`, + balance: quotaFromLocal.balance, + currency: quotaFromLocal.currency || 'USD', + quota: quotaFromLocal.quota, + statistics: localStatistics, + lastRefreshAt: new Date().toISOString() + }, + accountId, + platform, + 'local', + null, + scriptMeta + ) + } + providerResult = await this._getBalanceFromProvider(provider, account) + } + + const isRemoteSuccess = + providerResult.status === 'success' && ['api', 'script'].includes(providerResult.queryMethod) + + // 仅缓存“真实远程查询成功”的结果,避免把字段/本地降级结果当作 API 结果缓存 1h + if (isRemoteSuccess) { + await this.redis.setAccountBalance( + platform, + accountId, + providerResult, + this.CACHE_TTL_SECONDS + ) + } + + const source = isRemoteSuccess ? 'api' : 'local' + + return this._buildResponse( + { + status: providerResult.status, + errorMessage: providerResult.errorMessage, + balance: quotaFromLocal.balance ?? providerResult.balance, + currency: quotaFromLocal.currency || providerResult.currency || 'USD', + quota: quotaFromLocal.quota || providerResult.quota || null, + statistics: localStatistics, + lastRefreshAt: providerResult.lastRefreshAt + }, + accountId, + platform, + source, + null, + scriptMeta + ) + } + + async _getBalanceFromScript(scriptConfig, accountId, platform) { + try { + const result = await balanceScriptService.execute({ + scriptBody: scriptConfig.scriptBody, + timeoutSeconds: scriptConfig.timeoutSeconds || 10, + variables: { + baseUrl: scriptConfig.baseUrl || '', + apiKey: scriptConfig.apiKey || '', + token: scriptConfig.token || '', + accountId, + platform, + extra: scriptConfig.extra || '' + } + }) + + const mapped = result?.mapped || {} + return { + status: mapped.status || 'error', + balance: typeof mapped.balance === 'number' ? mapped.balance : null, + currency: mapped.currency || 'USD', + quota: mapped.quota || null, + queryMethod: 'api', + rawData: mapped.rawData || result?.response?.data || null, + lastRefreshAt: new Date().toISOString(), + errorMessage: mapped.errorMessage || '' + } + } catch (error) { + return { + status: 'error', + balance: null, + currency: 'USD', + quota: null, + queryMethod: 'api', + rawData: null, + lastRefreshAt: new Date().toISOString(), + errorMessage: error.message || '脚本执行失败' + } + } + } + + async _getBalanceFromProvider(provider, account) { + try { + const result = await provider.queryBalance(account) + return { + status: 'success', + balance: typeof result?.balance === 'number' ? result.balance : null, + currency: result?.currency || 'USD', + quota: result?.quota || null, + queryMethod: result?.queryMethod || 'api', + rawData: result?.rawData || null, + lastRefreshAt: new Date().toISOString(), + errorMessage: '' + } + } catch (error) { + return { + status: 'error', + balance: null, + currency: 'USD', + quota: null, + queryMethod: 'api', + rawData: null, + lastRefreshAt: new Date().toISOString(), + errorMessage: error.message || '查询失败' + } + } + } + + async _getBalanceFromLocal(accountId, platform) { + const cached = await this.redis.getLocalBalance(platform, accountId) + if (cached && cached.statistics) { + return cached + } + + const statistics = await this._computeLocalStatistics(accountId) + const localBalance = { + status: 'success', + balance: null, + currency: 'USD', + statistics, + queryMethod: 'local', + lastCalculated: new Date().toISOString() + } + + await this.redis.setLocalBalance(platform, accountId, localBalance, this.LOCAL_TTL_SECONDS) + return localBalance + } + + async _computeLocalStatistics(accountId) { + const safeNumber = (value) => { + const num = Number(value) + return Number.isFinite(num) ? num : 0 + } + + try { + const usageStats = await this.redis.getAccountUsageStats(accountId) + const dailyCost = safeNumber(usageStats?.daily?.cost || 0) + const monthlyCost = await this._computeMonthlyCost(accountId) + const totalCost = await this._computeTotalCost(accountId) + + return { + totalCost, + dailyCost, + monthlyCost, + totalRequests: safeNumber(usageStats?.total?.requests || 0), + dailyRequests: safeNumber(usageStats?.daily?.requests || 0), + monthlyRequests: safeNumber(usageStats?.monthly?.requests || 0) + } + } catch (error) { + this.logger.debug(`本地统计计算失败: ${accountId}`, error) + return { + totalCost: 0, + dailyCost: 0, + monthlyCost: 0, + totalRequests: 0, + dailyRequests: 0, + monthlyRequests: 0 + } + } + } + + async _computeMonthlyCost(accountId) { + const tzDate = this.redis.getDateInTimezone(new Date()) + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` + + const pattern = `account_usage:model:monthly:${accountId}:*:${currentMonth}` + return await this._sumModelCostsByKeysPattern(pattern) + } + + async _computeTotalCost(accountId) { + const pattern = `account_usage:model:monthly:${accountId}:*:*` + return await this._sumModelCostsByKeysPattern(pattern) + } + + async _sumModelCostsByKeysPattern(pattern) { + try { + const client = this.redis.getClientSafe() + let totalCost = 0 + let cursor = '0' + const scanCount = 200 + let iterations = 0 + const maxIterations = 2000 + + do { + const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', scanCount) + cursor = nextCursor + iterations += 1 + + if (!keys || keys.length === 0) { + continue + } + + const pipeline = client.pipeline() + keys.forEach((key) => pipeline.hgetall(key)) + const results = await pipeline.exec() + + for (let i = 0; i < results.length; i += 1) { + const [, data] = results[i] || [] + if (!data || Object.keys(data).length === 0) { + continue + } + + const parts = String(keys[i]).split(':') + const model = parts[4] || 'unknown' + + const usage = { + input_tokens: parseInt(data.inputTokens || 0), + output_tokens: parseInt(data.outputTokens || 0), + cache_creation_input_tokens: parseInt(data.cacheCreateTokens || 0), + cache_read_input_tokens: parseInt(data.cacheReadTokens || 0) + } + + // 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费 + const eph5m = parseInt(data.ephemeral5mTokens || 0) + const eph1h = parseInt(data.ephemeral1hTokens || 0) + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, model) + totalCost += costResult.costs.total || 0 + } + + if (iterations >= maxIterations) { + this.logger.warn(`SCAN 次数超过上限,停止汇总:${pattern}`) + break + } + } while (cursor !== '0') + + return totalCost + } catch (error) { + this.logger.debug(`汇总模型费用失败: ${pattern}`, error) + return 0 + } + } + + _buildQuotaFromLocal(account, statistics) { + if (!account || !Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) { + return { balance: null, currency: null, quota: null } + } + + const dailyQuota = Number(account.dailyQuota || 0) + const used = Number(statistics?.dailyCost || 0) + + const resetAt = this._computeNextResetAt(account.quotaResetTime || '00:00') + + // 不限制 + if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) { + return { + balance: null, + currency: 'USD', + quota: { + daily: Infinity, + used, + remaining: Infinity, + percentage: 0, + unlimited: true, + resetAt + } + } + } + + const remaining = Math.max(0, dailyQuota - used) + const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0 + + return { + balance: remaining, + currency: 'USD', + quota: { + daily: dailyQuota, + used, + remaining, + resetAt, + percentage: Math.round(percentage * 100) / 100 + } + } + } + + _computeNextResetAt(resetTime) { + const now = new Date() + const tzNow = this.redis.getDateInTimezone(now) + const offsetMs = tzNow.getTime() - now.getTime() + + const [h, m] = String(resetTime || '00:00') + .split(':') + .map((n) => parseInt(n, 10)) + + const resetHour = Number.isFinite(h) ? h : 0 + const resetMinute = Number.isFinite(m) ? m : 0 + + const year = tzNow.getUTCFullYear() + const month = tzNow.getUTCMonth() + const day = tzNow.getUTCDate() + + let resetAtMs = Date.UTC(year, month, day, resetHour, resetMinute, 0, 0) - offsetMs + if (resetAtMs <= now.getTime()) { + resetAtMs += 24 * 60 * 60 * 1000 + } + + return new Date(resetAtMs).toISOString() + } + + _buildResponse(balanceData, accountId, platform, source, ttlSeconds = null, extraData = {}) { + const now = new Date() + + const amount = typeof balanceData.balance === 'number' ? balanceData.balance : null + const currency = balanceData.currency || 'USD' + + let cacheExpiresAt = null + if (source === 'cache') { + const ttl = + typeof ttlSeconds === 'number' && ttlSeconds > 0 ? ttlSeconds : this.CACHE_TTL_SECONDS + cacheExpiresAt = new Date(Date.now() + ttl * 1000).toISOString() + } + + return { + success: true, + data: { + accountId, + platform, + balance: + typeof amount === 'number' + ? { + amount, + currency, + formattedAmount: this._formatCurrency(amount, currency) + } + : null, + quota: balanceData.quota || null, + statistics: balanceData.statistics || {}, + source, + lastRefreshAt: balanceData.lastRefreshAt || now.toISOString(), + cacheExpiresAt, + status: balanceData.status || 'success', + error: balanceData.errorMessage || null, + ...(extraData && typeof extraData === 'object' ? extraData : {}) + } + } + } + + _formatCurrency(amount, currency = 'USD') { + try { + if (typeof amount !== 'number' || !Number.isFinite(amount)) { + return 'N/A' + } + return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount) + } catch (error) { + return `$${amount.toFixed(2)}` + } + } + + _parseBoolean(value) { + if (typeof value === 'boolean') { + return value + } + if (typeof value !== 'string') { + return null + } + const normalized = value.trim().toLowerCase() + if (normalized === 'true' || normalized === '1' || normalized === 'yes') { + return true + } + if (normalized === 'false' || normalized === '0' || normalized === 'no') { + return false + } + return null + } + + _parseQueryMode(value) { + if (value === 'auto') { + return 'auto' + } + const parsed = this._parseBoolean(value) + return parsed ? 'api' : 'local' + } + + async _mapWithConcurrency(items, limit, mapper) { + const concurrency = Math.max(1, Number(limit) || 1) + const list = Array.isArray(items) ? items : [] + + const results = new Array(list.length) + let nextIndex = 0 + + const workers = new Array(Math.min(concurrency, list.length)).fill(null).map(async () => { + while (nextIndex < list.length) { + const currentIndex = nextIndex + nextIndex += 1 + results[currentIndex] = await mapper(list[currentIndex], currentIndex) + } + }) + + await Promise.all(workers) + return results + } +} + +const accountBalanceService = new AccountBalanceService() +module.exports = accountBalanceService +module.exports.AccountBalanceService = AccountBalanceService diff --git a/src/services/account/azureOpenaiAccountService.js b/src/services/account/azureOpenaiAccountService.js new file mode 100644 index 0000000..6f611d8 --- /dev/null +++ b/src/services/account/azureOpenaiAccountService.js @@ -0,0 +1,628 @@ +const redisClient = require('../../models/redis') +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const config = require('../../../config/config') +const logger = require('../../utils/logger') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +// 加密相关常量 +const ALGORITHM = 'aes-256-cbc' +const IV_LENGTH = 16 + +// 🚀 安全的加密密钥生成,支持动态salt +const ENCRYPTION_SALT = config.security?.azureOpenaiSalt || 'azure-openai-account-default-salt' + +class EncryptionKeyManager { + constructor() { + this.keyCache = new Map() + this.keyRotationInterval = 24 * 60 * 60 * 1000 // 24小时 + } + + getKey(version = 'current') { + const cached = this.keyCache.get(version) + if (cached && Date.now() - cached.timestamp < this.keyRotationInterval) { + return cached.key + } + + // 生成新密钥 + const key = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32) + this.keyCache.set(version, { + key, + timestamp: Date.now() + }) + + logger.debug('🔑 Azure OpenAI encryption key generated/refreshed') + return key + } + + // 清理过期密钥 + cleanup() { + const now = Date.now() + for (const [version, cached] of this.keyCache.entries()) { + if (now - cached.timestamp > this.keyRotationInterval) { + this.keyCache.delete(version) + } + } + } +} + +const encryptionKeyManager = new EncryptionKeyManager() + +// 定期清理过期密钥 +setInterval( + () => { + encryptionKeyManager.cleanup() + }, + 60 * 60 * 1000 +) // 每小时清理一次 + +// 生成加密密钥 - 使用安全的密钥管理器 +function generateEncryptionKey() { + return encryptionKeyManager.getKey() +} + +// Azure OpenAI 账户键前缀 +const AZURE_OPENAI_ACCOUNT_KEY_PREFIX = 'azure_openai:account:' +const SHARED_AZURE_OPENAI_ACCOUNTS_KEY = 'shared_azure_openai_accounts' +const ACCOUNT_SESSION_MAPPING_PREFIX = 'azure_openai_session_account_mapping:' + +// 加密函数 +function encrypt(text) { + if (!text) { + return '' + } + const key = generateEncryptionKey() + const iv = crypto.randomBytes(IV_LENGTH) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + return `${iv.toString('hex')}:${encrypted.toString('hex')}` +} + +// 解密函数 - 移除缓存以提高安全性 +function decrypt(text) { + if (!text) { + return '' + } + + try { + const key = generateEncryptionKey() + // IV 是固定长度的 32 个十六进制字符(16 字节) + const ivHex = text.substring(0, 32) + const encryptedHex = text.substring(33) // 跳过冒号 + + if (ivHex.length !== 32 || !encryptedHex) { + throw new Error('Invalid encrypted text format') + } + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + const result = decrypted.toString() + + return result + } catch (error) { + logger.error('Azure OpenAI decryption error:', error.message) + return '' + } +} + +// 创建账户 +async function createAccount(accountData) { + const accountId = uuidv4() + const now = new Date().toISOString() + + const account = { + id: accountId, + name: accountData.name, + description: accountData.description || '', + accountType: accountData.accountType || 'shared', + groupId: accountData.groupId || null, + priority: accountData.priority || 50, + // Azure OpenAI 特有字段 + azureEndpoint: accountData.azureEndpoint || '', + apiVersion: accountData.apiVersion || '2024-02-01', // 使用稳定版本 + deploymentName: accountData.deploymentName || 'gpt-4', // 使用默认部署名称 + apiKey: encrypt(accountData.apiKey || ''), + // 支持的模型 + supportedModels: JSON.stringify( + accountData.supportedModels || ['gpt-4', 'gpt-4-turbo', 'gpt-35-turbo', 'gpt-35-turbo-16k'] + ), + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + // 注意:Azure OpenAI 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, + + // 状态字段 + isActive: accountData.isActive !== false ? 'true' : 'false', + status: 'active', + schedulable: accountData.schedulable !== false ? 'true' : 'false', + disableAutoProtection: + accountData.disableAutoProtection === true || accountData.disableAutoProtection === 'true' + ? 'true' + : 'false', // 关闭自动防护 + createdAt: now, + updatedAt: now + } + + // 代理配置 + if (accountData.proxy) { + account.proxy = + typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy) + } + + const client = redisClient.getClientSafe() + await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account) + await redisClient.addToIndex('azure_openai:account:index', accountId) + + // 如果是共享账户,添加到共享账户集合 + if (account.accountType === 'shared') { + await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + } + + logger.info(`Created Azure OpenAI account: ${accountId}`) + return account +} + +// 获取账户 +async function getAccount(accountId) { + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + // 解密敏感数据(仅用于内部处理,不返回给前端) + if (accountData.apiKey) { + accountData.apiKey = decrypt(accountData.apiKey) + } + + // 解析代理配置 + if (accountData.proxy && typeof accountData.proxy === 'string') { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + // 解析支持的模型 + if (accountData.supportedModels && typeof accountData.supportedModels === 'string') { + try { + accountData.supportedModels = JSON.parse(accountData.supportedModels) + } catch (e) { + accountData.supportedModels = ['gpt-4', 'gpt-35-turbo'] + } + } + + return accountData +} + +// 更新账户 +async function updateAccount(accountId, updates) { + const existingAccount = await getAccount(accountId) + if (!existingAccount) { + throw new Error('Account not found') + } + + updates.updatedAt = new Date().toISOString() + + // 加密敏感数据 + if (updates.apiKey) { + updates.apiKey = encrypt(updates.apiKey) + } + + // 处理代理配置 + if (updates.proxy) { + updates.proxy = + typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy) + } + + // 处理支持的模型 + if (updates.supportedModels) { + updates.supportedModels = + typeof updates.supportedModels === 'string' + ? updates.supportedModels + : JSON.stringify(updates.supportedModels) + } + + // ✅ 直接保存 subscriptionExpiresAt(如果提供) + // Azure OpenAI 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段 + if (updates.subscriptionExpiresAt !== undefined) { + // 直接保存,不做任何调整 + } + + // 自动防护开关 + if (updates.disableAutoProtection !== undefined) { + updates.disableAutoProtection = + updates.disableAutoProtection === true || updates.disableAutoProtection === 'true' + ? 'true' + : 'false' + } + + // 更新账户类型时处理共享账户集合 + const client = redisClient.getClientSafe() + if (updates.accountType && updates.accountType !== existingAccount.accountType) { + if (updates.accountType === 'shared') { + await client.sadd(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + } else { + await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + } + } + + await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + logger.info(`Updated Azure OpenAI account: ${accountId}`) + + // 合并更新后的账户数据 + const updatedAccount = { ...existingAccount, ...updates } + + // 返回时解析代理配置 + if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') { + try { + updatedAccount.proxy = JSON.parse(updatedAccount.proxy) + } catch (e) { + updatedAccount.proxy = null + } + } + + return updatedAccount +} + +// 删除账户 +async function deleteAccount(accountId) { + // 首先从所有分组中移除此账户 + const accountGroupService = require('../accountGroupService') + await accountGroupService.removeAccountFromAllGroups(accountId) + + const client = redisClient.getClientSafe() + const accountKey = `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}` + + // 从Redis中删除账户数据 + await client.del(accountKey) + + // 从索引中移除 + await redisClient.removeFromIndex('azure_openai:account:index', accountId) + + // 从共享账户集合中移除 + await client.srem(SHARED_AZURE_OPENAI_ACCOUNTS_KEY, accountId) + + logger.info(`Deleted Azure OpenAI account: ${accountId}`) + return true +} + +// 获取所有账户 +async function getAllAccounts() { + const accountIds = await redisClient.getAllIdsByIndex( + 'azure_openai:account:index', + `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}*`, + /^azure_openai:account:(.+)$/ + ) + + if (!accountIds || accountIds.length === 0) { + return [] + } + + const keys = accountIds.map((id) => `${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${id}`) + const accounts = [] + const dataList = await redisClient.batchHgetallChunked(keys) + + for (let i = 0; i < keys.length; i++) { + const accountData = dataList[i] + if (accountData && Object.keys(accountData).length > 0) { + // 不返回敏感数据给前端 + delete accountData.apiKey + + // 解析代理配置 + if (accountData.proxy && typeof accountData.proxy === 'string') { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + // 解析支持的模型 + if (accountData.supportedModels && typeof accountData.supportedModels === 'string') { + try { + accountData.supportedModels = JSON.parse(accountData.supportedModels) + } catch (e) { + accountData.supportedModels = ['gpt-4', 'gpt-35-turbo'] + } + } + + accounts.push({ + ...accountData, + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', + + // ✅ 前端显示订阅过期时间(业务字段) + expiresAt: accountData.subscriptionExpiresAt || null, + platform: 'azure-openai' + }) + } + } + + return accounts +} + +// 获取共享账户 +async function getSharedAccounts() { + const client = redisClient.getClientSafe() + const accountIds = await client.smembers(SHARED_AZURE_OPENAI_ACCOUNTS_KEY) + + if (!accountIds || accountIds.length === 0) { + return [] + } + + const accounts = [] + for (const accountId of accountIds) { + const account = await getAccount(accountId) + if (account && account.isActive === 'true') { + accounts.push(account) + } + } + + return accounts +} + +/** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ +function isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() +} + +// 选择可用账户 +async function selectAvailableAccount(sessionId = null) { + // 如果有会话ID,尝试获取之前分配的账户 + if (sessionId) { + const client = redisClient.getClientSafe() + const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}` + const accountId = await client.get(mappingKey) + + if (accountId) { + const account = await getAccount(accountId) + if (account && account.isActive === 'true' && account.schedulable === 'true') { + const isTempUnavail = await upstreamErrorHelper.isTempUnavailable(accountId, 'azure-openai') + if (!isTempUnavail) { + logger.debug(`Reusing Azure OpenAI account ${accountId} for session ${sessionId}`) + return account + } + logger.warn( + `⏱️ Session-bound Azure OpenAI account ${accountId} temporarily unavailable, falling back to pool` + ) + } + } + } + + // 获取所有共享账户 + const sharedAccounts = await getSharedAccounts() + + // 过滤出可用的账户(异步过滤,包含临时不可用检查) + const availableAccounts = [] + for (const acc of sharedAccounts) { + // 检查账户订阅是否过期 + if (isSubscriptionExpired(acc)) { + logger.debug( + `⏰ Skipping expired Azure OpenAI account: ${acc.name}, expired at ${acc.subscriptionExpiresAt}` + ) + continue + } + + if (acc.isActive !== 'true' || acc.schedulable !== 'true') { + continue + } + + // 检查临时不可用状态 + const isTempUnavail = await upstreamErrorHelper.isTempUnavailable(acc.id, 'azure-openai') + if (isTempUnavail) { + logger.debug(`⏱️ Skipping temporarily unavailable Azure OpenAI account: ${acc.name}`) + continue + } + + availableAccounts.push(acc) + } + + if (availableAccounts.length === 0) { + throw new Error('No available Azure OpenAI accounts') + } + + // 按优先级排序并选择 + availableAccounts.sort((a, b) => (b.priority || 50) - (a.priority || 50)) + const selectedAccount = availableAccounts[0] + + // 如果有会话ID,保存映射关系 + if (sessionId && selectedAccount) { + const client = redisClient.getClientSafe() + const mappingKey = `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionId}` + await client.setex(mappingKey, 3600, selectedAccount.id) // 1小时过期 + } + + logger.debug(`Selected Azure OpenAI account: ${selectedAccount.id}`) + return selectedAccount +} + +// 更新账户使用量 +async function updateAccountUsage(accountId, tokens) { + const client = redisClient.getClientSafe() + const now = new Date().toISOString() + + // 使用 HINCRBY 原子操作更新使用量 + await client.hincrby(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'totalTokensUsed', tokens) + await client.hset(`${AZURE_OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, 'lastUsedAt', now) + + logger.debug(`Updated Azure OpenAI account ${accountId} usage: ${tokens} tokens`) +} + +// 健康检查单个账户 +async function healthCheckAccount(accountId) { + try { + const account = await getAccount(accountId) + if (!account) { + return { id: accountId, status: 'error', message: 'Account not found' } + } + + // 简单检查配置是否完整 + if (!account.azureEndpoint || !account.apiKey || !account.deploymentName) { + return { + id: accountId, + status: 'error', + message: 'Incomplete configuration' + } + } + + // 可以在这里添加实际的API调用测试 + // 暂时返回成功状态 + return { + id: accountId, + status: 'healthy', + message: 'Account is configured correctly' + } + } catch (error) { + logger.error(`Health check failed for Azure OpenAI account ${accountId}:`, error) + return { + id: accountId, + status: 'error', + message: error.message + } + } +} + +// 批量健康检查 +async function performHealthChecks() { + const accounts = await getAllAccounts() + const results = [] + + for (const account of accounts) { + const result = await healthCheckAccount(account.id) + results.push(result) + } + + return results +} + +// 切换账户的可调度状态 +async function toggleSchedulable(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const newSchedulable = account.schedulable === 'true' ? 'false' : 'true' + await updateAccount(accountId, { schedulable: newSchedulable }) + + return { + id: accountId, + schedulable: newSchedulable === 'true' + } +} + +// 迁移 API Keys 以支持 Azure OpenAI +async function migrateApiKeysForAzureSupport() { + const client = redisClient.getClientSafe() + const apiKeyIds = await client.smembers('api_keys') + + let migratedCount = 0 + for (const keyId of apiKeyIds) { + const keyData = await client.hgetall(`api_key:${keyId}`) + if (keyData && !keyData.azureOpenaiAccountId) { + // 添加 Azure OpenAI 账户ID字段(初始为空) + await client.hset(`api_key:${keyId}`, 'azureOpenaiAccountId', '') + migratedCount++ + } + } + + logger.info(`Migrated ${migratedCount} API keys for Azure OpenAI support`) + return migratedCount +} + +// 🔄 重置Azure OpenAI账户所有异常状态 +async function resetAccountStatus(accountId) { + try { + const accountData = await getAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + const client = redisClient.getClientSafe() + const accountKey = `azure_openai:account:${accountId}` + + const updates = { + status: 'active', + errorMessage: '', + schedulable: 'true', + isActive: 'true' + } + + const fieldsToDelete = [ + 'rateLimitedAt', + 'rateLimitStatus', + 'unauthorizedAt', + 'unauthorizedCount', + 'overloadedAt', + 'overloadStatus', + 'blockedAt', + 'quotaStoppedAt' + ] + + await client.hset(accountKey, updates) + await client.hdel(accountKey, ...fieldsToDelete) + + logger.success(`Reset all error status for Azure OpenAI account ${accountId}`) + + // 清除临时不可用状态 + await upstreamErrorHelper.clearTempUnavailable(accountId, 'azure-openai').catch(() => {}) + + // 异步发送 Webhook 通知(忽略错误) + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || accountId, + platform: 'azure-openai', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.warn( + 'Failed to send webhook notification for Azure OpenAI status reset:', + webhookError + ) + } + + return { success: true, accountId } + } catch (error) { + logger.error(`❌ Failed to reset Azure OpenAI account status: ${accountId}`, error) + throw error + } +} + +module.exports = { + createAccount, + getAccount, + updateAccount, + deleteAccount, + getAllAccounts, + getSharedAccounts, + selectAvailableAccount, + updateAccountUsage, + healthCheckAccount, + performHealthChecks, + toggleSchedulable, + migrateApiKeysForAzureSupport, + resetAccountStatus, + encrypt, + decrypt +} diff --git a/src/services/account/bedrockAccountService.js b/src/services/account/bedrockAccountService.js new file mode 100644 index 0000000..f1c7bfd --- /dev/null +++ b/src/services/account/bedrockAccountService.js @@ -0,0 +1,849 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const config = require('../../../config/config') +const bedrockRelayService = require('../relay/bedrockRelayService') +const LRUCache = require('../../utils/lruCache') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +class BedrockAccountService { + constructor() { + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'salt' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info('🧹 Bedrock decrypt cache cleanup completed', this._decryptCache.getStats()) + }, + 10 * 60 * 1000 + ) + } + + // 🏢 创建Bedrock账户 + async createAccount(options = {}) { + const { + name = 'Unnamed Bedrock Account', + description = '', + region = process.env.AWS_REGION || 'us-east-1', + awsCredentials = null, // { accessKeyId, secretAccessKey, sessionToken } + bearerToken = null, // AWS Bearer Token for Bedrock API Keys + defaultModel = 'us.anthropic.claude-sonnet-4-20250514-v1:0', + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + priority = 50, // 调度优先级 (1-100,数字越小优先级越高) + schedulable = true, // 是否可被调度 + credentialType = 'access_key', // 'access_key', 'bearer_token'(默认为 access_key) + disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用) + } = options + + const accountId = uuidv4() + + const accountData = { + id: accountId, + name, + description, + region, + defaultModel, + isActive, + accountType, + priority, + schedulable, + credentialType, + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + // 注意:Bedrock 使用 AWS 凭证,没有 OAuth token,因此没有 expiresAt + subscriptionExpiresAt: options.subscriptionExpiresAt || null, + + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + type: 'bedrock', // 标识这是Bedrock账户 + disableAutoProtection // 关闭自动防护 + } + + // 加密存储AWS凭证 + if (awsCredentials) { + accountData.awsCredentials = this._encryptAwsCredentials(awsCredentials) + } + + // 加密存储 Bearer Token + if (bearerToken) { + accountData.bearerToken = this._encryptAwsCredentials({ token: bearerToken }) + } + + const client = redis.getClientSafe() + await client.set(`bedrock_account:${accountId}`, JSON.stringify(accountData)) + await redis.addToIndex('bedrock_account:index', accountId) + + logger.info(`✅ 创建Bedrock账户成功 - ID: ${accountId}, 名称: ${name}, 区域: ${region}`) + + return { + success: true, + data: { + id: accountId, + name, + description, + region, + defaultModel, + isActive, + accountType, + priority, + schedulable, + credentialType, + createdAt: accountData.createdAt, + type: 'bedrock' + } + } + } + + // 🔍 获取账户信息 + async getAccount(accountId) { + try { + const client = redis.getClientSafe() + const accountData = await client.get(`bedrock_account:${accountId}`) + if (!accountData) { + return { success: false, error: 'Account not found' } + } + + const account = JSON.parse(accountData) + + // 根据凭证类型解密对应的凭证 + // 增强逻辑:优先按照 credentialType 解密,如果字段不存在则尝试解密实际存在的字段(兜底) + try { + let accessKeyDecrypted = false + let bearerTokenDecrypted = false + + // 第一步:按照 credentialType 尝试解密对应的凭证 + if (account.credentialType === 'access_key' && account.awsCredentials) { + // Access Key 模式:解密 AWS 凭证 + account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) + accessKeyDecrypted = true + logger.debug( + `🔓 解密 Access Key 成功 - ID: ${accountId}, 类型: ${account.credentialType}` + ) + } else if (account.credentialType === 'bearer_token' && account.bearerToken) { + // Bearer Token 模式:解密 Bearer Token + const decrypted = this._decryptAwsCredentials(account.bearerToken) + account.bearerToken = decrypted.token + bearerTokenDecrypted = true + logger.debug( + `🔓 解密 Bearer Token 成功 - ID: ${accountId}, 类型: ${account.credentialType}` + ) + } else if (!account.credentialType || account.credentialType === 'default') { + // 向后兼容:旧版本账号可能没有 credentialType 字段,尝试解密所有存在的凭证 + if (account.awsCredentials) { + account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) + accessKeyDecrypted = true + } + if (account.bearerToken) { + const decrypted = this._decryptAwsCredentials(account.bearerToken) + account.bearerToken = decrypted.token + bearerTokenDecrypted = true + } + logger.debug( + `🔓 兼容模式解密 - ID: ${accountId}, Access Key: ${accessKeyDecrypted}, Bearer Token: ${bearerTokenDecrypted}` + ) + } + + // 第二步:兜底逻辑 - 如果按照 credentialType 没有解密到任何凭证,尝试解密实际存在的字段 + if (!accessKeyDecrypted && !bearerTokenDecrypted) { + logger.warn( + `⚠️ credentialType="${account.credentialType}" 与实际字段不匹配,尝试兜底解密 - ID: ${accountId}` + ) + if (account.awsCredentials) { + account.awsCredentials = this._decryptAwsCredentials(account.awsCredentials) + accessKeyDecrypted = true + logger.warn( + `🔓 兜底解密 Access Key 成功 - ID: ${accountId}, credentialType 应为 'access_key'` + ) + } + if (account.bearerToken) { + const decrypted = this._decryptAwsCredentials(account.bearerToken) + account.bearerToken = decrypted.token + bearerTokenDecrypted = true + logger.warn( + `🔓 兜底解密 Bearer Token 成功 - ID: ${accountId}, credentialType 应为 'bearer_token'` + ) + } + } + + // 验证至少解密了一种凭证 + if (!accessKeyDecrypted && !bearerTokenDecrypted) { + logger.error( + `❌ 未找到任何凭证可解密 - ID: ${accountId}, credentialType: ${account.credentialType}, hasAwsCredentials: ${!!account.awsCredentials}, hasBearerToken: ${!!account.bearerToken}` + ) + return { + success: false, + error: 'No valid credentials found in account data' + } + } + } catch (decryptError) { + logger.error( + `❌ 解密Bedrock凭证失败 - ID: ${accountId}, 类型: ${account.credentialType}`, + decryptError + ) + return { + success: false, + error: `Credentials decryption failed: ${decryptError.message}` + } + } + + logger.debug(`🔍 获取Bedrock账户 - ID: ${accountId}, 名称: ${account.name}`) + + return { + success: true, + data: account + } + } catch (error) { + logger.error(`❌ 获取Bedrock账户失败 - ID: ${accountId}`, error) + return { success: false, error: error.message } + } + } + + // 📋 获取所有账户列表 + async getAllAccounts() { + try { + const _client = redis.getClientSafe() + const accountIds = await redis.getAllIdsByIndex( + 'bedrock_account:index', + 'bedrock_account:*', + /^bedrock_account:(.+)$/ + ) + const keys = accountIds.map((id) => `bedrock_account:${id}`) + const accounts = [] + const dataList = await redis.batchGetChunked(keys) + + for (let i = 0; i < keys.length; i++) { + const accountData = dataList[i] + if (accountData) { + const account = JSON.parse(accountData) + + // 返回给前端时,不包含敏感信息,只显示掩码 + accounts.push({ + id: account.id, + name: account.name, + description: account.description, + region: account.region, + defaultModel: account.defaultModel, + isActive: account.isActive, + accountType: account.accountType, + priority: account.priority, + schedulable: account.schedulable, + credentialType: account.credentialType, + + // ✅ 前端显示订阅过期时间(业务字段) + expiresAt: account.subscriptionExpiresAt || null, + + createdAt: account.createdAt, + updatedAt: account.updatedAt, + type: 'bedrock', + platform: 'bedrock', + // 根据凭证类型判断是否有凭证 + hasCredentials: + account.credentialType === 'bearer_token' + ? !!account.bearerToken + : !!account.awsCredentials + }) + } + } + + // 按优先级和名称排序 + accounts.sort((a, b) => { + if (a.priority !== b.priority) { + return a.priority - b.priority + } + return a.name.localeCompare(b.name) + }) + + logger.debug(`📋 获取所有Bedrock账户 - 共 ${accounts.length} 个`) + + return { + success: true, + data: accounts + } + } catch (error) { + logger.error('❌ 获取Bedrock账户列表失败', error) + return { success: false, error: error.message } + } + } + + // ✏️ 更新账户信息 + async updateAccount(accountId, updates = {}) { + try { + // 获取原始账户数据(不解密凭证) + const client = redis.getClientSafe() + const accountData = await client.get(`bedrock_account:${accountId}`) + if (!accountData) { + return { success: false, error: 'Account not found' } + } + + const account = JSON.parse(accountData) + + // 更新字段 + if (updates.name !== undefined) { + account.name = updates.name + } + if (updates.description !== undefined) { + account.description = updates.description + } + if (updates.region !== undefined) { + account.region = updates.region + } + if (updates.defaultModel !== undefined) { + account.defaultModel = updates.defaultModel + } + if (updates.isActive !== undefined) { + account.isActive = updates.isActive + } + if (updates.accountType !== undefined) { + account.accountType = updates.accountType + } + if (updates.priority !== undefined) { + account.priority = updates.priority + } + if (updates.schedulable !== undefined) { + account.schedulable = updates.schedulable + } + if (updates.credentialType !== undefined) { + account.credentialType = updates.credentialType + } + + // 更新AWS凭证 + if (updates.awsCredentials !== undefined) { + if (updates.awsCredentials) { + account.awsCredentials = this._encryptAwsCredentials(updates.awsCredentials) + } else { + delete account.awsCredentials + } + } else if (account.awsCredentials && account.awsCredentials.accessKeyId) { + // 如果没有提供新凭证但现有凭证是明文格式,重新加密 + const plainCredentials = account.awsCredentials + account.awsCredentials = this._encryptAwsCredentials(plainCredentials) + logger.info(`🔐 重新加密Bedrock账户凭证 - ID: ${accountId}`) + } + + // 更新 Bearer Token + if (updates.bearerToken !== undefined) { + if (updates.bearerToken) { + account.bearerToken = this._encryptAwsCredentials({ token: updates.bearerToken }) + } else { + delete account.bearerToken + } + } + + // ✅ 直接保存 subscriptionExpiresAt(如果提供) + // Bedrock 没有 token 刷新逻辑,不会覆盖此字段 + if (updates.subscriptionExpiresAt !== undefined) { + account.subscriptionExpiresAt = updates.subscriptionExpiresAt + } + + // 自动防护开关 + if (updates.disableAutoProtection !== undefined) { + account.disableAutoProtection = updates.disableAutoProtection + } + + account.updatedAt = new Date().toISOString() + + await client.set(`bedrock_account:${accountId}`, JSON.stringify(account)) + + logger.info(`✅ 更新Bedrock账户成功 - ID: ${accountId}, 名称: ${account.name}`) + + return { + success: true, + data: { + id: account.id, + name: account.name, + description: account.description, + region: account.region, + defaultModel: account.defaultModel, + isActive: account.isActive, + accountType: account.accountType, + priority: account.priority, + schedulable: account.schedulable, + credentialType: account.credentialType, + updatedAt: account.updatedAt, + type: 'bedrock' + } + } + } catch (error) { + logger.error(`❌ 更新Bedrock账户失败 - ID: ${accountId}`, error) + return { success: false, error: error.message } + } + } + + // 🗑️ 删除账户 + async deleteAccount(accountId) { + try { + const accountResult = await this.getAccount(accountId) + if (!accountResult.success) { + return accountResult + } + + const client = redis.getClientSafe() + await client.del(`bedrock_account:${accountId}`) + await redis.removeFromIndex('bedrock_account:index', accountId) + + logger.info(`✅ 删除Bedrock账户成功 - ID: ${accountId}`) + + return { success: true } + } catch (error) { + logger.error(`❌ 删除Bedrock账户失败 - ID: ${accountId}`, error) + return { success: false, error: error.message } + } + } + + // 🎯 选择可用的Bedrock账户 (用于请求转发) + async selectAvailableAccount() { + try { + const accountsResult = await this.getAllAccounts() + if (!accountsResult.success) { + return { success: false, error: 'Failed to get accounts' } + } + + const availableAccounts = accountsResult.data.filter((account) => { + // ✅ 检查账户订阅是否过期 + if (this.isSubscriptionExpired(account)) { + logger.debug( + `⏰ Skipping expired Bedrock account: ${account.name}, expired at ${account.subscriptionExpiresAt || account.expiresAt}` + ) + return false + } + + return account.isActive && account.schedulable + }) + + if (availableAccounts.length === 0) { + return { success: false, error: 'No available Bedrock accounts' } + } + + // 简单的轮询选择策略 - 选择优先级最高的账户 + const selectedAccount = availableAccounts[0] + + // 获取完整账户信息(包含解密的凭证) + const fullAccountResult = await this.getAccount(selectedAccount.id) + if (!fullAccountResult.success) { + return { success: false, error: 'Failed to get selected account details' } + } + + logger.debug(`🎯 选择Bedrock账户 - ID: ${selectedAccount.id}, 名称: ${selectedAccount.name}`) + + return { + success: true, + data: fullAccountResult.data + } + } catch (error) { + logger.error('❌ 选择Bedrock账户失败', error) + return { success: false, error: error.message } + } + } + + // 🧪 测试账户连接 + async testAccount(accountId) { + try { + const accountResult = await this.getAccount(accountId) + if (!accountResult.success) { + return accountResult + } + + const account = accountResult.data + + logger.info( + `🧪 测试Bedrock账户连接 - ID: ${accountId}, 名称: ${account.name}, 凭证类型: ${account.credentialType}` + ) + + // 验证凭证是否已解密 + const hasValidCredentials = + (account.credentialType === 'access_key' && account.awsCredentials) || + (account.credentialType === 'bearer_token' && account.bearerToken) || + (!account.credentialType && (account.awsCredentials || account.bearerToken)) + + if (!hasValidCredentials) { + logger.error( + `❌ 测试失败:账户没有有效凭证 - ID: ${accountId}, credentialType: ${account.credentialType}` + ) + return { + success: false, + error: 'No valid credentials found after decryption' + } + } + + // 尝试创建 Bedrock 客户端来验证凭证格式 + try { + bedrockRelayService._getBedrockClient(account.region, account) + logger.debug(`✅ Bedrock客户端创建成功 - ID: ${accountId}`) + } catch (clientError) { + logger.error(`❌ 创建Bedrock客户端失败 - ID: ${accountId}`, clientError) + return { + success: false, + error: `Failed to create Bedrock client: ${clientError.message}` + } + } + + // 获取可用模型列表(硬编码,但至少验证了凭证格式正确) + const models = await bedrockRelayService.getAvailableModels(account) + + if (models && models.length > 0) { + logger.info( + `✅ Bedrock账户测试成功 - ID: ${accountId}, 发现 ${models.length} 个模型, 凭证类型: ${account.credentialType}` + ) + return { + success: true, + data: { + status: 'connected', + modelsCount: models.length, + region: account.region, + credentialType: account.credentialType + } + } + } else { + return { + success: false, + error: 'Unable to retrieve models from Bedrock' + } + } + } catch (error) { + logger.error(`❌ 测试Bedrock账户失败 - ID: ${accountId}`, error) + return { + success: false, + error: error.message + } + } + } + + /** + * 🧪 测试 Bedrock 账户连接(SSE 流式返回,供前端测试页面使用) + * @param {string} accountId - 账户ID + * @param {Object} res - Express response 对象 + * @param {string} model - 测试使用的模型 + */ + async testAccountConnection(accountId, res, model = null) { + const { InvokeModelWithResponseStreamCommand } = require('@aws-sdk/client-bedrock-runtime') + + try { + // 获取账户信息 + const accountResult = await this.getAccount(accountId) + if (!accountResult.success) { + throw new Error(accountResult.error || 'Account not found') + } + + const account = accountResult.data + + // 根据账户类型选择合适的测试模型 + if (!model) { + // Access Key 模式使用 Haiku(更快更便宜) + model = account.defaultModel || 'us.anthropic.claude-3-5-haiku-20241022-v1:0' + } + + logger.info( + `🧪 Testing Bedrock account connection: ${account.name} (${accountId}), model: ${model}, credentialType: ${account.credentialType}` + ) + + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + res.status(200) + + // 发送 test_start 事件 + res.write(`data: ${JSON.stringify({ type: 'test_start' })}\n\n`) + + // 构造测试请求体(Bedrock 格式) + const bedrockPayload = { + anthropic_version: 'bedrock-2023-05-31', + max_tokens: 256, + messages: [ + { + role: 'user', + content: + 'Hello! Please respond with a simple greeting to confirm the connection is working. And tell me who are you?' + } + ] + } + + // 获取 Bedrock 客户端 + const region = account.region || bedrockRelayService.defaultRegion + const client = bedrockRelayService._getBedrockClient(region, account) + + // 创建流式调用命令 + const command = new InvokeModelWithResponseStreamCommand({ + modelId: model, + body: JSON.stringify(bedrockPayload), + contentType: 'application/json', + accept: 'application/json' + }) + + logger.debug(`🌊 Bedrock test stream - model: ${model}, region: ${region}`) + + const startTime = Date.now() + const response = await client.send(command) + + // 处理流式响应 + // let responseText = '' + for await (const chunk of response.body) { + if (chunk.chunk) { + const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes)) + + // 提取文本内容 + if (chunkData.type === 'content_block_delta' && chunkData.delta?.text) { + const { text } = chunkData.delta + // responseText += text + + // 发送 content 事件 + res.write(`data: ${JSON.stringify({ type: 'content', text })}\n\n`) + } + + // 检测错误 + if (chunkData.type === 'error') { + throw new Error(chunkData.error?.message || 'Bedrock API error') + } + } + } + + const duration = Date.now() - startTime + logger.info(`✅ Bedrock test completed - model: ${model}, duration: ${duration}ms`) + + // 发送 message_stop 事件(前端兼容) + res.write(`data: ${JSON.stringify({ type: 'message_stop' })}\n\n`) + + // 发送 test_complete 事件 + res.write(`data: ${JSON.stringify({ type: 'test_complete', success: true })}\n\n`) + + // 结束响应 + res.end() + + logger.info(`✅ Test request completed for Bedrock account: ${account.name}`) + } catch (error) { + logger.error(`❌ Test Bedrock account connection failed:`, error) + + // 发送错误事件给前端 + try { + // 检查响应流是否仍然可写 + if (!res.writableEnded && !res.destroyed) { + if (!res.headersSent) { + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.status(200) + } + const errorMsg = error.message || '测试失败' + res.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`) + res.end() + } + } catch (writeError) { + logger.error('Failed to write error to response stream:', writeError) + } + + // 不再重新抛出错误,避免路由层再次处理 + // throw error + } + } + + /** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() + } + + // 🔑 生成加密密钥(缓存优化) + _generateEncryptionKey() { + if (!this._encryptionKeyCache) { + this._encryptionKeyCache = crypto + .createHash('sha256') + .update(config.security.encryptionKey) + .digest() + logger.info('🔑 Bedrock encryption key derived and cached for performance optimization') + } + return this._encryptionKeyCache + } + + // 🔐 加密AWS凭证 + _encryptAwsCredentials(credentials) { + try { + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + + const credentialsString = JSON.stringify(credentials) + let encrypted = cipher.update(credentialsString, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return { + encrypted, + iv: iv.toString('hex') + } + } catch (error) { + logger.error('❌ AWS凭证加密失败', error) + throw new Error('Credentials encryption failed') + } + } + + // 🔓 解密AWS凭证 + _decryptAwsCredentials(encryptedData) { + try { + // 检查数据格式 + if (!encryptedData || typeof encryptedData !== 'object') { + logger.error('❌ 无效的加密数据格式:', encryptedData) + throw new Error('Invalid encrypted data format') + } + + // 检查是否为加密格式 (有 encrypted 和 iv 字段) + if (encryptedData.encrypted && encryptedData.iv) { + // 🎯 检查缓存 + const cacheKey = crypto + .createHash('sha256') + .update(JSON.stringify(encryptedData)) + .digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + // 加密数据 - 进行解密 + const key = this._generateEncryptionKey() + const iv = Buffer.from(encryptedData.iv, 'hex') + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + + let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + const result = JSON.parse(decrypted) + + // 💾 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, result, 5 * 60 * 1000) + + // 📊 定期打印缓存统计 + if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) { + this._decryptCache.printStats() + } + + return result + } else if (encryptedData.accessKeyId) { + // 纯文本数据 - 直接返回 (向后兼容) + logger.warn('⚠️ 发现未加密的AWS凭证,建议更新账户以启用加密') + return encryptedData + } else { + // 既不是加密格式也不是有效的凭证格式 + logger.error('❌ 缺少加密数据字段:', { + hasEncrypted: !!encryptedData.encrypted, + hasIv: !!encryptedData.iv, + hasAccessKeyId: !!encryptedData.accessKeyId + }) + throw new Error('Missing encrypted data fields or valid credentials') + } + } catch (error) { + logger.error('❌ AWS凭证解密失败', error) + throw new Error('Credentials decryption failed') + } + } + + // 🔍 获取账户统计信息 + async getAccountStats() { + try { + const accountsResult = await this.getAllAccounts() + if (!accountsResult.success) { + return { success: false, error: accountsResult.error } + } + + const accounts = accountsResult.data + const stats = { + total: accounts.length, + active: accounts.filter((acc) => acc.isActive).length, + inactive: accounts.filter((acc) => !acc.isActive).length, + schedulable: accounts.filter((acc) => acc.schedulable).length, + byRegion: {}, + byCredentialType: {} + } + + // 按区域统计 + accounts.forEach((acc) => { + stats.byRegion[acc.region] = (stats.byRegion[acc.region] || 0) + 1 + stats.byCredentialType[acc.credentialType] = + (stats.byCredentialType[acc.credentialType] || 0) + 1 + }) + + return { success: true, data: stats } + } catch (error) { + logger.error('❌ 获取Bedrock账户统计失败', error) + return { success: false, error: error.message } + } + } + + // 🔄 重置Bedrock账户所有异常状态 + async resetAccountStatus(accountId) { + try { + const accountData = await this.getAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + const client = redis.getClientSafe() + const accountKey = `bedrock:account:${accountId}` + + const updates = { + status: 'active', + errorMessage: '', + schedulable: 'true', + isActive: 'true' + } + + const fieldsToDelete = [ + 'rateLimitedAt', + 'rateLimitStatus', + 'unauthorizedAt', + 'unauthorizedCount', + 'overloadedAt', + 'overloadStatus', + 'blockedAt', + 'quotaStoppedAt' + ] + + await client.hset(accountKey, updates) + await client.hdel(accountKey, ...fieldsToDelete) + + logger.success(`Reset all error status for Bedrock account ${accountId}`) + + // 清除临时不可用状态 + await upstreamErrorHelper.clearTempUnavailable(accountId, 'bedrock').catch(() => {}) + + // 异步发送 Webhook 通知(忽略错误) + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || accountId, + platform: 'bedrock', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.warn('Failed to send webhook notification for Bedrock status reset:', webhookError) + } + + return { success: true, accountId } + } catch (error) { + logger.error(`❌ Failed to reset Bedrock account status: ${accountId}`, error) + throw error + } + } +} + +module.exports = new BedrockAccountService() diff --git a/src/services/account/ccrAccountService.js b/src/services/account/ccrAccountService.js new file mode 100644 index 0000000..e26f663 --- /dev/null +++ b/src/services/account/ccrAccountService.js @@ -0,0 +1,938 @@ +const { v4: uuidv4 } = require('uuid') +const ProxyHelper = require('../../utils/proxyHelper') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const { createEncryptor } = require('../../utils/commonHelper') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +class CcrAccountService { + constructor() { + // Redis键前缀 + this.ACCOUNT_KEY_PREFIX = 'ccr_account:' + this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts' + + // 使用 commonHelper 的加密器 + this._encryptor = createEncryptor('ccr-account-salt') + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._encryptor.clearCache() + logger.info('🧹 CCR account decrypt cache cleanup completed', this._encryptor.getStats()) + }, + 10 * 60 * 1000 + ) + } + + // 🏢 创建CCR账户 + async createAccount(options = {}) { + const { + name = 'CCR Account', + description = '', + apiUrl = '', + apiKey = '', + priority = 50, // 默认优先级50(1-100) + supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有 + userAgent = 'claude-relay-service/1.0.0', + rateLimitDuration = 60, // 限流时间(分钟) + proxy = null, + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + schedulable = true, // 是否可被调度 + dailyQuota = 0, // 每日额度限制(美元),0表示不限制 + quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) + disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用) + } = options + + // 验证必填字段 + if (!apiUrl || !apiKey) { + throw new Error('API URL and API Key are required for CCR account') + } + + const accountId = uuidv4() + + // 处理 supportedModels,确保向后兼容 + const processedModels = this._processModelMapping(supportedModels) + + const accountData = { + id: accountId, + platform: 'ccr', + name, + description, + apiUrl, + apiKey: this._encryptSensitiveData(apiKey), + priority: priority.toString(), + supportedModels: JSON.stringify(processedModels), + userAgent, + rateLimitDuration: rateLimitDuration.toString(), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + // 注意:CCR 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt + subscriptionExpiresAt: options.subscriptionExpiresAt || null, + + createdAt: new Date().toISOString(), + lastUsedAt: '', + status: 'active', + errorMessage: '', + // 限流相关 + rateLimitedAt: '', + rateLimitStatus: '', + // 调度控制 + schedulable: schedulable.toString(), + // 额度管理相关 + dailyQuota: dailyQuota.toString(), // 每日额度限制(美元) + dailyUsage: '0', // 当日使用金额(美元) + // 使用与统计一致的时区日期,避免边界问题 + lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) + quotaResetTime, // 额度重置时间 + quotaStoppedAt: '', // 因额度停用的时间 + disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护 + } + + const client = redis.getClientSafe() + logger.debug( + `[DEBUG] Saving CCR account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}` + ) + logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`) + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData) + await redis.addToIndex('ccr_account:index', accountId) + + // 如果是共享账户,添加到共享账户集合 + if (accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } + + logger.success(`🏢 Created CCR account: ${name} (${accountId})`) + + return { + id: accountId, + name, + description, + apiUrl, + priority, + supportedModels, + userAgent, + rateLimitDuration, + isActive, + proxy, + accountType, + status: 'active', + createdAt: accountData.createdAt, + dailyQuota, + dailyUsage: 0, + lastResetDate: accountData.lastResetDate, + quotaResetTime, + quotaStoppedAt: null + } + } + + // 📋 获取所有CCR账户 + async getAllAccounts() { + try { + const accountIds = await redis.getAllIdsByIndex( + 'ccr_account:index', + `${this.ACCOUNT_KEY_PREFIX}*`, + /^ccr_account:(.+)$/ + ) + const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`) + const accounts = [] + const dataList = await redis.batchHgetallChunked(keys) + + for (let i = 0; i < keys.length; i++) { + const accountData = dataList[i] + if (accountData && Object.keys(accountData).length > 0) { + // 获取限流状态信息 + const rateLimitInfo = this._getRateLimitInfo(accountData) + + accounts.push({ + id: accountData.id, + platform: accountData.platform, + name: accountData.name, + description: accountData.description, + apiUrl: accountData.apiUrl, + priority: parseInt(accountData.priority) || 50, + supportedModels: JSON.parse(accountData.supportedModels || '[]'), + userAgent: accountData.userAgent, + rateLimitDuration: Number.isNaN(parseInt(accountData.rateLimitDuration)) + ? 60 + : parseInt(accountData.rateLimitDuration), + isActive: accountData.isActive === 'true', + proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null, + accountType: accountData.accountType || 'shared', + createdAt: accountData.createdAt, + lastUsedAt: accountData.lastUsedAt, + status: accountData.status || 'active', + errorMessage: accountData.errorMessage, + rateLimitInfo, + schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度 + + // ✅ 前端显示订阅过期时间(业务字段) + expiresAt: accountData.subscriptionExpiresAt || null, + + // 额度管理相关 + dailyQuota: parseFloat(accountData.dailyQuota || '0'), + dailyUsage: parseFloat(accountData.dailyUsage || '0'), + lastResetDate: accountData.lastResetDate || '', + quotaResetTime: accountData.quotaResetTime || '00:00', + quotaStoppedAt: accountData.quotaStoppedAt || null, + disableAutoProtection: accountData.disableAutoProtection === 'true' + }) + } + } + + return accounts + } catch (error) { + logger.error('❌ Failed to get CCR accounts:', error) + throw error + } + } + + // 🔍 获取单个账户(内部使用,包含敏感信息) + async getAccount(accountId) { + const client = redis.getClientSafe() + logger.debug(`[DEBUG] Getting CCR account data for ID: ${accountId}`) + const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + logger.debug(`[DEBUG] No CCR account data found for ID: ${accountId}`) + return null + } + + logger.debug(`[DEBUG] Raw CCR account data keys: ${Object.keys(accountData).join(', ')}`) + logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`) + + // 解密敏感字段(只解密apiKey,apiUrl不加密) + const decryptedKey = this._decryptSensitiveData(accountData.apiKey) + logger.debug( + `[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}` + ) + + accountData.apiKey = decryptedKey + + // 解析JSON字段 + const parsedModels = JSON.parse(accountData.supportedModels || '[]') + logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`) + + accountData.supportedModels = parsedModels + accountData.priority = parseInt(accountData.priority) || 50 + { + const _parsedDuration = parseInt(accountData.rateLimitDuration) + accountData.rateLimitDuration = Number.isNaN(_parsedDuration) ? 60 : _parsedDuration + } + accountData.isActive = accountData.isActive === 'true' + accountData.schedulable = accountData.schedulable !== 'false' // 默认为true + accountData.disableAutoProtection = accountData.disableAutoProtection === 'true' + + if (accountData.proxy) { + accountData.proxy = JSON.parse(accountData.proxy) + } + + logger.debug( + `[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}` + ) + + return accountData + } + + // 📝 更新账户 + async updateAccount(accountId, updates) { + try { + const existingAccount = await this.getAccount(accountId) + if (!existingAccount) { + throw new Error('CCR Account not found') + } + + const client = redis.getClientSafe() + const updatedData = {} + + // 处理各个字段的更新 + logger.debug( + `[DEBUG] CCR update request received with fields: ${Object.keys(updates).join(', ')}` + ) + logger.debug(`[DEBUG] CCR Updates content: ${JSON.stringify(updates, null, 2)}`) + + if (updates.name !== undefined) { + updatedData.name = updates.name + } + if (updates.description !== undefined) { + updatedData.description = updates.description + } + if (updates.apiUrl !== undefined) { + updatedData.apiUrl = updates.apiUrl + } + if (updates.apiKey !== undefined) { + updatedData.apiKey = this._encryptSensitiveData(updates.apiKey) + } + if (updates.priority !== undefined) { + updatedData.priority = updates.priority.toString() + } + if (updates.supportedModels !== undefined) { + logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`) + // 处理 supportedModels,确保向后兼容 + const processedModels = this._processModelMapping(updates.supportedModels) + updatedData.supportedModels = JSON.stringify(processedModels) + } + if (updates.userAgent !== undefined) { + updatedData.userAgent = updates.userAgent + } + if (updates.rateLimitDuration !== undefined) { + updatedData.rateLimitDuration = updates.rateLimitDuration.toString() + } + if (updates.proxy !== undefined) { + updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + if (updates.isActive !== undefined) { + updatedData.isActive = updates.isActive.toString() + } + if (updates.schedulable !== undefined) { + updatedData.schedulable = updates.schedulable.toString() + } + if (updates.dailyQuota !== undefined) { + updatedData.dailyQuota = updates.dailyQuota.toString() + } + if (updates.quotaResetTime !== undefined) { + updatedData.quotaResetTime = updates.quotaResetTime + } + + // ✅ 直接保存 subscriptionExpiresAt(如果提供) + // CCR 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段 + if (updates.subscriptionExpiresAt !== undefined) { + updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt + } + + // 自动防护开关 + if (updates.disableAutoProtection !== undefined) { + updatedData.disableAutoProtection = updates.disableAutoProtection.toString() + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData) + + // 处理共享账户集合变更 + if (updates.accountType !== undefined) { + updatedData.accountType = updates.accountType + if (updates.accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } else { + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + } + } + + logger.success(`📝 Updated CCR account: ${accountId}`) + return await this.getAccount(accountId) + } catch (error) { + logger.error(`❌ Failed to update CCR account ${accountId}:`, error) + throw error + } + } + + // 🗑️ 删除账户 + async deleteAccount(accountId) { + try { + const client = redis.getClientSafe() + + // 从共享账户集合中移除 + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + + // 从索引中移除 + await redis.removeFromIndex('ccr_account:index', accountId) + + // 删除账户数据 + const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + if (result === 0) { + throw new Error('CCR Account not found or already deleted') + } + + logger.success(`🗑️ Deleted CCR account: ${accountId}`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to delete CCR account ${accountId}:`, error) + throw error + } + } + + // 🚫 标记账户为限流状态 + async markAccountRateLimited(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('CCR Account not found') + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountRateLimited` + ) + upstreamErrorHelper.recordErrorHistory(accountId, 'ccr', 429, 'rate_limit').catch(() => {}) + return { success: true, skipped: true } + } + + // 如果限流时间设置为 0,表示不启用限流机制,直接返回 + if (account.rateLimitDuration === 0) { + logger.info( + `ℹ️ CCR account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit` + ) + return { success: true, skipped: true } + } + + const now = new Date().toISOString() + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + status: 'rate_limited', + rateLimitedAt: now, + rateLimitStatus: 'active', + errorMessage: 'Rate limited by upstream service' + }) + + logger.warn(`⏱️ Marked CCR account as rate limited: ${account.name} (${accountId})`) + return { success: true, rateLimitedAt: now } + } catch (error) { + logger.error(`❌ Failed to mark CCR account as rate limited: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账户限流状态 + async removeAccountRateLimit(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 获取账户当前状态和额度信息 + const [, quotaStoppedAt] = await client.hmget(accountKey, 'status', 'quotaStoppedAt') + + // 删除限流相关字段 + await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') + + // 根据不同情况决定是否恢复账户 + let newStatus = 'active' + let errorMessage = '' + + // 如果因额度问题停用,不要自动激活 + if (quotaStoppedAt) { + newStatus = 'quota_exceeded' + errorMessage = 'Account stopped due to quota exceeded' + logger.info( + `ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded` + ) + } else { + logger.success(`Removed rate limit for CCR account: ${accountId}`) + } + + await client.hmset(accountKey, { + status: newStatus, + errorMessage + }) + + return { success: true, newStatus } + } catch (error) { + logger.error(`❌ Failed to remove rate limit for CCR account: ${accountId}`, error) + throw error + } + } + + // 🔍 检查账户是否被限流 + async isAccountRateLimited(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + const [rateLimitedAt, rateLimitDuration] = await client.hmget( + accountKey, + 'rateLimitedAt', + 'rateLimitDuration' + ) + + if (rateLimitedAt) { + const limitTime = new Date(rateLimitedAt) + const duration = parseInt(rateLimitDuration) || 60 + const now = new Date() + const expireTime = new Date(limitTime.getTime() + duration * 60 * 1000) + + if (now < expireTime) { + return true + } else { + // 限流时间已过,自动移除限流状态 + await this.removeAccountRateLimit(accountId) + return false + } + } + return false + } catch (error) { + logger.error(`❌ Failed to check rate limit status for CCR account: ${accountId}`, error) + return false + } + } + + // 🔥 标记账户为过载状态 + async markAccountOverloaded(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('CCR Account not found') + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountOverloaded` + ) + upstreamErrorHelper.recordErrorHistory(accountId, 'ccr', 529, 'overload').catch(() => {}) + return { success: true, skipped: true } + } + + const now = new Date().toISOString() + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + status: 'overloaded', + overloadedAt: now, + errorMessage: 'Account overloaded' + }) + + logger.warn(`🔥 Marked CCR account as overloaded: ${account.name} (${accountId})`) + return { success: true, overloadedAt: now } + } catch (error) { + logger.error(`❌ Failed to mark CCR account as overloaded: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账户过载状态 + async removeAccountOverload(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 删除过载相关字段 + await client.hdel(accountKey, 'overloadedAt') + + await client.hmset(accountKey, { + status: 'active', + errorMessage: '' + }) + + logger.success(`Removed overload status for CCR account: ${accountId}`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error) + throw error + } + } + + // 🔍 检查账户是否过载 + async isAccountOverloaded(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + const status = await client.hget(accountKey, 'status') + return status === 'overloaded' + } catch (error) { + logger.error(`❌ Failed to check overload status for CCR account: ${accountId}`, error) + return false + } + } + + // 🚫 标记账户为未授权状态 + async markAccountUnauthorized(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('CCR Account not found') + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountUnauthorized` + ) + upstreamErrorHelper.recordErrorHistory(accountId, 'ccr', 401, 'auth_error').catch(() => {}) + return { success: true, skipped: true } + } + + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + status: 'unauthorized', + errorMessage: 'API key invalid or unauthorized' + }) + + logger.warn(`🚫 Marked CCR account as unauthorized: ${account.name} (${accountId})`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark CCR account as unauthorized: ${accountId}`, error) + throw error + } + } + + // 🔄 处理模型映射 + _processModelMapping(supportedModels) { + // 如果是空值,返回空对象(支持所有模型) + if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) { + return {} + } + + // 如果已经是对象格式(新的映射表格式),直接返回 + if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) { + return supportedModels + } + + // 如果是数组格式(旧格式),转换为映射表 + if (Array.isArray(supportedModels)) { + const mapping = {} + supportedModels.forEach((model) => { + if (model && typeof model === 'string') { + mapping[model] = model // 默认映射:原模型名 -> 原模型名 + } + }) + return mapping + } + + return {} + } + + // 🔍 检查模型是否被支持 + isModelSupported(modelMapping, requestedModel) { + // 如果映射表为空,支持所有模型 + if (!modelMapping || Object.keys(modelMapping).length === 0) { + return true + } + + // 检查请求的模型是否在映射表的键中(精确匹配) + if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) { + return true + } + + // 尝试大小写不敏感匹配 + const requestedModelLower = requestedModel.toLowerCase() + for (const key of Object.keys(modelMapping)) { + if (key.toLowerCase() === requestedModelLower) { + return true + } + } + + return false + } + + // 🔄 获取映射后的模型名称 + getMappedModel(modelMapping, requestedModel) { + // 如果映射表为空,返回原模型 + if (!modelMapping || Object.keys(modelMapping).length === 0) { + return requestedModel + } + + // 精确匹配 + if (modelMapping[requestedModel]) { + return modelMapping[requestedModel] + } + + // 大小写不敏感匹配 + const requestedModelLower = requestedModel.toLowerCase() + for (const [key, value] of Object.entries(modelMapping)) { + if (key.toLowerCase() === requestedModelLower) { + return value + } + } + + // 如果不存在映射则返回原模型名 + return requestedModel + } + + // 🔐 加密敏感数据 + _encryptSensitiveData(data) { + return this._encryptor.encrypt(data) + } + + // 🔓 解密敏感数据 + _decryptSensitiveData(encryptedData) { + return this._encryptor.decrypt(encryptedData) + } + + // 🔍 获取限流状态信息 + _getRateLimitInfo(accountData) { + const { rateLimitedAt } = accountData + const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 + + if (rateLimitedAt) { + const limitTime = new Date(rateLimitedAt) + const now = new Date() + const expireTime = new Date(limitTime.getTime() + rateLimitDuration * 60 * 1000) + const remainingMs = expireTime.getTime() - now.getTime() + + return { + isRateLimited: remainingMs > 0, + rateLimitedAt, + rateLimitExpireAt: expireTime.toISOString(), + remainingTimeMs: Math.max(0, remainingMs), + remainingTimeMinutes: Math.max(0, Math.ceil(remainingMs / (60 * 1000))) + } + } + + return { + isRateLimited: false, + rateLimitedAt: null, + rateLimitExpireAt: null, + remainingTimeMs: 0, + remainingTimeMinutes: 0 + } + } + + // 🔧 创建代理客户端 + _createProxyAgent(proxy) { + return ProxyHelper.createProxyAgent(proxy) + } + + // 💰 检查配额使用情况(可选实现) + async checkQuotaUsage(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + const dailyQuota = parseFloat(account.dailyQuota || '0') + // 如果未设置额度限制,则不限制 + if (dailyQuota <= 0) { + return false + } + + // 检查是否需要重置每日使用量 + const today = redis.getDateStringInTimezone() + if (account.lastResetDate !== today) { + await this.resetDailyUsage(accountId) + return false // 刚重置,不会超额 + } + + // 获取当日使用统计 + const usageStats = await this.getAccountUsageStats(accountId) + if (!usageStats) { + return false + } + + const dailyUsage = usageStats.dailyUsage || 0 + const isExceeded = dailyUsage >= dailyQuota + + if (isExceeded) { + // 标记账户因额度停用 + const client = redis.getClientSafe() + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + status: 'quota_exceeded', + errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`, + quotaStoppedAt: new Date().toISOString() + }) + logger.warn( + `💰 CCR account ${account.name} (${accountId}) quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}` + ) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'ccr', + status: 'quota_exceeded', + errorCode: 'QUOTA_EXCEEDED', + reason: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`, + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.warn('Failed to send webhook notification for CCR quota exceeded:', webhookError) + } + } + + return isExceeded + } catch (error) { + logger.error(`❌ Failed to check quota usage for CCR account ${accountId}:`, error) + return false + } + } + + // 🔄 重置每日使用量(可选实现) + async resetDailyUsage(accountId) { + try { + const client = redis.getClientSafe() + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + dailyUsage: '0', + lastResetDate: redis.getDateStringInTimezone(), + quotaStoppedAt: '' + }) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to reset daily usage for CCR account: ${accountId}`, error) + throw error + } + } + + // 🚫 检查账户是否超额 + async isAccountQuotaExceeded(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + const dailyQuota = parseFloat(account.dailyQuota || '0') + // 如果未设置额度限制,则不限制 + if (dailyQuota <= 0) { + return false + } + + // 获取当日使用统计 + const usageStats = await this.getAccountUsageStats(accountId) + if (!usageStats) { + return false + } + + const dailyUsage = usageStats.dailyUsage || 0 + const isExceeded = dailyUsage >= dailyQuota + + if (isExceeded && !account.quotaStoppedAt) { + // 标记账户因额度停用 + const client = redis.getClientSafe() + await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, { + status: 'quota_exceeded', + errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`, + quotaStoppedAt: new Date().toISOString() + }) + logger.warn(`💰 CCR account ${account.name} (${accountId}) quota exceeded`) + } + + return isExceeded + } catch (error) { + logger.error(`❌ Failed to check quota for CCR account ${accountId}:`, error) + return false + } + } + + // 🔄 重置所有CCR账户的每日使用量 + async resetAllDailyUsage() { + try { + const accounts = await this.getAllAccounts() + const today = redis.getDateStringInTimezone() + let resetCount = 0 + + for (const account of accounts) { + if (account.lastResetDate !== today) { + await this.resetDailyUsage(account.id) + resetCount += 1 + } + } + + logger.success(`Reset daily usage for ${resetCount} CCR accounts`) + return { success: true, resetCount } + } catch (error) { + logger.error('❌ Failed to reset all CCR daily usage:', error) + throw error + } + } + + // 📊 获取CCR账户使用统计(含每日费用) + async getAccountUsageStats(accountId) { + try { + // 使用统一的 Redis 统计 + const usageStats = await redis.getAccountUsageStats(accountId) + + // 叠加账户自身的额度配置 + const accountData = await this.getAccount(accountId) + if (!accountData) { + return null + } + + const dailyQuota = parseFloat(accountData.dailyQuota || '0') + const currentDailyCost = usageStats?.daily?.cost || 0 + + return { + dailyQuota, + dailyUsage: currentDailyCost, + remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null, + usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0, + lastResetDate: accountData.lastResetDate, + quotaResetTime: accountData.quotaResetTime, + quotaStoppedAt: accountData.quotaStoppedAt, + isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota, + fullUsageStats: usageStats + } + } catch (error) { + logger.error('❌ Failed to get CCR account usage stats:', error) + return null + } + } + + // 🔄 重置CCR账户所有异常状态 + async resetAccountStatus(accountId) { + try { + const accountData = await this.getAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + const updates = { + status: 'active', + errorMessage: '', + schedulable: 'true', + isActive: 'true' + } + + const fieldsToDelete = [ + 'rateLimitedAt', + 'rateLimitStatus', + 'unauthorizedAt', + 'unauthorizedCount', + 'overloadedAt', + 'overloadStatus', + 'blockedAt', + 'quotaStoppedAt' + ] + + await client.hset(accountKey, updates) + await client.hdel(accountKey, ...fieldsToDelete) + + logger.success(`Reset all error status for CCR account ${accountId}`) + + // 清除临时不可用状态 + await upstreamErrorHelper.clearTempUnavailable(accountId, 'ccr').catch(() => {}) + + // 异步发送 Webhook 通知(忽略错误) + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || accountId, + platform: 'ccr', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.warn('Failed to send webhook notification for CCR status reset:', webhookError) + } + + return { success: true, accountId } + } catch (error) { + logger.error(`❌ Failed to reset CCR account status: ${accountId}`, error) + throw error + } + } + + /** + * ⏰ 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() + } +} + +module.exports = new CcrAccountService() diff --git a/src/services/account/claudeAccountService.js b/src/services/account/claudeAccountService.js new file mode 100644 index 0000000..fa2e71a --- /dev/null +++ b/src/services/account/claudeAccountService.js @@ -0,0 +1,3508 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const ProxyHelper = require('../../utils/proxyHelper') +const axios = require('axios') +const redis = require('../../models/redis') +const config = require('../../../config/config') +const logger = require('../../utils/logger') +const { maskToken } = require('../../utils/tokenMask') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') +const { + logRefreshStart, + logRefreshSuccess, + logRefreshError, + logTokenUsage, + logRefreshSkipped +} = require('../../utils/tokenRefreshLogger') +const tokenRefreshService = require('../tokenRefreshService') +const LRUCache = require('../../utils/lruCache') +const { formatDateWithTimezone, getISOStringWithTimezone } = require('../../utils/dateHelper') +const { isOpus45OrNewer, RATE_LIMITED_MODEL_FAMILIES } = require('../../utils/modelHelper') +const { + parseBooleanLike, + normalizeOptionalNonNegativeInteger, + normalizeTempUnavailablePolicyInput +} = require('../../utils/tempUnavailablePolicy') + +/** + * Check if account is Pro (not Max) + * Compatible with both API real-time data (hasClaudePro) and local config (accountType) + * @param {Object} info - Subscription info object + * @returns {boolean} + */ +function isProAccount(info) { + // API real-time status takes priority + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return true + } + // Local configured account type + return info.accountType === 'claude_pro' +} + +class ClaudeAccountService { + constructor() { + // console.anthropic.com 已迁移至 platform.claude.com,旧域名对 refresh_token grant 返回 404 + this.claudeApiUrl = 'https://platform.claude.com/v1/oauth/token' + this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e' + let maxWarnings = parseInt(process.env.CLAUDE_5H_WARNING_MAX_NOTIFICATIONS || '', 10) + + if (Number.isNaN(maxWarnings) && config.claude?.fiveHourWarning) { + maxWarnings = parseInt(config.claude.fiveHourWarning.maxNotificationsPerWindow, 10) + } + + if (Number.isNaN(maxWarnings) || maxWarnings < 1) { + maxWarnings = 1 + } + + this.maxFiveHourWarningsPerWindow = Math.min(maxWarnings, 10) + + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'salt' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + // scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info('🧹 Claude decrypt cache cleanup completed', this._decryptCache.getStats()) + }, + 10 * 60 * 1000 + ) + } + + // 🏢 创建Claude账户 + async createAccount(options = {}) { + const { + name = 'Unnamed Account', + description = '', + email = '', + password = '', + refreshToken = '', + claudeAiOauth = null, // Claude标准格式的OAuth数据 + proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' } + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + platform = 'claude', + priority = 50, // 调度优先级 (1-100,数字越小优先级越高) + schedulable = true, // 是否可被调度 + subscriptionInfo = null, // 手动设置的订阅信息 + autoStopOnWarning = false, // 5小时使用量接近限制时自动停止调度 + useUnifiedUserAgent = false, // 是否使用统一Claude Code版本的User-Agent + useUnifiedClientId = false, // 是否使用统一的客户端标识 + unifiedClientId = '', // 统一的客户端标识 + expiresAt = null, // 账户订阅到期时间 + extInfo = null, // 额外扩展信息 + maxConcurrency = 0, // 账户级用户消息串行队列:0=使用全局配置,>0=强制启用串行 + interceptWarmup = false, // 拦截预热请求(标题生成、Warmup等) + disableTempUnavailable = false, // 是否禁用账号级临时冷却(temp_unavailable) + tempUnavailable503TtlSeconds = null, // 账号级 503 冷却秒数(null 跟随全局) + tempUnavailable5xxTtlSeconds = null // 账号级 5xx 冷却秒数(null 跟随全局) + } = options + + const accountId = uuidv4() + const normalizedTempUnavailablePolicy = normalizeTempUnavailablePolicyInput({ + disableTempUnavailable, + tempUnavailable503TtlSeconds, + tempUnavailable5xxTtlSeconds + }) + const normalized503Ttl = normalizedTempUnavailablePolicy.tempUnavailable503TtlSeconds + const normalized5xxTtl = normalizedTempUnavailablePolicy.tempUnavailable5xxTtlSeconds + + let accountData + const normalizedExtInfo = this._normalizeExtInfo(extInfo, claudeAiOauth) + + if (claudeAiOauth) { + // 使用Claude标准格式的OAuth数据 + accountData = { + id: accountId, + name, + description, + email: this._encryptSensitiveData(email), + password: this._encryptSensitiveData(password), + claudeAiOauth: this._encryptSensitiveData(JSON.stringify(claudeAiOauth)), + accessToken: this._encryptSensitiveData(claudeAiOauth.accessToken), + refreshToken: this._encryptSensitiveData(claudeAiOauth.refreshToken), + expiresAt: claudeAiOauth.expiresAt.toString(), + scopes: claudeAiOauth.scopes.join(' '), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, // 账号类型:'dedicated' 或 'shared' 或 'group' + platform, + priority: priority.toString(), // 调度优先级 + createdAt: new Date().toISOString(), + lastUsedAt: '', + lastRefreshAt: '', + status: 'active', // 有OAuth数据的账户直接设为active + errorMessage: '', + schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 + useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent + useUnifiedClientId: useUnifiedClientId.toString(), // 是否使用统一的客户端标识 + unifiedClientId: unifiedClientId || '', // 统一的客户端标识 + // 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空 + subscriptionInfo: subscriptionInfo + ? JSON.stringify(subscriptionInfo) + : claudeAiOauth.subscriptionInfo + ? JSON.stringify(claudeAiOauth.subscriptionInfo) + : '', + // 账户订阅到期时间 + subscriptionExpiresAt: expiresAt || '', + // 扩展信息 + extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '', + // 账户级用户消息串行队列限制 + maxConcurrency: maxConcurrency.toString(), + // 拦截预热请求 + interceptWarmup: interceptWarmup.toString(), + // 账号级临时冷却覆盖(空字符串表示跟随全局配置) + disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable.toString(), + tempUnavailable503TtlSeconds: normalized503Ttl !== null ? normalized503Ttl.toString() : '', + tempUnavailable5xxTtlSeconds: normalized5xxTtl !== null ? normalized5xxTtl.toString() : '' + } + } else { + // 兼容旧格式 + accountData = { + id: accountId, + name, + description, + email: this._encryptSensitiveData(email), + password: this._encryptSensitiveData(password), + refreshToken: this._encryptSensitiveData(refreshToken), + accessToken: '', + expiresAt: '', + scopes: '', + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, // 账号类型:'dedicated' 或 'shared' 或 'group' + platform, + priority: priority.toString(), // 调度优先级 + createdAt: new Date().toISOString(), + lastUsedAt: '', + lastRefreshAt: '', + status: 'created', // created, active, expired, error + errorMessage: '', + schedulable: schedulable.toString(), // 是否可被调度 + autoStopOnWarning: autoStopOnWarning.toString(), // 5小时使用量接近限制时自动停止调度 + useUnifiedUserAgent: useUnifiedUserAgent.toString(), // 是否使用统一Claude Code版本的User-Agent + // 手动设置的订阅信息 + subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '', + // 账户订阅到期时间 + subscriptionExpiresAt: expiresAt || '', + // 扩展信息 + extInfo: normalizedExtInfo ? JSON.stringify(normalizedExtInfo) : '', + // 账户级用户消息串行队列限制 + maxConcurrency: maxConcurrency.toString(), + // 拦截预热请求 + interceptWarmup: interceptWarmup.toString(), + // 账号级临时冷却覆盖(空字符串表示跟随全局配置) + disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable.toString(), + tempUnavailable503TtlSeconds: normalized503Ttl !== null ? normalized503Ttl.toString() : '', + tempUnavailable5xxTtlSeconds: normalized5xxTtl !== null ? normalized5xxTtl.toString() : '' + } + } + + await redis.setClaudeAccount(accountId, accountData) + + logger.success(`🏢 Created Claude account: ${name} (${accountId})`) + + // 如果有 OAuth 数据和 accessToken,且包含 user:profile 权限,尝试获取 profile 信息 + if (claudeAiOauth && claudeAiOauth.accessToken) { + // 检查是否有 user:profile 权限(标准 OAuth 有,Setup Token 没有) + const hasProfileScope = claudeAiOauth.scopes && claudeAiOauth.scopes.includes('user:profile') + + if (hasProfileScope) { + try { + const agent = this._createProxyAgent(proxy) + await this.fetchAndUpdateAccountProfile(accountId, claudeAiOauth.accessToken, agent) + logger.info(`📊 Successfully fetched profile info for new account: ${name}`) + } catch (profileError) { + logger.warn(`⚠️ Failed to fetch profile info for new account: ${profileError.message}`) + } + } else { + logger.info(`⏩ Skipping profile fetch for account ${name} (no user:profile scope)`) + } + } + + return { + id: accountId, + name, + description, + email, + isActive, + proxy, + accountType, + platform, + priority, + status: accountData.status, + createdAt: accountData.createdAt, + expiresAt: accountData.expiresAt, + subscriptionExpiresAt: + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null, + scopes: claudeAiOauth ? claudeAiOauth.scopes : [], + autoStopOnWarning, + useUnifiedUserAgent, + useUnifiedClientId, + unifiedClientId, + extInfo: normalizedExtInfo, + interceptWarmup, + disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable, + tempUnavailable503TtlSeconds: normalized503Ttl, + tempUnavailable5xxTtlSeconds: normalized5xxTtl + } + } + + // 🔄 刷新Claude账户token + async refreshAccountToken(accountId) { + let lockAcquired = false + + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + const refreshToken = this._decryptSensitiveData(accountData.refreshToken) + + if (!refreshToken) { + throw new Error('No refresh token available - manual token update required') + } + + // 尝试获取分布式锁 + lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'claude') + + if (!lockAcquired) { + // 如果无法获取锁,说明另一个进程正在刷新 + logger.info( + `🔒 Token refresh already in progress for account: ${accountData.name} (${accountId})` + ) + logRefreshSkipped(accountId, accountData.name, 'claude', 'already_locked') + + // 等待一段时间后返回,期望其他进程已完成刷新 + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // 重新获取账户数据(可能已被其他进程刷新) + const updatedData = await redis.getClaudeAccount(accountId) + if (updatedData && updatedData.accessToken) { + const accessToken = this._decryptSensitiveData(updatedData.accessToken) + return { + success: true, + accessToken, + expiresAt: updatedData.expiresAt + } + } + + throw new Error('Token refresh in progress by another process') + } + + // 记录开始刷新 + logRefreshStart(accountId, accountData.name, 'claude', 'manual_refresh') + logger.info(`🔄 Starting token refresh for account: ${accountData.name} (${accountId})`) + + // 创建代理agent + const agent = this._createProxyAgent(accountData.proxy) + + const axiosConfig = { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/plain, */*', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'https://claude.ai/', + Origin: 'https://claude.ai' + }, + timeout: 30000 + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.post( + this.claudeApiUrl, + { + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: this.claudeOauthClientId + }, + axiosConfig + ) + + if (response.status === 200) { + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('Token refresh response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 Token refresh response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + + const { access_token, refresh_token, expires_in } = response.data + + // 检查是否有套餐信息 + if ( + response.data.subscription || + response.data.plan || + response.data.tier || + response.data.account_type + ) { + const subscriptionInfo = { + subscription: response.data.subscription, + plan: response.data.plan, + tier: response.data.tier, + accountType: response.data.account_type, + features: response.data.features, + limits: response.data.limits + } + logger.info('🎯 Found subscription info in refresh response:', subscriptionInfo) + + // 将套餐信息存储在账户数据中 + accountData.subscriptionInfo = JSON.stringify(subscriptionInfo) + } + + // 更新账户数据 + accountData.accessToken = this._encryptSensitiveData(access_token) + accountData.refreshToken = this._encryptSensitiveData(refresh_token) + accountData.expiresAt = (Date.now() + expires_in * 1000).toString() + accountData.lastRefreshAt = new Date().toISOString() + accountData.status = 'active' + accountData.errorMessage = '' + + await redis.setClaudeAccount(accountId, accountData) + + // 刷新成功后,如果有 user:profile 权限,尝试获取账号 profile 信息 + // 检查账户的 scopes 是否包含 user:profile(标准 OAuth 有,Setup Token 没有) + const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile') + + if (hasProfileScope) { + try { + await this.fetchAndUpdateAccountProfile(accountId, access_token, agent) + } catch (profileError) { + logger.warn(`⚠️ Failed to fetch profile info after refresh: ${profileError.message}`) + } + } else { + logger.debug( + `⏩ Skipping profile fetch after refresh for account ${accountId} (no user:profile scope)` + ) + } + + // 记录刷新成功 + logRefreshSuccess(accountId, accountData.name, 'claude', { + accessToken: access_token, + refreshToken: refresh_token, + expiresAt: accountData.expiresAt, + scopes: accountData.scopes + }) + + logger.success( + `🔄 Refreshed token for account: ${accountData.name} (${accountId}) - Access Token: ${maskToken(access_token)}` + ) + + return { + success: true, + accessToken: access_token, + expiresAt: accountData.expiresAt + } + } else { + throw new Error(`Token refresh failed with status: ${response.status}`) + } + } catch (error) { + // 记录刷新失败 + const accountData = await redis.getClaudeAccount(accountId) + if (accountData) { + logRefreshError(accountId, accountData.name, 'claude', error) + + // disableAutoProtection 检查:跳过状态修改,仅记录日志和错误历史 + if ( + accountData.disableAutoProtection === true || + accountData.disableAutoProtection === 'true' + ) { + logger.info( + `🛡️ Account ${accountData.name} (${accountId}) has auto-protection disabled, skipping error status on token refresh failure` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', 0, 'token_refresh_failed', { + errorBody: error.message + }) + .catch(() => {}) + } else { + accountData.status = 'error' + accountData.errorMessage = error.message + await redis.setClaudeAccount(accountId, accountData) + } + + // 发送Webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name, + platform: 'claude-oauth', + status: 'error', + errorCode: 'CLAUDE_OAUTH_ERROR', + reason: `Token refresh failed: ${error.message}` + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } + + logger.error(`❌ Failed to refresh token for account ${accountId}:`, error) + + throw error + } finally { + // 释放锁 + if (lockAcquired) { + await tokenRefreshService.releaseRefreshLock(accountId, 'claude') + } + } + } + + // 🔍 获取账户信息 + async getAccount(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + return accountData + } catch (error) { + logger.error('❌ Failed to get Claude account:', error) + return null + } + } + + // 🎯 获取有效的访问token + async getValidAccessToken(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + if (accountData.isActive !== 'true') { + throw new Error('Account is disabled') + } + + // 检查token是否过期 + const expiresAt = parseInt(accountData.expiresAt) + const now = Date.now() + const isExpired = !expiresAt || now >= expiresAt - 60000 // 60秒提前刷新 + + // 记录token使用情况 + logTokenUsage(accountId, accountData.name, 'claude', accountData.expiresAt, isExpired) + + if (isExpired) { + logger.info(`🔄 Token expired/expiring for account ${accountId}, attempting refresh...`) + try { + const refreshResult = await this.refreshAccountToken(accountId) + return refreshResult.accessToken + } catch (refreshError) { + logger.warn(`⚠️ Token refresh failed for account ${accountId}: ${refreshError.message}`) + // 如果刷新失败,仍然尝试使用当前token(可能是手动添加的长期有效token) + const currentToken = this._decryptSensitiveData(accountData.accessToken) + if (currentToken) { + logger.info(`🔄 Using current token for account ${accountId} (refresh failed)`) + return currentToken + } + throw refreshError + } + } + + const accessToken = this._decryptSensitiveData(accountData.accessToken) + + if (!accessToken) { + throw new Error('No access token available') + } + + // 更新最后使用时间和会话窗口 + accountData.lastUsedAt = new Date().toISOString() + await this.updateSessionWindow(accountId, accountData) + await redis.setClaudeAccount(accountId, accountData) + + return accessToken + } catch (error) { + logger.error(`❌ Failed to get valid access token for account ${accountId}:`, error) + throw error + } + } + + // 📋 获取所有Claude账户 + async getAllAccounts() { + try { + const accounts = await redis.getAllClaudeAccounts() + + // 处理返回数据,移除敏感信息并添加限流状态和会话窗口信息 + const processedAccounts = await Promise.all( + accounts.map(async (account) => { + const [rateLimitInfo, sessionWindowInfo, modelRateLimitEntries, isOverloaded] = + await Promise.all([ + this.getAccountRateLimitInfo(account.id), + this.getSessionWindowInfo(account.id), + Promise.all( + RATE_LIMITED_MODEL_FAMILIES.map(async (family) => [ + family, + await this.getAccountModelRateLimitInfo(account.id, family, account) + ]) + ), + this.isAccountOverloaded(account.id) + ]) + + // 各模型家族的独立限流状态(opus/sonnet/haiku/fable) + const modelRateLimitStatus = Object.fromEntries(modelRateLimitEntries) + const opusRateLimitStatus = modelRateLimitStatus.opus + const fableRateLimitStatus = modelRateLimitStatus.fable + + // 构建 Claude Usage 快照(从 Redis 读取) + const claudeUsage = this.buildClaudeUsageSnapshot(account) + + // 判断授权类型:检查 scopes 是否包含 OAuth 相关权限 + const scopes = account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [] + const isOAuth = scopes.includes('user:profile') && scopes.includes('user:inference') + const authType = isOAuth ? 'oauth' : 'setup-token' + const parsedExtInfo = this._safeParseJson(account.extInfo) + const parsedProxy = this._safeParseAccountFieldJson(account.proxy, 'proxy', account.id) + const parsedSubscriptionInfo = this._safeParseAccountFieldJson( + account.subscriptionInfo, + 'subscriptionInfo', + account.id + ) + const normalizedTempUnavailablePolicy = normalizeTempUnavailablePolicyInput({ + disableTempUnavailable: account.disableTempUnavailable, + tempUnavailable503TtlSeconds: account.tempUnavailable503TtlSeconds, + tempUnavailable5xxTtlSeconds: account.tempUnavailable5xxTtlSeconds + }) + + return { + id: account.id, + name: account.name, + description: account.description, + email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '', + isActive: account.isActive === 'true', + proxy: parsedProxy, + status: account.status, + errorMessage: account.errorMessage, + accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 + priority: parseInt(account.priority) || 50, // 兼容旧数据,默认优先级50 + platform: account.platform || 'claude', // 添加平台标识,用于前端区分 + authType, // OAuth 或 Setup Token + createdAt: account.createdAt, + lastUsedAt: account.lastUsedAt, + lastRefreshAt: account.lastRefreshAt, + expiresAt: account.expiresAt || null, + subscriptionExpiresAt: + account.subscriptionExpiresAt && account.subscriptionExpiresAt !== '' + ? account.subscriptionExpiresAt + : null, + // 添加 scopes 字段用于判断认证方式 + // 处理空字符串的情况,避免返回 [''] + scopes: account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [], + // 添加 refreshToken 是否存在的标记(不返回实际值) + hasRefreshToken: !!account.refreshToken, + // 添加套餐信息(如果存在) + subscriptionInfo: parsedSubscriptionInfo, + // 添加限流状态信息 + rateLimitStatus: rateLimitInfo + ? { + isRateLimited: rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt, + minutesRemaining: rateLimitInfo.minutesRemaining + } + : null, + // Opus 专属限流状态(仅影响 Opus 模型路由) + opusRateLimitStatus, + // Fable 专属限流状态(仅影响 Fable 模型路由) + fableRateLimitStatus, + // 各模型家族的独立限流状态(opus/sonnet/haiku/fable) + modelRateLimitStatus, + // 过载状态(429/529 自动保护) + overloadStatus: { + isOverloaded, + lastOverloadAt: account.lastOverloadAt || null + }, + // 添加会话窗口信息 + sessionWindow: sessionWindowInfo || { + hasActiveWindow: false, + windowStart: null, + windowEnd: null, + progress: 0, + remainingTime: null, + lastRequestTime: null + }, + // 添加 Claude Usage 信息(三窗口) + claudeUsage: claudeUsage || null, + // 添加调度状态 + schedulable: account.schedulable !== 'false', // 默认为true,兼容历史数据 + // 添加自动停止调度设置 + autoStopOnWarning: account.autoStopOnWarning === 'true', // 默认为false + // 添加5小时自动停止状态 + fiveHourAutoStopped: account.fiveHourAutoStopped === 'true', + fiveHourStoppedAt: account.fiveHourStoppedAt || null, + // 添加统一User-Agent设置 + useUnifiedUserAgent: account.useUnifiedUserAgent === 'true', // 默认为false + // 添加统一客户端标识设置 + useUnifiedClientId: account.useUnifiedClientId === 'true', // 默认为false + unifiedClientId: account.unifiedClientId || '', // 统一的客户端标识 + // 添加停止原因 + stoppedReason: account.stoppedReason || null, + // 扩展信息 + extInfo: parsedExtInfo, + // 账户级用户消息串行队列限制 + maxConcurrency: parseInt(account.maxConcurrency || '0', 10), + // 拦截预热请求 + interceptWarmup: account.interceptWarmup === 'true', + // 账号级临时冷却覆盖 + disableTempUnavailable: normalizedTempUnavailablePolicy.disableTempUnavailable, + tempUnavailable503TtlSeconds: + normalizedTempUnavailablePolicy.tempUnavailable503TtlSeconds, + tempUnavailable5xxTtlSeconds: + normalizedTempUnavailablePolicy.tempUnavailable5xxTtlSeconds + } + }) + ) + + return processedAccounts + } catch (error) { + logger.error('❌ Failed to get Claude accounts:', error) + throw error + } + } + + // 📋 获取单个账号的概要信息(用于前端展示会话窗口等状态) + async getAccountOverview(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + const [sessionWindowInfo, rateLimitInfo] = await Promise.all([ + this.getSessionWindowInfo(accountId), + this.getAccountRateLimitInfo(accountId) + ]) + + const sessionWindow = sessionWindowInfo || { + hasActiveWindow: false, + windowStart: null, + windowEnd: null, + progress: 0, + remainingTime: null, + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null + } + + const rateLimitStatus = rateLimitInfo + ? { + isRateLimited: !!rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt || null, + minutesRemaining: rateLimitInfo.minutesRemaining || 0, + rateLimitEndAt: rateLimitInfo.rateLimitEndAt || null + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0, + rateLimitEndAt: null + } + + return { + id: accountData.id, + accountType: accountData.accountType || 'shared', + platform: accountData.platform || 'claude', + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', + sessionWindow, + rateLimitStatus + } + } catch (error) { + logger.error(`❌ Failed to build Claude account overview for ${accountId}:`, error) + return null + } + } + + // 📝 更新Claude账户 + async updateAccount(accountId, updates) { + try { + const accountData = await redis.getClaudeAccount(accountId) + + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + const allowedUpdates = [ + 'name', + 'description', + 'email', + 'password', + 'refreshToken', + 'proxy', + 'isActive', + 'claudeAiOauth', + 'accountType', + 'priority', + 'schedulable', + 'subscriptionInfo', + 'autoStopOnWarning', + 'useUnifiedUserAgent', + 'useUnifiedClientId', + 'unifiedClientId', + 'subscriptionExpiresAt', + 'extInfo', + 'maxConcurrency', + 'interceptWarmup', + 'disableTempUnavailable', + 'tempUnavailable503TtlSeconds', + 'tempUnavailable5xxTtlSeconds' + ] + const updatedData = { ...accountData } + let shouldClearAutoStopFields = false + let extInfoProvided = false + + // 检查是否新增了 refresh token + const oldRefreshToken = this._decryptSensitiveData(accountData.refreshToken) + + for (const [field, value] of Object.entries(updates)) { + if (allowedUpdates.includes(field)) { + if (['email', 'password', 'refreshToken'].includes(field)) { + updatedData[field] = this._encryptSensitiveData(value) + } else if (field === 'proxy') { + updatedData[field] = value ? JSON.stringify(value) : '' + } else if (field === 'priority' || field === 'maxConcurrency') { + updatedData[field] = value.toString() + } else if (field === 'disableTempUnavailable') { + updatedData[field] = parseBooleanLike(value) ? 'true' : 'false' + } else if ( + field === 'tempUnavailable503TtlSeconds' || + field === 'tempUnavailable5xxTtlSeconds' + ) { + const normalizedTtl = normalizeOptionalNonNegativeInteger(value) + updatedData[field] = normalizedTtl !== null ? normalizedTtl.toString() : '' + } else if (field === 'subscriptionInfo') { + // 处理订阅信息更新 + updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value) + } else if (field === 'subscriptionExpiresAt') { + // 处理订阅到期时间,允许 null 值(永不过期) + updatedData[field] = value ? value.toString() : '' + } else if (field === 'extInfo') { + const normalized = this._normalizeExtInfo(value, updates.claudeAiOauth) + updatedData.extInfo = normalized ? JSON.stringify(normalized) : '' + extInfoProvided = true + } else if (field === 'claudeAiOauth') { + // 更新 Claude AI OAuth 数据 + if (value) { + updatedData.claudeAiOauth = this._encryptSensitiveData(JSON.stringify(value)) + updatedData.accessToken = this._encryptSensitiveData(value.accessToken) + updatedData.refreshToken = this._encryptSensitiveData(value.refreshToken) + updatedData.expiresAt = value.expiresAt.toString() + updatedData.scopes = value.scopes.join(' ') + updatedData.status = 'active' + updatedData.errorMessage = '' + updatedData.lastRefreshAt = new Date().toISOString() + + if (!extInfoProvided) { + const normalized = this._normalizeExtInfo(value.extInfo, value) + if (normalized) { + updatedData.extInfo = JSON.stringify(normalized) + } + } + } + } else { + updatedData[field] = value !== null && value !== undefined ? value.toString() : '' + } + } + } + + // 如果新增了 refresh token(之前没有,现在有了),更新过期时间为10分钟 + if (updates.refreshToken && !oldRefreshToken && updates.refreshToken.trim()) { + const newExpiresAt = Date.now() + 10 * 60 * 1000 // 10分钟 + updatedData.expiresAt = newExpiresAt.toString() + logger.info( + `🔄 New refresh token added for account ${accountId}, setting expiry to 10 minutes` + ) + } + + // 如果通过 claudeAiOauth 更新,也要检查是否新增了 refresh token + if (updates.claudeAiOauth && updates.claudeAiOauth.refreshToken && !oldRefreshToken) { + // 如果 expiresAt 设置的时间过长(超过1小时),调整为10分钟 + const providedExpiry = parseInt(updates.claudeAiOauth.expiresAt) + const now = Date.now() + const oneHour = 60 * 60 * 1000 + + if (providedExpiry - now > oneHour) { + const newExpiresAt = now + 10 * 60 * 1000 // 10分钟 + updatedData.expiresAt = newExpiresAt.toString() + logger.info( + `🔄 Adjusted expiry time to 10 minutes for account ${accountId} with refresh token` + ) + } + } + + updatedData.updatedAt = new Date().toISOString() + + // 如果是手动修改调度状态,清除所有自动停止相关的字段 + if (Object.prototype.hasOwnProperty.call(updates, 'schedulable')) { + // 清除所有自动停止的标记,防止自动恢复 + delete updatedData.rateLimitAutoStopped + delete updatedData.fiveHourAutoStopped + delete updatedData.fiveHourStoppedAt + delete updatedData.tempErrorAutoStopped + // 兼容旧的标记(逐步迁移) + delete updatedData.autoStoppedAt + delete updatedData.stoppedReason + shouldClearAutoStopFields = true + + await this._clearFiveHourWarningMetadata(accountId, updatedData) + + // 如果是手动启用调度,记录日志 + if (updates.schedulable === true || updates.schedulable === 'true') { + logger.info(`✅ Manually enabled scheduling for account ${accountId}`) + } else { + logger.info(`⛔ Manually disabled scheduling for account ${accountId}`) + } + } + + // 检查是否手动禁用了账号,如果是则发送webhook通知 + if (updates.isActive === 'false' && accountData.isActive === 'true') { + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: updatedData.name || 'Unknown Account', + platform: 'claude-oauth', + status: 'disabled', + errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED', + reason: 'Account manually disabled by administrator' + }) + } catch (webhookError) { + logger.error( + 'Failed to send webhook notification for manual account disable:', + webhookError + ) + } + } + + await redis.setClaudeAccount(accountId, updatedData) + + if (shouldClearAutoStopFields) { + const fieldsToRemove = [ + 'rateLimitAutoStopped', + 'fiveHourAutoStopped', + 'fiveHourStoppedAt', + 'tempErrorAutoStopped', + 'autoStoppedAt', + 'stoppedReason' + ] + await this._removeAccountFields(accountId, fieldsToRemove, 'manual_schedule_update') + } + + logger.success(`📝 Updated Claude account: ${accountId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to update Claude account:', error) + throw error + } + } + + // 🗑️ 删除Claude账户 + async deleteAccount(accountId) { + try { + // 首先从所有分组中移除此账户 + const accountGroupService = require('../accountGroupService') + await accountGroupService.removeAccountFromAllGroups(accountId) + + const result = await redis.deleteClaudeAccount(accountId) + + if (result === 0) { + throw new Error('Account not found') + } + + logger.success(`🗑️ Deleted Claude account: ${accountId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to delete Claude account:', error) + throw error + } + } + + /** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置过期时间,视为永不过期 + } + + const expiryDate = new Date(account.subscriptionExpiresAt) + const now = new Date() + + if (expiryDate <= now) { + logger.debug( + `⏰ Account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` + ) + return true + } + + return false + } + + // 🎯 智能选择可用账户(支持sticky会话和模型过滤) + async selectAvailableAccount(sessionHash = null, modelName = null) { + try { + const accounts = await redis.getAllClaudeAccounts() + + let activeAccounts = accounts.filter( + (account) => + account.isActive === 'true' && + account.status !== 'error' && + account.schedulable !== 'false' && + !this.isSubscriptionExpired(account) + ) + + // Filter Opus models based on account type and model version + if (modelName && modelName.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(modelName) + + activeAccounts = activeAccounts.filter((account) => { + if (account.subscriptionInfo) { + try { + const info = JSON.parse(account.subscriptionInfo) + + // Free account: does not support any Opus model + if (info.accountType === 'free') { + return false + } + + // Pro account: only supports Opus 4.5+ + if (isProAccount(info)) { + return isNewOpus + } + + // Max account: supports all Opus versions + return true + } catch (e) { + // Parse failed, assume legacy data (Max), default support + return true + } + } + // Account without subscription info, default to supported (legacy data compatibility) + return true + }) + + if (activeAccounts.length === 0) { + const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)' + throw new Error(`No Claude accounts available that support ${modelDesc} model`) + } + } + + if (activeAccounts.length === 0) { + throw new Error('No active Claude accounts available') + } + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccountId = await redis.getSessionAccountMapping(sessionHash) + if (mappedAccountId) { + // 验证映射的账户是否仍然可用 + const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId) + if (mappedAccount) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` + ) + return mappedAccountId + } else { + logger.warn( + `⚠️ Mapped account ${mappedAccountId} is no longer available, selecting new account` + ) + // 清理无效的映射 + await redis.deleteSessionAccountMapping(sessionHash) + } + } + } + + // 如果没有映射或映射无效,选择新账户 + // 优先选择最久未使用的账户(负载均衡) + const sortedAccounts = activeAccounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) + + const selectedAccountId = sortedAccounts[0].id + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + // 从配置获取TTL(小时),转换为秒 + const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60 + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds) + logger.info( + `🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` + ) + } + + return selectedAccountId + } catch (error) { + logger.error('❌ Failed to select available account:', error) + throw error + } + } + + // 🎯 基于API Key选择账户(支持专属绑定、共享池和模型过滤) + async selectAccountForApiKey(apiKeyData, sessionHash = null, modelName = null) { + try { + // 如果API Key绑定了专属账户,优先使用 + if (apiKeyData.claudeAccountId) { + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) + if ( + boundAccount && + boundAccount.isActive === 'true' && + boundAccount.status !== 'error' && + boundAccount.schedulable !== 'false' && + !this.isSubscriptionExpired(boundAccount) + ) { + logger.info( + `🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` + ) + return apiKeyData.claudeAccountId + } else { + logger.warn( + `⚠️ Bound account ${apiKeyData.claudeAccountId} is not available, falling back to shared pool` + ) + } + } + + // 如果没有绑定账户或绑定账户不可用,从共享池选择 + const accounts = await redis.getAllClaudeAccounts() + + let sharedAccounts = accounts.filter( + (account) => + account.isActive === 'true' && + account.status !== 'error' && + account.schedulable !== 'false' && + (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 + !this.isSubscriptionExpired(account) + ) + + // Filter Opus models based on account type and model version + if (modelName && modelName.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(modelName) + + sharedAccounts = sharedAccounts.filter((account) => { + if (account.subscriptionInfo) { + try { + const info = JSON.parse(account.subscriptionInfo) + + // Free account: does not support any Opus model + if (info.accountType === 'free') { + return false + } + + // Pro account: only supports Opus 4.5+ + if (isProAccount(info)) { + return isNewOpus + } + + // Max account: supports all Opus versions + return true + } catch (e) { + // Parse failed, assume legacy data (Max), default support + return true + } + } + // Account without subscription info, default to supported (legacy data compatibility) + return true + }) + + if (sharedAccounts.length === 0) { + const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)' + throw new Error(`No shared Claude accounts available that support ${modelDesc} model`) + } + } + + if (sharedAccounts.length === 0) { + throw new Error('No active shared Claude accounts available') + } + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccountId = await redis.getSessionAccountMapping(sessionHash) + if (mappedAccountId) { + // 验证映射的账户是否仍然在共享池中且可用 + const mappedAccount = sharedAccounts.find((acc) => acc.id === mappedAccountId) + if (mappedAccount) { + // 如果映射的账户被限流了,删除映射并重新选择 + const isRateLimited = await this.isAccountRateLimited(mappedAccountId) + if (isRateLimited) { + logger.warn( + `⚠️ Mapped account ${mappedAccountId} is rate limited, selecting new account` + ) + await redis.deleteSessionAccountMapping(sessionHash) + } else { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` + ) + return mappedAccountId + } + } else { + logger.warn( + `⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account` + ) + // 清理无效的映射 + await redis.deleteSessionAccountMapping(sessionHash) + } + } + } + + // 将账户分为限流和非限流两组 + const nonRateLimitedAccounts = [] + const rateLimitedAccounts = [] + + for (const account of sharedAccounts) { + const isRateLimited = await this.isAccountRateLimited(account.id) + if (isRateLimited) { + const rateLimitInfo = await this.getAccountRateLimitInfo(account.id) + account._rateLimitInfo = rateLimitInfo // 临时存储限流信息 + rateLimitedAccounts.push(account) + } else { + nonRateLimitedAccounts.push(account) + } + } + + // 优先从非限流账户中选择 + let candidateAccounts = nonRateLimitedAccounts + + // 如果没有非限流账户,则从限流账户中选择(按限流时间排序,最早限流的优先) + if (candidateAccounts.length === 0) { + logger.warn('⚠️ All shared accounts are rate limited, selecting from rate limited pool') + candidateAccounts = rateLimitedAccounts.sort((a, b) => { + const aRateLimitedAt = new Date(a._rateLimitInfo.rateLimitedAt).getTime() + const bRateLimitedAt = new Date(b._rateLimitInfo.rateLimitedAt).getTime() + return aRateLimitedAt - bRateLimitedAt // 最早限流的优先 + }) + } else { + // 非限流账户按最后使用时间排序(最久未使用的优先) + candidateAccounts = candidateAccounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) + } + + if (candidateAccounts.length === 0) { + throw new Error('No available shared Claude accounts') + } + + const selectedAccountId = candidateAccounts[0].id + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + // 从配置获取TTL(小时),转换为秒 + const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60 + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds) + logger.info( + `🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}` + ) + return selectedAccountId + } catch (error) { + logger.error('❌ Failed to select account for API key:', error) + throw error + } + } + + // 🌐 创建代理agent(使用统一的代理工具) + _createProxyAgent(proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + logger.info( + `🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else if (proxyConfig) { + logger.debug('🌐 Failed to create proxy agent for Claude') + } else { + logger.debug('🌐 No proxy configured for Claude request') + } + return proxyAgent + } + + // 🔐 加密敏感数据 + _encryptSensitiveData(data) { + if (!data) { + return '' + } + + try { + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + // 将IV和加密数据一起返回,用:分隔 + return `${iv.toString('hex')}:${encrypted}` + } catch (error) { + logger.error('❌ Encryption error:', error) + return data + } + } + + // 🔓 解密敏感数据 + _decryptSensitiveData(encryptedData) { + if (!encryptedData) { + return '' + } + + // 🎯 检查缓存 + const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + let decrypted = '' + + // 检查是否是新格式(包含IV) + if (encryptedData.includes(':')) { + // 新格式:iv:encryptedData + const parts = encryptedData.split(':') + if (parts.length === 2) { + const key = this._generateEncryptionKey() + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // 💾 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) + + // 📊 定期打印缓存统计 + if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) { + this._decryptCache.printStats() + } + + return decrypted + } + } + + // 旧格式或格式错误,尝试旧方式解密(向后兼容) + // 注意:在新版本Node.js中这将失败,但我们会捕获错误 + try { + const decipher = crypto.createDecipher('aes-256-cbc', config.security.encryptionKey) + decrypted = decipher.update(encryptedData, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // 💾 旧格式也存入缓存 + this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) + + return decrypted + } catch (oldError) { + // 如果旧方式也失败,返回原数据 + logger.warn('⚠️ Could not decrypt data, returning as-is:', oldError.message) + return encryptedData + } + } catch (error) { + logger.error('❌ Decryption error:', error) + return encryptedData + } + } + + // 🔑 生成加密密钥(辅助方法) + _generateEncryptionKey() { + // 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算 + // scryptSync 是故意设计为慢速的密钥派生函数(防暴力破解) + // 但在高并发场景下,每次都重新计算会导致 CPU 100% 占用 + if (!this._encryptionKeyCache) { + // 只在第一次调用时计算,后续使用缓存 + // 由于输入参数固定,派生结果永远相同,不影响数据兼容性 + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + logger.info('🔑 Encryption key derived and cached for performance optimization') + } + return this._encryptionKeyCache + } + + // 🎭 掩码邮箱地址 + _maskEmail(email) { + if (!email || !email.includes('@')) { + return email + } + + const [username, domain] = email.split('@') + const maskedUsername = + username.length > 2 + ? `${username.slice(0, 2)}***${username.slice(-1)}` + : `${username.slice(0, 1)}***` + + return `${maskedUsername}@${domain}` + } + + // 🔢 安全转换为数字或null + _toNumberOrNull(value) { + if (value === undefined || value === null || value === '') { + return null + } + + const num = Number(value) + return Number.isFinite(num) ? num : null + } + + // 🧹 清理错误账户 + async cleanupErrorAccounts() { + try { + const accounts = await redis.getAllClaudeAccounts() + let cleanedCount = 0 + + for (const account of accounts) { + if (account.status === 'error' && account.lastRefreshAt) { + const lastRefresh = new Date(account.lastRefreshAt) + const now = new Date() + const hoursSinceLastRefresh = (now - lastRefresh) / (1000 * 60 * 60) + + // 如果错误状态超过24小时,尝试重新激活 + if (hoursSinceLastRefresh > 24) { + account.status = 'created' + account.errorMessage = '' + await redis.setClaudeAccount(account.id, account) + cleanedCount++ + } + } + } + + if (cleanedCount > 0) { + logger.success(`🧹 Reset ${cleanedCount} error accounts`) + } + + return cleanedCount + } catch (error) { + logger.error('❌ Failed to cleanup error accounts:', error) + return 0 + } + } + + // 🚫 标记账号为限流状态 + async markAccountRateLimited(accountId, sessionHash = null, rateLimitResetTimestamp = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // disableAutoProtection 检查:跳过自动禁用,仅记录错误历史 + if ( + accountData.disableAutoProtection === true || + accountData.disableAutoProtection === 'true' + ) { + logger.info( + `🛡️ Account ${accountData.name} (${accountId}) has auto-protection disabled, skipping rate limit marking` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', 429, 'rate_limit') + .catch(() => {}) + return { success: true, skipped: true } + } + + // 无上游权威 reset 时间的 429 大概率不是真实限流(如 overage、org_level_disabled、extra usage), + // 不标记账号限流,直接透传错误给客户端(与 sub2api 对 Anthropic 平台无 reset 头 429 的处理一致) + if (!rateLimitResetTimestamp) { + logger.warn( + `⚠️ 429 without authoritative reset header for account ${accountData.name} (${accountId}), skipping rate limit marking` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', 429, 'rate_limit') + .catch(() => {}) + return { success: true, skipped: true } + } + + // 设置限流状态和时间 + const updatedAccountData = { ...accountData } + updatedAccountData.rateLimitedAt = new Date().toISOString() + updatedAccountData.rateLimitStatus = 'limited' + // 限流时停止调度,与 OpenAI 账号保持一致 + updatedAccountData.schedulable = 'false' + // 使用独立的限流自动停止标记,避免与其他自动停止冲突 + updatedAccountData.rateLimitAutoStopped = 'true' + + // 将Unix时间戳(秒)转换为毫秒并创建Date对象 + const resetTime = new Date(rateLimitResetTimestamp * 1000) + updatedAccountData.rateLimitEndAt = resetTime.toISOString() + + // 计算当前会话窗口的开始时间(重置时间减去5小时) + const windowStartTime = new Date(resetTime.getTime() - 5 * 60 * 60 * 1000) + updatedAccountData.sessionWindowStart = windowStartTime.toISOString() + updatedAccountData.sessionWindowEnd = resetTime.toISOString() + + const now = new Date() + const minutesUntilEnd = Math.ceil((resetTime - now) / (1000 * 60)) + logger.warn( + `🚫 Account marked as rate limited with accurate reset time: ${accountData.name} (${accountId}) - ${minutesUntilEnd} minutes remaining until ${resetTime.toISOString()}` + ) + + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 如果有会话哈希,删除粘性会话映射 + if (sessionHash) { + await redis.deleteSessionAccountMapping(sessionHash) + logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`) + } + + // 发送Webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude-oauth', + status: 'error', + errorCode: 'CLAUDE_OAUTH_RATE_LIMITED', + reason: `Account rate limited (429 error). Reset at: ${formatDateWithTimezone(rateLimitResetTimestamp)}`, + timestamp: getISOStringWithTimezone(new Date()) + }) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error) + throw error + } + } + + // 🧩 按模型家族存储的限流字段名(沿用既有 opus/fable 字段名,向后兼容旧数据) + _modelRateLimitFields(family) { + return { + atField: `${family}RateLimitedAt`, + endField: `${family}RateLimitEndAt` + } + } + + // 🚫 标记账号在某个模型家族上的限流(仅影响该模型路由,不停用整个账号) + async markAccountModelRateLimited(accountId, family, rateLimitResetTimestamp = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + const { atField, endField } = this._modelRateLimitFields(family) + const updatedAccountData = { ...accountData } + updatedAccountData[atField] = new Date().toISOString() + + if (rateLimitResetTimestamp) { + const resetTime = new Date(rateLimitResetTimestamp * 1000) + updatedAccountData[endField] = resetTime.toISOString() + logger.warn( + `🚫 Account ${accountData.name} (${accountId}) reached ${family} model cap, resets at ${resetTime.toISOString()}` + ) + } else { + // 如果缺少准确时间戳,保留现有值但记录警告,便于后续人工干预 + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) reported ${family} limit without reset timestamp` + ) + } + + await redis.setClaudeAccount(accountId, updatedAccountData) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark ${family} rate limit for account: ${accountId}`, error) + throw error + } + } + + // ✅ 清除账号在某个模型家族上的限流状态 + async clearAccountModelRateLimit(accountId, family) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return { success: true } + } + + const { atField, endField } = this._modelRateLimitFields(family) + const updatedAccountData = { ...accountData } + delete updatedAccountData[atField] + delete updatedAccountData[endField] + + await redis.setClaudeAccount(accountId, updatedAccountData) + + const redisKey = `claude:account:${accountId}` + if (redis.client && typeof redis.client.hdel === 'function') { + await redis.client.hdel(redisKey, atField, endField) + } + + logger.info(`✅ Cleared ${family} rate limit state for account ${accountId}`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to clear ${family} rate limit for account: ${accountId}`, error) + throw error + } + } + + // 📊 获取账号在某个模型家族上的限流信息(自动清理过期状态) + async getAccountModelRateLimitInfo(accountId, family, accountData = null) { + try { + const { atField, endField } = this._modelRateLimitFields(family) + const data = accountData || (await redis.getClaudeAccount(accountId)) + if (!data || Object.keys(data).length === 0 || !data[endField]) { + return { isRateLimited: false, rateLimitedAt: null, resetAt: null, minutesRemaining: 0 } + } + + const resetAtMs = Date.parse(data[endField]) + if (Number.isNaN(resetAtMs)) { + return { + isRateLimited: false, + rateLimitedAt: data[atField] || null, + resetAt: null, + minutesRemaining: 0 + } + } + + const nowMs = Date.now() + if (nowMs >= resetAtMs) { + // 自动清理过期标记,避免前端持续显示陈旧状态 + await this.clearAccountModelRateLimit(accountId, family).catch(() => {}) + return { + isRateLimited: false, + rateLimitedAt: data[atField] || null, + resetAt: data[endField], + minutesRemaining: 0 + } + } + + return { + isRateLimited: true, + rateLimitedAt: data[atField] || null, + resetAt: data[endField], + minutesRemaining: Math.max(0, Math.ceil((resetAtMs - nowMs) / (1000 * 60))) + } + } catch (error) { + logger.error(`❌ Failed to get ${family} rate limit info for account: ${accountId}`, error) + return { isRateLimited: false, rateLimitedAt: null, resetAt: null, minutesRemaining: 0 } + } + } + + // 🔍 检查账号在某个模型家族上是否处于限流状态(自动清理过期标记) + async isAccountModelRateLimited(accountId, family) { + try { + const { endField } = this._modelRateLimitFields(family) + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0 || !accountData[endField]) { + return false + } + + const resetTime = new Date(accountData[endField]) + if (Number.isNaN(resetTime.getTime()) || new Date() >= resetTime) { + await this.clearAccountModelRateLimit(accountId, family) + return false + } + + return true + } catch (error) { + logger.error( + `❌ Failed to check ${family} rate limit status for account: ${accountId}`, + error + ) + return false + } + } + + // ♻️ 检查并清理已过期的模型家族限流标记 + async clearExpiredModelRateLimit(accountId, family) { + try { + const { endField } = this._modelRateLimitFields(family) + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0 || !accountData[endField]) { + return { success: true } + } + + const resetTime = new Date(accountData[endField]) + if (Number.isNaN(resetTime.getTime()) || new Date() >= resetTime) { + await this.clearAccountModelRateLimit(accountId, family) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to clear expired ${family} rate limit for account: ${accountId}`, + error + ) + throw error + } + } + + // ---- 以下为按模型家族限流的兼容封装:保持既有调用方与 Redis 字段名不变 ---- + + // 🚫 标记账号的 Opus 限流状态(不影响其他模型调度) + async markAccountOpusRateLimited(accountId, rateLimitResetTimestamp = null) { + return this.markAccountModelRateLimited(accountId, 'opus', rateLimitResetTimestamp) + } + + // ✅ 清除账号的 Opus 限流状态 + async clearAccountOpusRateLimit(accountId) { + return this.clearAccountModelRateLimit(accountId, 'opus') + } + + // 📊 获取账号 Opus 限流信息(自动清理过期状态) + async getAccountOpusRateLimitInfo(accountId, accountData = null) { + return this.getAccountModelRateLimitInfo(accountId, 'opus', accountData) + } + + // 🔍 检查账号是否处于 Opus 限流状态(自动清理过期标记) + async isAccountOpusRateLimited(accountId) { + return this.isAccountModelRateLimited(accountId, 'opus') + } + + // ♻️ 检查并清理已过期的 Opus 限流标记 + async clearExpiredOpusRateLimit(accountId) { + return this.clearExpiredModelRateLimit(accountId, 'opus') + } + + // 🚫 标记账号的 Fable 限流状态(不影响其他模型调度) + async markAccountFableRateLimited(accountId, rateLimitResetTimestamp = null) { + return this.markAccountModelRateLimited(accountId, 'fable', rateLimitResetTimestamp) + } + + // ✅ 清除账号的 Fable 限流状态 + async clearAccountFableRateLimit(accountId) { + return this.clearAccountModelRateLimit(accountId, 'fable') + } + + // 📊 获取账号 Fable 限流信息(自动清理过期状态) + async getAccountFableRateLimitInfo(accountId, accountData = null) { + return this.getAccountModelRateLimitInfo(accountId, 'fable', accountData) + } + + // 🔍 检查账号是否处于 Fable 限流状态(自动清理过期标记) + async isAccountFableRateLimited(accountId) { + return this.isAccountModelRateLimited(accountId, 'fable') + } + + // ♻️ 检查并清理已过期的 Fable 限流标记 + async clearExpiredFableRateLimit(accountId) { + return this.clearExpiredModelRateLimit(accountId, 'fable') + } + + // ✅ 移除账号的限流状态 + async removeAccountRateLimit(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + const accountKey = `claude:account:${accountId}` + + // 清除限流状态 + const redisKey = `claude:account:${accountId}` + await redis.client.hdel(redisKey, 'rateLimitedAt', 'rateLimitStatus', 'rateLimitEndAt') + delete accountData.rateLimitedAt + delete accountData.rateLimitStatus + delete accountData.rateLimitEndAt // 清除限流结束时间 + + const hadAutoStop = accountData.rateLimitAutoStopped === 'true' + + // 只恢复因限流而自动停止的账户 + if (hadAutoStop && accountData.schedulable === 'false') { + accountData.schedulable = 'true' + logger.info(`✅ Auto-resuming scheduling for account ${accountId} after rate limit cleared`) + logger.info( + `📊 Account ${accountId} state after recovery: schedulable=${accountData.schedulable}` + ) + } else { + logger.info( + `ℹ️ Account ${accountId} did not need auto-resume: autoStopped=${accountData.rateLimitAutoStopped}, schedulable=${accountData.schedulable}` + ) + } + + if (hadAutoStop) { + await redis.client.hdel(redisKey, 'rateLimitAutoStopped') + delete accountData.rateLimitAutoStopped + } + await redis.setClaudeAccount(accountId, accountData) + + // 显式删除Redis中的限流字段,避免旧标记阻止账号恢复调度 + await redis.client.hdel( + accountKey, + 'rateLimitedAt', + 'rateLimitStatus', + 'rateLimitEndAt', + 'rateLimitAutoStopped' + ) + + logger.success(`Rate limit removed for account: ${accountData.name} (${accountId})`) + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error) + throw error + } + } + + // 🔍 检查账号是否处于限流状态 + async isAccountRateLimited(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return false + } + + const now = new Date() + + // 检查是否有限流状态(包括字段缺失但有自动停止标记的情况) + if ( + (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) || + (accountData.rateLimitAutoStopped === 'true' && accountData.rateLimitEndAt) + ) { + // 优先使用 rateLimitEndAt(基于会话窗口) + if (accountData.rateLimitEndAt) { + const rateLimitEndAt = new Date(accountData.rateLimitEndAt) + + // 如果当前时间超过限流结束时间,自动解除 + if (now >= rateLimitEndAt) { + await this.removeAccountRateLimit(accountId) + return false + } + + return true + } else if (accountData.rateLimitedAt) { + // 兼容旧数据:使用1小时限流 + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const hoursSinceRateLimit = (now - rateLimitedAt) / (1000 * 60 * 60) + + // 如果限流超过1小时,自动解除 + if (hoursSinceRateLimit >= 1) { + await this.removeAccountRateLimit(accountId) + return false + } + + return true + } + } + + return false + } catch (error) { + logger.error(`❌ Failed to check rate limit status for account: ${accountId}`, error) + return false + } + } + + // 📊 获取账号的限流信息 + async getAccountRateLimitInfo(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)) + + let minutesRemaining + let rateLimitEndAt + + // 优先使用 rateLimitEndAt(基于会话窗口) + if (accountData.rateLimitEndAt) { + ;({ rateLimitEndAt } = accountData) + const endTime = new Date(accountData.rateLimitEndAt) + minutesRemaining = Math.max(0, Math.ceil((endTime - now) / (1000 * 60))) + } else { + // 兼容旧数据:使用1小时限流 + minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit) + // 计算预期的结束时间 + const endTime = new Date(rateLimitedAt.getTime() + 60 * 60 * 1000) + rateLimitEndAt = endTime.toISOString() + } + + return { + isRateLimited: minutesRemaining > 0, + rateLimitedAt: accountData.rateLimitedAt, + minutesSinceRateLimit, + minutesRemaining, + rateLimitEndAt // 新增:限流结束时间 + } + } + + return { + isRateLimited: false, + rateLimitedAt: null, + minutesSinceRateLimit: 0, + minutesRemaining: 0, + rateLimitEndAt: null + } + } catch (error) { + logger.error(`❌ Failed to get rate limit info for account: ${accountId}`, error) + return null + } + } + + // 🕐 更新会话窗口 + async updateSessionWindow(accountId, accountData = null) { + try { + // 如果没有传入accountData,从Redis获取 + if (!accountData) { + accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + } + + const now = new Date() + const currentTime = now.getTime() + + let shouldClearSessionStatus = false + let shouldClearFiveHourFlags = false + + // 检查当前是否有活跃的会话窗口 + if (accountData.sessionWindowStart && accountData.sessionWindowEnd) { + const windowEnd = new Date(accountData.sessionWindowEnd).getTime() + + // 如果当前时间在窗口内,只更新最后请求时间 + if (currentTime < windowEnd) { + accountData.lastRequestTime = now.toISOString() + return accountData + } + + // 窗口已过期,记录日志 + const windowStart = new Date(accountData.sessionWindowStart) + logger.info( + `⏰ Session window expired for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${new Date(windowEnd).toISOString()}` + ) + } + + // 基于当前时间计算新的会话窗口 + const windowStart = this._calculateSessionWindowStart(now) + const windowEnd = this._calculateSessionWindowEnd(windowStart) + + // 更新会话窗口信息 + accountData.sessionWindowStart = windowStart.toISOString() + accountData.sessionWindowEnd = windowEnd.toISOString() + accountData.lastRequestTime = now.toISOString() + + // 清除会话窗口状态,因为进入了新窗口 + if (accountData.sessionWindowStatus) { + delete accountData.sessionWindowStatus + delete accountData.sessionWindowStatusUpdatedAt + await this._clearFiveHourWarningMetadata(accountId, accountData) + shouldClearSessionStatus = true + } + + // 如果账户因为5小时限制被自动停止,现在恢复调度 + if (accountData.fiveHourAutoStopped === 'true' && accountData.schedulable === 'false') { + logger.info( + `✅ Auto-resuming scheduling for account ${accountData.name} (${accountId}) - new session window started` + ) + accountData.schedulable = 'true' + delete accountData.fiveHourAutoStopped + delete accountData.fiveHourStoppedAt + await this._clearFiveHourWarningMetadata(accountId, accountData) + shouldClearFiveHourFlags = true + + // 发送Webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude', + status: 'resumed', + errorCode: 'CLAUDE_5H_LIMIT_RESUMED', + reason: '进入新的5小时窗口,已自动恢复调度', + timestamp: getISOStringWithTimezone(new Date()) + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } + + if (shouldClearSessionStatus || shouldClearFiveHourFlags) { + const fieldsToRemove = [] + if (shouldClearFiveHourFlags) { + fieldsToRemove.push('fiveHourAutoStopped', 'fiveHourStoppedAt') + } + if (shouldClearSessionStatus) { + fieldsToRemove.push('sessionWindowStatus', 'sessionWindowStatusUpdatedAt') + } + await this._removeAccountFields(accountId, fieldsToRemove, 'session_window_refresh') + } + + logger.info( + `🕐 Created new session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()} (from current time)` + ) + + return accountData + } catch (error) { + logger.error(`❌ Failed to update session window for account ${accountId}:`, error) + throw error + } + } + + // 🕐 计算会话窗口开始时间 + _calculateSessionWindowStart(requestTime) { + // 从当前时间开始创建窗口,只将分钟取整到整点 + const windowStart = new Date(requestTime) + windowStart.setMinutes(0) + windowStart.setSeconds(0) + windowStart.setMilliseconds(0) + + return windowStart + } + + // 🕐 计算会话窗口结束时间 + _calculateSessionWindowEnd(startTime) { + const endTime = new Date(startTime) + endTime.setHours(endTime.getHours() + 5) // 加5小时 + return endTime + } + + async _clearFiveHourWarningMetadata(accountId, accountData = null) { + if (accountData) { + delete accountData.fiveHourWarningWindow + delete accountData.fiveHourWarningCount + delete accountData.fiveHourWarningLastSentAt + } + + try { + if (redis.client && typeof redis.client.hdel === 'function') { + await redis.client.hdel( + `claude:account:${accountId}`, + 'fiveHourWarningWindow', + 'fiveHourWarningCount', + 'fiveHourWarningLastSentAt' + ) + } + } catch (error) { + logger.warn( + `⚠️ Failed to clear five-hour warning metadata for account ${accountId}: ${error.message}` + ) + } + } + + // 📊 获取会话窗口信息 + async getSessionWindowInfo(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + // 如果没有会话窗口信息,返回null + if (!accountData.sessionWindowStart || !accountData.sessionWindowEnd) { + return { + hasActiveWindow: false, + windowStart: null, + windowEnd: null, + progress: 0, + remainingTime: null, + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null + } + } + + const now = new Date() + const windowStart = new Date(accountData.sessionWindowStart) + const windowEnd = new Date(accountData.sessionWindowEnd) + const currentTime = now.getTime() + + // 检查窗口是否已过期 + if (currentTime >= windowEnd.getTime()) { + return { + hasActiveWindow: false, + windowStart: accountData.sessionWindowStart, + windowEnd: accountData.sessionWindowEnd, + progress: 100, + remainingTime: 0, + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null + } + } + + // 计算进度百分比 + const totalDuration = windowEnd.getTime() - windowStart.getTime() + const elapsedTime = currentTime - windowStart.getTime() + const progress = Math.round((elapsedTime / totalDuration) * 100) + + // 计算剩余时间(分钟) + const remainingTime = Math.round((windowEnd.getTime() - currentTime) / (1000 * 60)) + + return { + hasActiveWindow: true, + windowStart: accountData.sessionWindowStart, + windowEnd: accountData.sessionWindowEnd, + progress, + remainingTime, + lastRequestTime: accountData.lastRequestTime || null, + sessionWindowStatus: accountData.sessionWindowStatus || null + } + } catch (error) { + logger.error(`❌ Failed to get session window info for account ${accountId}:`, error) + return null + } + } + + // 📊 获取 OAuth Usage 数据 + async fetchOAuthUsage(accountId, accessToken = null, agent = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 如果没有提供 accessToken,使用 getValidAccessToken 自动检查过期并刷新 + if (!accessToken) { + accessToken = await this.getValidAccessToken(accountId) + } + + // 如果没有提供 agent,创建代理 + if (!agent) { + agent = this._createProxyAgent(accountData.proxy) + } + + logger.debug(`📊 Fetching OAuth usage for account: ${accountData.name} (${accountId})`) + + // 请求 OAuth usage 接口 + const axiosConfig = { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'anthropic-beta': 'oauth-2025-04-20', + 'User-Agent': 'claude-cli/2.0.53 (external, cli)', + 'Accept-Language': 'en-US,en;q=0.9' + }, + timeout: 15000 + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.get('https://api.anthropic.com/api/oauth/usage', axiosConfig) + + if (response.status === 200 && response.data) { + logger.debug('✅ Successfully fetched OAuth usage data:', { + accountId, + fiveHour: response.data.five_hour?.utilization, + sevenDay: response.data.seven_day?.utilization, + sevenDayOpus: response.data.seven_day_sonnet?.utilization + }) + + return response.data + } + + logger.warn(`⚠️ Failed to fetch OAuth usage for account ${accountId}: ${response.status}`) + return null + } catch (error) { + // 403 错误通常表示使用的是 Setup Token 而非 OAuth + if (error.response?.status === 403) { + logger.debug( + `⚠️ OAuth usage API returned 403 for account ${accountId}. This account likely uses Setup Token instead of OAuth.` + ) + return null + } + + // 其他错误正常记录 + logger.error( + `❌ Failed to fetch OAuth usage for account ${accountId}:`, + error.response?.data || error.message + ) + return null + } + } + + // 📊 构建 Claude Usage 快照(从 Redis 数据) + buildClaudeUsageSnapshot(accountData) { + const updatedAt = accountData.claudeUsageUpdatedAt + + const fiveHourUtilization = this._toNumberOrNull(accountData.claudeFiveHourUtilization) + const fiveHourResetsAt = accountData.claudeFiveHourResetsAt + const sevenDayUtilization = this._toNumberOrNull(accountData.claudeSevenDayUtilization) + const sevenDayResetsAt = accountData.claudeSevenDayResetsAt + const sevenDayOpusUtilization = this._toNumberOrNull(accountData.claudeSevenDayOpusUtilization) + const sevenDayOpusResetsAt = accountData.claudeSevenDayOpusResetsAt + + const hasFiveHourData = fiveHourUtilization !== null || fiveHourResetsAt + const hasSevenDayData = sevenDayUtilization !== null || sevenDayResetsAt + const hasSevenDayOpusData = sevenDayOpusUtilization !== null || sevenDayOpusResetsAt + + if (!updatedAt && !hasFiveHourData && !hasSevenDayData && !hasSevenDayOpusData) { + return null + } + + const now = Date.now() + + return { + updatedAt, + fiveHour: { + utilization: fiveHourUtilization, + resetsAt: fiveHourResetsAt, + remainingSeconds: fiveHourResetsAt + ? Math.max(0, Math.floor((new Date(fiveHourResetsAt).getTime() - now) / 1000)) + : null + }, + sevenDay: { + utilization: sevenDayUtilization, + resetsAt: sevenDayResetsAt, + remainingSeconds: sevenDayResetsAt + ? Math.max(0, Math.floor((new Date(sevenDayResetsAt).getTime() - now) / 1000)) + : null + }, + sevenDayOpus: { + utilization: sevenDayOpusUtilization, + resetsAt: sevenDayOpusResetsAt, + remainingSeconds: sevenDayOpusResetsAt + ? Math.max(0, Math.floor((new Date(sevenDayOpusResetsAt).getTime() - now) / 1000)) + : null + } + } + } + + // 📊 更新 Claude Usage 快照到 Redis + async updateClaudeUsageSnapshot(accountId, usageData) { + if (!usageData || typeof usageData !== 'object') { + return + } + + const updates = {} + + // 5小时窗口 + if (usageData.five_hour) { + if (usageData.five_hour.utilization !== undefined) { + updates.claudeFiveHourUtilization = String(usageData.five_hour.utilization) + } + if (usageData.five_hour.resets_at) { + updates.claudeFiveHourResetsAt = usageData.five_hour.resets_at + } + } + + // 7天窗口 + if (usageData.seven_day) { + if (usageData.seven_day.utilization !== undefined) { + updates.claudeSevenDayUtilization = String(usageData.seven_day.utilization) + } + if (usageData.seven_day.resets_at) { + updates.claudeSevenDayResetsAt = usageData.seven_day.resets_at + } + } + + // 7天Opus窗口 + if (usageData.seven_day_sonnet) { + if (usageData.seven_day_sonnet.utilization !== undefined) { + updates.claudeSevenDayOpusUtilization = String(usageData.seven_day_sonnet.utilization) + } + if (usageData.seven_day_sonnet.resets_at) { + updates.claudeSevenDayOpusResetsAt = usageData.seven_day_sonnet.resets_at + } + } + + if (Object.keys(updates).length === 0) { + return + } + + updates.claudeUsageUpdatedAt = new Date().toISOString() + + const accountData = await redis.getClaudeAccount(accountId) + if (accountData && Object.keys(accountData).length > 0) { + Object.assign(accountData, updates) + await redis.setClaudeAccount(accountId, accountData) + logger.debug( + `📊 Updated Claude usage snapshot for account ${accountId}:`, + Object.keys(updates) + ) + } + } + + // 📊 获取账号 Profile 信息并更新账号类型 + async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 检查账户是否有 user:profile 权限 + const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile') + if (!hasProfileScope) { + logger.warn( + `⚠️ Account ${accountId} does not have user:profile scope, cannot fetch profile` + ) + throw new Error('Account does not have user:profile permission') + } + + // 如果没有提供 accessToken,使用账号存储的 token + if (!accessToken) { + accessToken = this._decryptSensitiveData(accountData.accessToken) + if (!accessToken) { + throw new Error('No access token available') + } + } + + // 如果没有提供 agent,创建代理 + if (!agent) { + agent = this._createProxyAgent(accountData.proxy) + } + + logger.info(`📊 Fetching profile info for account: ${accountData.name} (${accountId})`) + + // 请求 profile 接口 + const axiosConfig = { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + 'Accept-Language': 'en-US,en;q=0.9' + }, + timeout: 15000 + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.get('https://api.anthropic.com/api/oauth/profile', axiosConfig) + + if (response.status === 200 && response.data) { + const profileData = response.data + + logger.info('✅ Successfully fetched profile data:', { + email: profileData.account?.email, + hasClaudeMax: profileData.account?.has_claude_max, + hasClaudePro: profileData.account?.has_claude_pro, + organizationType: profileData.organization?.organization_type + }) + + const organizationType = String( + profileData.organization?.organization_type || '' + ).toLowerCase() + const isEnterpriseOrg = organizationType === 'claude_enterprise' + const hasClaudeMax = profileData.account?.has_claude_max === true || isEnterpriseOrg + const hasClaudePro = profileData.account?.has_claude_pro === true && !hasClaudeMax + + // 构建订阅信息 + const subscriptionInfo = { + // 账号信息 + email: profileData.account?.email, + fullName: profileData.account?.full_name, + displayName: profileData.account?.display_name, + hasClaudeMax, + hasClaudePro, + accountUuid: profileData.account?.uuid, + + // 组织信息 + organizationName: profileData.organization?.name, + organizationUuid: profileData.organization?.uuid, + billingType: profileData.organization?.billing_type, + rateLimitTier: profileData.organization?.rate_limit_tier, + organizationType: profileData.organization?.organization_type, + + // 账号类型:Enterprise 组织按 Max 能力处理,确保可调度 Opus + accountType: hasClaudeMax ? 'claude_max' : hasClaudePro ? 'claude_pro' : 'claude_max', + + // 更新时间 + profileFetchedAt: new Date().toISOString() + } + + // 更新账户数据 + accountData.subscriptionInfo = JSON.stringify(subscriptionInfo) + accountData.profileUpdatedAt = new Date().toISOString() + + // 如果提供了邮箱,更新邮箱字段 + if (profileData.account?.email) { + accountData.email = this._encryptSensitiveData(profileData.account.email) + } + + await redis.setClaudeAccount(accountId, accountData) + + logger.success( + `✅ Updated account profile for ${accountData.name} (${accountId}) - Type: ${subscriptionInfo.accountType}` + ) + + return subscriptionInfo + } else { + throw new Error(`Failed to fetch profile with status: ${response.status}`) + } + } catch (error) { + if (error.response?.status === 401) { + logger.warn(`⚠️ Profile API returned 401 for account ${accountId} - token may be invalid`) + } else if (error.response?.status === 403) { + logger.warn( + `⚠️ Profile API returned 403 for account ${accountId} - insufficient permissions` + ) + } else { + logger.error(`❌ Failed to fetch profile for account ${accountId}:`, error.message) + } + throw error + } + } + + // 🔄 手动更新所有账号的 Profile 信息 + async updateAllAccountProfiles() { + try { + logger.info('🔄 Starting batch profile update for all accounts...') + + const accounts = await redis.getAllClaudeAccounts() + let successCount = 0 + let failureCount = 0 + const results = [] + + for (const account of accounts) { + // 跳过未激活或错误状态的账号 + if (account.isActive !== 'true' || account.status === 'error') { + logger.info(`⏩ Skipping inactive/error account: ${account.name} (${account.id})`) + continue + } + + // 跳过没有 user:profile 权限的账号(Setup Token 账号) + const hasProfileScope = account.scopes && account.scopes.includes('user:profile') + if (!hasProfileScope) { + logger.info( + `⏩ Skipping account without user:profile scope: ${account.name} (${account.id})` + ) + results.push({ + accountId: account.id, + accountName: account.name, + success: false, + error: 'No user:profile permission (Setup Token account)' + }) + continue + } + + try { + // 获取有效的 access token + const accessToken = await this.getValidAccessToken(account.id) + if (accessToken) { + const profileInfo = await this.fetchAndUpdateAccountProfile(account.id, accessToken) + successCount++ + results.push({ + accountId: account.id, + accountName: account.name, + success: true, + accountType: profileInfo.accountType + }) + } + } catch (error) { + failureCount++ + results.push({ + accountId: account.id, + accountName: account.name, + success: false, + error: error.message + }) + logger.warn( + `⚠️ Failed to update profile for account ${account.name} (${account.id}): ${error.message}` + ) + } + + // 添加延迟以避免触发限流 + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + logger.success(`Profile update completed: ${successCount} success, ${failureCount} failed`) + + return { + totalAccounts: accounts.length, + successCount, + failureCount, + results + } + } catch (error) { + logger.error('❌ Failed to update account profiles:', error) + throw error + } + } + + // 🔄 初始化所有账户的会话窗口(从历史数据恢复) + async initializeSessionWindows(forceRecalculate = false) { + try { + logger.info('🔄 Initializing session windows for all Claude accounts...') + + const accounts = await redis.getAllClaudeAccounts() + let validWindowCount = 0 + let expiredWindowCount = 0 + let noWindowCount = 0 + const now = new Date() + + for (const account of accounts) { + // 如果强制重算,清除现有窗口信息 + if (forceRecalculate && (account.sessionWindowStart || account.sessionWindowEnd)) { + logger.info(`🔄 Force recalculating window for account ${account.name} (${account.id})`) + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + await redis.setClaudeAccount(account.id, account) + } + + // 检查现有会话窗口 + if (account.sessionWindowStart && account.sessionWindowEnd) { + const windowEnd = new Date(account.sessionWindowEnd) + const windowStart = new Date(account.sessionWindowStart) + const timeUntilExpires = Math.round((windowEnd.getTime() - now.getTime()) / (1000 * 60)) + + if (now.getTime() < windowEnd.getTime()) { + // 窗口仍然有效,保留它 + validWindowCount++ + logger.info( + `✅ Account ${account.name} (${account.id}) has valid window: ${windowStart.toISOString()} - ${windowEnd.toISOString()} (${timeUntilExpires} minutes remaining)` + ) + } else { + // 窗口已过期,清除它 + expiredWindowCount++ + logger.warn( + `⏰ Account ${account.name} (${account.id}) window expired: ${windowStart.toISOString()} - ${windowEnd.toISOString()}` + ) + + // 清除过期的窗口信息 + delete account.sessionWindowStart + delete account.sessionWindowEnd + delete account.lastRequestTime + await redis.setClaudeAccount(account.id, account) + } + } else { + noWindowCount++ + logger.info( + `📭 Account ${account.name} (${account.id}) has no session window - will create on next request` + ) + } + } + + logger.success('Session window initialization completed:') + logger.success(` Total accounts: ${accounts.length}`) + logger.success(` Valid windows: ${validWindowCount}`) + logger.success(` Expired windows: ${expiredWindowCount}`) + logger.success(` No windows: ${noWindowCount}`) + + return { + total: accounts.length, + validWindows: validWindowCount, + expiredWindows: expiredWindowCount, + noWindows: noWindowCount + } + } catch (error) { + logger.error('❌ Failed to initialize session windows:', error) + return { + total: 0, + validWindows: 0, + expiredWindows: 0, + noWindows: 0, + error: error.message + } + } + } + + // 🚫 通用的账户错误标记方法 + async markAccountError(accountId, errorType, sessionHash = null) { + const ERROR_CONFIG = { + unauthorized: { + status: 'unauthorized', + errorMessage: 'Account unauthorized (401 errors detected)', + timestampField: 'unauthorizedAt', + errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED', + logMessage: 'unauthorized' + }, + blocked: { + status: 'blocked', + errorMessage: 'Account blocked (403 error detected - account may be suspended by Claude)', + timestampField: 'blockedAt', + errorCode: 'CLAUDE_OAUTH_BLOCKED', + logMessage: 'blocked' + } + } + + try { + const errorConfig = ERROR_CONFIG[errorType] + if (!errorConfig) { + throw new Error(`Unsupported error type: ${errorType}`) + } + + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // disableAutoProtection 检查:跳过自动禁用,仅记录错误历史 + if ( + accountData.disableAutoProtection === true || + accountData.disableAutoProtection === 'true' + ) { + logger.info( + `🛡️ Account ${accountData.name} (${accountId}) has auto-protection disabled, skipping ${errorType} marking` + ) + const statusCode = errorType === 'unauthorized' ? 401 : 403 + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', statusCode, errorType) + .catch(() => {}) + return { success: true, skipped: true } + } + + // 更新账户状态 + const updatedAccountData = { ...accountData } + updatedAccountData.status = errorConfig.status + updatedAccountData.schedulable = 'false' // 设置为不可调度 + updatedAccountData.errorMessage = errorConfig.errorMessage + updatedAccountData[errorConfig.timestampField] = new Date().toISOString() + + // 保存更新后的账户数据 + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 如果有sessionHash,删除粘性会话映射 + if (sessionHash) { + await redis.client.del(`sticky_session:${sessionHash}`) + logger.info(`🗑️ Deleted sticky session mapping for hash: ${sessionHash}`) + } + + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) marked as ${errorConfig.logMessage} and disabled for scheduling` + ) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name, + platform: 'claude-oauth', + status: errorConfig.status, + errorCode: errorConfig.errorCode, + reason: errorConfig.errorMessage, + timestamp: getISOStringWithTimezone(new Date()) + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account ${accountId} as ${errorType}:`, error) + throw error + } + } + + // 🚫 标记账户为未授权状态(401错误) + async markAccountUnauthorized(accountId, sessionHash = null) { + return this.markAccountError(accountId, 'unauthorized', sessionHash) + } + + // 🚫 标记账户为被封锁状态(403错误) + async markAccountBlocked(accountId, sessionHash = null) { + return this.markAccountError(accountId, 'blocked', sessionHash) + } + + // 🔄 重置账户所有异常状态 + async resetAccountStatus(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 重置账户状态 + const updatedAccountData = { ...accountData } + + // 根据是否有有效的accessToken来设置status + if (updatedAccountData.accessToken) { + updatedAccountData.status = 'active' + } else { + updatedAccountData.status = 'created' + } + + // 恢复可调度状态(管理员手动重置时恢复调度是合理的) + updatedAccountData.schedulable = 'true' + // 清除所有自动停止相关的标记 + delete updatedAccountData.rateLimitAutoStopped + delete updatedAccountData.fiveHourAutoStopped + delete updatedAccountData.fiveHourStoppedAt + delete updatedAccountData.tempErrorAutoStopped + delete updatedAccountData.fiveHourWarningWindow + delete updatedAccountData.fiveHourWarningCount + delete updatedAccountData.fiveHourWarningLastSentAt + // 兼容旧的标记 + delete updatedAccountData.autoStoppedAt + delete updatedAccountData.stoppedReason + + // 清除错误相关字段 + delete updatedAccountData.errorMessage + delete updatedAccountData.unauthorizedAt + delete updatedAccountData.blockedAt + delete updatedAccountData.rateLimitedAt + delete updatedAccountData.rateLimitStatus + delete updatedAccountData.rateLimitEndAt + delete updatedAccountData.tempErrorAt + delete updatedAccountData.sessionWindowStart + delete updatedAccountData.sessionWindowEnd + for (const family of RATE_LIMITED_MODEL_FAMILIES) { + const { atField, endField } = this._modelRateLimitFields(family) + delete updatedAccountData[atField] + delete updatedAccountData[endField] + } + delete updatedAccountData.lastOverloadAt + + // 保存更新后的账户数据 + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段) + const fieldsToDelete = [ + 'errorMessage', + 'unauthorizedAt', + 'blockedAt', + 'rateLimitedAt', + 'rateLimitStatus', + 'rateLimitEndAt', + 'tempErrorAt', + 'sessionWindowStart', + 'sessionWindowEnd', + ...RATE_LIMITED_MODEL_FAMILIES.flatMap((family) => [ + `${family}RateLimitedAt`, + `${family}RateLimitEndAt` + ]), + 'lastOverloadAt', + // 新的独立标记 + 'rateLimitAutoStopped', + 'fiveHourAutoStopped', + 'fiveHourStoppedAt', + 'fiveHourWarningWindow', + 'fiveHourWarningCount', + 'fiveHourWarningLastSentAt', + 'tempErrorAutoStopped', + // 兼容旧的标记 + 'autoStoppedAt', + 'stoppedReason' + ] + await redis.client.hdel(`claude:account:${accountId}`, ...fieldsToDelete) + + // 清除401错误计数 + const errorKey = `claude_account:${accountId}:401_errors` + await redis.client.del(errorKey) + + // 清除限流状态(如果存在) + const rateLimitKey = `ratelimit:${accountId}` + await redis.client.del(rateLimitKey) + + // 清除5xx错误计数 + const serverErrorKey = `claude_account:${accountId}:5xx_errors` + await redis.client.del(serverErrorKey) + + // 清除过载状态 + const overloadKey = `account:overload:${accountId}` + await redis.client.del(overloadKey) + + // 清除临时不可用状态 + await Promise.all([ + upstreamErrorHelper.clearTempUnavailable(accountId, 'claude-official').catch(() => {}), + upstreamErrorHelper.clearTempUnavailable(accountId, 'claude').catch(() => {}) + ]) + + logger.info( + `✅ Successfully reset all error states for account ${accountData.name} (${accountId})` + ) + + return { + success: true, + account: { + id: accountId, + name: accountData.name, + status: updatedAccountData.status, + schedulable: updatedAccountData.schedulable === 'true' + } + } + } catch (error) { + logger.error(`❌ Failed to reset account status for ${accountId}:`, error) + throw error + } + } + + // 🧹 清理临时错误账户 + async cleanupTempErrorAccounts() { + try { + const accounts = await redis.getAllClaudeAccounts() + let cleanedCount = 0 + const TEMP_ERROR_RECOVERY_MINUTES = 5 // 临时错误状态恢复时间(分钟) + + for (const account of accounts) { + if (account.status === 'temp_error' && account.tempErrorAt) { + const tempErrorAt = new Date(account.tempErrorAt) + const now = new Date() + const minutesSinceTempError = (now - tempErrorAt) / (1000 * 60) + + // 如果临时错误状态超过指定时间,尝试重新激活 + if (minutesSinceTempError > TEMP_ERROR_RECOVERY_MINUTES) { + account.status = 'active' // 恢复为 active 状态 + // 只恢复因临时错误而自动停止的账户 + if (account.tempErrorAutoStopped === 'true') { + account.schedulable = 'true' // 恢复为可调度 + delete account.tempErrorAutoStopped + } + delete account.errorMessage + delete account.tempErrorAt + await redis.setClaudeAccount(account.id, account) + + // 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段) + await redis.client.hdel( + `claude:account:${account.id}`, + 'errorMessage', + 'tempErrorAt', + 'tempErrorAutoStopped' + ) + + // 同时清除500错误计数 + await this.clearInternalErrors(account.id) + cleanedCount++ + logger.success(`🧹 Reset temp_error status for account ${account.name} (${account.id})`) + } + } + } + + if (cleanedCount > 0) { + logger.success(`🧹 Reset ${cleanedCount} temp_error accounts`) + } + + return cleanedCount + } catch (error) { + logger.error('❌ Failed to cleanup temp_error accounts:', error) + return 0 + } + } + + // 记录5xx服务器错误 + async recordServerError(accountId, statusCode) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + // 增加错误计数,设置5分钟过期时间 + await redis.client.incr(key) + await redis.client.expire(key, 300) // 5分钟 + + logger.info(`📝 Recorded ${statusCode} error for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to record ${statusCode} error for account ${accountId}:`, error) + } + } + + // 记录500内部错误(保留以便向后兼容) + async recordInternalError(accountId) { + return this.recordServerError(accountId, 500) + } + + // 获取5xx错误计数 + async getServerErrorCount(accountId) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + const count = await redis.client.get(key) + return parseInt(count) || 0 + } catch (error) { + logger.error(`❌ Failed to get 5xx error count for account ${accountId}:`, error) + return 0 + } + } + + // 获取500错误计数(保留以便向后兼容) + async getInternalErrorCount(accountId) { + return this.getServerErrorCount(accountId) + } + + // 清除500错误计数 + async clearInternalErrors(accountId) { + try { + const key = `claude_account:${accountId}:5xx_errors` + + await redis.client.del(key) + logger.info(`✅ Cleared 5xx error count for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to clear 5xx errors for account ${accountId}:`, error) + } + } + + // 标记账号为临时错误状态 + async markAccountTempError(accountId, sessionHash = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // disableAutoProtection 检查:跳过自动禁用,仅记录错误历史 + if ( + accountData.disableAutoProtection === true || + accountData.disableAutoProtection === 'true' + ) { + logger.info( + `🛡️ Account ${accountData.name} (${accountId}) has auto-protection disabled, skipping temp error marking` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', 500, 'server_error') + .catch(() => {}) + return { success: true, skipped: true } + } + + // 更新账户状态 + const updatedAccountData = { ...accountData } + updatedAccountData.status = 'temp_error' // 新增的临时错误状态 + updatedAccountData.schedulable = 'false' // 设置为不可调度 + updatedAccountData.errorMessage = 'Account temporarily disabled due to consecutive 500 errors' + updatedAccountData.tempErrorAt = new Date().toISOString() + // 使用独立的临时错误自动停止标记 + updatedAccountData.tempErrorAutoStopped = 'true' + + // 保存更新后的账户数据 + await redis.setClaudeAccount(accountId, updatedAccountData) + + // 设置 5 分钟后自动恢复(一次性定时器) + setTimeout( + async () => { + try { + const account = await redis.getClaudeAccount(accountId) + if (account && account.status === 'temp_error' && account.tempErrorAt) { + // 验证是否确实过了 5 分钟(防止重复定时器) + const tempErrorAt = new Date(account.tempErrorAt) + const now = new Date() + const minutesSince = (now - tempErrorAt) / (1000 * 60) + + if (minutesSince >= 5) { + // 恢复账户 + account.status = 'active' + // 只恢复因临时错误而自动停止的账户 + if (account.tempErrorAutoStopped === 'true') { + account.schedulable = 'true' + delete account.tempErrorAutoStopped + } + delete account.errorMessage + delete account.tempErrorAt + + await redis.setClaudeAccount(accountId, account) + + // 显式删除 Redis 字段 + await redis.client.hdel( + `claude:account:${accountId}`, + 'errorMessage', + 'tempErrorAt', + 'tempErrorAutoStopped' + ) + + // 清除 500 错误计数 + await this.clearInternalErrors(accountId) + + logger.success( + `✅ Auto-recovered temp_error after 5 minutes: ${account.name} (${accountId})` + ) + } else { + logger.debug( + `⏰ Temp error timer triggered but only ${minutesSince.toFixed(1)} minutes passed for ${account.name} (${accountId})` + ) + } + } + } catch (error) { + logger.error(`❌ Failed to auto-recover temp_error account ${accountId}:`, error) + } + }, + 6 * 60 * 1000 + ) // 6 分钟后执行,确保已过 5 分钟 + + // 如果有sessionHash,删除粘性会话映射 + if (sessionHash) { + await redis.client.del(`sticky_session:${sessionHash}`) + logger.info(`🗑️ Deleted sticky session mapping for hash: ${sessionHash}`) + } + + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) marked as temp_error and disabled for scheduling` + ) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name, + platform: 'claude-oauth', + status: 'temp_error', + errorCode: 'CLAUDE_OAUTH_TEMP_ERROR', + reason: 'Account temporarily disabled due to consecutive 500 errors' + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account ${accountId} as temp_error:`, error) + throw error + } + } + + // 更新会话窗口状态(allowed, allowed_warning, rejected) + async updateSessionWindowStatus(accountId, status) { + try { + // 参数验证 + if (!accountId || !status) { + logger.warn( + `Invalid parameters for updateSessionWindowStatus: accountId=${accountId}, status=${status}` + ) + return + } + + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + logger.warn(`Account not found: ${accountId}`) + return + } + + // 验证状态值是否有效 + const validStatuses = ['allowed', 'allowed_warning', 'rejected'] + if (!validStatuses.includes(status)) { + logger.warn(`Invalid session window status: ${status} for account ${accountId}`) + return + } + + const now = new Date() + const nowIso = now.toISOString() + + // 更新会话窗口状态 + accountData.sessionWindowStatus = status + accountData.sessionWindowStatusUpdatedAt = nowIso + + // 如果状态是 allowed_warning 且账户设置了自动停止调度 + if (status === 'allowed_warning' && accountData.autoStopOnWarning === 'true') { + const alreadyAutoStopped = + accountData.schedulable === 'false' && accountData.fiveHourAutoStopped === 'true' + + if (!alreadyAutoStopped) { + const windowIdentifier = + accountData.sessionWindowEnd || accountData.sessionWindowStart || 'unknown' + + let warningCount = 0 + if (accountData.fiveHourWarningWindow === windowIdentifier) { + const parsedCount = parseInt(accountData.fiveHourWarningCount || '0', 10) + warningCount = Number.isNaN(parsedCount) ? 0 : parsedCount + } + + const maxWarningsPerWindow = this.maxFiveHourWarningsPerWindow + + logger.warn( + `⚠️ Account ${accountData.name} (${accountId}) approaching 5h limit, auto-stopping scheduling` + ) + accountData.schedulable = 'false' + // 使用独立的5小时限制自动停止标记 + accountData.fiveHourAutoStopped = 'true' + accountData.fiveHourStoppedAt = nowIso + // 设置停止原因,供前端显示 + accountData.stoppedReason = '5小时使用量接近限制,已自动停止调度' + + const canSendWarning = warningCount < maxWarningsPerWindow + let updatedWarningCount = warningCount + + accountData.fiveHourWarningWindow = windowIdentifier + if (canSendWarning) { + updatedWarningCount += 1 + accountData.fiveHourWarningLastSentAt = nowIso + } + accountData.fiveHourWarningCount = updatedWarningCount.toString() + + if (canSendWarning) { + // 发送Webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Claude Account', + platform: 'claude', + status: 'warning', + errorCode: 'CLAUDE_5H_LIMIT_WARNING', + reason: '5小时使用量接近限制,已自动停止调度', + timestamp: getISOStringWithTimezone(now) + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } else { + logger.debug( + `⚠️ Account ${accountData.name} (${accountId}) reached max ${maxWarningsPerWindow} warning notifications for current 5h window, skipping webhook` + ) + } + } else { + logger.debug( + `⚠️ Account ${accountData.name} (${accountId}) already auto-stopped for 5h limit, skipping duplicate warning` + ) + } + } + + await redis.setClaudeAccount(accountId, accountData) + + logger.info( + `📊 Updated session window status for account ${accountData.name} (${accountId}): ${status}` + ) + } catch (error) { + logger.error(`❌ Failed to update session window status for account ${accountId}:`, error) + } + } + + // 🚫 标记账号为过载状态(529错误) + async markAccountOverloaded(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + // disableAutoProtection 检查:跳过过载标记,仅记录错误历史 + if ( + accountData.disableAutoProtection === true || + accountData.disableAutoProtection === 'true' + ) { + logger.info( + `🛡️ Account ${accountData.name} (${accountId}) has auto-protection disabled, skipping overload marking` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-official', 529, 'overload') + .catch(() => {}) + return { success: true, skipped: true } + } + + // 获取配置的过载处理时间(分钟) + const overloadMinutes = config.overloadHandling?.enabled || 0 + + if (overloadMinutes === 0) { + logger.info('⏭️ 529 error handling is disabled') + return { success: false, error: '529 error handling is disabled' } + } + + const overloadKey = `account:overload:${accountId}` + const ttl = overloadMinutes * 60 // 转换为秒 + + await redis.setex( + overloadKey, + ttl, + JSON.stringify({ + accountId, + accountName: accountData.name, + markedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + ttl * 1000).toISOString() + }) + ) + + logger.warn( + `🚫 Account ${accountData.name} (${accountId}) marked as overloaded for ${overloadMinutes} minutes` + ) + + // 在账号上记录最后一次529错误 + const updates = { + lastOverloadAt: new Date().toISOString(), + errorMessage: `529错误 - 过载${overloadMinutes}分钟` + } + + const updatedAccountData = { ...accountData, ...updates } + await redis.setClaudeAccount(accountId, updatedAccountData) + + return { success: true, accountName: accountData.name, duration: overloadMinutes } + } catch (error) { + logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, error) + // 不抛出错误,避免影响主请求流程 + return { success: false, error: error.message } + } + } + + // ✅ 检查账号是否过载 + async isAccountOverloaded(accountId) { + try { + // 如果529处理未启用,直接返回false + const overloadMinutes = config.overloadHandling?.enabled || 0 + if (overloadMinutes === 0) { + return false + } + + const overloadKey = `account:overload:${accountId}` + const overloadData = await redis.get(overloadKey) + + if (overloadData) { + // 账号处于过载状态 + return true + } + + // 账号未过载 + return false + } catch (error) { + logger.error(`❌ Failed to check if account is overloaded: ${accountId}`, error) + return false + } + } + + // 🔄 移除账号的过载状态 + async removeAccountOverload(accountId) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + const overloadKey = `account:overload:${accountId}` + await redis.del(overloadKey) + + logger.info(`✅ Account ${accountData.name} (${accountId}) overload status removed`) + + // 清理账号上的错误信息 + if (accountData.errorMessage && accountData.errorMessage.includes('529错误')) { + const updatedAccountData = { ...accountData } + delete updatedAccountData.errorMessage + delete updatedAccountData.lastOverloadAt + await redis.setClaudeAccount(accountId, updatedAccountData) + } + } catch (error) { + logger.error(`❌ Failed to remove overload status for account: ${accountId}`, error) + // 不抛出错误,移除过载状态失败不应该影响主流程 + } + } + + /** + * 检查并恢复因5小时限制被自动停止的账号 + * 用于定时任务自动恢复 + * @returns {Promise<{checked: number, recovered: number, accounts: Array}>} + */ + async checkAndRecoverFiveHourStoppedAccounts() { + const result = { + checked: 0, + recovered: 0, + accounts: [] + } + + try { + const accounts = await this.getAllAccounts() + const now = new Date() + + for (const account of accounts) { + // 只检查因5小时限制被自动停止的账号 + // 重要:不恢复手动停止的账号(没有fiveHourAutoStopped标记的) + if (account.fiveHourAutoStopped === true && account.schedulable === false) { + result.checked++ + + // 使用分布式锁防止并发修改 + const lockKey = `lock:account:${account.id}:recovery` + const lockValue = `${Date.now()}_${Math.random()}` + const lockTTL = 5000 // 5秒锁超时 + + try { + // 尝试获取锁 + const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTTL) + if (!lockAcquired) { + logger.debug( + `⏭️ Account ${account.name} (${account.id}) is being processed by another instance` + ) + continue + } + + // 重新获取账号数据,确保是最新的 + const latestAccount = await redis.getClaudeAccount(account.id) + if ( + !latestAccount || + latestAccount.fiveHourAutoStopped !== 'true' || + latestAccount.schedulable !== 'false' + ) { + // 账号状态已变化,跳过 + await redis.releaseAccountLock(lockKey, lockValue) + continue + } + + // 检查当前时间是否已经进入新的5小时窗口 + let shouldRecover = false + let newWindowStart = null + let newWindowEnd = null + + if (latestAccount.sessionWindowEnd) { + const windowEnd = new Date(latestAccount.sessionWindowEnd) + + // 使用严格的时间比较,添加1分钟缓冲避免边界问题 + if (now.getTime() > windowEnd.getTime() + 60000) { + shouldRecover = true + + // 计算新的窗口时间(基于窗口结束时间,而不是当前时间) + // 这样可以保证窗口时间的连续性 + newWindowStart = new Date(windowEnd) + newWindowStart.setMilliseconds(newWindowStart.getMilliseconds() + 1) + newWindowEnd = new Date(newWindowStart) + newWindowEnd.setHours(newWindowEnd.getHours() + 5) + + logger.info( + `🔄 Account ${latestAccount.name} (${latestAccount.id}) has entered new session window. ` + + `Old window: ${latestAccount.sessionWindowStart} - ${latestAccount.sessionWindowEnd}, ` + + `New window: ${newWindowStart.toISOString()} - ${newWindowEnd.toISOString()}` + ) + } + } else { + // 如果没有窗口结束时间,但有停止时间,检查是否已经过了5小时 + if (latestAccount.fiveHourStoppedAt) { + const stoppedAt = new Date(latestAccount.fiveHourStoppedAt) + const hoursSinceStopped = (now.getTime() - stoppedAt.getTime()) / (1000 * 60 * 60) + + // 使用严格的5小时判断,加上1分钟缓冲 + if (hoursSinceStopped > 5.017) { + // 5小时1分钟 + shouldRecover = true + newWindowStart = this._calculateSessionWindowStart(now) + newWindowEnd = this._calculateSessionWindowEnd(newWindowStart) + + logger.info( + `🔄 Account ${latestAccount.name} (${latestAccount.id}) stopped ${hoursSinceStopped.toFixed(2)} hours ago, recovering` + ) + } + } + } + + if (shouldRecover) { + // 恢复账号调度 + const updatedAccountData = { ...latestAccount } + + // 恢复调度状态 + updatedAccountData.schedulable = 'true' + delete updatedAccountData.fiveHourAutoStopped + delete updatedAccountData.fiveHourStoppedAt + await this._clearFiveHourWarningMetadata(account.id, updatedAccountData) + delete updatedAccountData.stoppedReason + + // 更新会话窗口(如果有新窗口) + if (newWindowStart && newWindowEnd) { + updatedAccountData.sessionWindowStart = newWindowStart.toISOString() + updatedAccountData.sessionWindowEnd = newWindowEnd.toISOString() + + // 清除会话窗口状态 + delete updatedAccountData.sessionWindowStatus + delete updatedAccountData.sessionWindowStatusUpdatedAt + } + + // 保存更新 + await redis.setClaudeAccount(account.id, updatedAccountData) + + const fieldsToRemove = ['fiveHourAutoStopped', 'fiveHourStoppedAt'] + if (newWindowStart && newWindowEnd) { + fieldsToRemove.push('sessionWindowStatus', 'sessionWindowStatusUpdatedAt') + } + await this._removeAccountFields(account.id, fieldsToRemove, 'five_hour_recovery_task') + + result.recovered++ + result.accounts.push({ + id: latestAccount.id, + name: latestAccount.name, + oldWindow: latestAccount.sessionWindowEnd + ? { + start: latestAccount.sessionWindowStart, + end: latestAccount.sessionWindowEnd + } + : null, + newWindow: + newWindowStart && newWindowEnd + ? { + start: newWindowStart.toISOString(), + end: newWindowEnd.toISOString() + } + : null + }) + + logger.info( + `✅ Auto-resumed scheduling for account ${latestAccount.name} (${latestAccount.id}) - 5-hour limit expired` + ) + } + + // 释放锁 + await redis.releaseAccountLock(lockKey, lockValue) + } catch (error) { + // 确保释放锁 + if (lockKey && lockValue) { + try { + await redis.releaseAccountLock(lockKey, lockValue) + } catch (unlockError) { + logger.error(`Failed to release lock for account ${account.id}:`, unlockError) + } + } + logger.error( + `❌ Failed to check/recover 5-hour stopped account ${account.name} (${account.id}):`, + error + ) + } + } + } + + if (result.recovered > 0) { + logger.info( + `🔄 5-hour limit recovery completed: ${result.recovered}/${result.checked} accounts recovered` + ) + } + + return result + } catch (error) { + logger.error('❌ Failed to check and recover 5-hour stopped accounts:', error) + throw error + } + } + + /** + * 规范化扩展信息,提取组织与账户UUID + * @param {object|string|null} extInfoSource - 原始扩展信息 + * @param {object|null} oauthPayload - OAuth 数据载荷 + * @returns {object|null} 规范化后的扩展信息 + */ + _normalizeExtInfo(extInfoSource, oauthPayload) { + let extInfo = null + + if (extInfoSource) { + if (typeof extInfoSource === 'string') { + extInfo = this._safeParseJson(extInfoSource) + } else if (typeof extInfoSource === 'object') { + extInfo = { ...extInfoSource } + } + } + + if (!extInfo && oauthPayload && typeof oauthPayload === 'object') { + if (oauthPayload.extInfo) { + if (typeof oauthPayload.extInfo === 'string') { + extInfo = this._safeParseJson(oauthPayload.extInfo) + } else if (typeof oauthPayload.extInfo === 'object') { + extInfo = { ...oauthPayload.extInfo } + } + } + + if (!extInfo) { + const organization = oauthPayload.organization || null + const account = oauthPayload.account || null + + const normalized = {} + const orgUuid = + organization?.uuid || + organization?.id || + organization?.organization_uuid || + organization?.organization_id + const accountUuid = + account?.uuid || account?.id || account?.account_uuid || account?.account_id + + if (orgUuid) { + normalized.org_uuid = orgUuid + } + + if (accountUuid) { + normalized.account_uuid = accountUuid + } + + extInfo = Object.keys(normalized).length > 0 ? normalized : null + } + } + + if (!extInfo || typeof extInfo !== 'object') { + return null + } + + const result = {} + + if (extInfo.org_uuid && typeof extInfo.org_uuid === 'string') { + result.org_uuid = extInfo.org_uuid + } + + if (extInfo.account_uuid && typeof extInfo.account_uuid === 'string') { + result.account_uuid = extInfo.account_uuid + } + + return Object.keys(result).length > 0 ? result : null + } + + /** + * 安全解析 JSON 字符串 + * @param {string} value - 需要解析的字符串 + * @returns {object|null} 解析结果 + */ + _safeParseJson(value) { + if (!value || typeof value !== 'string') { + return null + } + + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' ? parsed : null + } catch (error) { + logger.warn('⚠️ 解析扩展信息失败,已忽略:', error.message) + return null + } + } + + _safeParseAccountFieldJson(value, fieldName, accountId = '') { + if (!value) { + return null + } + if (typeof value === 'object') { + return value + } + if (typeof value !== 'string') { + return null + } + + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' ? parsed : null + } catch (error) { + logger.warn( + `⚠️ Failed to parse ${fieldName} for Claude account ${accountId || 'unknown'}, ignored: ${error.message}` + ) + return null + } + } + + async _removeAccountFields(accountId, fields = [], context = 'general_cleanup') { + if (!Array.isArray(fields) || fields.length === 0) { + return + } + + const filteredFields = fields.filter((field) => typeof field === 'string' && field.trim()) + if (filteredFields.length === 0) { + return + } + + const accountKey = `claude:account:${accountId}` + + try { + await redis.client.hdel(accountKey, ...filteredFields) + logger.debug( + `🧹 已在 ${context} 阶段为账号 ${accountId} 删除字段 [${filteredFields.join(', ')}]` + ) + } catch (error) { + logger.error( + `❌ 无法在 ${context} 阶段为账号 ${accountId} 删除字段 [${filteredFields.join(', ')}]:`, + error + ) + } + } +} + +module.exports = new ClaudeAccountService() diff --git a/src/services/account/claudeConsoleAccountService.js b/src/services/account/claudeConsoleAccountService.js new file mode 100644 index 0000000..1b83c6f --- /dev/null +++ b/src/services/account/claudeConsoleAccountService.js @@ -0,0 +1,1632 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const ProxyHelper = require('../../utils/proxyHelper') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const config = require('../../../config/config') +const LRUCache = require('../../utils/lruCache') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +class ClaudeConsoleAccountService { + constructor() { + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'claude-console-salt' + + // Redis键前缀 + this.ACCOUNT_KEY_PREFIX = 'claude_console_account:' + this.SHARED_ACCOUNTS_KEY = 'shared_claude_console_accounts' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + // scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info( + '🧹 Claude Console decrypt cache cleanup completed', + this._decryptCache.getStats() + ) + }, + 10 * 60 * 1000 + ) + } + + _getBlockedHandlingMinutes() { + const raw = process.env.CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES + if (raw === undefined || raw === null || raw === '') { + return 0 + } + + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + return 0 + } + + return parsed + } + + // 🏢 创建Claude Console账户 + async createAccount(options = {}) { + const { + name = 'Claude Console Account', + description = '', + apiUrl = '', + apiKey = '', + priority = 50, // 默认优先级50(1-100) + supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有 + userAgent = 'claude-cli/1.0.69 (external, cli)', + rateLimitDuration = 60, // 限流时间(分钟) + proxy = null, + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + schedulable = true, // 是否可被调度 + dailyQuota = 0, // 每日额度限制(美元),0表示不限制 + quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) + maxConcurrentTasks = 0, // 最大并发任务数,0表示无限制 + disableAutoProtection = false, // 是否关闭自动防护(429/401/400/529 不自动禁用) + interceptWarmup = false // 拦截预热请求(标题生成、Warmup等) + } = options + + // 验证必填字段 + if (!apiUrl || !apiKey) { + throw new Error('API URL and API Key are required for Claude Console account') + } + + const accountId = uuidv4() + + // 处理 supportedModels,确保向后兼容 + const processedModels = this._processModelMapping(supportedModels) + + const accountData = { + id: accountId, + platform: 'claude-console', + name, + description, + apiUrl, + apiKey: this._encryptSensitiveData(apiKey), + priority: priority.toString(), + supportedModels: JSON.stringify(processedModels), + userAgent, + rateLimitDuration: rateLimitDuration.toString(), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + createdAt: new Date().toISOString(), + lastUsedAt: '', + status: 'active', + errorMessage: '', + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + // 注意:Claude Console 没有 OAuth token,因此没有 expiresAt(token过期) + subscriptionExpiresAt: options.subscriptionExpiresAt || null, + + // 限流相关 + rateLimitedAt: '', + rateLimitStatus: '', + // 调度控制 + schedulable: schedulable.toString(), + // 额度管理相关 + dailyQuota: dailyQuota.toString(), // 每日额度限制(美元) + dailyUsage: '0', // 当日使用金额(美元) + // 使用与统计一致的时区日期,避免边界问题 + lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区) + quotaResetTime, // 额度重置时间 + quotaStoppedAt: '', // 因额度停用的时间 + maxConcurrentTasks: maxConcurrentTasks.toString(), // 最大并发任务数,0表示无限制 + disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护 + interceptWarmup: interceptWarmup.toString() // 拦截预热请求 + } + + const client = redis.getClientSafe() + logger.debug( + `[DEBUG] Saving account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}` + ) + logger.debug(`[DEBUG] Account data to save: ${JSON.stringify(accountData, null, 2)}`) + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData) + await redis.addToIndex('claude_console_account:index', accountId) + + // 如果是共享账户,添加到共享账户集合 + if (accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } + + logger.success(`🏢 Created Claude Console account: ${name} (${accountId})`) + + return { + id: accountId, + name, + description, + apiUrl, + priority, + supportedModels, + userAgent, + rateLimitDuration, + isActive, + proxy, + accountType, + status: 'active', + createdAt: accountData.createdAt, + dailyQuota, + dailyUsage: 0, + lastResetDate: accountData.lastResetDate, + quotaResetTime, + quotaStoppedAt: null, + maxConcurrentTasks, // 新增:返回并发限制配置 + disableAutoProtection, // 新增:返回自动防护开关 + interceptWarmup, // 新增:返回预热请求拦截开关 + activeTaskCount: 0 // 新增:新建账户当前并发数为0 + } + } + + // 📋 获取所有Claude Console账户 + async getAllAccounts() { + try { + const client = redis.getClientSafe() + const accountIds = await redis.getAllIdsByIndex( + 'claude_console_account:index', + `${this.ACCOUNT_KEY_PREFIX}*`, + /^claude_console_account:(.+)$/ + ) + const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`) + const accounts = [] + const dataList = await redis.batchHgetallChunked(keys) + + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + const accountData = dataList[i] + if (accountData && Object.keys(accountData).length > 0) { + if (!accountData.id) { + logger.warn(`⚠️ 检测到缺少ID的Claude Console账户数据,执行清理: ${key}`) + await client.del(key) + continue + } + + // 获取限流状态信息 + const rateLimitInfo = this._getRateLimitInfo(accountData) + + // 获取实时并发计数 + const activeTaskCount = await redis.getConsoleAccountConcurrency(accountData.id) + + accounts.push({ + id: accountData.id, + platform: accountData.platform, + name: accountData.name, + description: accountData.description, + apiUrl: accountData.apiUrl, + priority: parseInt(accountData.priority) || 50, + supportedModels: JSON.parse(accountData.supportedModels || '[]'), + userAgent: accountData.userAgent, + rateLimitDuration: Number.isNaN(parseInt(accountData.rateLimitDuration)) + ? 60 + : parseInt(accountData.rateLimitDuration), + isActive: accountData.isActive === 'true', + proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null, + accountType: accountData.accountType || 'shared', + createdAt: accountData.createdAt, + lastUsedAt: accountData.lastUsedAt, + status: accountData.status || 'active', + errorMessage: accountData.errorMessage, + rateLimitInfo, + schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度 + + // ✅ 前端显示订阅过期时间(业务字段) + expiresAt: accountData.subscriptionExpiresAt || null, + + // 额度管理相关 + dailyQuota: parseFloat(accountData.dailyQuota || '0'), + dailyUsage: parseFloat(accountData.dailyUsage || '0'), + lastResetDate: accountData.lastResetDate || '', + quotaResetTime: accountData.quotaResetTime || '00:00', + quotaStoppedAt: accountData.quotaStoppedAt || null, + + // 并发控制相关 + maxConcurrentTasks: parseInt(accountData.maxConcurrentTasks) || 0, + activeTaskCount, + disableAutoProtection: accountData.disableAutoProtection === 'true', + // 拦截预热请求 + interceptWarmup: accountData.interceptWarmup === 'true' + }) + } + } + + return accounts + } catch (error) { + logger.error('❌ Failed to get Claude Console accounts:', error) + throw error + } + } + + // 🔍 获取单个账户(内部使用,包含敏感信息) + async getAccount(accountId) { + const client = redis.getClientSafe() + logger.debug(`[DEBUG] Getting account data for ID: ${accountId}`) + const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + logger.debug(`[DEBUG] No account data found for ID: ${accountId}`) + return null + } + + logger.debug(`[DEBUG] Raw account data keys: ${Object.keys(accountData).join(', ')}`) + logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`) + + // 解密敏感字段(只解密apiKey,apiUrl不加密) + const decryptedKey = this._decryptSensitiveData(accountData.apiKey) + logger.debug( + `[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}` + ) + + accountData.apiKey = decryptedKey + + // 解析JSON字段 + const parsedModels = JSON.parse(accountData.supportedModels || '[]') + logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`) + + accountData.supportedModels = parsedModels + accountData.priority = parseInt(accountData.priority) || 50 + { + const _parsedDuration = parseInt(accountData.rateLimitDuration) + accountData.rateLimitDuration = Number.isNaN(_parsedDuration) ? 60 : _parsedDuration + } + accountData.isActive = accountData.isActive === 'true' + accountData.schedulable = accountData.schedulable !== 'false' // 默认为true + accountData.disableAutoProtection = accountData.disableAutoProtection === 'true' + + if (accountData.proxy) { + accountData.proxy = JSON.parse(accountData.proxy) + } + + // 解析并发控制字段 + accountData.maxConcurrentTasks = parseInt(accountData.maxConcurrentTasks) || 0 + // 获取实时并发计数 + accountData.activeTaskCount = await redis.getConsoleAccountConcurrency(accountId) + + logger.debug( + `[DEBUG] Final account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}` + ) + + return accountData + } + + // 📝 更新账户 + async updateAccount(accountId, updates) { + try { + const existingAccount = await this.getAccount(accountId) + if (!existingAccount) { + throw new Error('Account not found') + } + + const client = redis.getClientSafe() + const updatedData = {} + + // 处理各个字段的更新 + logger.debug( + `[DEBUG] Update request received with fields: ${Object.keys(updates).join(', ')}` + ) + logger.debug(`[DEBUG] Updates content: ${JSON.stringify(updates, null, 2)}`) + + if (updates.name !== undefined) { + updatedData.name = updates.name + } + if (updates.description !== undefined) { + updatedData.description = updates.description + } + if (updates.apiUrl !== undefined) { + logger.debug(`[DEBUG] Updating apiUrl from frontend: ${updates.apiUrl}`) + updatedData.apiUrl = updates.apiUrl + } + if (updates.apiKey !== undefined) { + logger.debug(`[DEBUG] Updating apiKey (length: ${updates.apiKey?.length})`) + updatedData.apiKey = this._encryptSensitiveData(updates.apiKey) + } + if (updates.priority !== undefined) { + updatedData.priority = updates.priority.toString() + } + if (updates.supportedModels !== undefined) { + logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`) + // 处理 supportedModels,确保向后兼容 + const processedModels = this._processModelMapping(updates.supportedModels) + updatedData.supportedModels = JSON.stringify(processedModels) + } + if (updates.userAgent !== undefined) { + updatedData.userAgent = updates.userAgent + } + if (updates.rateLimitDuration !== undefined) { + updatedData.rateLimitDuration = updates.rateLimitDuration.toString() + } + if (updates.proxy !== undefined) { + updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + if (updates.isActive !== undefined) { + updatedData.isActive = updates.isActive.toString() + } + if (updates.schedulable !== undefined) { + updatedData.schedulable = updates.schedulable.toString() + // 如果是手动修改调度状态,清除所有自动停止相关的字段 + // 防止自动恢复 + updatedData.rateLimitAutoStopped = '' + updatedData.quotaAutoStopped = '' + // 兼容旧的标记 + updatedData.autoStoppedAt = '' + updatedData.stoppedReason = '' + + // 记录日志 + if (updates.schedulable === true || updates.schedulable === 'true') { + logger.info(`✅ Manually enabled scheduling for Claude Console account ${accountId}`) + } else { + logger.info(`⛔ Manually disabled scheduling for Claude Console account ${accountId}`) + } + } + + // 额度管理相关字段 + if (updates.dailyQuota !== undefined) { + updatedData.dailyQuota = updates.dailyQuota.toString() + } + if (updates.quotaResetTime !== undefined) { + updatedData.quotaResetTime = updates.quotaResetTime + } + if (updates.dailyUsage !== undefined) { + updatedData.dailyUsage = updates.dailyUsage.toString() + } + if (updates.lastResetDate !== undefined) { + updatedData.lastResetDate = updates.lastResetDate + } + if (updates.quotaStoppedAt !== undefined) { + updatedData.quotaStoppedAt = updates.quotaStoppedAt + } + + // 并发控制相关字段 + if (updates.maxConcurrentTasks !== undefined) { + updatedData.maxConcurrentTasks = updates.maxConcurrentTasks.toString() + } + if (updates.disableAutoProtection !== undefined) { + updatedData.disableAutoProtection = updates.disableAutoProtection.toString() + } + if (updates.interceptWarmup !== undefined) { + updatedData.interceptWarmup = updates.interceptWarmup.toString() + } + + // ✅ 直接保存 subscriptionExpiresAt(如果提供) + // Claude Console 没有 token 刷新逻辑,不会覆盖此字段 + if (updates.subscriptionExpiresAt !== undefined) { + updatedData.subscriptionExpiresAt = updates.subscriptionExpiresAt + } + + // 处理账户类型变更 + if (updates.accountType && updates.accountType !== existingAccount.accountType) { + updatedData.accountType = updates.accountType + + if (updates.accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } else { + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + } + } + + updatedData.updatedAt = new Date().toISOString() + + // 检查是否手动禁用了账号,如果是则发送webhook通知 + if (updates.isActive === false && existingAccount.isActive === true) { + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: updatedData.name || existingAccount.name || 'Unknown Account', + platform: 'claude-console', + status: 'disabled', + errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED', + reason: 'Account manually disabled by administrator' + }) + } catch (webhookError) { + logger.error( + 'Failed to send webhook notification for manual account disable:', + webhookError + ) + } + } + + logger.debug(`[DEBUG] Final updatedData to save: ${JSON.stringify(updatedData, null, 2)}`) + logger.debug(`[DEBUG] Updating Redis key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData) + + logger.success(`📝 Updated Claude Console account: ${accountId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to update Claude Console account:', error) + throw error + } + } + + // 🗑️ 删除账户 + async deleteAccount(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + // 从Redis删除 + await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + await redis.removeFromIndex('claude_console_account:index', accountId) + + // 从共享账户集合中移除 + if (account.accountType === 'shared') { + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + } + + logger.success(`🗑️ Deleted Claude Console account: ${accountId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to delete Claude Console account:', error) + throw error + } + } + + // 🚫 标记账号为限流状态 + async markAccountRateLimited(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountRateLimited` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-console', 429, 'rate_limit') + .catch(() => {}) + return { success: true, skipped: true } + } + + // 如果限流时间设置为 0,表示不启用限流机制,直接返回 + if (account.rateLimitDuration === 0) { + logger.info( + `ℹ️ Claude Console account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit` + ) + return { success: true, skipped: true } + } + + const updates = { + rateLimitedAt: new Date().toISOString(), + rateLimitStatus: 'limited', + isActive: 'false', // 禁用账户 + schedulable: 'false', // 停止调度,与其他平台保持一致 + errorMessage: `Rate limited at ${new Date().toISOString()}`, + // 使用独立的限流自动停止标记 + rateLimitAutoStopped: 'true' + } + + // 只有当前状态不是quota_exceeded时才设置为rate_limited + // 避免覆盖更重要的配额超限状态 + const currentStatus = await client.hget(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'status') + if (currentStatus !== 'quota_exceeded') { + updates.status = 'rate_limited' + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + const { getISOStringWithTimezone } = require('../../utils/dateHelper') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED', + reason: `Account rate limited (429 error) and has been disabled. ${account.rateLimitDuration ? `Will be automatically re-enabled after ${account.rateLimitDuration} minutes` : 'Manual intervention required to re-enable'}`, + timestamp: getISOStringWithTimezone(new Date()) + }) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + + logger.warn( + `🚫 Claude Console account marked as rate limited: ${account.name} (${accountId})` + ) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as rate limited: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账号的限流状态 + async removeAccountRateLimit(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 获取账户当前状态和额度信息 + const [currentStatus, quotaStoppedAt] = await client.hmget( + accountKey, + 'status', + 'quotaStoppedAt' + ) + + // 删除限流相关字段 + await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus') + + // 根据不同情况决定是否恢复账户 + if (currentStatus === 'rate_limited') { + if (quotaStoppedAt) { + // 还有额度限制,改为quota_exceeded状态 + await client.hset(accountKey, { + status: 'quota_exceeded' + // isActive保持false + }) + logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`) + } else { + // 没有额度限制,完全恢复 + const accountData = await client.hgetall(accountKey) + const updateData = { + isActive: 'true', + status: 'active', + errorMessage: '' + } + + const hadAutoStop = accountData.rateLimitAutoStopped === 'true' + + // 只恢复因限流而自动停止的账户 + if (hadAutoStop && accountData.schedulable === 'false') { + updateData.schedulable = 'true' // 恢复调度 + logger.info( + `✅ Auto-resuming scheduling for Claude Console account ${accountId} after rate limit cleared` + ) + } + + if (hadAutoStop) { + await client.hdel(accountKey, 'rateLimitAutoStopped') + } + + await client.hset(accountKey, updateData) + logger.success(`Rate limit removed and account re-enabled: ${accountId}`) + } + } else { + if (await client.hdel(accountKey, 'rateLimitAutoStopped')) { + logger.info( + `ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during rate limit recovery` + ) + } + logger.success(`Rate limit removed for Claude Console account: ${accountId}`) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error) + throw error + } + } + + // 🔍 检查账号是否处于限流状态 + async isAccountRateLimited(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + // 如果限流时间设置为 0,表示不启用限流机制 + if (account.rateLimitDuration === 0) { + return false + } + + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const rateLimitedAt = new Date(account.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = (now - rateLimitedAt) / (1000 * 60) + + // 使用账户配置的限流时间 + const rateLimitDuration = + typeof account.rateLimitDuration === 'number' && !Number.isNaN(account.rateLimitDuration) + ? account.rateLimitDuration + : 60 + + if (minutesSinceRateLimit >= rateLimitDuration) { + await this.removeAccountRateLimit(accountId) + return false + } + + return true + } + + return false + } catch (error) { + logger.error( + `❌ Failed to check rate limit status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + + // 🔍 检查账号是否因额度超限而被停用(懒惰检查) + async isAccountQuotaExceeded(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + // 如果没有设置额度限制,不会超额 + const dailyQuota = parseFloat(account.dailyQuota || '0') + if (isNaN(dailyQuota) || dailyQuota <= 0) { + return false + } + + // 如果账户没有被额度停用,检查当前使用情况 + if (!account.quotaStoppedAt) { + return false + } + + // 检查是否应该重置额度(到了新的重置时间点) + if (this._shouldResetQuota(account)) { + await this.resetDailyUsage(accountId) + return false + } + + // 仍在额度超限状态 + return true + } catch (error) { + logger.error( + `❌ Failed to check quota exceeded status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + + // 🔍 判断是否应该重置账户额度 + _shouldResetQuota(account) { + // 与 Redis 统计一致:按配置时区判断“今天”与时间点 + const tzNow = redis.getDateInTimezone(new Date()) + const today = redis.getDateStringInTimezone(tzNow) + + // 如果已经是今天重置过的,不需要重置 + if (account.lastResetDate === today) { + return false + } + + // 检查是否到了重置时间点(按配置时区的小时/分钟) + const resetTime = account.quotaResetTime || '00:00' + const [resetHour, resetMinute] = resetTime.split(':').map((n) => parseInt(n)) + + const currentHour = tzNow.getUTCHours() + const currentMinute = tzNow.getUTCMinutes() + + // 如果当前时间已过重置时间且不是同一天重置的,应该重置 + return currentHour > resetHour || (currentHour === resetHour && currentMinute >= resetMinute) + } + + // 🚫 标记账号为未授权状态(401错误) + async markAccountUnauthorized(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountUnauthorized` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-console', 401, 'auth_error') + .catch(() => {}) + return { success: true, skipped: true } + } + + const updates = { + schedulable: 'false', + status: 'unauthorized', + errorMessage: 'API Key无效或已过期(401错误)', + unauthorizedAt: new Date().toISOString(), + unauthorizedCount: String((parseInt(account.unauthorizedCount || '0') || 0) + 1) + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_UNAUTHORIZED', + reason: 'API Key无效或已过期(401错误),账户已停止调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send unauthorized webhook notification:', webhookError) + } + + logger.warn( + `🚫 Claude Console account marked as unauthorized: ${account.name} (${accountId})` + ) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as unauthorized: ${accountId}`, error) + throw error + } + } + + // 🚫 标记账号为临时封禁状态(400错误 - 账户临时禁用) + async markConsoleAccountBlocked(accountId, errorDetails = '') { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markConsoleAccountBlocked` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-console', 403, 'server_error') + .catch(() => {}) + return { success: true, skipped: true } + } + + const blockedMinutes = this._getBlockedHandlingMinutes() + + if (blockedMinutes <= 0) { + logger.info( + `ℹ️ CLAUDE_CONSOLE_BLOCKED_HANDLING_MINUTES 未设置或为0,跳过账户封禁:${account.name} (${accountId})` + ) + + if (account.blockedStatus === 'blocked') { + try { + await this.removeAccountBlocked(accountId) + } catch (cleanupError) { + logger.warn(`⚠️ 尝试移除账户封禁状态失败:${accountId}`, cleanupError) + } + } + + return { success: false, skipped: true } + } + + const updates = { + blockedAt: new Date().toISOString(), + blockedStatus: 'blocked', + isActive: 'false', // 禁用账户(与429保持一致) + schedulable: 'false', // 停止调度(与429保持一致) + status: 'account_blocked', // 设置状态(与429保持一致) + errorMessage: '账户临时被禁用(400错误)', + // 使用独立的封禁自动停止标记 + blockedAutoStopped: 'true' + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知,包含完整错误详情 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_BLOCKED', + reason: `账户临时被禁用(400错误)。账户将在 ${blockedMinutes} 分钟后自动恢复。`, + errorDetails: errorDetails || '无错误详情', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send blocked webhook notification:', webhookError) + } + + logger.warn(`🚫 Claude Console account temporarily blocked: ${account.name} (${accountId})`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as blocked: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账号的临时封禁状态 + async removeAccountBlocked(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 获取账户当前状态和额度信息 + const [currentStatus, quotaStoppedAt] = await client.hmget( + accountKey, + 'status', + 'quotaStoppedAt' + ) + + // 删除封禁相关字段 + await client.hdel(accountKey, 'blockedAt', 'blockedStatus') + + // 根据不同情况决定是否恢复账户 + if (currentStatus === 'account_blocked') { + if (quotaStoppedAt) { + // 还有额度限制,改为quota_exceeded状态 + await client.hset(accountKey, { + status: 'quota_exceeded' + // isActive保持false + }) + logger.info( + `⚠️ Blocked status removed but quota exceeded remains for account: ${accountId}` + ) + } else { + // 没有额度限制,完全恢复 + const accountData = await client.hgetall(accountKey) + const updateData = { + isActive: 'true', + status: 'active', + errorMessage: '' + } + + const hadAutoStop = accountData.blockedAutoStopped === 'true' + + // 只恢复因封禁而自动停止的账户 + if (hadAutoStop && accountData.schedulable === 'false') { + updateData.schedulable = 'true' // 恢复调度 + logger.info( + `✅ Auto-resuming scheduling for Claude Console account ${accountId} after blocked status cleared` + ) + } + + if (hadAutoStop) { + await client.hdel(accountKey, 'blockedAutoStopped') + } + + await client.hset(accountKey, updateData) + logger.success(`Blocked status removed and account re-enabled: ${accountId}`) + } + } else { + if (await client.hdel(accountKey, 'blockedAutoStopped')) { + logger.info( + `ℹ️ Removed stale auto-stop flag for Claude Console account ${accountId} during blocked status recovery` + ) + } + logger.success(`Blocked status removed for Claude Console account: ${accountId}`) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove blocked status for Claude Console account: ${accountId}`, + error + ) + throw error + } + } + + // 🔍 检查账号是否处于临时封禁状态 + async isAccountBlocked(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + if (account.blockedStatus === 'blocked' && account.blockedAt) { + const blockedDuration = this._getBlockedHandlingMinutes() + + if (blockedDuration <= 0) { + await this.removeAccountBlocked(accountId) + return false + } + + const blockedAt = new Date(account.blockedAt) + const now = new Date() + const minutesSinceBlocked = (now - blockedAt) / (1000 * 60) + + // 禁用时长过后自动恢复 + if (minutesSinceBlocked >= blockedDuration) { + await this.removeAccountBlocked(accountId) + return false + } + + return true + } + + return false + } catch (error) { + logger.error( + `❌ Failed to check blocked status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + + // 🚫 标记账号为过载状态(529错误) + async markAccountOverloaded(accountId) { + try { + const client = redis.getClientSafe() + const account = await this.getAccount(accountId) + + if (!account) { + throw new Error('Account not found') + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountOverloaded` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'claude-console', 529, 'overload') + .catch(() => {}) + return { success: true, skipped: true } + } + + const updates = { + overloadedAt: new Date().toISOString(), + overloadStatus: 'overloaded', + errorMessage: '服务过载(529错误)' + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || 'Claude Console Account', + platform: 'claude-console', + status: 'error', + errorCode: 'CLAUDE_CONSOLE_OVERLOADED', + reason: '服务过载(529错误)。账户将暂时停止调度', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.error('Failed to send overload webhook notification:', webhookError) + } + + logger.warn(`🚫 Claude Console account marked as overloaded: ${account.name} (${accountId})`) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark Claude Console account as overloaded: ${accountId}`, error) + throw error + } + } + + // ✅ 移除账号的过载状态 + async removeAccountOverload(accountId) { + try { + const client = redis.getClientSafe() + + await client.hdel(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'overloadedAt', 'overloadStatus') + + logger.success(`Overload status removed for Claude Console account: ${accountId}`) + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove overload status for Claude Console account: ${accountId}`, + error + ) + throw error + } + } + + // 🔍 检查账号是否处于过载状态 + async isAccountOverloaded(accountId) { + try { + const account = await this.getAccount(accountId) + if (!account) { + return false + } + + if (account.overloadStatus === 'overloaded' && account.overloadedAt) { + const overloadedAt = new Date(account.overloadedAt) + const now = new Date() + const minutesSinceOverload = (now - overloadedAt) / (1000 * 60) + + // 过载状态持续10分钟后自动恢复 + if (minutesSinceOverload >= 10) { + await this.removeAccountOverload(accountId) + return false + } + + return true + } + + return false + } catch (error) { + logger.error( + `❌ Failed to check overload status for Claude Console account: ${accountId}`, + error + ) + return false + } + } + + // 🚫 标记账号为封锁状态(模型不支持等原因) + async blockAccount(accountId, reason) { + try { + const client = redis.getClientSafe() + + // 获取账户信息用于webhook通知 + const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`) + + const updates = { + status: 'blocked', + errorMessage: reason, + blockedAt: new Date().toISOString() + } + + await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + logger.warn(`🚫 Claude Console account blocked: ${accountId} - ${reason}`) + + // 发送Webhook通知 + if (accountData && Object.keys(accountData).length > 0) { + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Unknown Account', + platform: 'claude-console', + status: 'blocked', + errorCode: 'CLAUDE_CONSOLE_BLOCKED', + reason + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to block Claude Console account: ${accountId}`, error) + throw error + } + } + + // 🌐 创建代理agent(使用统一的代理工具) + _createProxyAgent(proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + logger.info( + `🌐 Using proxy for Claude Console request: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else if (proxyConfig) { + logger.debug('🌐 Failed to create proxy agent for Claude Console') + } else { + logger.debug('🌐 No proxy configured for Claude Console request') + } + return proxyAgent + } + + // 🔐 加密敏感数据 + _encryptSensitiveData(data) { + if (!data) { + return '' + } + + try { + const key = this._generateEncryptionKey() + const iv = crypto.randomBytes(16) + + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let encrypted = cipher.update(data, 'utf8', 'hex') + encrypted += cipher.final('hex') + + return `${iv.toString('hex')}:${encrypted}` + } catch (error) { + logger.error('❌ Encryption error:', error) + return data + } + } + + // 🔓 解密敏感数据 + _decryptSensitiveData(encryptedData) { + if (!encryptedData) { + return '' + } + + // 🎯 检查缓存 + const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + if (encryptedData.includes(':')) { + const parts = encryptedData.split(':') + if (parts.length === 2) { + const key = this._generateEncryptionKey() + const iv = Buffer.from(parts[0], 'hex') + const encrypted = parts[1] + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + + // 💾 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) + + // 📊 定期打印缓存统计 + if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) { + this._decryptCache.printStats() + } + + return decrypted + } + } + + return encryptedData + } catch (error) { + logger.error('❌ Decryption error:', error) + return encryptedData + } + } + + // 🔑 生成加密密钥 + _generateEncryptionKey() { + // 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算 + // scryptSync 是故意设计为慢速的密钥派生函数(防暴力破解) + // 但在高并发场景下,每次都重新计算会导致 CPU 100% 占用 + if (!this._encryptionKeyCache) { + // 只在第一次调用时计算,后续使用缓存 + // 由于输入参数固定,派生结果永远相同,不影响数据兼容性 + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + logger.info('🔑 Console encryption key derived and cached for performance optimization') + } + return this._encryptionKeyCache + } + + // 🎭 掩码API URL + _maskApiUrl(apiUrl) { + if (!apiUrl) { + return '' + } + + try { + const url = new URL(apiUrl) + return `${url.protocol}//${url.hostname}/***` + } catch { + return '***' + } + } + + // 📊 获取限流信息 + _getRateLimitInfo(accountData) { + if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) { + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)) + const __parsedDuration = parseInt(accountData.rateLimitDuration) + const rateLimitDuration = Number.isNaN(__parsedDuration) ? 60 : __parsedDuration + const minutesRemaining = Math.max(0, rateLimitDuration - minutesSinceRateLimit) + + return { + isRateLimited: minutesRemaining > 0, + rateLimitedAt: accountData.rateLimitedAt, + minutesSinceRateLimit, + minutesRemaining + } + } + + return { + isRateLimited: false, + rateLimitedAt: null, + minutesSinceRateLimit: 0, + minutesRemaining: 0 + } + } + + // 🔄 处理模型映射,确保向后兼容 + _processModelMapping(supportedModels) { + // 如果是空值,返回空对象(支持所有模型) + if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) { + return {} + } + + // 如果已经是对象格式(新的映射表格式),直接返回 + if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) { + return supportedModels + } + + // 如果是数组格式(旧格式),转换为映射表 + if (Array.isArray(supportedModels)) { + const mapping = {} + supportedModels.forEach((model) => { + if (model && typeof model === 'string') { + mapping[model] = model // 映射到自身 + } + }) + return mapping + } + + // 其他情况返回空对象 + return {} + } + + // 🔍 检查模型是否支持(用于调度) + isModelSupported(modelMapping, requestedModel) { + // 如果映射表为空,支持所有模型 + if (!modelMapping || Object.keys(modelMapping).length === 0) { + return true + } + + // 检查请求的模型是否在映射表的键中(精确匹配) + if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) { + return true + } + + // 尝试大小写不敏感匹配 + const requestedModelLower = requestedModel.toLowerCase() + for (const key of Object.keys(modelMapping)) { + if (key.toLowerCase() === requestedModelLower) { + return true + } + } + + return false + } + + // 🔄 获取映射后的模型名称 + getMappedModel(modelMapping, requestedModel) { + // 如果映射表为空,返回原模型 + if (!modelMapping || Object.keys(modelMapping).length === 0) { + return requestedModel + } + + // 精确匹配 + if (modelMapping[requestedModel]) { + return modelMapping[requestedModel] + } + + // 大小写不敏感匹配 + const requestedModelLower = requestedModel.toLowerCase() + for (const [key, value] of Object.entries(modelMapping)) { + if (key.toLowerCase() === requestedModelLower) { + return value + } + } + + // 如果不存在则返回原模型 + return requestedModel + } + + // 💰 检查账户使用额度(基于实时统计数据) + async checkQuotaUsage(accountId) { + try { + // 获取实时的使用统计(包含费用) + const usageStats = await redis.getAccountUsageStats(accountId) + const currentDailyCost = usageStats.daily.cost || 0 + + // 获取账户配置 + const accountData = await this.getAccount(accountId) + if (!accountData) { + logger.warn(`Account not found: ${accountId}`) + return + } + + // 解析额度配置,确保数值有效 + const dailyQuota = parseFloat(accountData.dailyQuota || '0') + if (isNaN(dailyQuota) || dailyQuota <= 0) { + // 没有设置有效额度,无需检查 + return + } + + // 检查是否已经因额度停用(避免重复操作) + if (accountData.quotaStoppedAt) { + return + } + + // 检查是否超过额度限制 + if (currentDailyCost >= dailyQuota) { + // 使用原子操作避免竞态条件 - 再次检查是否已设置quotaStoppedAt + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // double-check locking pattern - 检查quotaStoppedAt而不是status + const existingQuotaStop = await client.hget(accountKey, 'quotaStoppedAt') + if (existingQuotaStop) { + return // 已经被其他进程处理 + } + + // 超过额度,停止调度但保持账户状态正常 + // 不修改 isActive 和 status,只用独立字段标记配额超限 + const updates = { + quotaStoppedAt: new Date().toISOString(), + errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`, + schedulable: false, // 停止调度 + // 使用独立的额度超限自动停止标记 + quotaAutoStopped: 'true' + } + + await this.updateAccount(accountId, updates) + + logger.warn( + `💰 Account ${accountId} exceeded daily quota: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + ) + + // 发送webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || 'Unknown Account', + platform: 'claude-console', + status: 'quota_exceeded', + errorCode: 'CLAUDE_CONSOLE_QUOTA_EXCEEDED', + reason: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}` + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification for quota exceeded:', webhookError) + } + } + + logger.debug( + `💰 Quota check for account ${accountId}: $${currentDailyCost.toFixed(4)} / $${dailyQuota.toFixed(2)}` + ) + } catch (error) { + logger.error('Failed to check quota usage:', error) + } + } + + // 🔄 重置账户每日使用量(恢复因额度停用的账户) + async resetDailyUsage(accountId) { + try { + const accountData = await this.getAccount(accountId) + if (!accountData) { + return + } + + const today = redis.getDateStringInTimezone() + const updates = { + lastResetDate: today + } + + // 如果账户因配额超限被停用,恢复账户 + // 新逻辑:不再依赖 isActive === false 和 status 判断 + // 只要有 quotaStoppedAt 就说明是因配额超限被停止的 + if (accountData.quotaStoppedAt) { + updates.errorMessage = '' + updates.quotaStoppedAt = '' + + // 只恢复因额度超限而自动停止的账户 + if (accountData.quotaAutoStopped === 'true') { + updates.schedulable = true + updates.quotaAutoStopped = '' + } + + logger.info(`✅ Restored account ${accountId} after daily quota reset`) + } + + await this.updateAccount(accountId, updates) + + logger.debug(`🔄 Reset daily usage for account ${accountId}`) + } catch (error) { + logger.error('Failed to reset daily usage:', error) + } + } + + // 🔄 重置所有账户的每日使用量 + async resetAllDailyUsage() { + try { + const accounts = await this.getAllAccounts() + // 与统计一致使用配置时区日期 + const today = redis.getDateStringInTimezone() + let resetCount = 0 + + for (const account of accounts) { + // 只重置需要重置的账户 + if (account.lastResetDate !== today) { + await this.resetDailyUsage(account.id) + resetCount += 1 + } + } + + logger.success(`Reset daily usage for ${resetCount} Claude Console accounts`) + } catch (error) { + logger.error('Failed to reset all daily usage:', error) + } + } + + // 📊 获取账户使用统计(基于实时数据) + async getAccountUsageStats(accountId) { + try { + // 获取实时的使用统计(包含费用) + const usageStats = await redis.getAccountUsageStats(accountId) + const currentDailyCost = usageStats.daily.cost || 0 + + // 获取账户配置 + const accountData = await this.getAccount(accountId) + if (!accountData) { + return null + } + + const dailyQuota = parseFloat(accountData.dailyQuota || '0') + + return { + dailyQuota, + dailyUsage: currentDailyCost, // 使用实时计算的费用 + remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null, + usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0, + lastResetDate: accountData.lastResetDate, + quotaStoppedAt: accountData.quotaStoppedAt, + isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota, + // 额外返回完整的使用统计 + fullUsageStats: usageStats + } + } catch (error) { + logger.error('Failed to get account usage stats:', error) + return null + } + } + + // 🔄 重置账户所有异常状态 + async resetAccountStatus(accountId) { + try { + const accountData = await this.getAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 准备要更新的字段 + const updates = { + status: 'active', + errorMessage: '', + schedulable: 'true', + isActive: 'true' // 重要:必须恢复isActive状态 + } + + // 删除所有异常状态相关的字段 + const fieldsToDelete = [ + 'rateLimitedAt', + 'rateLimitStatus', + 'unauthorizedAt', + 'unauthorizedCount', + 'overloadedAt', + 'overloadStatus', + 'blockedAt', + 'quotaStoppedAt' + ] + + // 执行更新 + await client.hset(accountKey, updates) + await client.hdel(accountKey, ...fieldsToDelete) + + logger.success(`Reset all error status for Claude Console account ${accountId}`) + + // 清除临时不可用状态 + await upstreamErrorHelper.clearTempUnavailable(accountId, 'claude-console').catch(() => {}) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || accountId, + platform: 'claude-console', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.warn('Failed to send webhook notification:', webhookError) + } + + return { success: true, accountId } + } catch (error) { + logger.error(`❌ Failed to reset Claude Console account status: ${accountId}`, error) + throw error + } + } + + /** + * ⏰ 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() + } + + // 🚫 标记账户的 count_tokens 端点不可用 + async markCountTokensUnavailable(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 检查账户是否存在 + const exists = await client.exists(accountKey) + if (!exists) { + logger.warn( + `⚠️ Cannot mark count_tokens unavailable for non-existent account: ${accountId}` + ) + return { success: false, reason: 'Account not found' } + } + + await client.hset(accountKey, { + countTokensUnavailable: 'true', + countTokensUnavailableAt: new Date().toISOString() + }) + + logger.info( + `🚫 Marked count_tokens endpoint as unavailable for Claude Console account: ${accountId}` + ) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark count_tokens unavailable for account ${accountId}:`, error) + throw error + } + } + + // ✅ 移除账户的 count_tokens 不可用标记 + async removeCountTokensUnavailable(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + await client.hdel(accountKey, 'countTokensUnavailable', 'countTokensUnavailableAt') + + logger.info( + `✅ Removed count_tokens unavailable mark for Claude Console account: ${accountId}` + ) + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove count_tokens unavailable mark for account ${accountId}:`, + error + ) + throw error + } + } + + // 🔍 检查账户的 count_tokens 端点是否不可用 + async isCountTokensUnavailable(accountId) { + try { + const client = redis.getClientSafe() + const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + const value = await client.hget(accountKey, 'countTokensUnavailable') + return value === 'true' + } catch (error) { + logger.error(`❌ Failed to check count_tokens availability for account ${accountId}:`, error) + return false // 出错时默认返回可用,避免误阻断 + } + } +} + +module.exports = new ClaudeConsoleAccountService() diff --git a/src/services/account/droidAccountService.js b/src/services/account/droidAccountService.js new file mode 100644 index 0000000..cd9b798 --- /dev/null +++ b/src/services/account/droidAccountService.js @@ -0,0 +1,1563 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const axios = require('axios') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const { maskToken } = require('../../utils/tokenMask') +const ProxyHelper = require('../../utils/proxyHelper') +const { createEncryptor, isTruthy } = require('../../utils/commonHelper') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +/** + * Droid 账户管理服务 + * + * 支持 WorkOS OAuth 集成,管理 Droid (Factory.ai) 账户 + * 提供账户创建、token 刷新、代理配置等功能 + */ +class DroidAccountService { + constructor() { + // WorkOS OAuth 配置 + this.oauthTokenUrl = 'https://api.workos.com/user_management/authenticate' + this.factoryApiBaseUrl = 'https://api.factory.ai/api/llm' + + this.workosClientId = 'client_01HNM792M5G5G1A2THWPXKFMXB' + + // Token 刷新策略 + this.refreshIntervalHours = 6 // 每6小时刷新一次 + this.tokenValidHours = 8 // Token 有效期8小时 + + // 使用 commonHelper 的加密器 + this._encryptor = createEncryptor('droid-account-salt') + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._encryptor.clearCache() + logger.info('🧹 Droid decrypt cache cleanup completed', this._encryptor.getStats()) + }, + 10 * 60 * 1000 + ) + + this.supportedEndpointTypes = new Set(['anthropic', 'openai', 'comm']) + } + + _sanitizeEndpointType(endpointType) { + if (!endpointType) { + return 'anthropic' + } + + const normalized = String(endpointType).toLowerCase() + if (normalized === 'openai') { + return 'openai' + } + + if (normalized === 'comm') { + return 'comm' + } + + if (this.supportedEndpointTypes.has(normalized)) { + return normalized + } + + return 'anthropic' + } + + // 使用 commonHelper 的 isTruthy + _isTruthy(value) { + return isTruthy(value) + } + + // 加密敏感数据 + _encryptSensitiveData(text) { + return this._encryptor.encrypt(text) + } + + // 解密敏感数据(带缓存) + _decryptSensitiveData(encryptedText) { + return this._encryptor.decrypt(encryptedText) + } + + _parseApiKeyEntries(rawEntries) { + if (!rawEntries) { + return [] + } + + if (Array.isArray(rawEntries)) { + return rawEntries + } + + if (typeof rawEntries === 'string') { + try { + const parsed = JSON.parse(rawEntries) + return Array.isArray(parsed) ? parsed : [] + } catch (error) { + logger.warn('⚠️ Failed to parse Droid API Key entries:', error.message) + return [] + } + } + + return [] + } + + _buildApiKeyEntries(apiKeys, existingEntries = [], clearExisting = false) { + const now = new Date().toISOString() + const normalizedExisting = Array.isArray(existingEntries) ? existingEntries : [] + + const entries = clearExisting + ? [] + : normalizedExisting + .filter((entry) => entry && entry.id && entry.encryptedKey) + .map((entry) => ({ + ...entry, + status: entry.status || 'active' // 确保有默认状态 + })) + + const hashSet = new Set(entries.map((entry) => entry.hash).filter(Boolean)) + + if (!Array.isArray(apiKeys) || apiKeys.length === 0) { + return entries + } + + for (const rawKey of apiKeys) { + if (typeof rawKey !== 'string') { + continue + } + + const trimmed = rawKey.trim() + if (!trimmed) { + continue + } + + const hash = crypto.createHash('sha256').update(trimmed).digest('hex') + if (hashSet.has(hash)) { + continue + } + + hashSet.add(hash) + + entries.push({ + id: uuidv4(), + hash, + encryptedKey: this._encryptSensitiveData(trimmed), + createdAt: now, + lastUsedAt: '', + usageCount: '0', + status: 'active', // 新增状态字段 + errorMessage: '' // 新增错误信息字段 + }) + } + + return entries + } + + _maskApiKeyEntries(entries) { + if (!Array.isArray(entries)) { + return [] + } + + return entries.map((entry) => ({ + id: entry.id, + createdAt: entry.createdAt || '', + lastUsedAt: entry.lastUsedAt || '', + usageCount: entry.usageCount || '0', + status: entry.status || 'active', // 新增状态字段 + errorMessage: entry.errorMessage || '' // 新增错误信息字段 + })) + } + + _decryptApiKeyEntry(entry) { + if (!entry || !entry.encryptedKey) { + return null + } + + const apiKey = this._decryptSensitiveData(entry.encryptedKey) + if (!apiKey) { + return null + } + + const usageCountNumber = Number(entry.usageCount) + + return { + id: entry.id, + key: apiKey, + hash: entry.hash || '', + createdAt: entry.createdAt || '', + lastUsedAt: entry.lastUsedAt || '', + usageCount: Number.isFinite(usageCountNumber) && usageCountNumber >= 0 ? usageCountNumber : 0, + status: entry.status || 'active', // 新增状态字段 + errorMessage: entry.errorMessage || '' // 新增错误信息字段 + } + } + + async getDecryptedApiKeyEntries(accountId) { + if (!accountId) { + return [] + } + + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return [] + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + return entries + .map((entry) => this._decryptApiKeyEntry(entry)) + .filter((entry) => entry && entry.key) + } + + async touchApiKeyUsage(accountId, keyId) { + if (!accountId || !keyId) { + return + } + + try { + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + const index = entries.findIndex((entry) => entry.id === keyId) + + if (index === -1) { + return + } + + const updatedEntry = { ...entries[index] } + updatedEntry.lastUsedAt = new Date().toISOString() + const usageCount = Number(updatedEntry.usageCount) + updatedEntry.usageCount = String( + Number.isFinite(usageCount) && usageCount >= 0 ? usageCount + 1 : 1 + ) + + entries[index] = updatedEntry + + accountData.apiKeys = JSON.stringify(entries) + accountData.apiKeyCount = String(entries.length) + + await redis.setDroidAccount(accountId, accountData) + } catch (error) { + logger.warn(`⚠️ Failed to update API key usage for Droid account ${accountId}:`, error) + } + } + + /** + * 删除指定的 Droid API Key 条目 + */ + async removeApiKeyEntry(accountId, keyId) { + if (!accountId || !keyId) { + return { removed: false, remainingCount: 0 } + } + + try { + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return { removed: false, remainingCount: 0 } + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + if (!entries || entries.length === 0) { + return { removed: false, remainingCount: 0 } + } + + const filtered = entries.filter((entry) => entry && entry.id !== keyId) + if (filtered.length === entries.length) { + return { removed: false, remainingCount: entries.length } + } + + accountData.apiKeys = filtered.length ? JSON.stringify(filtered) : '' + accountData.apiKeyCount = String(filtered.length) + + await redis.setDroidAccount(accountId, accountData) + + logger.warn( + `🚫 已删除 Droid API Key ${keyId}(Account: ${accountId}),剩余 ${filtered.length}` + ) + + return { removed: true, remainingCount: filtered.length } + } catch (error) { + logger.error(`❌ 删除 Droid API Key 失败:${keyId}(Account: ${accountId})`, error) + return { removed: false, remainingCount: 0, error } + } + } + + /** + * 标记指定的 Droid API Key 条目为异常状态 + */ + async markApiKeyAsError(accountId, keyId, errorMessage = '') { + if (!accountId || !keyId) { + return { marked: false, error: '参数无效' } + } + + try { + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return { marked: false, error: '账户不存在' } + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + if (!entries || entries.length === 0) { + return { marked: false, error: '无API Key条目' } + } + + let marked = false + const updatedEntries = entries.map((entry) => { + if (entry && entry.id === keyId) { + marked = true + return { + ...entry, + status: 'error', + errorMessage: errorMessage || 'API Key异常' + } + } + return entry + }) + + if (!marked) { + return { marked: false, error: '未找到指定的API Key' } + } + + accountData.apiKeys = JSON.stringify(updatedEntries) + await redis.setDroidAccount(accountId, accountData) + + logger.warn( + `⚠️ 已标记 Droid API Key ${keyId} 为异常状态(Account: ${accountId}):${errorMessage}` + ) + + return { marked: true } + } catch (error) { + logger.error(`❌ 标记 Droid API Key 异常状态失败:${keyId}(Account: ${accountId})`, error) + return { marked: false, error: error.message } + } + } + + /** + * 使用 WorkOS Refresh Token 刷新并验证凭证 + */ + async _refreshTokensWithWorkOS(refreshToken, proxyConfig = null, organizationId = null) { + if (!refreshToken || typeof refreshToken !== 'string') { + throw new Error('Refresh Token 无效') + } + + const formData = new URLSearchParams() + formData.append('grant_type', 'refresh_token') + formData.append('refresh_token', refreshToken) + formData.append('client_id', this.workosClientId) + if (organizationId) { + formData.append('organization_id', organizationId) + } + + const requestOptions = { + method: 'POST', + url: this.oauthTokenUrl, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + data: formData.toString(), + timeout: 30000 + } + + if (proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + requestOptions.httpAgent = proxyAgent + requestOptions.httpsAgent = proxyAgent + requestOptions.proxy = false + logger.info( + `🌐 使用代理验证 Droid Refresh Token: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } + } + + const response = await axios(requestOptions) + if (!response.data || !response.data.access_token) { + throw new Error('WorkOS OAuth 返回数据无效') + } + + const { + access_token, + refresh_token, + user, + organization_id, + expires_in, + token_type, + authentication_method + } = response.data + + let expiresAt = response.data.expires_at || '' + if (!expiresAt) { + const expiresInSeconds = + typeof expires_in === 'number' && Number.isFinite(expires_in) + ? expires_in + : this.tokenValidHours * 3600 + expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString() + } + + return { + accessToken: access_token, + refreshToken: refresh_token || refreshToken, + expiresAt, + expiresIn: typeof expires_in === 'number' && Number.isFinite(expires_in) ? expires_in : null, + user: user || null, + organizationId: organization_id || '', + tokenType: token_type || 'Bearer', + authenticationMethod: authentication_method || '' + } + } + + /** + * 使用 Factory CLI 接口获取组织 ID 列表 + */ + async _fetchFactoryOrgIds(accessToken, proxyConfig = null) { + if (!accessToken) { + return [] + } + + const requestOptions = { + method: 'GET', + url: 'https://app.factory.ai/api/cli/org', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-factory-client': 'cli', + 'User-Agent': this.userAgent + }, + timeout: 15000 + } + + if (proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + requestOptions.httpAgent = proxyAgent + requestOptions.httpsAgent = proxyAgent + requestOptions.proxy = false + } + } + + try { + const response = await axios(requestOptions) + const data = response.data || {} + if (Array.isArray(data.workosOrgIds) && data.workosOrgIds.length > 0) { + return data.workosOrgIds + } + logger.warn('⚠️ 未从 Factory CLI 接口获取到 workosOrgIds') + return [] + } catch (error) { + logger.warn('⚠️ 获取 Factory 组织信息失败:', error.message) + return [] + } + } + + /** + * 创建 Droid 账户 + * + * @param {Object} options - 账户配置选项 + * @returns {Promise} 创建的账户信息 + */ + async createAccount(options = {}) { + const { + name = 'Unnamed Droid Account', + description = '', + refreshToken = '', // WorkOS refresh token + accessToken = '', // WorkOS access token (可选) + expiresAt = '', // Token 过期时间 + proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' } + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + platform = 'droid', + priority = 50, // 调度优先级 (1-100) + schedulable = true, // 是否可被调度 + endpointType = 'anthropic', // 默认端点类型: 'anthropic', 'openai' 或 'comm' + organizationId = '', + ownerEmail = '', + ownerName = '', + userId = '', + tokenType = 'Bearer', + authenticationMethod = '', + expiresIn = null, + apiKeys = [], + userAgent = '', // 自定义 User-Agent + disableAutoProtection = false // 是否关闭自动防护(429/401/400/529 不自动禁用) + } = options + + const accountId = uuidv4() + + const normalizedEndpointType = this._sanitizeEndpointType(endpointType) + + let normalizedRefreshToken = refreshToken + let normalizedAccessToken = accessToken + let normalizedExpiresAt = expiresAt || '' + let normalizedExpiresIn = expiresIn + let normalizedOrganizationId = organizationId || '' + let normalizedOwnerEmail = ownerEmail || '' + let normalizedOwnerName = ownerName || '' + let normalizedOwnerDisplayName = ownerName || ownerEmail || '' + let normalizedUserId = userId || '' + let normalizedTokenType = tokenType || 'Bearer' + let normalizedAuthenticationMethod = authenticationMethod || '' + let lastRefreshAt = accessToken ? new Date().toISOString() : '' + let status = accessToken ? 'active' : 'created' + + const apiKeyEntries = this._buildApiKeyEntries(apiKeys) + const hasApiKeys = apiKeyEntries.length > 0 + + if (hasApiKeys) { + normalizedAuthenticationMethod = 'api_key' + normalizedAccessToken = '' + normalizedRefreshToken = '' + normalizedExpiresAt = '' + normalizedExpiresIn = null + lastRefreshAt = '' + status = 'active' + } + + const normalizedAuthMethod = + typeof normalizedAuthenticationMethod === 'string' + ? normalizedAuthenticationMethod.toLowerCase().trim() + : '' + + const isApiKeyProvision = normalizedAuthMethod === 'api_key' + const isManualProvision = normalizedAuthMethod === 'manual' + + const provisioningMode = isApiKeyProvision ? 'api_key' : isManualProvision ? 'manual' : 'oauth' + + if (isApiKeyProvision) { + logger.info( + `🔍 [Droid api_key] 初始密钥 - AccountName: ${name}, KeyCount: ${apiKeyEntries.length}` + ) + } else { + logger.info( + `🔍 [Droid ${provisioningMode}] 初始令牌 - AccountName: ${name}, AccessToken: ${ + normalizedAccessToken || '[empty]' + }, RefreshToken: ${normalizedRefreshToken || '[empty]'}` + ) + } + + let proxyConfig = null + if (proxy && typeof proxy === 'object') { + proxyConfig = proxy + } else if (typeof proxy === 'string' && proxy.trim()) { + try { + proxyConfig = JSON.parse(proxy) + } catch (error) { + logger.warn('⚠️ Droid 代理配置解析失败,已忽略:', error.message) + proxyConfig = null + } + } + + if (!isApiKeyProvision && normalizedRefreshToken && isManualProvision) { + try { + const refreshed = await this._refreshTokensWithWorkOS(normalizedRefreshToken, proxyConfig) + + logger.info( + `🔍 [Droid manual] 刷新后令牌 - AccountName: ${name}, AccessToken: ${refreshed.accessToken || '[empty]'}, RefreshToken: ${refreshed.refreshToken || '[empty]'}, ExpiresAt: ${refreshed.expiresAt || '[empty]'}, ExpiresIn: ${ + refreshed.expiresIn !== null && refreshed.expiresIn !== undefined + ? refreshed.expiresIn + : '[empty]' + }` + ) + + normalizedAccessToken = refreshed.accessToken + normalizedRefreshToken = refreshed.refreshToken + normalizedExpiresAt = refreshed.expiresAt || normalizedExpiresAt + normalizedTokenType = refreshed.tokenType || normalizedTokenType + normalizedAuthenticationMethod = + refreshed.authenticationMethod || normalizedAuthenticationMethod + if (refreshed.expiresIn !== null) { + normalizedExpiresIn = refreshed.expiresIn + } + if (refreshed.organizationId) { + normalizedOrganizationId = refreshed.organizationId + } + + if (refreshed.user) { + const userInfo = refreshed.user + if (typeof userInfo.email === 'string' && userInfo.email.trim()) { + normalizedOwnerEmail = userInfo.email.trim() + } + const nameParts = [] + if (typeof userInfo.first_name === 'string' && userInfo.first_name.trim()) { + nameParts.push(userInfo.first_name.trim()) + } + if (typeof userInfo.last_name === 'string' && userInfo.last_name.trim()) { + nameParts.push(userInfo.last_name.trim()) + } + const derivedName = + nameParts.join(' ').trim() || + (typeof userInfo.name === 'string' ? userInfo.name.trim() : '') || + (typeof userInfo.display_name === 'string' ? userInfo.display_name.trim() : '') + + if (derivedName) { + normalizedOwnerName = derivedName + normalizedOwnerDisplayName = derivedName + } else if (normalizedOwnerEmail) { + normalizedOwnerName = normalizedOwnerName || normalizedOwnerEmail + normalizedOwnerDisplayName = + normalizedOwnerDisplayName || normalizedOwnerEmail || normalizedOwnerName + } + + if (typeof userInfo.id === 'string' && userInfo.id.trim()) { + normalizedUserId = userInfo.id.trim() + } + } + + lastRefreshAt = new Date().toISOString() + status = 'active' + logger.success(`使用 Refresh Token 成功验证并刷新 Droid 账户: ${name} (${accountId})`) + } catch (error) { + logger.error('❌ 使用 Refresh Token 验证 Droid 账户失败:', error) + throw new Error(`Refresh Token 验证失败:${error.message}`) + } + } else if (!isApiKeyProvision && normalizedRefreshToken && !isManualProvision) { + try { + const orgIds = await this._fetchFactoryOrgIds(normalizedAccessToken, proxyConfig) + const selectedOrgId = + normalizedOrganizationId || + (Array.isArray(orgIds) + ? orgIds.find((id) => typeof id === 'string' && id.trim()) + : null) || + '' + + if (!selectedOrgId) { + logger.warn(`⚠️ [Droid oauth] 未获取到组织ID,跳过 WorkOS 刷新: ${name} (${accountId})`) + } else { + const refreshed = await this._refreshTokensWithWorkOS( + normalizedRefreshToken, + proxyConfig, + selectedOrgId + ) + + logger.info( + `🔍 [Droid oauth] 组织刷新后令牌 - AccountName: ${name}, AccessToken: ${refreshed.accessToken || '[empty]'}, RefreshToken: ${refreshed.refreshToken || '[empty]'}, OrganizationId: ${ + refreshed.organizationId || selectedOrgId + }, ExpiresAt: ${refreshed.expiresAt || '[empty]'}` + ) + + normalizedAccessToken = refreshed.accessToken + normalizedRefreshToken = refreshed.refreshToken + normalizedExpiresAt = refreshed.expiresAt || normalizedExpiresAt + normalizedTokenType = refreshed.tokenType || normalizedTokenType + normalizedAuthenticationMethod = + refreshed.authenticationMethod || normalizedAuthenticationMethod + if (refreshed.expiresIn !== null && refreshed.expiresIn !== undefined) { + normalizedExpiresIn = refreshed.expiresIn + } + if (refreshed.organizationId) { + normalizedOrganizationId = refreshed.organizationId + } else { + normalizedOrganizationId = selectedOrgId + } + + if (refreshed.user) { + const userInfo = refreshed.user + if (typeof userInfo.email === 'string' && userInfo.email.trim()) { + normalizedOwnerEmail = userInfo.email.trim() + } + const nameParts = [] + if (typeof userInfo.first_name === 'string' && userInfo.first_name.trim()) { + nameParts.push(userInfo.first_name.trim()) + } + if (typeof userInfo.last_name === 'string' && userInfo.last_name.trim()) { + nameParts.push(userInfo.last_name.trim()) + } + const derivedName = + nameParts.join(' ').trim() || + (typeof userInfo.name === 'string' ? userInfo.name.trim() : '') || + (typeof userInfo.display_name === 'string' ? userInfo.display_name.trim() : '') + + if (derivedName) { + normalizedOwnerName = derivedName + normalizedOwnerDisplayName = derivedName + } else if (normalizedOwnerEmail) { + normalizedOwnerName = normalizedOwnerName || normalizedOwnerEmail + normalizedOwnerDisplayName = + normalizedOwnerDisplayName || normalizedOwnerEmail || normalizedOwnerName + } + + if (typeof userInfo.id === 'string' && userInfo.id.trim()) { + normalizedUserId = userInfo.id.trim() + } + } + + lastRefreshAt = new Date().toISOString() + status = 'active' + } + } catch (error) { + logger.warn(`⚠️ [Droid oauth] 初始化刷新失败: ${name} (${accountId}) - ${error.message}`) + } + } + + if (!isApiKeyProvision && !normalizedExpiresAt) { + let expiresInSeconds = null + if (typeof normalizedExpiresIn === 'number' && Number.isFinite(normalizedExpiresIn)) { + expiresInSeconds = normalizedExpiresIn + } else if ( + typeof normalizedExpiresIn === 'string' && + normalizedExpiresIn.trim() && + !Number.isNaN(Number(normalizedExpiresIn)) + ) { + expiresInSeconds = Number(normalizedExpiresIn) + } + + if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0) { + expiresInSeconds = this.tokenValidHours * 3600 + } + + normalizedExpiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString() + normalizedExpiresIn = expiresInSeconds + } + + logger.info( + `🔍 [Droid ${provisioningMode}] 写入前令牌快照 - AccountName: ${name}, AccessToken: ${normalizedAccessToken || '[empty]'}, RefreshToken: ${normalizedRefreshToken || '[empty]'}, ExpiresAt: ${normalizedExpiresAt || '[empty]'}, ExpiresIn: ${ + normalizedExpiresIn !== null && normalizedExpiresIn !== undefined + ? normalizedExpiresIn + : '[empty]' + }` + ) + + const accountData = { + id: accountId, + name, + description, + refreshToken: this._encryptSensitiveData(normalizedRefreshToken), + accessToken: this._encryptSensitiveData(normalizedAccessToken), + expiresAt: normalizedExpiresAt || '', // OAuth Token 过期时间(技术字段,自动刷新) + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + subscriptionExpiresAt: options.subscriptionExpiresAt || null, + + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + platform, + priority: priority.toString(), + createdAt: new Date().toISOString(), + lastUsedAt: '', + lastRefreshAt, + status, // created, active, expired, error + errorMessage: '', + schedulable: schedulable.toString(), + endpointType: normalizedEndpointType, // anthropic, openai 或 comm + organizationId: normalizedOrganizationId || '', + owner: normalizedOwnerName || normalizedOwnerEmail || '', + ownerEmail: normalizedOwnerEmail || '', + ownerName: normalizedOwnerName || '', + ownerDisplayName: + normalizedOwnerDisplayName || normalizedOwnerName || normalizedOwnerEmail || '', + userId: normalizedUserId || '', + tokenType: normalizedTokenType || 'Bearer', + authenticationMethod: normalizedAuthenticationMethod || '', + expiresIn: + normalizedExpiresIn !== null && normalizedExpiresIn !== undefined + ? String(normalizedExpiresIn) + : '', + apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '', + apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0', + apiKeyStrategy: hasApiKeys ? 'random_sticky' : '', + userAgent: userAgent || '', // 自定义 User-Agent + disableAutoProtection: disableAutoProtection.toString() // 关闭自动防护 + } + + await redis.setDroidAccount(accountId, accountData) + + logger.success( + `🏢 Created Droid account: ${name} (${accountId}) - Endpoint: ${normalizedEndpointType}` + ) + + try { + const verifyAccount = await this.getAccount(accountId) + logger.info( + `🔍 [Droid ${provisioningMode}] Redis 写入后验证 - AccountName: ${name}, AccessToken: ${verifyAccount?.accessToken || '[empty]'}, RefreshToken: ${verifyAccount?.refreshToken || '[empty]'}, ExpiresAt: ${verifyAccount?.expiresAt || '[empty]'}` + ) + } catch (verifyError) { + logger.warn( + `⚠️ [Droid ${provisioningMode}] 写入后验证失败: ${name} (${accountId}) - ${verifyError.message}` + ) + } + return { id: accountId, ...accountData } + } + + /** + * 获取 Droid 账户信息 + */ + async getAccount(accountId) { + const account = await redis.getDroidAccount(accountId) + if (!account || Object.keys(account).length === 0) { + return null + } + + // 解密敏感数据 + const apiKeyEntries = this._parseApiKeyEntries(account.apiKeys) + + return { + ...account, + id: accountId, + endpointType: this._sanitizeEndpointType(account.endpointType), + refreshToken: this._decryptSensitiveData(account.refreshToken), + accessToken: this._decryptSensitiveData(account.accessToken), + apiKeys: this._maskApiKeyEntries(apiKeyEntries), + apiKeyCount: apiKeyEntries.length + } + } + + /** + * 获取所有 Droid 账户 + */ + async getAllAccounts() { + const accounts = await redis.getAllDroidAccounts() + return accounts.map((account) => ({ + ...account, + endpointType: this._sanitizeEndpointType(account.endpointType), + // 不解密完整 token,只返回掩码 + refreshToken: account.refreshToken ? '***ENCRYPTED***' : '', + accessToken: account.accessToken + ? maskToken(this._decryptSensitiveData(account.accessToken)) + : '', + + // ✅ 前端显示订阅过期时间(业务字段) + expiresAt: account.subscriptionExpiresAt || null, + platform: account.platform || 'droid', + + apiKeyCount: (() => { + const parsedCount = this._parseApiKeyEntries(account.apiKeys).length + if (account.apiKeyCount === undefined || account.apiKeyCount === null) { + return parsedCount + } + const numeric = Number(account.apiKeyCount) + return Number.isFinite(numeric) && numeric >= 0 ? numeric : parsedCount + })() + })) + } + + /** + * 更新 Droid 账户 + */ + async updateAccount(accountId, updates) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error(`Droid account not found: ${accountId}`) + } + + const storedAccount = await redis.getDroidAccount(accountId) + const hasStoredAccount = + storedAccount && typeof storedAccount === 'object' && Object.keys(storedAccount).length > 0 + const sanitizedUpdates = { ...updates } + + if (typeof sanitizedUpdates.accessToken === 'string') { + sanitizedUpdates.accessToken = sanitizedUpdates.accessToken.trim() + } + if (typeof sanitizedUpdates.refreshToken === 'string') { + sanitizedUpdates.refreshToken = sanitizedUpdates.refreshToken.trim() + } + + if (sanitizedUpdates.endpointType) { + sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType) + } + + // 处理 userAgent 字段 + if (typeof sanitizedUpdates.userAgent === 'string') { + sanitizedUpdates.userAgent = sanitizedUpdates.userAgent.trim() + } + + const parseProxyConfig = (value) => { + if (!value) { + return null + } + if (typeof value === 'object') { + return value + } + if (typeof value === 'string' && value.trim()) { + try { + return JSON.parse(value) + } catch (error) { + logger.warn('⚠️ Failed to parse stored Droid proxy config:', error.message) + } + } + return null + } + + let proxyConfig = null + if (updates.proxy !== undefined) { + if (updates.proxy && typeof updates.proxy === 'object') { + proxyConfig = updates.proxy + sanitizedUpdates.proxy = JSON.stringify(updates.proxy) + } else if (typeof updates.proxy === 'string' && updates.proxy.trim()) { + proxyConfig = parseProxyConfig(updates.proxy) + sanitizedUpdates.proxy = updates.proxy + } else { + sanitizedUpdates.proxy = '' + } + } else if (account.proxy) { + proxyConfig = parseProxyConfig(account.proxy) + } + + const hasNewRefreshToken = + typeof sanitizedUpdates.refreshToken === 'string' && sanitizedUpdates.refreshToken + + if (hasNewRefreshToken) { + try { + const refreshed = await this._refreshTokensWithWorkOS( + sanitizedUpdates.refreshToken, + proxyConfig + ) + + sanitizedUpdates.accessToken = refreshed.accessToken + sanitizedUpdates.refreshToken = refreshed.refreshToken || sanitizedUpdates.refreshToken + sanitizedUpdates.expiresAt = + refreshed.expiresAt || sanitizedUpdates.expiresAt || account.expiresAt || '' + + if (refreshed.expiresIn !== null && refreshed.expiresIn !== undefined) { + sanitizedUpdates.expiresIn = String(refreshed.expiresIn) + } + + sanitizedUpdates.tokenType = refreshed.tokenType || account.tokenType || 'Bearer' + sanitizedUpdates.authenticationMethod = + refreshed.authenticationMethod || account.authenticationMethod || '' + sanitizedUpdates.organizationId = + sanitizedUpdates.organizationId || + refreshed.organizationId || + account.organizationId || + '' + sanitizedUpdates.lastRefreshAt = new Date().toISOString() + sanitizedUpdates.status = 'active' + sanitizedUpdates.errorMessage = '' + + if (refreshed.user) { + const userInfo = refreshed.user + const email = typeof userInfo.email === 'string' ? userInfo.email.trim() : '' + if (email) { + sanitizedUpdates.ownerEmail = email + } + + const nameParts = [] + if (typeof userInfo.first_name === 'string' && userInfo.first_name.trim()) { + nameParts.push(userInfo.first_name.trim()) + } + if (typeof userInfo.last_name === 'string' && userInfo.last_name.trim()) { + nameParts.push(userInfo.last_name.trim()) + } + + const derivedName = + nameParts.join(' ').trim() || + (typeof userInfo.name === 'string' ? userInfo.name.trim() : '') || + (typeof userInfo.display_name === 'string' ? userInfo.display_name.trim() : '') + + if (derivedName) { + sanitizedUpdates.ownerName = derivedName + sanitizedUpdates.ownerDisplayName = derivedName + sanitizedUpdates.owner = derivedName + } else if (sanitizedUpdates.ownerEmail) { + sanitizedUpdates.ownerName = sanitizedUpdates.ownerName || sanitizedUpdates.ownerEmail + sanitizedUpdates.ownerDisplayName = + sanitizedUpdates.ownerDisplayName || sanitizedUpdates.ownerEmail + sanitizedUpdates.owner = sanitizedUpdates.owner || sanitizedUpdates.ownerEmail + } + + if (typeof userInfo.id === 'string' && userInfo.id.trim()) { + sanitizedUpdates.userId = userInfo.id.trim() + } + } + } catch (error) { + logger.error('❌ 使用新的 Refresh Token 更新 Droid 账户失败:', error) + throw new Error(`Refresh Token 验证失败:${error.message || '未知错误'}`) + } + } + + // ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存 + // subscriptionExpiresAt 是业务字段,与 token 刷新独立 + if (sanitizedUpdates.subscriptionExpiresAt !== undefined) { + // 直接保存,不做任何调整 + } + + if (sanitizedUpdates.proxy === undefined) { + sanitizedUpdates.proxy = account.proxy || '' + } + + // 使用 Redis 中的原始数据获取加密的 API Key 条目 + const existingApiKeyEntries = this._parseApiKeyEntries( + hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'apiKeys') + ? storedAccount.apiKeys + : '' + ) + const newApiKeysInput = Array.isArray(updates.apiKeys) ? updates.apiKeys : [] + const removeApiKeysInput = Array.isArray(updates.removeApiKeys) ? updates.removeApiKeys : [] + const wantsClearApiKeys = Boolean(updates.clearApiKeys) + const rawApiKeyMode = + typeof updates.apiKeyUpdateMode === 'string' + ? updates.apiKeyUpdateMode.trim().toLowerCase() + : '' + + let apiKeyUpdateMode = ['append', 'replace', 'delete', 'update'].includes(rawApiKeyMode) + ? rawApiKeyMode + : '' + + if (!apiKeyUpdateMode) { + if (wantsClearApiKeys) { + apiKeyUpdateMode = 'replace' + } else if (removeApiKeysInput.length > 0) { + apiKeyUpdateMode = 'delete' + } else { + apiKeyUpdateMode = 'append' + } + } + + if (sanitizedUpdates.apiKeys !== undefined) { + delete sanitizedUpdates.apiKeys + } + if (sanitizedUpdates.clearApiKeys !== undefined) { + delete sanitizedUpdates.clearApiKeys + } + if (sanitizedUpdates.apiKeyUpdateMode !== undefined) { + delete sanitizedUpdates.apiKeyUpdateMode + } + if (sanitizedUpdates.removeApiKeys !== undefined) { + delete sanitizedUpdates.removeApiKeys + } + + let mergedApiKeys = existingApiKeyEntries + let apiKeysUpdated = false + let addedCount = 0 + let removedCount = 0 + + if (apiKeyUpdateMode === 'delete') { + const removalHashes = new Set() + + for (const candidate of removeApiKeysInput) { + if (typeof candidate !== 'string') { + continue + } + const trimmed = candidate.trim() + if (!trimmed) { + continue + } + const hash = crypto.createHash('sha256').update(trimmed).digest('hex') + removalHashes.add(hash) + } + + if (removalHashes.size > 0) { + mergedApiKeys = existingApiKeyEntries.filter( + (entry) => entry && entry.hash && !removalHashes.has(entry.hash) + ) + removedCount = existingApiKeyEntries.length - mergedApiKeys.length + apiKeysUpdated = removedCount > 0 + + if (!apiKeysUpdated) { + logger.warn( + `⚠️ 删除模式未匹配任何 Droid API Key: ${accountId} (提供 ${removalHashes.size} 条)` + ) + } + } else if (removeApiKeysInput.length > 0) { + logger.warn(`⚠️ 删除模式未收到有效的 Droid API Key: ${accountId}`) + } + } else if (apiKeyUpdateMode === 'update') { + // 更新模式:根据提供的 key 匹配现有条目并更新状态 + mergedApiKeys = [...existingApiKeyEntries] + const updatedHashes = new Set() + + for (const updateItem of newApiKeysInput) { + if (!updateItem || typeof updateItem !== 'object') { + continue + } + + const key = updateItem.key || updateItem.apiKey || '' + if (!key || typeof key !== 'string') { + continue + } + + const trimmed = key.trim() + if (!trimmed) { + continue + } + + const hash = crypto.createHash('sha256').update(trimmed).digest('hex') + updatedHashes.add(hash) + + // 查找现有条目 + const existingIndex = mergedApiKeys.findIndex((entry) => entry && entry.hash === hash) + + if (existingIndex !== -1) { + // 更新现有条目的状态信息 + const existingEntry = mergedApiKeys[existingIndex] + mergedApiKeys[existingIndex] = { + ...existingEntry, + status: updateItem.status || existingEntry.status || 'active', + errorMessage: + updateItem.errorMessage !== undefined + ? updateItem.errorMessage + : existingEntry.errorMessage || '', + lastUsedAt: + updateItem.lastUsedAt !== undefined + ? updateItem.lastUsedAt + : existingEntry.lastUsedAt || '', + usageCount: + updateItem.usageCount !== undefined + ? String(updateItem.usageCount) + : existingEntry.usageCount || '0' + } + apiKeysUpdated = true + } + } + + if (!apiKeysUpdated) { + logger.warn( + `⚠️ 更新模式未匹配任何 Droid API Key: ${accountId} (提供 ${updatedHashes.size} 个哈希)` + ) + } + } else { + const clearExisting = apiKeyUpdateMode === 'replace' || wantsClearApiKeys + const baselineCount = clearExisting ? 0 : existingApiKeyEntries.length + + mergedApiKeys = this._buildApiKeyEntries( + newApiKeysInput, + existingApiKeyEntries, + clearExisting + ) + + addedCount = Math.max(mergedApiKeys.length - baselineCount, 0) + apiKeysUpdated = clearExisting || addedCount > 0 + } + + if (apiKeysUpdated) { + sanitizedUpdates.apiKeys = mergedApiKeys.length ? JSON.stringify(mergedApiKeys) : '' + sanitizedUpdates.apiKeyCount = String(mergedApiKeys.length) + + if (apiKeyUpdateMode === 'delete') { + logger.info( + `🔑 删除模式更新 Droid API keys for ${accountId}: 已移除 ${removedCount} 条,剩余 ${mergedApiKeys.length}` + ) + } else if (apiKeyUpdateMode === 'update') { + logger.info( + `🔑 更新模式更新 Droid API keys for ${accountId}: 更新了 ${newApiKeysInput.length} 个 API Key 的状态信息` + ) + } else if (apiKeyUpdateMode === 'replace' || wantsClearApiKeys) { + logger.info( + `🔑 覆盖模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` + ) + } else { + logger.info( + `🔑 追加模式更新 Droid API keys for ${accountId}: 当前总数 ${mergedApiKeys.length},新增 ${addedCount}` + ) + } + + if (mergedApiKeys.length > 0) { + sanitizedUpdates.authenticationMethod = 'api_key' + sanitizedUpdates.status = sanitizedUpdates.status || 'active' + } else if (!sanitizedUpdates.accessToken && !account.accessToken) { + const shouldPreserveApiKeyMode = + account.authenticationMethod && + account.authenticationMethod.toLowerCase().trim() === 'api_key' && + (apiKeyUpdateMode === 'replace' || apiKeyUpdateMode === 'delete') + + sanitizedUpdates.authenticationMethod = shouldPreserveApiKeyMode + ? 'api_key' + : account.authenticationMethod === 'api_key' + ? '' + : account.authenticationMethod + } + } + + const encryptedUpdates = { ...sanitizedUpdates } + + if (sanitizedUpdates.refreshToken !== undefined) { + encryptedUpdates.refreshToken = this._encryptSensitiveData(sanitizedUpdates.refreshToken) + } + if (sanitizedUpdates.accessToken !== undefined) { + encryptedUpdates.accessToken = this._encryptSensitiveData(sanitizedUpdates.accessToken) + } + + const baseAccountData = hasStoredAccount ? { ...storedAccount } : { id: accountId } + + const updatedData = { + ...baseAccountData, + ...encryptedUpdates + } + + if (!Object.prototype.hasOwnProperty.call(updatedData, 'refreshToken')) { + updatedData.refreshToken = + hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'refreshToken') + ? storedAccount.refreshToken + : this._encryptSensitiveData(account.refreshToken) + } + + if (!Object.prototype.hasOwnProperty.call(updatedData, 'accessToken')) { + updatedData.accessToken = + hasStoredAccount && Object.prototype.hasOwnProperty.call(storedAccount, 'accessToken') + ? storedAccount.accessToken + : this._encryptSensitiveData(account.accessToken) + } + + if (!Object.prototype.hasOwnProperty.call(updatedData, 'proxy')) { + updatedData.proxy = hasStoredAccount ? storedAccount.proxy || '' : account.proxy || '' + } + + await redis.setDroidAccount(accountId, updatedData) + logger.info(`✅ Updated Droid account: ${accountId}`) + + return this.getAccount(accountId) + } + + /** + * 删除 Droid 账户 + */ + async deleteAccount(accountId) { + await redis.deleteDroidAccount(accountId) + logger.success(`🗑️ Deleted Droid account: ${accountId}`) + } + + /** + * 刷新 Droid 账户的 access token + * + * 使用 WorkOS OAuth refresh token 刷新 access token + */ + async refreshAccessToken(accountId, proxyConfig = null) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error(`Droid account not found: ${accountId}`) + } + + if (!account.refreshToken) { + throw new Error(`Droid account ${accountId} has no refresh token`) + } + + logger.info(`🔄 Refreshing Droid account token: ${account.name} (${accountId})`) + + try { + const proxy = proxyConfig || (account.proxy ? JSON.parse(account.proxy) : null) + const refreshed = await this._refreshTokensWithWorkOS( + account.refreshToken, + proxy, + account.organizationId || null + ) + + // 更新账户信息 + await this.updateAccount(accountId, { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken || account.refreshToken, + expiresAt: refreshed.expiresAt, + expiresIn: + refreshed.expiresIn !== null && refreshed.expiresIn !== undefined + ? String(refreshed.expiresIn) + : account.expiresIn, + tokenType: refreshed.tokenType || account.tokenType || 'Bearer', + authenticationMethod: refreshed.authenticationMethod || account.authenticationMethod || '', + organizationId: refreshed.organizationId || account.organizationId, + lastRefreshAt: new Date().toISOString(), + status: 'active', + errorMessage: '' + }) + + // 记录用户信息 + if (refreshed.user) { + const { user } = refreshed + const updates = {} + logger.info( + `✅ Droid token refreshed for: ${user.email} (${user.first_name} ${user.last_name})` + ) + logger.info(` Organization ID: ${refreshed.organizationId || 'N/A'}`) + + if (typeof user.email === 'string' && user.email.trim()) { + updates.ownerEmail = user.email.trim() + } + const nameParts = [] + if (typeof user.first_name === 'string' && user.first_name.trim()) { + nameParts.push(user.first_name.trim()) + } + if (typeof user.last_name === 'string' && user.last_name.trim()) { + nameParts.push(user.last_name.trim()) + } + const derivedName = + nameParts.join(' ').trim() || + (typeof user.name === 'string' ? user.name.trim() : '') || + (typeof user.display_name === 'string' ? user.display_name.trim() : '') + + if (derivedName) { + updates.ownerName = derivedName + updates.ownerDisplayName = derivedName + updates.owner = derivedName + } else if (updates.ownerEmail) { + updates.owner = updates.ownerEmail + updates.ownerName = updates.ownerEmail + updates.ownerDisplayName = updates.ownerEmail + } + + if (typeof user.id === 'string' && user.id.trim()) { + updates.userId = user.id.trim() + } + + if (Object.keys(updates).length > 0) { + await this.updateAccount(accountId, updates) + } + } + + logger.success(`Droid account token refreshed successfully: ${accountId}`) + + return { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken || account.refreshToken, + expiresAt: refreshed.expiresAt + } + } catch (error) { + logger.error(`❌ Failed to refresh Droid account token: ${accountId}`, error) + + // 更新账户状态为错误 + await this.updateAccount(accountId, { + status: 'error', + errorMessage: error.message || 'Token refresh failed' + }) + + throw error + } + } + + /** + * 检查 token 是否需要刷新 + */ + shouldRefreshToken(account) { + if (!account.lastRefreshAt) { + return true // 从未刷新过 + } + + const lastRefreshTime = new Date(account.lastRefreshAt).getTime() + const hoursSinceRefresh = (Date.now() - lastRefreshTime) / (1000 * 60 * 60) + + return hoursSinceRefresh >= this.refreshIntervalHours + } + + /** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() + } + + /** + * 获取有效的 access token(自动刷新) + */ + async getValidAccessToken(accountId) { + let account = await this.getAccount(accountId) + if (!account) { + throw new Error(`Droid account not found: ${accountId}`) + } + + if ( + typeof account.authenticationMethod === 'string' && + account.authenticationMethod.toLowerCase().trim() === 'api_key' + ) { + throw new Error(`Droid account ${accountId} 已配置为 API Key 模式,不能获取 Access Token`) + } + + // 检查是否需要刷新 + if (this.shouldRefreshToken(account)) { + logger.info(`🔄 Droid account token needs refresh: ${accountId}`) + const proxyConfig = account.proxy ? JSON.parse(account.proxy) : null + await this.refreshAccessToken(accountId, proxyConfig) + account = await this.getAccount(accountId) + } + + if (!account.accessToken) { + throw new Error(`Droid account ${accountId} has no valid access token`) + } + + return account.accessToken + } + + /** + * 获取可调度的 Droid 账户列表 + */ + async getSchedulableAccounts(endpointType = null) { + const allAccounts = await redis.getAllDroidAccounts() + + const normalizedFilter = endpointType ? this._sanitizeEndpointType(endpointType) : null + + return allAccounts + .filter((account) => { + const isActive = this._isTruthy(account.isActive) + const isSchedulable = this._isTruthy(account.schedulable) + const status = typeof account.status === 'string' ? account.status.toLowerCase() : '' + + // ✅ 检查账户订阅是否过期 + if (this.isSubscriptionExpired(account)) { + logger.debug( + `⏰ Skipping expired Droid account: ${account.name}, expired at ${account.subscriptionExpiresAt}` + ) + return false + } + + if (!isActive || !isSchedulable || status !== 'active') { + return false + } + + if (!normalizedFilter) { + return true + } + + const accountEndpoint = this._sanitizeEndpointType(account.endpointType) + + if (normalizedFilter === 'openai') { + return accountEndpoint === 'openai' || accountEndpoint === 'anthropic' + } + + if (normalizedFilter === 'anthropic') { + return accountEndpoint === 'anthropic' || accountEndpoint === 'openai' + } + + // comm 端点可以使用任何类型的账户 + if (normalizedFilter === 'comm') { + return true + } + + return accountEndpoint === normalizedFilter + }) + .map((account) => ({ + ...account, + endpointType: this._sanitizeEndpointType(account.endpointType), + priority: parseInt(account.priority, 10) || 50, + // 解密 accessToken 用于使用 + accessToken: this._decryptSensitiveData(account.accessToken) + })) + .sort((a, b) => a.priority - b.priority) // 按优先级排序 + } + + /** + * 选择一个可用的 Droid 账户(简单轮询) + */ + async selectAccount(endpointType = null) { + let accounts = await this.getSchedulableAccounts(endpointType) + + if (accounts.length === 0 && endpointType) { + logger.warn( + `No Droid accounts found for endpoint ${endpointType}, falling back to any available account` + ) + accounts = await this.getSchedulableAccounts(null) + } + + if (accounts.length === 0) { + throw new Error( + `No schedulable Droid accounts available${endpointType ? ` for endpoint type: ${endpointType}` : ''}` + ) + } + + // 简单轮询:选择最高优先级且最久未使用的账户 + let selectedAccount = accounts[0] + for (const account of accounts) { + if (account.priority < selectedAccount.priority) { + selectedAccount = account + } else if (account.priority === selectedAccount.priority) { + // 相同优先级,选择最久未使用的 + const selectedLastUsed = new Date(selectedAccount.lastUsedAt || 0).getTime() + const accountLastUsed = new Date(account.lastUsedAt || 0).getTime() + if (accountLastUsed < selectedLastUsed) { + selectedAccount = account + } + } + } + + // 更新最后使用时间 + await this.updateAccount(selectedAccount.id, { + lastUsedAt: new Date().toISOString() + }) + + logger.info( + `✅ Selected Droid account: ${selectedAccount.name} (${selectedAccount.id}) - Endpoint: ${this._sanitizeEndpointType(selectedAccount.endpointType)}` + ) + + return selectedAccount + } + + /** + * 获取 Factory.ai API 的完整 URL + */ + getFactoryApiUrl(endpointType, endpoint) { + const normalizedType = this._sanitizeEndpointType(endpointType) + const baseUrls = { + anthropic: `${this.factoryApiBaseUrl}/a${endpoint}`, + openai: `${this.factoryApiBaseUrl}/o${endpoint}`, + comm: `${this.factoryApiBaseUrl}/o${endpoint}` + } + + return baseUrls[normalizedType] || baseUrls.openai + } + + async touchLastUsedAt(accountId) { + if (!accountId) { + return + } + + try { + const client = redis.getClientSafe() + await client.hset(`droid:account:${accountId}`, 'lastUsedAt', new Date().toISOString()) + } catch (error) { + logger.warn(`⚠️ Failed to update lastUsedAt for Droid account ${accountId}:`, error) + } + } + + // 🔄 重置Droid账户所有异常状态 + async resetAccountStatus(accountId) { + try { + const accountData = await this.getAccount(accountId) + if (!accountData) { + throw new Error('Account not found') + } + + const client = redis.getClientSafe() + const accountKey = `droid:account:${accountId}` + + const updates = { + status: 'active', + errorMessage: '', + schedulable: 'true', + isActive: 'true' + } + + const fieldsToDelete = [ + 'rateLimitedAt', + 'rateLimitStatus', + 'unauthorizedAt', + 'unauthorizedCount', + 'overloadedAt', + 'overloadStatus', + 'blockedAt', + 'quotaStoppedAt' + ] + + await client.hset(accountKey, updates) + await client.hdel(accountKey, ...fieldsToDelete) + + logger.success(`Reset all error status for Droid account ${accountId}`) + + // 清除临时不可用状态 + await upstreamErrorHelper.clearTempUnavailable(accountId, 'droid').catch(() => {}) + + // 异步发送 Webhook 通知(忽略错误) + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: accountData.name || accountId, + platform: 'droid', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + } catch (webhookError) { + logger.warn('Failed to send webhook notification for Droid status reset:', webhookError) + } + + return { success: true, accountId } + } catch (error) { + logger.error(`❌ Failed to reset Droid account status: ${accountId}`, error) + throw error + } + } +} + +// 导出单例 +module.exports = new DroidAccountService() diff --git a/src/services/account/geminiAccountService.js b/src/services/account/geminiAccountService.js new file mode 100644 index 0000000..7dc0f41 --- /dev/null +++ b/src/services/account/geminiAccountService.js @@ -0,0 +1,1921 @@ +const redisClient = require('../../models/redis') +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const https = require('https') +const logger = require('../../utils/logger') +const { OAuth2Client } = require('google-auth-library') +const { maskToken } = require('../../utils/tokenMask') +const ProxyHelper = require('../../utils/proxyHelper') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') +const { + logRefreshStart, + logRefreshSuccess, + logRefreshError, + logTokenUsage, + logRefreshSkipped +} = require('../../utils/tokenRefreshLogger') +const tokenRefreshService = require('../tokenRefreshService') +const { createEncryptor } = require('../../utils/commonHelper') +const antigravityClient = require('../antigravityClient') + +// Gemini 账户键前缀 +const GEMINI_ACCOUNT_KEY_PREFIX = 'gemini_account:' +const SHARED_GEMINI_ACCOUNTS_KEY = 'shared_gemini_accounts' +const ACCOUNT_SESSION_MAPPING_PREFIX = 'gemini_session_account_mapping:' + +// Gemini OAuth 配置 - 支持 Gemini CLI 与 Antigravity 两种 OAuth 应用 +const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' +const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' + +const OAUTH_PROVIDERS = { + [OAUTH_PROVIDER_GEMINI_CLI]: { + // Gemini CLI OAuth 配置(公开) + clientId: + process.env.GEMINI_OAUTH_CLIENT_ID || + '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com', + clientSecret: process.env.GEMINI_OAUTH_CLIENT_SECRET || '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', + scopes: ['https://www.googleapis.com/auth/cloud-platform'] + }, + [OAUTH_PROVIDER_ANTIGRAVITY]: { + // Antigravity OAuth 配置(参考 gcli2api) + clientId: + process.env.ANTIGRAVITY_OAUTH_CLIENT_ID || + '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com', + clientSecret: + process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET || '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', + scopes: [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs' + ] + } +} + +if (!process.env.GEMINI_OAUTH_CLIENT_SECRET) { + logger.warn( + '⚠️ GEMINI_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)' + ) +} +if (!process.env.ANTIGRAVITY_OAUTH_CLIENT_SECRET) { + logger.warn( + '⚠️ ANTIGRAVITY_OAUTH_CLIENT_SECRET 未设置,使用内置默认值(建议在生产环境通过环境变量覆盖)' + ) +} + +function normalizeOauthProvider(oauthProvider) { + if (!oauthProvider) { + return OAUTH_PROVIDER_GEMINI_CLI + } + return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY + ? OAUTH_PROVIDER_ANTIGRAVITY + : OAUTH_PROVIDER_GEMINI_CLI +} + +function getOauthProviderConfig(oauthProvider) { + const normalized = normalizeOauthProvider(oauthProvider) + return OAUTH_PROVIDERS[normalized] || OAUTH_PROVIDERS[OAUTH_PROVIDER_GEMINI_CLI] +} + +// 🌐 TCP Keep-Alive Agent 配置 +// 解决长时间流式请求中 NAT/防火墙空闲超时导致的连接中断问题 +const keepAliveAgent = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 30000, // 每30秒发送一次 keep-alive 探测 + timeout: 120000, // 120秒连接超时 + maxSockets: 100, // 最大并发连接数 + maxFreeSockets: 10 // 保持的空闲连接数 +}) + +logger.info('🌐 Gemini HTTPS Agent initialized with TCP Keep-Alive support') + +// 使用 commonHelper 的加密器 +const encryptor = createEncryptor('gemini-account-salt') +const { encrypt, decrypt } = encryptor + +async function fetchAvailableModelsAntigravity( + accessToken, + proxyConfig = null, + refreshToken = null +) { + try { + let effectiveToken = accessToken + if (refreshToken) { + try { + const client = await getOauthClient( + accessToken, + refreshToken, + proxyConfig, + OAUTH_PROVIDER_ANTIGRAVITY + ) + if (client && client.getAccessToken) { + const latest = await client.getAccessToken() + if (latest?.token) { + effectiveToken = latest.token + } + } + } catch (error) { + logger.warn('Failed to refresh Antigravity access token for models list:', { + message: error.message + }) + } + } + + const data = await antigravityClient.fetchAvailableModels({ + accessToken: effectiveToken, + proxyConfig + }) + const modelsDict = data?.models + const created = Math.floor(Date.now() / 1000) + + const models = [] + const seen = new Set() + const { + getAntigravityModelAlias, + getAntigravityModelMetadata, + normalizeAntigravityModelInput + } = require('../../utils/antigravityModel') + + const pushModel = (modelId) => { + if (!modelId || seen.has(modelId)) { + return + } + seen.add(modelId) + const metadata = getAntigravityModelMetadata(modelId) + const entry = { + id: modelId, + object: 'model', + created, + owned_by: 'antigravity' + } + if (metadata?.name) { + entry.name = metadata.name + } + if (metadata?.maxCompletionTokens) { + entry.max_completion_tokens = metadata.maxCompletionTokens + } + if (metadata?.thinking) { + entry.thinking = metadata.thinking + } + models.push(entry) + } + + if (modelsDict && typeof modelsDict === 'object') { + for (const modelId of Object.keys(modelsDict)) { + const normalized = normalizeAntigravityModelInput(modelId) + const alias = getAntigravityModelAlias(normalized) + if (!alias) { + continue + } + pushModel(alias) + + if (alias.endsWith('-thinking')) { + pushModel(alias.replace(/-thinking$/, '')) + } + + if (alias.startsWith('gemini-claude-')) { + pushModel(alias.replace(/^gemini-/, '')) + } + } + } + + return models + } catch (error) { + logger.error('Failed to fetch Antigravity models:', error.response?.data || error.message) + return [ + { + id: 'gemini-2.5-flash', + object: 'model', + created: Math.floor(Date.now() / 1000), + owned_by: 'antigravity' + } + ] + } +} + +async function countTokensAntigravity(client, contents, model, proxyConfig = null) { + const { token } = await client.getAccessToken() + const response = await antigravityClient.countTokens({ + accessToken: token, + proxyConfig, + contents, + model + }) + return response +} + +// 🧹 定期清理缓存(每10分钟) +setInterval( + () => { + encryptor.clearCache() + logger.info('🧹 Gemini decrypt cache cleanup completed', encryptor.getStats()) + }, + 10 * 60 * 1000 +) + +// 创建 OAuth2 客户端(支持代理配置) +function createOAuth2Client(redirectUri = null, proxyConfig = null, oauthProvider = null) { + // 如果没有提供 redirectUri,使用默认值 + const uri = redirectUri || 'http://localhost:45462' + const oauthConfig = getOauthProviderConfig(oauthProvider) + + // 准备客户端选项 + const clientOptions = { + clientId: oauthConfig.clientId, + clientSecret: oauthConfig.clientSecret, + redirectUri: uri + } + + // 如果有代理配置,设置 transporterOptions + if (proxyConfig) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + // 通过 transporterOptions 传递代理配置给底层的 Gaxios + clientOptions.transporterOptions = { + agent: proxyAgent, + httpsAgent: proxyAgent + } + logger.debug('Created OAuth2Client with proxy configuration') + } + } + + return new OAuth2Client(clientOptions) +} + +// 生成授权 URL (支持 PKCE 和代理) +async function generateAuthUrl( + state = null, + redirectUri = null, + proxyConfig = null, + oauthProvider = null +) { + // 使用新的 redirect URI + const finalRedirectUri = redirectUri || 'https://codeassist.google.com/authcode' + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) + const oAuth2Client = createOAuth2Client(finalRedirectUri, proxyConfig, normalizedProvider) + + if (proxyConfig) { + logger.info( + `🌐 Using proxy for Gemini auth URL generation: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini auth URL generation') + } + + // 生成 PKCE code verifier + const codeVerifier = await oAuth2Client.generateCodeVerifierAsync() + const stateValue = state || crypto.randomBytes(32).toString('hex') + + const authUrl = oAuth2Client.generateAuthUrl({ + redirect_uri: finalRedirectUri, + access_type: 'offline', + scope: oauthConfig.scopes, + code_challenge_method: 'S256', + code_challenge: codeVerifier.codeChallenge, + state: stateValue, + prompt: 'select_account' + }) + + return { + authUrl, + state: stateValue, + codeVerifier: codeVerifier.codeVerifier, + redirectUri: finalRedirectUri, + oauthProvider: normalizedProvider + } +} + +// 轮询检查 OAuth 授权状态 +async function pollAuthorizationStatus(sessionId, maxAttempts = 60, interval = 2000) { + let attempts = 0 + const client = redisClient.getClientSafe() + + while (attempts < maxAttempts) { + try { + const sessionData = await client.get(`oauth_session:${sessionId}`) + if (!sessionData) { + throw new Error('OAuth session not found') + } + + const session = JSON.parse(sessionData) + if (session.code) { + // 授权码已获取,交换 tokens + const tokens = await exchangeCodeForTokens(session.code) + + // 清理 session + await client.del(`oauth_session:${sessionId}`) + + return { + success: true, + tokens + } + } + + if (session.error) { + // 授权失败 + await client.del(`oauth_session:${sessionId}`) + return { + success: false, + error: session.error + } + } + + // 等待下一次轮询 + await new Promise((resolve) => setTimeout(resolve, interval)) + attempts++ + } catch (error) { + logger.error('Error polling authorization status:', error) + throw error + } + } + + // 超时 + await client.del(`oauth_session:${sessionId}`) + return { + success: false, + error: 'Authorization timeout' + } +} + +// 交换授权码获取 tokens (支持 PKCE 和代理) +async function exchangeCodeForTokens( + code, + redirectUri = null, + codeVerifier = null, + proxyConfig = null, + oauthProvider = null +) { + try { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) + // 创建带代理配置的 OAuth2Client + const oAuth2Client = createOAuth2Client(redirectUri, proxyConfig, normalizedProvider) + + if (proxyConfig) { + logger.info( + `🌐 Using proxy for Gemini token exchange: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini token exchange') + } + + const tokenParams = { + code, + redirect_uri: redirectUri + } + + // 如果提供了 codeVerifier,添加到参数中 + if (codeVerifier) { + tokenParams.codeVerifier = codeVerifier + } + + const { tokens } = await oAuth2Client.getToken(tokenParams) + + // 转换为兼容格式 + return { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + scope: tokens.scope || oauthConfig.scopes.join(' '), + token_type: tokens.token_type || 'Bearer', + expiry_date: tokens.expiry_date || Date.now() + tokens.expires_in * 1000 + } + } catch (error) { + logger.error('Error exchanging code for tokens:', error) + throw new Error('Failed to exchange authorization code') + } +} + +// 刷新访问令牌 +async function refreshAccessToken(refreshToken, proxyConfig = null, oauthProvider = null) { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) + // 创建带代理配置的 OAuth2Client + const oAuth2Client = createOAuth2Client(null, proxyConfig, normalizedProvider) + + try { + // 设置 refresh_token + oAuth2Client.setCredentials({ + refresh_token: refreshToken + }) + + if (proxyConfig) { + logger.info( + `🔄 Using proxy for Gemini token refresh: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } else { + logger.debug('🔄 No proxy configured for Gemini token refresh') + } + + // 调用 refreshAccessToken 获取新的 tokens + const response = await oAuth2Client.refreshAccessToken() + const { credentials } = response + + // 检查是否成功获取了新的 access_token + if (!credentials || !credentials.access_token) { + throw new Error('No access token returned from refresh') + } + + logger.info( + `🔄 Successfully refreshed Gemini token. New expiry: ${new Date(credentials.expiry_date).toISOString()}` + ) + + return { + access_token: credentials.access_token, + refresh_token: credentials.refresh_token || refreshToken, // 保留原 refresh_token 如果没有返回新的 + scope: credentials.scope || oauthConfig.scopes.join(' '), + token_type: credentials.token_type || 'Bearer', + expiry_date: credentials.expiry_date || Date.now() + 3600000 // 默认1小时过期 + } + } catch (error) { + logger.error('Error refreshing access token:', { + message: error.message, + code: error.code, + response: error.response?.data, + hasProxy: !!proxyConfig, + proxy: proxyConfig ? ProxyHelper.maskProxyInfo(proxyConfig) : 'No proxy' + }) + throw new Error(`Failed to refresh access token: ${error.message}`) + } +} + +// 创建 Gemini 账户 +async function createAccount(accountData) { + const id = uuidv4() + const now = new Date().toISOString() + const oauthProvider = normalizeOauthProvider(accountData.oauthProvider) + const oauthConfig = getOauthProviderConfig(oauthProvider) + + // 处理凭证数据 + let geminiOauth = null + let accessToken = '' + let refreshToken = '' + let expiresAt = '' + + if (accountData.geminiOauth || accountData.accessToken) { + // 如果提供了完整的 OAuth 数据 + if (accountData.geminiOauth) { + geminiOauth = + typeof accountData.geminiOauth === 'string' + ? accountData.geminiOauth + : JSON.stringify(accountData.geminiOauth) + + const oauthData = + typeof accountData.geminiOauth === 'string' + ? JSON.parse(accountData.geminiOauth) + : accountData.geminiOauth + + accessToken = oauthData.access_token || '' + refreshToken = oauthData.refresh_token || '' + expiresAt = oauthData.expiry_date ? new Date(oauthData.expiry_date).toISOString() : '' + } else { + // 如果只提供了 access token + ;({ accessToken } = accountData) + refreshToken = accountData.refreshToken || '' + + // 构造完整的 OAuth 数据 + geminiOauth = JSON.stringify({ + access_token: accessToken, + refresh_token: refreshToken, + scope: accountData.scope || oauthConfig.scopes.join(' '), + token_type: accountData.tokenType || 'Bearer', + expiry_date: accountData.expiryDate || Date.now() + 3600000 // 默认1小时 + }) + + expiresAt = new Date(accountData.expiryDate || Date.now() + 3600000).toISOString() + } + } + + const account = { + id, + platform: 'gemini', // 标识为 Gemini 账户 + name: accountData.name || 'Gemini Account', + description: accountData.description || '', + accountType: accountData.accountType || 'shared', + isActive: 'true', + status: 'active', + + // 调度相关 + schedulable: accountData.schedulable !== undefined ? String(accountData.schedulable) : 'true', + priority: accountData.priority || 50, // 调度优先级 (1-100,数字越小优先级越高) + + // OAuth 相关字段(加密存储) + geminiOauth: geminiOauth ? encrypt(geminiOauth) : '', + accessToken: accessToken ? encrypt(accessToken) : '', + refreshToken: refreshToken ? encrypt(refreshToken) : '', + expiresAt, // OAuth Token 过期时间(技术字段,自动刷新) + // 只有OAuth方式才有scopes,手动添加的没有 + scopes: accountData.geminiOauth ? accountData.scopes || oauthConfig.scopes.join(' ') : '', + oauthProvider, + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, + + // 代理设置 + proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '', + + // 项目 ID(Google Cloud/Workspace 账号需要) + projectId: accountData.projectId || '', + + // 临时项目 ID(从 loadCodeAssist 接口自动获取) + tempProjectId: accountData.tempProjectId || '', + + // 支持的模型列表(可选) + supportedModels: accountData.supportedModels || [], // 空数组表示支持所有模型 + + // 自动防护开关 + disableAutoProtection: + accountData.disableAutoProtection === true || accountData.disableAutoProtection === 'true' + ? 'true' + : 'false', + + // 时间戳 + createdAt: now, + updatedAt: now, + lastUsedAt: '', + lastRefreshAt: '' + } + + // 保存到 Redis + const client = redisClient.getClientSafe() + await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${id}`, account) + await redisClient.addToIndex('gemini_account:index', id) + + // 如果是共享账户,添加到共享账户集合 + if (account.accountType === 'shared') { + await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, id) + } + + logger.info(`Created Gemini account: ${id}`) + + // 返回时解析代理配置 + const returnAccount = { ...account } + if (returnAccount.proxy) { + try { + returnAccount.proxy = JSON.parse(returnAccount.proxy) + } catch (e) { + returnAccount.proxy = null + } + } + + return returnAccount +} + +// 获取账户 +async function getAccount(accountId) { + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + // 解密敏感字段 + if (accountData.geminiOauth) { + accountData.geminiOauth = decrypt(accountData.geminiOauth) + } + if (accountData.accessToken) { + accountData.accessToken = decrypt(accountData.accessToken) + } + if (accountData.refreshToken) { + accountData.refreshToken = decrypt(accountData.refreshToken) + } + + // 解析代理配置 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + // 如果解析失败,保持原样或设置为null + accountData.proxy = null + } + } + + // 转换 schedulable 字符串为布尔值(与 claudeConsoleAccountService 保持一致) + accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false + + return accountData +} + +// 更新账户 +async function updateAccount(accountId, updates) { + const existingAccount = await getAccount(accountId) + if (!existingAccount) { + throw new Error('Account not found') + } + + const now = new Date().toISOString() + updates.updatedAt = now + + // 检查是否新增了 refresh token + // existingAccount.refreshToken 已经是解密后的值了(从 getAccount 返回) + const oldRefreshToken = existingAccount.refreshToken || '' + let needUpdateExpiry = false + + // 处理代理设置 + if (updates.proxy !== undefined) { + updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + + // 处理 schedulable 字段,确保正确转换为字符串存储 + if (updates.schedulable !== undefined) { + updates.schedulable = updates.schedulable.toString() + } + + if (updates.oauthProvider !== undefined) { + updates.oauthProvider = normalizeOauthProvider(updates.oauthProvider) + } + + // 加密敏感字段 + if (updates.geminiOauth) { + updates.geminiOauth = encrypt( + typeof updates.geminiOauth === 'string' + ? updates.geminiOauth + : JSON.stringify(updates.geminiOauth) + ) + } + if (updates.accessToken) { + updates.accessToken = encrypt(updates.accessToken) + } + if (updates.refreshToken) { + updates.refreshToken = encrypt(updates.refreshToken) + // 如果之前没有 refresh token,现在有了,标记需要更新过期时间 + if (!oldRefreshToken && updates.refreshToken) { + needUpdateExpiry = true + } + } + + // 更新账户类型时处理共享账户集合 + const client = redisClient.getClientSafe() + if (updates.accountType && updates.accountType !== existingAccount.accountType) { + if (updates.accountType === 'shared') { + await client.sadd(SHARED_GEMINI_ACCOUNTS_KEY, accountId) + } else { + await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId) + } + } + + // ✅ 关键:如果新增了 refresh token,只更新 token 过期时间 + // 不要覆盖 subscriptionExpiresAt + if (needUpdateExpiry) { + const newExpiry = new Date(Date.now() + 10 * 60 * 1000).toISOString() + updates.expiresAt = newExpiry // 只更新 OAuth Token 过期时间 + // ⚠️ 重要:不要修改 subscriptionExpiresAt + logger.info( + `🔄 New refresh token added for Gemini account ${accountId}, setting token expiry to 10 minutes` + ) + } + + // ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存 + // subscriptionExpiresAt 是业务字段,与 token 刷新独立 + if (updates.subscriptionExpiresAt !== undefined) { + // 直接保存,不做任何调整 + } + + // 自动防护开关 + if (updates.disableAutoProtection !== undefined) { + updates.disableAutoProtection = + updates.disableAutoProtection === true || updates.disableAutoProtection === 'true' + ? 'true' + : 'false' + } + + // 如果通过 geminiOauth 更新,也要检查是否新增了 refresh token + if (updates.geminiOauth && !oldRefreshToken) { + const oauthData = + typeof updates.geminiOauth === 'string' + ? JSON.parse(decrypt(updates.geminiOauth)) + : updates.geminiOauth + + if (oauthData.refresh_token) { + // 如果 expiry_date 设置的时间过长(超过1小时),调整为10分钟 + const providedExpiry = oauthData.expiry_date || 0 + const currentTime = Date.now() + const oneHour = 60 * 60 * 1000 + + if (providedExpiry - currentTime > oneHour) { + const newExpiry = new Date(currentTime + 10 * 60 * 1000).toISOString() + updates.expiresAt = newExpiry + logger.info( + `🔄 Adjusted expiry time to 10 minutes for Gemini account ${accountId} with refresh token` + ) + } + } + } + + // 检查是否手动禁用了账号,如果是则发送webhook通知 + if (updates.isActive === 'false' && existingAccount.isActive !== 'false') { + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: updates.name || existingAccount.name || 'Unknown Account', + platform: 'gemini', + status: 'disabled', + errorCode: 'GEMINI_MANUALLY_DISABLED', + reason: 'Account manually disabled by administrator' + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification for manual account disable:', webhookError) + } + } + + await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + logger.info(`Updated Gemini account: ${accountId}`) + + // 合并更新后的账户数据 + const updatedAccount = { ...existingAccount, ...updates } + + // 返回时解析代理配置 + if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') { + try { + updatedAccount.proxy = JSON.parse(updatedAccount.proxy) + } catch (e) { + updatedAccount.proxy = null + } + } + + return updatedAccount +} + +// 删除账户 +async function deleteAccount(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 从 Redis 删除 + const client = redisClient.getClientSafe() + await client.del(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`) + await redisClient.removeFromIndex('gemini_account:index', accountId) + + // 从共享账户集合中移除 + if (account.accountType === 'shared') { + await client.srem(SHARED_GEMINI_ACCOUNTS_KEY, accountId) + } + + // 清理会话映射(使用反向索引) + const sessionHashes = await client.smembers(`gemini_account_sessions:${accountId}`) + if (sessionHashes.length > 0) { + const pipeline = client.pipeline() + sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`)) + pipeline.del(`gemini_account_sessions:${accountId}`) + await pipeline.exec() + } + + logger.info(`Deleted Gemini account: ${accountId}`) + return true +} + +// 获取所有账户 +async function getAllAccounts() { + const _client = redisClient.getClientSafe() + const accountIds = await redisClient.getAllIdsByIndex( + 'gemini_account:index', + `${GEMINI_ACCOUNT_KEY_PREFIX}*`, + /^gemini_account:(.+)$/ + ) + const keys = accountIds.map((id) => `${GEMINI_ACCOUNT_KEY_PREFIX}${id}`) + const accounts = [] + const dataList = await redisClient.batchHgetallChunked(keys) + + for (let i = 0; i < keys.length; i++) { + const accountData = dataList[i] + if (accountData && Object.keys(accountData).length > 0) { + // 获取限流状态信息 + const rateLimitInfo = await getAccountRateLimitInfo(accountData.id) + + // 解析代理配置 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + // 如果解析失败,设置为null + accountData.proxy = null + } + } + + // 转换 schedulable 字符串为布尔值(与 getAccount 保持一致) + accountData.schedulable = accountData.schedulable !== 'false' // 默认为true,只有明确设置为'false'才为false + + const tokenExpiresAt = accountData.expiresAt || null + const subscriptionExpiresAt = + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null + + // 不解密敏感字段,只返回基本信息 + accounts.push({ + ...accountData, + geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '', + accessToken: accountData.accessToken ? '[ENCRYPTED]' : '', + refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '', + + // ✅ 前端显示订阅过期时间(业务字段) + // 注意:前端看到的 expiresAt 实际上是 subscriptionExpiresAt + tokenExpiresAt, + subscriptionExpiresAt, + expiresAt: subscriptionExpiresAt, + + // 添加 scopes 字段用于判断认证方式 + // 处理空字符串和默认值的情况 + scopes: + accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], + // 添加 hasRefreshToken 标记 + hasRefreshToken: !!accountData.refreshToken, + // 添加限流状态信息(统一格式) + rateLimitStatus: rateLimitInfo + ? { + isRateLimited: rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt, + minutesRemaining: rateLimitInfo.minutesRemaining + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0 + } + }) + } + } + + return accounts +} + +// 选择可用账户(支持专属和共享账户) +async function selectAvailableAccount(apiKeyId, sessionHash = null) { + // 首先检查是否有粘性会话 + const client = redisClient.getClientSafe() + if (sessionHash) { + const mappedAccountId = await client.get(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`) + + if (mappedAccountId) { + const account = await getAccount(mappedAccountId) + if (account && account.isActive === 'true' && !isTokenExpired(account)) { + logger.debug(`Using sticky session account: ${mappedAccountId}`) + return account + } + } + } + + // 获取 API Key 信息 + const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`) + + // 检查是否绑定了 Gemini 账户 + if (apiKeyData.geminiAccountId) { + const account = await getAccount(apiKeyData.geminiAccountId) + if (account && account.isActive === 'true') { + // 检查 token 是否过期 + const isExpired = isTokenExpired(account) + + // 记录token使用情况 + logTokenUsage(account.id, account.name, 'gemini', account.expiresAt, isExpired) + + if (isExpired) { + await refreshAccountToken(account.id) + return await getAccount(account.id) + } + + // 创建粘性会话映射 + if (sessionHash) { + await client.setex( + `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, + 3600, // 1小时过期 + account.id + ) + await client.sadd(`gemini_account_sessions:${account.id}`, sessionHash) + await client.expire(`gemini_account_sessions:${account.id}`, 3600) + } + + return account + } + } + + // 从共享账户池选择 + const sharedAccountIds = await client.smembers(SHARED_GEMINI_ACCOUNTS_KEY) + const availableAccounts = [] + + for (const accountId of sharedAccountIds) { + const account = await getAccount(accountId) + if ( + account && + account.isActive === 'true' && + !isRateLimited(account) && + !isSubscriptionExpired(account) + ) { + availableAccounts.push(account) + } else if (account && isSubscriptionExpired(account)) { + logger.debug( + `⏰ Skipping expired Gemini account: ${account.name}, expired at ${account.subscriptionExpiresAt}` + ) + } + } + + if (availableAccounts.length === 0) { + throw new Error('No available Gemini accounts') + } + + // 选择最少使用的账户 + availableAccounts.sort((a, b) => { + const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0 + const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0 + return aLastUsed - bLastUsed + }) + + const selectedAccount = availableAccounts[0] + + // 检查并刷新 token + const isExpired = isTokenExpired(selectedAccount) + + // 记录token使用情况 + logTokenUsage( + selectedAccount.id, + selectedAccount.name, + 'gemini', + selectedAccount.expiresAt, + isExpired + ) + + if (isExpired) { + await refreshAccountToken(selectedAccount.id) + return await getAccount(selectedAccount.id) + } + + // 创建粘性会话映射 + if (sessionHash) { + await client.setex(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, 3600, selectedAccount.id) + await client.sadd(`gemini_account_sessions:${selectedAccount.id}`, sessionHash) + await client.expire(`gemini_account_sessions:${selectedAccount.id}`, 3600) + } + + return selectedAccount +} + +// 检查 token 是否过期 +function isTokenExpired(account) { + if (!account.expiresAt) { + return true + } + + const expiryTime = new Date(account.expiresAt).getTime() + const now = Date.now() + const buffer = 10 * 1000 // 10秒缓冲 + + return now >= expiryTime - buffer +} + +/** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ +function isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() +} + +// 检查账户是否被限流 +function isRateLimited(account) { + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const limitedAt = new Date(account.rateLimitedAt).getTime() + const now = Date.now() + const limitDuration = 60 * 60 * 1000 // 1小时 + + return now < limitedAt + limitDuration + } + return false +} + +// 刷新账户 token +async function refreshAccountToken(accountId) { + let lockAcquired = false + let account = null + + try { + account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + if (!account.refreshToken) { + throw new Error('No refresh token available') + } + + // 尝试获取分布式锁 + lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'gemini') + + if (!lockAcquired) { + // 如果无法获取锁,说明另一个进程正在刷新 + logger.info( + `🔒 Token refresh already in progress for Gemini account: ${account.name} (${accountId})` + ) + logRefreshSkipped(accountId, account.name, 'gemini', 'already_locked') + + // 等待一段时间后返回,期望其他进程已完成刷新 + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // 重新获取账户数据(可能已被其他进程刷新) + const updatedAccount = await getAccount(accountId) + if (updatedAccount && updatedAccount.accessToken) { + const oauthConfig = getOauthProviderConfig(updatedAccount.oauthProvider) + const accessToken = decrypt(updatedAccount.accessToken) + return { + access_token: accessToken, + refresh_token: updatedAccount.refreshToken ? decrypt(updatedAccount.refreshToken) : '', + expiry_date: updatedAccount.expiresAt ? new Date(updatedAccount.expiresAt).getTime() : 0, + scope: updatedAccount.scopes || oauthConfig.scopes.join(' '), + token_type: 'Bearer' + } + } + + throw new Error('Token refresh in progress by another process') + } + + // 记录开始刷新 + logRefreshStart(accountId, account.name, 'gemini', 'manual_refresh') + logger.info(`🔄 Starting token refresh for Gemini account: ${account.name} (${accountId})`) + + // account.refreshToken 已经是解密后的值(从 getAccount 返回) + // 传入账户的代理配置 + const newTokens = await refreshAccessToken( + account.refreshToken, + account.proxy, + account.oauthProvider + ) + + // 更新账户信息 + const updates = { + accessToken: newTokens.access_token, + refreshToken: newTokens.refresh_token || account.refreshToken, + expiresAt: new Date(newTokens.expiry_date).toISOString(), + lastRefreshAt: new Date().toISOString(), + geminiOauth: JSON.stringify(newTokens), + status: 'active', // 刷新成功后,将状态更新为 active + errorMessage: '' // 清空错误信息 + } + + await updateAccount(accountId, updates) + + // 记录刷新成功 + logRefreshSuccess(accountId, account.name, 'gemini', { + accessToken: newTokens.access_token, + refreshToken: newTokens.refresh_token, + expiresAt: newTokens.expiry_date, + scopes: newTokens.scope + }) + + logger.info( + `Refreshed token for Gemini account: ${accountId} - Access Token: ${maskToken(newTokens.access_token)}` + ) + + return newTokens + } catch (error) { + // 记录刷新失败 + logRefreshError(accountId, account ? account.name : 'Unknown', 'gemini', error) + + logger.error(`Failed to refresh token for account ${accountId}:`, error) + + // 标记账户为错误状态(只有在账户存在时) + if (account) { + try { + await updateAccount(accountId, { + status: 'error', + errorMessage: error.message + }) + + // 发送Webhook通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name, + platform: 'gemini', + status: 'error', + errorCode: 'GEMINI_ERROR', + reason: `Token refresh failed: ${error.message}` + }) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + } catch (updateError) { + logger.error('Failed to update account status after refresh error:', updateError) + } + } + + throw error + } finally { + // 释放锁 + if (lockAcquired) { + await tokenRefreshService.releaseRefreshLock(accountId, 'gemini') + } + } +} + +// 标记账户被使用 +async function markAccountUsed(accountId) { + await updateAccount(accountId, { + lastUsedAt: new Date().toISOString() + }) +} + +// 设置账户限流状态 +async function setAccountRateLimited(accountId, isLimited = true) { + const updates = isLimited + ? { + rateLimitStatus: 'limited', + rateLimitedAt: new Date().toISOString() + } + : { + rateLimitStatus: '', + rateLimitedAt: '' + } + + await updateAccount(accountId, updates) +} + +// 获取账户的限流信息(参考 claudeAccountService 的实现) +async function getAccountRateLimitInfo(accountId) { + try { + const account = await getAccount(accountId) + if (!account) { + return null + } + + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const rateLimitedAt = new Date(account.rateLimitedAt) + const now = new Date() + const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60)) + + // Gemini 限流持续时间为 1 小时 + const minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit) + const rateLimitEndAt = new Date(rateLimitedAt.getTime() + 60 * 60 * 1000).toISOString() + + return { + isRateLimited: minutesRemaining > 0, + rateLimitedAt: account.rateLimitedAt, + minutesSinceRateLimit, + minutesRemaining, + rateLimitEndAt + } + } + + return { + isRateLimited: false, + rateLimitedAt: null, + minutesSinceRateLimit: 0, + minutesRemaining: 0, + rateLimitEndAt: null + } + } catch (error) { + logger.error(`❌ Failed to get rate limit info for Gemini account: ${accountId}`, error) + return null + } +} + +// 获取配置的OAuth客户端 - 参考GeminiCliSimulator的getOauthClient方法(支持代理) +async function getOauthClient(accessToken, refreshToken, proxyConfig = null, oauthProvider = null) { + const normalizedProvider = normalizeOauthProvider(oauthProvider) + const oauthConfig = getOauthProviderConfig(normalizedProvider) + const client = createOAuth2Client(null, proxyConfig, normalizedProvider) + + const creds = { + access_token: accessToken, + refresh_token: refreshToken, + scope: oauthConfig.scopes.join(' '), + token_type: 'Bearer', + expiry_date: 1754269905646 + } + + if (proxyConfig) { + logger.info( + `🌐 Using proxy for Gemini OAuth client: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini OAuth client') + } + + // 设置凭据 + client.setCredentials(creds) + + // 验证凭据本地有效性 + const { token } = await client.getAccessToken() + + if (!token) { + return false + } + + // 验证服务器端token状态(检查是否被撤销) + await client.getTokenInfo(token) + + logger.info('✅ OAuth客户端已创建') + return client +} + +// 通用的 Code Assist API 转发函数(用于简单的请求/响应端点) +// 适用于:loadCodeAssist, onboardUser, countTokens, listExperiments 等不需要特殊处理的端点 +async function forwardToCodeAssist(client, apiMethod, requestBody, proxyConfig = null) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + + logger.info(`📡 ${apiMethod} API调用开始`) + + const axiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:${apiMethod}`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: requestBody, + timeout: 30000 + } + + // 添加代理配置 + if (proxyAgent) { + // 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com) + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info(`🌐 Using proxy for ${apiMethod}: ${ProxyHelper.getProxyDescription(proxyConfig)}`) + } else { + logger.debug(`🌐 No proxy configured for ${apiMethod}`) + } + + const response = await axios(axiosConfig) + + logger.info(`✅ ${apiMethod} API调用成功`) + return response.data +} + +// 调用 Google Code Assist API 的 loadCodeAssist 方法(支持代理) +async function loadCodeAssist(client, projectId = null, proxyConfig = null) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + // 🔍 只有个人账户(无 projectId)才需要调用 tokeninfo/userinfo + // 这些调用有助于 Google 获取临时 projectId + if (!projectId) { + const tokenInfoConfig = { + url: 'https://oauth2.googleapis.com/tokeninfo', + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded' + }, + data: new URLSearchParams({ access_token: token }).toString(), + timeout: 15000 + } + + if (proxyAgent) { + tokenInfoConfig.httpAgent = proxyAgent + tokenInfoConfig.httpsAgent = proxyAgent + tokenInfoConfig.proxy = false + } + + try { + await axios(tokenInfoConfig) + logger.info('📋 tokeninfo 接口验证成功') + } catch (error) { + logger.warn('⚠️ tokeninfo 接口调用失败:', error.message) + } + + const userInfoConfig = { + url: 'https://www.googleapis.com/oauth2/v2/userinfo', + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + Accept: '*/*' + }, + timeout: 15000 + } + + if (proxyAgent) { + userInfoConfig.httpAgent = proxyAgent + userInfoConfig.httpsAgent = proxyAgent + userInfoConfig.proxy = false + } + + try { + await axios(userInfoConfig) + logger.info('📋 userinfo 接口获取成功') + } catch (error) { + logger.warn('⚠️ userinfo 接口调用失败:', error.message) + } + } + + // 创建ClientMetadata + const clientMetadata = { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI' + } + + // 只有当projectId存在时才添加duetProject + if (projectId) { + clientMetadata.duetProject = projectId + } + + const request = { + metadata: clientMetadata + } + + // 只有当projectId存在时才添加cloudaicompanionProject + if (projectId) { + request.cloudaicompanionProject = projectId + } + + const axiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:loadCodeAssist`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: request, + timeout: 30000 + } + + // 添加代理配置 + if (proxyAgent) { + // 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com) + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info( + `🌐 Using proxy for Gemini loadCodeAssist: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini loadCodeAssist') + } + + const response = await axios(axiosConfig) + + logger.info('📋 loadCodeAssist API调用成功') + return response.data +} + +// 获取onboard层级 - 参考GeminiCliSimulator的getOnboardTier方法 +function getOnboardTier(loadRes) { + // 用户层级枚举 + const UserTierId = { + LEGACY: 'LEGACY', + FREE: 'FREE', + PRO: 'PRO' + } + + if (loadRes.currentTier) { + return loadRes.currentTier + } + + for (const tier of loadRes.allowedTiers || []) { + if (tier.isDefault) { + return tier + } + } + + return { + name: '', + description: '', + id: UserTierId.LEGACY, + userDefinedCloudaicompanionProject: true + } +} + +// 调用 Google Code Assist API 的 onboardUser 方法(包含轮询逻辑,支持代理) +async function onboardUser(client, tierId, projectId, clientMetadata, proxyConfig = null) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + + const onboardReq = { + tierId, + metadata: clientMetadata + } + + // 只有当projectId存在时才添加cloudaicompanionProject + if (projectId) { + onboardReq.cloudaicompanionProject = projectId + } + + // 创建基础axios配置 + const baseAxiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:onboardUser`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: onboardReq, + timeout: 30000 + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + baseAxiosConfig.httpAgent = proxyAgent + baseAxiosConfig.httpsAgent = proxyAgent + baseAxiosConfig.proxy = false + logger.info( + `🌐 Using proxy for Gemini onboardUser: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini onboardUser') + } + + logger.info('📋 开始onboardUser API调用', { + tierId, + projectId, + hasProjectId: !!projectId, + isFreeTier: tierId === 'free-tier' || tierId === 'FREE' + }) + + // 轮询onboardUser直到长运行操作完成 + let lroRes = await axios(baseAxiosConfig) + + let attempts = 0 + const maxAttempts = 12 // 最多等待1分钟(5秒 * 12次) + + while (!lroRes.data.done && attempts < maxAttempts) { + logger.info(`⏳ 等待onboardUser完成... (${attempts + 1}/${maxAttempts})`) + await new Promise((resolve) => setTimeout(resolve, 5000)) + + lroRes = await axios(baseAxiosConfig) + attempts++ + } + + if (!lroRes.data.done) { + throw new Error('onboardUser操作超时') + } + + logger.info('✅ onboardUser API调用完成') + return lroRes.data +} + +// 完整的用户设置流程 - 参考setup.ts的逻辑(支持代理) +async function setupUser( + client, + initialProjectId = null, + clientMetadata = null, + proxyConfig = null +) { + logger.info('🚀 setupUser 开始', { initialProjectId, hasClientMetadata: !!clientMetadata }) + + let projectId = initialProjectId || process.env.GOOGLE_CLOUD_PROJECT || null + logger.info('📋 初始项目ID', { projectId, fromEnv: !!process.env.GOOGLE_CLOUD_PROJECT }) + + // 默认的ClientMetadata + if (!clientMetadata) { + clientMetadata = { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + duetProject: projectId + } + logger.info('🔧 使用默认 ClientMetadata') + } + + // 调用loadCodeAssist + logger.info('📞 调用 loadCodeAssist...') + const loadRes = await loadCodeAssist(client, projectId, proxyConfig) + logger.info('✅ loadCodeAssist 完成', { + hasCloudaicompanionProject: !!loadRes.cloudaicompanionProject + }) + + // 如果没有projectId,尝试从loadRes获取 + if (!projectId && loadRes.cloudaicompanionProject) { + projectId = loadRes.cloudaicompanionProject + logger.info('📋 从 loadCodeAssist 获取项目ID', { projectId }) + } + + const tier = getOnboardTier(loadRes) + logger.info('🎯 获取用户层级', { + tierId: tier.id, + userDefinedProject: tier.userDefinedCloudaicompanionProject + }) + + if (tier.userDefinedCloudaiCompanionProject && !projectId) { + throw new Error('此账号需要设置GOOGLE_CLOUD_PROJECT环境变量或提供projectId') + } + + // 调用onboardUser + logger.info('📞 调用 onboardUser...', { tierId: tier.id, projectId }) + const lroRes = await onboardUser(client, tier.id, projectId, clientMetadata, proxyConfig) + logger.info('✅ onboardUser 完成', { hasDone: !!lroRes.done, hasResponse: !!lroRes.response }) + + const result = { + projectId: lroRes.response?.cloudaicompanionProject?.id || projectId || '', + userTier: tier.id, + loadRes, + onboardRes: lroRes.response || {} + } + + logger.info('🎯 setupUser 完成', { resultProjectId: result.projectId, userTier: result.userTier }) + return result +} + +// 调用 Code Assist API 计算 token 数量(支持代理) +async function countTokens(client, contents, model = 'gemini-2.0-flash-exp', proxyConfig = null) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + + // 按照 gemini-cli 的转换格式构造请求 + const request = { + request: { + model: `models/${model}`, + contents + } + } + + logger.info('📊 countTokens API调用开始', { model, contentsLength: contents.length }) + + const axiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:countTokens`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: request, + timeout: 30000 + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + // 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com) + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info( + `🌐 Using proxy for Gemini countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini countTokens') + } + + const response = await axios(axiosConfig) + + logger.info('✅ countTokens API调用成功', { totalTokens: response.data.totalTokens }) + return response.data +} + +// 调用 Code Assist API 生成内容(非流式) +async function generateContent( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + proxyConfig = null +) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + + // 按照 gemini-cli 的转换格式构造请求 + const request = { + model: requestData.model, + request: { + ...requestData.request, + session_id: sessionId + } + } + + // 只有当 userPromptId 存在时才添加 + if (userPromptId) { + request.user_prompt_id = userPromptId + } + + // 只有当projectId存在时才添加project字段 + if (projectId) { + request.project = projectId + } + + logger.info('🤖 generateContent API调用开始', { + model: requestData.model, + userPromptId, + projectId, + sessionId + }) + + // 添加详细的请求日志 + logger.info('📦 generateContent 请求详情', { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`, + requestBody: JSON.stringify(request, null, 2) + }) + + const axiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:generateContent`, + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: request, + timeout: 600000 // 生成内容可能需要更长时间 + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + // 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com) + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info( + `🌐 Using proxy for Gemini generateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + // 没有代理时,使用 keepAlive agent 防止长时间请求被中断 + axiosConfig.httpsAgent = keepAliveAgent + logger.debug('🌐 Using keepAlive agent for Gemini generateContent') + } + + const response = await axios(axiosConfig) + + logger.info('✅ generateContent API调用成功') + return response.data +} + +// 调用 Antigravity 上游生成内容(非流式) +async function generateContentAntigravity( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + proxyConfig = null +) { + const { token } = await client.getAccessToken() + const { model } = antigravityClient.buildAntigravityEnvelope({ + requestData, + projectId, + sessionId, + userPromptId + }) + + logger.info('🪐 Antigravity generateContent API调用开始', { + model, + userPromptId, + projectId, + sessionId + }) + + const { response } = await antigravityClient.request({ + accessToken: token, + proxyConfig, + requestData, + projectId, + sessionId, + userPromptId, + stream: false + }) + logger.info('✅ Antigravity generateContent API调用成功') + return response.data +} + +// 调用 Code Assist API 生成内容(流式) +async function generateContentStream( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + signal = null, + proxyConfig = null +) { + const axios = require('axios') + const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com' + const CODE_ASSIST_API_VERSION = 'v1internal' + + const { token } = await client.getAccessToken() + + // 按照 gemini-cli 的转换格式构造请求 + const request = { + model: requestData.model, + request: { + ...requestData.request, + session_id: sessionId + } + } + + // 只有当 userPromptId 存在时才添加 + if (userPromptId) { + request.user_prompt_id = userPromptId + } + + // 只有当projectId存在时才添加project字段 + if (projectId) { + request.project = projectId + } + + logger.info('🌊 streamGenerateContent API调用开始', { + model: requestData.model, + userPromptId, + projectId, + sessionId + }) + + const axiosConfig = { + url: `${CODE_ASSIST_ENDPOINT}/${CODE_ASSIST_API_VERSION}:streamGenerateContent`, + method: 'POST', + params: { + alt: 'sse' + }, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + data: request, + responseType: 'stream', + timeout: 0 // 流式请求不设置超时限制,由 keepAlive 和 AbortSignal 控制 + } + + // 添加代理配置 + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + if (proxyAgent) { + // 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode-pa.googleapis.com) + // 同时设置 httpAgent 和 httpsAgent 可能导致 axios/follow-redirects 选择错误的协议 + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info( + `🌐 Using proxy for Gemini streamGenerateContent: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } else { + // 没有代理时,使用 keepAlive agent 防止长时间流式请求被中断 + axiosConfig.httpsAgent = keepAliveAgent + logger.debug('🌐 Using keepAlive agent for Gemini streamGenerateContent') + } + + // 如果提供了中止信号,添加到配置中 + if (signal) { + axiosConfig.signal = signal + } + + const response = await axios(axiosConfig) + + logger.info('✅ streamGenerateContent API调用成功,开始流式传输') + return response.data // 返回流对象 +} + +// 调用 Antigravity 上游生成内容(流式) +async function generateContentStreamAntigravity( + client, + requestData, + userPromptId, + projectId = null, + sessionId = null, + signal = null, + proxyConfig = null +) { + const { token } = await client.getAccessToken() + const { model } = antigravityClient.buildAntigravityEnvelope({ + requestData, + projectId, + sessionId, + userPromptId + }) + + logger.info('🌊 Antigravity streamGenerateContent API调用开始', { + model, + userPromptId, + projectId, + sessionId + }) + + const { response } = await antigravityClient.request({ + accessToken: token, + proxyConfig, + requestData, + projectId, + sessionId, + userPromptId, + stream: true, + signal, + params: { alt: 'sse' } + }) + logger.info('✅ Antigravity streamGenerateContent API调用成功,开始流式传输') + return response.data +} + +// 更新账户的临时项目 ID +async function updateTempProjectId(accountId, tempProjectId) { + if (!tempProjectId) { + return + } + + try { + const account = await getAccount(accountId) + if (!account) { + logger.warn(`Account ${accountId} not found when updating tempProjectId`) + return + } + + // 只有在没有固定项目 ID 的情况下才更新临时项目 ID + if (!account.projectId && tempProjectId !== account.tempProjectId) { + await updateAccount(accountId, { tempProjectId }) + logger.info(`Updated tempProjectId for account ${accountId}: ${tempProjectId}`) + } + } catch (error) { + logger.error(`Failed to update tempProjectId for account ${accountId}:`, error) + } +} + +// 重置账户状态(清除所有异常状态) +async function resetAccountStatus(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const updates = { + // 根据是否有有效的 refreshToken 来设置 status + status: account.refreshToken ? 'active' : 'created', + // 恢复可调度状态 + schedulable: 'true', + // 清除错误相关字段 + errorMessage: '', + rateLimitedAt: '', + rateLimitStatus: '' + } + + await updateAccount(accountId, updates) + logger.info(`✅ Reset all error status for Gemini account ${accountId}`) + + // 清除临时不可用状态 + await upstreamErrorHelper.clearTempUnavailable(accountId, 'gemini').catch(() => {}) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'gemini', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + logger.info(`📢 Webhook notification sent for Gemini account ${account.name} status reset`) + } catch (webhookError) { + logger.error('Failed to send status reset webhook notification:', webhookError) + } + + return { + success: true, + message: 'Account status reset successfully' + } +} + +module.exports = { + generateAuthUrl, + pollAuthorizationStatus, + exchangeCodeForTokens, + refreshAccessToken, + createAccount, + getAccount, + updateAccount, + deleteAccount, + getAllAccounts, + selectAvailableAccount, + refreshAccountToken, + markAccountUsed, + setAccountRateLimited, + getAccountRateLimitInfo, + isTokenExpired, + getOauthClient, + forwardToCodeAssist, // 通用转发函数 + loadCodeAssist, + getOnboardTier, + onboardUser, + setupUser, + encrypt, + decrypt, + encryptor, // 暴露加密器以便测试和监控 + countTokens, + countTokensAntigravity, + generateContent, + generateContentStream, + generateContentAntigravity, + generateContentStreamAntigravity, + fetchAvailableModelsAntigravity, + updateTempProjectId, + resetAccountStatus +} diff --git a/src/services/account/geminiApiAccountService.js b/src/services/account/geminiApiAccountService.js new file mode 100644 index 0000000..a455c92 --- /dev/null +++ b/src/services/account/geminiApiAccountService.js @@ -0,0 +1,637 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const config = require('../../../config/config') +const LRUCache = require('../../utils/lruCache') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +class GeminiApiAccountService { + constructor() { + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'gemini-api-salt' + + // Redis 键前缀 + this.ACCOUNT_KEY_PREFIX = 'gemini_api_account:' + this.SHARED_ACCOUNTS_KEY = 'shared_gemini_api_accounts' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info('🧹 Gemini-API decrypt cache cleanup completed', this._decryptCache.getStats()) + }, + 10 * 60 * 1000 + ) + } + + // 创建账户 + async createAccount(options = {}) { + const { + name = 'Gemini API Account', + description = '', + apiKey = '', // 必填:Google AI Studio API Key + baseUrl = 'https://generativelanguage.googleapis.com', // 默认 Gemini API 基础 URL + proxy = null, + priority = 50, // 调度优先级 (1-100) + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + schedulable = true, // 是否可被调度 + supportedModels = [], // 支持的模型列表 + rateLimitDuration = 60, // 限流时间(分钟) + disableAutoProtection = false + } = options + + // 验证必填字段 + if (!apiKey) { + throw new Error('API Key is required for Gemini-API account') + } + + // 规范化 baseUrl(确保不以 / 结尾) + const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl + + const accountId = uuidv4() + + const accountData = { + id: accountId, + platform: 'gemini-api', + name, + description, + baseUrl: normalizedBaseUrl, + apiKey: this._encryptSensitiveData(apiKey), + priority: priority.toString(), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + schedulable: schedulable.toString(), + supportedModels: JSON.stringify(supportedModels), + + createdAt: new Date().toISOString(), + lastUsedAt: '', + status: 'active', + errorMessage: '', + + // 限流相关 + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitDuration: rateLimitDuration.toString(), + + // 自动防护开关 + disableAutoProtection: + disableAutoProtection === true || disableAutoProtection === 'true' ? 'true' : 'false' + } + + // 保存到 Redis + await this._saveAccount(accountId, accountData) + + logger.success(`Created Gemini-API account: ${name} (${accountId})`) + + return { + ...accountData, + apiKey: '***' // 返回时隐藏敏感信息 + } + } + + // 获取账户 + async getAccount(accountId) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + const accountData = await client.hgetall(key) + + if (!accountData || !accountData.id) { + return null + } + + // 解密敏感数据 + accountData.apiKey = this._decryptSensitiveData(accountData.apiKey) + + // 解析 JSON 字段 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + if (accountData.supportedModels) { + try { + accountData.supportedModels = JSON.parse(accountData.supportedModels) + } catch (e) { + accountData.supportedModels = [] + } + } + + return accountData + } + + // 更新账户 + async updateAccount(accountId, updates) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 处理敏感字段加密 + if (updates.apiKey) { + updates.apiKey = this._encryptSensitiveData(updates.apiKey) + } + + // 处理 JSON 字段 + if (updates.proxy !== undefined) { + updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + + if (updates.supportedModels !== undefined) { + updates.supportedModels = JSON.stringify(updates.supportedModels) + } + + // 规范化 baseUrl + if (updates.baseUrl) { + updates.baseUrl = updates.baseUrl.endsWith('/') + ? updates.baseUrl.slice(0, -1) + : updates.baseUrl + } + + // 处理 disableAutoProtection 布尔值转字符串 + if (updates.disableAutoProtection !== undefined) { + updates.disableAutoProtection = + updates.disableAutoProtection === true || updates.disableAutoProtection === 'true' + ? 'true' + : 'false' + } + + // 更新 Redis + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + await client.hset(key, updates) + + logger.info(`📝 Updated Gemini-API account: ${account.name}`) + + return { success: true } + } + + // 删除账户 + async deleteAccount(accountId) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 从共享账户列表中移除 + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + + // 从索引中移除 + await redis.removeFromIndex('gemini_api_account:index', accountId) + + // 删除账户数据 + await client.del(key) + + logger.info(`🗑️ Deleted Gemini-API account: ${accountId}`) + + return { success: true } + } + + // 获取所有账户 + async getAllAccounts(includeInactive = false) { + const client = redis.getClientSafe() + const accountIds = await client.smembers(this.SHARED_ACCOUNTS_KEY) + const accounts = [] + + for (const accountId of accountIds) { + const account = await this.getAccount(accountId) + if (account) { + // 过滤非活跃账户 + if (includeInactive || account.isActive === 'true') { + // 隐藏敏感信息 + account.apiKey = '***' + + // 获取限流状态信息 + const rateLimitInfo = this._getRateLimitInfo(account) + + // 格式化 rateLimitStatus 为对象 + account.rateLimitStatus = rateLimitInfo.isRateLimited + ? { + isRateLimited: true, + rateLimitedAt: account.rateLimitedAt || null, + minutesRemaining: rateLimitInfo.remainingMinutes || 0 + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0 + } + + // 转换 schedulable 字段为布尔值 + account.schedulable = account.schedulable !== 'false' + // 转换 isActive 字段为布尔值 + account.isActive = account.isActive === 'true' + + account.platform = account.platform || 'gemini-api' + + accounts.push(account) + } + } + } + + // 直接从 Redis 获取所有账户(包括非共享账户) + const allAccountIds = await redis.getAllIdsByIndex( + 'gemini_api_account:index', + `${this.ACCOUNT_KEY_PREFIX}*`, + /^gemini_api_account:(.+)$/ + ) + const keys = allAccountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`) + const dataList = await redis.batchHgetallChunked(keys) + for (let i = 0; i < allAccountIds.length; i++) { + const accountId = allAccountIds[i] + if (!accountIds.includes(accountId)) { + const accountData = dataList[i] + if (accountData && accountData.id) { + // 过滤非活跃账户 + if (includeInactive || accountData.isActive === 'true') { + // 隐藏敏感信息 + accountData.apiKey = '***' + + // 解析 JSON 字段 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + if (accountData.supportedModels) { + try { + accountData.supportedModels = JSON.parse(accountData.supportedModels) + } catch (e) { + accountData.supportedModels = [] + } + } + + // 获取限流状态信息 + const rateLimitInfo = this._getRateLimitInfo(accountData) + + // 格式化 rateLimitStatus 为对象 + accountData.rateLimitStatus = rateLimitInfo.isRateLimited + ? { + isRateLimited: true, + rateLimitedAt: accountData.rateLimitedAt || null, + minutesRemaining: rateLimitInfo.remainingMinutes || 0 + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0 + } + + // 转换 schedulable 字段为布尔值 + accountData.schedulable = accountData.schedulable !== 'false' + // 转换 isActive 字段为布尔值 + accountData.isActive = accountData.isActive === 'true' + + accountData.platform = accountData.platform || 'gemini-api' + + accounts.push(accountData) + } + } + } + } + + return accounts + } + + // 标记账户已使用 + async markAccountUsed(accountId) { + await this.updateAccount(accountId, { + lastUsedAt: new Date().toISOString() + }) + } + + // 标记账户限流 + async setAccountRateLimited(accountId, isLimited, duration = null) { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + if (isLimited) { + // disableAutoProtection 检查(仅在设置限流时) + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping setAccountRateLimited` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'gemini-api', 429, 'rate_limit') + .catch(() => {}) + return + } + + const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60 + const now = new Date() + const resetAt = new Date(now.getTime() + rateLimitDuration * 60000) + + await this.updateAccount(accountId, { + rateLimitedAt: now.toISOString(), + rateLimitStatus: 'limited', + rateLimitResetAt: resetAt.toISOString(), + rateLimitDuration: rateLimitDuration.toString(), + status: 'rateLimited', + schedulable: 'false', // 防止被调度 + errorMessage: `Rate limited until ${resetAt.toISOString()}` + }) + + logger.warn( + `⏳ Gemini-API account ${account.name} marked as rate limited for ${rateLimitDuration} minutes (until ${resetAt.toISOString()})` + ) + } else { + // 清除限流状态 + await this.updateAccount(accountId, { + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + status: 'active', + schedulable: 'true', + errorMessage: '' + }) + + logger.info(`✅ Rate limit cleared for Gemini-API account ${account.name}`) + } + } + + // 🚫 标记账户为未授权状态(401错误) + async markAccountUnauthorized(accountId, reason = 'Gemini API账号认证失败(401错误)') { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountUnauthorized` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'gemini-api', 401, 'auth_error') + .catch(() => {}) + return + } + + const now = new Date().toISOString() + const currentCount = parseInt(account.unauthorizedCount || '0', 10) + const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 + + await this.updateAccount(accountId, { + status: 'unauthorized', + schedulable: 'false', + errorMessage: reason, + unauthorizedAt: now, + unauthorizedCount: unauthorizedCount.toString() + }) + + logger.warn( + `🚫 Gemini-API account ${account.name || accountId} marked as unauthorized due to 401 error` + ) + + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'gemini-api', + status: 'unauthorized', + errorCode: 'GEMINI_API_UNAUTHORIZED', + reason, + timestamp: now + }) + logger.info( + `📢 Webhook notification sent for Gemini-API account ${account.name || accountId} unauthorized state` + ) + } catch (webhookError) { + logger.error('Failed to send unauthorized webhook notification:', webhookError) + } + } + + // 检查并清除过期的限流状态 + async checkAndClearRateLimit(accountId) { + const account = await this.getAccount(accountId) + if (!account || account.rateLimitStatus !== 'limited') { + return false + } + + const now = new Date() + let shouldClear = false + + // 优先使用 rateLimitResetAt 字段 + if (account.rateLimitResetAt) { + const resetAt = new Date(account.rateLimitResetAt) + shouldClear = now >= resetAt + } else { + // 如果没有 rateLimitResetAt,使用旧的逻辑 + const rateLimitedAt = new Date(account.rateLimitedAt) + const rateLimitDuration = parseInt(account.rateLimitDuration) || 60 + shouldClear = now - rateLimitedAt > rateLimitDuration * 60000 + } + + if (shouldClear) { + // 限流已过期,清除状态 + await this.setAccountRateLimited(accountId, false) + return true + } + + return false + } + + // 切换调度状态 + async toggleSchedulable(accountId) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const newSchedulableStatus = account.schedulable === 'true' ? 'false' : 'true' + await this.updateAccount(accountId, { + schedulable: newSchedulableStatus + }) + + logger.info( + `🔄 Toggled schedulable status for Gemini-API account ${account.name}: ${newSchedulableStatus}` + ) + + return { + success: true, + schedulable: newSchedulableStatus === 'true' + } + } + + // 重置账户状态(清除所有异常状态) + async resetAccountStatus(accountId) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const updates = { + // 根据是否有有效的 apiKey 来设置 status + status: account.apiKey ? 'active' : 'created', + // 恢复可调度状态 + schedulable: 'true', + // 清除错误相关字段 + errorMessage: '', + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + rateLimitDuration: '' + } + + await this.updateAccount(accountId, updates) + logger.info(`✅ Reset all error status for Gemini-API account ${accountId}`) + + // 清除临时不可用状态 + await upstreamErrorHelper.clearTempUnavailable(accountId, 'gemini-api').catch(() => {}) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'gemini-api', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + logger.info( + `📢 Webhook notification sent for Gemini-API account ${account.name} status reset` + ) + } catch (webhookError) { + logger.error('Failed to send status reset webhook notification:', webhookError) + } + + return { success: true, message: 'Account status reset successfully' } + } + + // API Key 不会过期 + isTokenExpired(_account) { + return false + } + + // 获取限流信息 + _getRateLimitInfo(accountData) { + if (accountData.rateLimitStatus !== 'limited') { + return { isRateLimited: false } + } + + const now = new Date() + let willBeAvailableAt + let remainingMinutes + + // 优先使用 rateLimitResetAt 字段 + if (accountData.rateLimitResetAt) { + willBeAvailableAt = new Date(accountData.rateLimitResetAt) + remainingMinutes = Math.max(0, Math.ceil((willBeAvailableAt - now) / 60000)) + } else { + // 如果没有 rateLimitResetAt,使用旧的逻辑 + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 + const elapsedMinutes = Math.floor((now - rateLimitedAt) / 60000) + remainingMinutes = Math.max(0, rateLimitDuration - elapsedMinutes) + willBeAvailableAt = new Date(rateLimitedAt.getTime() + rateLimitDuration * 60000) + } + + return { + isRateLimited: remainingMinutes > 0, + remainingMinutes, + willBeAvailableAt + } + } + + // 加密敏感数据 + _encryptSensitiveData(text) { + if (!text) { + return '' + } + + const key = this._getEncryptionKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + + return `${iv.toString('hex')}:${encrypted.toString('hex')}` + } + + // 解密敏感数据 + _decryptSensitiveData(text) { + if (!text || text === '') { + return '' + } + + // 检查缓存 + const cacheKey = crypto.createHash('sha256').update(text).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + const key = this._getEncryptionKey() + const [ivHex, encryptedHex] = text.split(':') + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + + const result = decrypted.toString() + + // 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, result, 5 * 60 * 1000) + + return result + } catch (error) { + logger.error('Decryption error:', error) + return '' + } + } + + // 获取加密密钥 + _getEncryptionKey() { + if (!this._encryptionKeyCache) { + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + } + return this._encryptionKeyCache + } + + // 保存账户到 Redis + async _saveAccount(accountId, accountData) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 保存账户数据 + await client.hset(key, accountData) + + // 添加到索引 + await redis.addToIndex('gemini_api_account:index', accountId) + + // 添加到共享账户列表 + if (accountData.accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } + } +} + +module.exports = new GeminiApiAccountService() diff --git a/src/services/account/openaiAccountService.js b/src/services/account/openaiAccountService.js new file mode 100644 index 0000000..8fa8086 --- /dev/null +++ b/src/services/account/openaiAccountService.js @@ -0,0 +1,1254 @@ +const redisClient = require('../../models/redis') +const { v4: uuidv4 } = require('uuid') +const axios = require('axios') +const ProxyHelper = require('../../utils/proxyHelper') +const config = require('../../../config/config') +const logger = require('../../utils/logger') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') +// const { maskToken } = require('../../utils/tokenMask') +const { + logRefreshStart, + logRefreshSuccess, + logRefreshError, + logTokenUsage, + logRefreshSkipped +} = require('../../utils/tokenRefreshLogger') +const tokenRefreshService = require('../tokenRefreshService') +const { createEncryptor } = require('../../utils/commonHelper') + +// 使用 commonHelper 的加密器 +const encryptor = createEncryptor('openai-account-salt') +const { encrypt, decrypt } = encryptor + +// OpenAI 账户键前缀 +const OPENAI_ACCOUNT_KEY_PREFIX = 'openai:account:' +const SHARED_OPENAI_ACCOUNTS_KEY = 'shared_openai_accounts' +const ACCOUNT_SESSION_MAPPING_PREFIX = 'openai_session_account_mapping:' + +// 🧹 定期清理缓存(每10分钟) +setInterval( + () => { + encryptor.clearCache() + logger.info('🧹 OpenAI decrypt cache cleanup completed', encryptor.getStats()) + }, + 10 * 60 * 1000 +) + +function toNumberOrNull(value) { + if (value === undefined || value === null || value === '') { + return null + } + + const num = Number(value) + return Number.isFinite(num) ? num : null +} + +function computeResetMeta(updatedAt, resetAfterSeconds) { + if (!updatedAt || resetAfterSeconds === null || resetAfterSeconds === undefined) { + return { + resetAt: null, + remainingSeconds: null + } + } + + const updatedMs = Date.parse(updatedAt) + if (Number.isNaN(updatedMs)) { + return { + resetAt: null, + remainingSeconds: null + } + } + + const resetMs = updatedMs + resetAfterSeconds * 1000 + return { + resetAt: new Date(resetMs).toISOString(), + remainingSeconds: Math.max(0, Math.round((resetMs - Date.now()) / 1000)) + } +} + +function buildCodexUsageSnapshot(accountData) { + const updatedAt = accountData.codexUsageUpdatedAt + + const primaryUsedPercent = toNumberOrNull(accountData.codexPrimaryUsedPercent) + const primaryResetAfterSeconds = toNumberOrNull(accountData.codexPrimaryResetAfterSeconds) + const primaryWindowMinutes = toNumberOrNull(accountData.codexPrimaryWindowMinutes) + const secondaryUsedPercent = toNumberOrNull(accountData.codexSecondaryUsedPercent) + const secondaryResetAfterSeconds = toNumberOrNull(accountData.codexSecondaryResetAfterSeconds) + const secondaryWindowMinutes = toNumberOrNull(accountData.codexSecondaryWindowMinutes) + const overSecondaryPercent = toNumberOrNull(accountData.codexPrimaryOverSecondaryLimitPercent) + + const hasPrimaryData = + primaryUsedPercent !== null || + primaryResetAfterSeconds !== null || + primaryWindowMinutes !== null + const hasSecondaryData = + secondaryUsedPercent !== null || + secondaryResetAfterSeconds !== null || + secondaryWindowMinutes !== null + + if (!updatedAt && !hasPrimaryData && !hasSecondaryData) { + return null + } + + const primaryMeta = computeResetMeta(updatedAt, primaryResetAfterSeconds) + const secondaryMeta = computeResetMeta(updatedAt, secondaryResetAfterSeconds) + + return { + updatedAt, + primary: { + usedPercent: primaryUsedPercent, + resetAfterSeconds: primaryResetAfterSeconds, + windowMinutes: primaryWindowMinutes, + resetAt: primaryMeta.resetAt, + remainingSeconds: primaryMeta.remainingSeconds + }, + secondary: { + usedPercent: secondaryUsedPercent, + resetAfterSeconds: secondaryResetAfterSeconds, + windowMinutes: secondaryWindowMinutes, + resetAt: secondaryMeta.resetAt, + remainingSeconds: secondaryMeta.remainingSeconds + }, + primaryOverSecondaryPercent: overSecondaryPercent + } +} + +// 刷新访问令牌 +async function refreshAccessToken(refreshToken, proxy = null) { + try { + // Codex CLI 的官方 CLIENT_ID + const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' + + // 准备请求数据 + const requestData = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: CLIENT_ID, + refresh_token: refreshToken, + scope: 'openid profile email' + }).toString() + + // 配置请求选项 + const requestOptions = { + method: 'POST', + url: 'https://auth.openai.com/oauth/token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Content-Length': requestData.length + }, + data: requestData, + timeout: config.requestTimeout || 600000 // 使用统一的请求超时配置 + } + + // 配置代理(如果有) + const proxyAgent = ProxyHelper.createProxyAgent(proxy) + if (proxyAgent) { + requestOptions.httpAgent = proxyAgent + requestOptions.httpsAgent = proxyAgent + requestOptions.proxy = false + logger.info( + `🌐 Using proxy for OpenAI token refresh: ${ProxyHelper.getProxyDescription(proxy)}` + ) + } else { + logger.debug('🌐 No proxy configured for OpenAI token refresh') + } + + // 发送请求 + logger.info('🔍 发送 token 刷新请求,使用代理:', !!requestOptions.httpsAgent) + const response = await axios(requestOptions) + + if (response.status === 200 && response.data) { + const result = response.data + + logger.info('✅ Successfully refreshed OpenAI token') + + // 返回新的 token 信息 + return { + access_token: result.access_token, + id_token: result.id_token, + refresh_token: result.refresh_token || refreshToken, // 如果没有返回新的,保留原来的 + expires_in: result.expires_in || 3600, + expiry_date: Date.now() + (result.expires_in || 3600) * 1000 // 计算过期时间 + } + } else { + throw new Error(`Failed to refresh token: ${response.status} ${response.statusText}`) + } + } catch (error) { + if (error.response) { + // 服务器响应了错误状态码 + const errorData = error.response.data || {} + logger.error('OpenAI token refresh failed:', { + status: error.response.status, + data: errorData, + headers: error.response.headers + }) + + // 构建详细的错误信息 + let errorMessage = `OpenAI 服务器返回错误 (${error.response.status})` + + if (error.response.status === 400) { + if (errorData.error === 'invalid_grant') { + errorMessage = 'Refresh Token 无效或已过期,请重新授权' + } else if (errorData.error === 'invalid_request') { + errorMessage = `请求参数错误:${errorData.error_description || errorData.error}` + } else { + errorMessage = `请求错误:${errorData.error_description || errorData.error || '未知错误'}` + } + } else if (error.response.status === 401) { + errorMessage = '认证失败:Refresh Token 无效' + } else if (error.response.status === 403) { + errorMessage = '访问被拒绝:可能是 IP 被封或账户被禁用' + } else if (error.response.status === 429) { + errorMessage = '请求过于频繁,请稍后重试' + } else if (error.response.status >= 500) { + errorMessage = 'OpenAI 服务器内部错误,请稍后重试' + } else if (errorData.error_description) { + errorMessage = errorData.error_description + } else if (errorData.error) { + errorMessage = errorData.error + } else if (errorData.message) { + errorMessage = errorData.message + } + + const fullError = new Error(errorMessage) + fullError.status = error.response.status + fullError.details = errorData + throw fullError + } else if (error.request) { + // 请求已发出但没有收到响应 + logger.error('OpenAI token refresh no response:', error.message) + + let errorMessage = '无法连接到 OpenAI 服务器' + if (proxy) { + errorMessage += `(代理: ${ProxyHelper.getProxyDescription(proxy)})` + } + if (error.code === 'ECONNREFUSED') { + errorMessage += ' - 连接被拒绝' + } else if (error.code === 'ETIMEDOUT') { + errorMessage += ' - 连接超时' + } else if (error.code === 'ENOTFOUND') { + errorMessage += ' - 无法解析域名' + } else if (error.code === 'EPROTO') { + errorMessage += ' - 协议错误(可能是代理配置问题)' + } else if (error.message) { + errorMessage += ` - ${error.message}` + } + + const fullError = new Error(errorMessage) + fullError.code = error.code + throw fullError + } else { + // 设置请求时发生错误 + logger.error('OpenAI token refresh error:', error.message) + const fullError = new Error(`请求设置错误: ${error.message}`) + fullError.originalError = error + throw fullError + } + } +} + +// 检查 token 是否过期 +function isTokenExpired(account) { + if (!account.expiresAt) { + return false + } + return new Date(account.expiresAt) <= new Date() +} + +/** + * 检查账户订阅是否过期 + * @param {Object} account - 账户对象 + * @returns {boolean} - true: 已过期, false: 未过期 + */ +function isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置视为永不过期 + } + const expiryDate = new Date(account.subscriptionExpiresAt) + return expiryDate <= new Date() +} + +// 刷新账户的 access token(带分布式锁) +async function refreshAccountToken(accountId) { + let lockAcquired = false + let account = null + let accountName = accountId + + try { + account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + accountName = account.name || accountId + + // 检查是否有 refresh token + // account.refreshToken 在 getAccount 中已经被解密了,直接使用即可 + const refreshToken = account.refreshToken || null + + if (!refreshToken) { + logRefreshSkipped(accountId, accountName, 'openai', 'No refresh token available') + throw new Error('No refresh token available') + } + + // 尝试获取分布式锁 + lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'openai') + + if (!lockAcquired) { + // 如果无法获取锁,说明另一个进程正在刷新 + logger.info( + `🔒 Token refresh already in progress for OpenAI account: ${accountName} (${accountId})` + ) + logRefreshSkipped(accountId, accountName, 'openai', 'already_locked') + + // 等待一段时间后返回,期望其他进程已完成刷新 + await new Promise((resolve) => setTimeout(resolve, 2000)) + + // 重新获取账户数据(可能已被其他进程刷新) + const updatedAccount = await getAccount(accountId) + if (updatedAccount && !isTokenExpired(updatedAccount)) { + return { + access_token: decrypt(updatedAccount.accessToken), + id_token: updatedAccount.idToken, + refresh_token: updatedAccount.refreshToken, + expires_in: 3600, + expiry_date: new Date(updatedAccount.expiresAt).getTime() + } + } + + throw new Error('Token refresh in progress by another process') + } + + // 获取锁成功,开始刷新 + logRefreshStart(accountId, accountName, 'openai') + logger.info(`🔄 Starting token refresh for OpenAI account: ${accountName} (${accountId})`) + + // 获取代理配置 + let proxy = null + if (account.proxy) { + try { + proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn(`Failed to parse proxy config for account ${accountId}:`, e) + } + } + + const newTokens = await refreshAccessToken(refreshToken, proxy) + if (!newTokens) { + throw new Error('Failed to refresh token') + } + + // 准备更新数据 - 不要在这里加密,让 updateAccount 统一处理 + const updates = { + accessToken: newTokens.access_token, // 不加密,让 updateAccount 处理 + expiresAt: new Date(newTokens.expiry_date).toISOString() + } + + // 如果有新的 ID token,也更新它(这对于首次未提供 ID Token 的账户特别重要) + if (newTokens.id_token) { + updates.idToken = newTokens.id_token // 不加密,让 updateAccount 处理 + + // 如果之前没有 ID Token,尝试解析并更新用户信息 + if (!account.idToken || account.idToken === '') { + try { + const idTokenParts = newTokens.id_token.split('.') + if (idTokenParts.length === 3) { + const payload = JSON.parse(Buffer.from(idTokenParts[1], 'base64').toString()) + const authClaims = payload['https://api.openai.com/auth'] || {} + + // 更新账户信息 - 使用正确的字段名 + // OpenAI ID Token中用户ID在chatgpt_account_id、chatgpt_user_id和user_id字段 + if (authClaims.chatgpt_account_id) { + updates.accountId = authClaims.chatgpt_account_id + } + if (authClaims.chatgpt_user_id) { + updates.chatgptUserId = authClaims.chatgpt_user_id + } else if (authClaims.user_id) { + // 有些情况下可能只有user_id字段 + updates.chatgptUserId = authClaims.user_id + } + if (authClaims.organizations?.[0]?.id) { + updates.organizationId = authClaims.organizations[0].id + } + if (authClaims.organizations?.[0]?.role) { + updates.organizationRole = authClaims.organizations[0].role + } + if (authClaims.organizations?.[0]?.title) { + updates.organizationTitle = authClaims.organizations[0].title + } + if (payload.email) { + updates.email = payload.email // 不加密,让 updateAccount 处理 + } + if (payload.email_verified !== undefined) { + updates.emailVerified = payload.email_verified + } + + logger.info(`Updated user info from ID Token for account ${accountId}`) + } + } catch (e) { + logger.warn(`Failed to parse ID Token for account ${accountId}:`, e) + } + } + } + + // 如果返回了新的 refresh token,更新它 + if (newTokens.refresh_token && newTokens.refresh_token !== refreshToken) { + updates.refreshToken = newTokens.refresh_token // 不加密,让 updateAccount 处理 + logger.info(`Updated refresh token for account ${accountId}`) + } + + // 更新账户信息 + await updateAccount(accountId, updates) + + logRefreshSuccess(accountId, accountName, 'openai', newTokens) // 传入完整的 newTokens 对象 + return newTokens + } catch (error) { + logRefreshError(accountId, account?.name || accountName, 'openai', error.message) + + // 发送 Webhook 通知(如果启用) + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account?.name || accountName, + platform: 'openai', + status: 'error', + errorCode: 'OPENAI_TOKEN_REFRESH_FAILED', + reason: `Token refresh failed: ${error.message}`, + timestamp: new Date().toISOString() + }) + logger.info( + `📢 Webhook notification sent for OpenAI account ${account?.name || accountName} refresh failure` + ) + } catch (webhookError) { + logger.error('Failed to send webhook notification:', webhookError) + } + + throw error + } finally { + // 确保释放锁 + if (lockAcquired) { + await tokenRefreshService.releaseRefreshLock(accountId, 'openai') + logger.debug(`🔓 Released refresh lock for OpenAI account ${accountId}`) + } + } +} + +// 创建账户 +async function createAccount(accountData) { + const accountId = uuidv4() + const now = new Date().toISOString() + + // 处理OAuth数据 + let oauthData = {} + if (accountData.openaiOauth) { + oauthData = + typeof accountData.openaiOauth === 'string' + ? JSON.parse(accountData.openaiOauth) + : accountData.openaiOauth + } + + // 处理账户信息 + const accountInfo = accountData.accountInfo || {} + + // 检查邮箱是否已经是加密格式(包含冒号分隔的32位十六进制字符) + const isEmailEncrypted = + accountInfo.email && accountInfo.email.length >= 33 && accountInfo.email.charAt(32) === ':' + + const account = { + id: accountId, + name: accountData.name, + description: accountData.description || '', + accountType: accountData.accountType || 'shared', + groupId: accountData.groupId || null, + priority: accountData.priority || 50, + rateLimitDuration: + accountData.rateLimitDuration !== undefined && accountData.rateLimitDuration !== null + ? accountData.rateLimitDuration + : 60, + // OAuth相关字段(加密存储) + // ID Token 现在是可选的,如果没有提供会在首次刷新时自动获取 + idToken: oauthData.idToken && oauthData.idToken.trim() ? encrypt(oauthData.idToken) : '', + accessToken: + oauthData.accessToken && oauthData.accessToken.trim() ? encrypt(oauthData.accessToken) : '', + refreshToken: + oauthData.refreshToken && oauthData.refreshToken.trim() + ? encrypt(oauthData.refreshToken) + : '', + openaiOauth: encrypt(JSON.stringify(oauthData)), + // 账户信息字段 - 确保所有字段都被保存,即使是空字符串 + accountId: accountInfo.accountId || '', + chatgptUserId: accountInfo.chatgptUserId || '', + organizationId: accountInfo.organizationId || '', + organizationRole: accountInfo.organizationRole || '', + organizationTitle: accountInfo.organizationTitle || '', + planType: accountInfo.planType || '', + // 邮箱字段:检查是否已经加密,避免双重加密 + email: isEmailEncrypted ? accountInfo.email : encrypt(accountInfo.email || ''), + emailVerified: accountInfo.emailVerified === true ? 'true' : 'false', + // 过期时间 + expiresAt: oauthData.expires_in + ? new Date(Date.now() + oauthData.expires_in * 1000).toISOString() + : new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), // OAuth Token 过期时间(技术字段) + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + subscriptionExpiresAt: accountData.subscriptionExpiresAt || null, + + // 状态字段 + isActive: accountData.isActive !== false ? 'true' : 'false', + status: 'active', + schedulable: accountData.schedulable !== false ? 'true' : 'false', + // 自动防护开关 + disableAutoProtection: + accountData.disableAutoProtection === true || accountData.disableAutoProtection === 'true' + ? 'true' + : 'false', + lastRefresh: now, + createdAt: now, + updatedAt: now + } + + // 代理配置 + if (accountData.proxy) { + account.proxy = + typeof accountData.proxy === 'string' ? accountData.proxy : JSON.stringify(accountData.proxy) + } + + const client = redisClient.getClientSafe() + await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, account) + await redisClient.addToIndex('openai:account:index', accountId) + + // 如果是共享账户,添加到共享账户集合 + if (account.accountType === 'shared') { + await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId) + } + + logger.info(`Created OpenAI account: ${accountId}`) + return account +} + +// 获取账户 +async function getAccount(accountId) { + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + // 解密敏感数据(仅用于内部处理,不返回给前端) + if (accountData.idToken) { + accountData.idToken = decrypt(accountData.idToken) + } + // 注意:accessToken 在 openaiRoutes.js 中会被单独解密,这里不解密 + // if (accountData.accessToken) { + // accountData.accessToken = decrypt(accountData.accessToken) + // } + if (accountData.refreshToken) { + accountData.refreshToken = decrypt(accountData.refreshToken) + } + if (accountData.email) { + accountData.email = decrypt(accountData.email) + } + if (accountData.openaiOauth) { + try { + accountData.openaiOauth = JSON.parse(decrypt(accountData.openaiOauth)) + } catch (e) { + accountData.openaiOauth = null + } + } + + // 解析代理配置 + if (accountData.proxy && typeof accountData.proxy === 'string') { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + return accountData +} + +// 更新账户 +async function updateAccount(accountId, updates) { + const existingAccount = await getAccount(accountId) + if (!existingAccount) { + throw new Error('Account not found') + } + + updates.updatedAt = new Date().toISOString() + + // 加密敏感数据 + if (updates.openaiOauth) { + const oauthData = + typeof updates.openaiOauth === 'string' + ? updates.openaiOauth + : JSON.stringify(updates.openaiOauth) + updates.openaiOauth = encrypt(oauthData) + } + if (updates.idToken) { + updates.idToken = encrypt(updates.idToken) + } + if (updates.accessToken) { + updates.accessToken = encrypt(updates.accessToken) + } + if (updates.refreshToken && updates.refreshToken.trim()) { + updates.refreshToken = encrypt(updates.refreshToken) + } + if (updates.email) { + updates.email = encrypt(updates.email) + } + + // 处理代理配置 + if (updates.proxy) { + updates.proxy = + typeof updates.proxy === 'string' ? updates.proxy : JSON.stringify(updates.proxy) + } + + // ✅ 如果通过路由映射更新了 subscriptionExpiresAt,直接保存 + // subscriptionExpiresAt 是业务字段,与 token 刷新独立 + if (updates.subscriptionExpiresAt !== undefined) { + // 直接保存,不做任何调整 + } + + // 处理 disableAutoProtection 布尔值转字符串 + if (updates.disableAutoProtection !== undefined) { + updates.disableAutoProtection = + updates.disableAutoProtection === true || updates.disableAutoProtection === 'true' + ? 'true' + : 'false' + } + + // 更新账户类型时处理共享账户集合 + const client = redisClient.getClientSafe() + if (updates.accountType && updates.accountType !== existingAccount.accountType) { + if (updates.accountType === 'shared') { + await client.sadd(SHARED_OPENAI_ACCOUNTS_KEY, accountId) + } else { + await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId) + } + } + + await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) + + logger.info(`Updated OpenAI account: ${accountId}`) + + // 合并更新后的账户数据 + const updatedAccount = { ...existingAccount, ...updates } + + // 返回时解析代理配置 + if (updatedAccount.proxy && typeof updatedAccount.proxy === 'string') { + try { + updatedAccount.proxy = JSON.parse(updatedAccount.proxy) + } catch (e) { + updatedAccount.proxy = null + } + } + + return updatedAccount +} + +// 删除账户 +async function deleteAccount(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 从 Redis 删除 + const client = redisClient.getClientSafe() + await client.del(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) + await redisClient.removeFromIndex('openai:account:index', accountId) + + // 从共享账户集合中移除 + if (account.accountType === 'shared') { + await client.srem(SHARED_OPENAI_ACCOUNTS_KEY, accountId) + } + + // 清理会话映射(使用反向索引) + const sessionHashes = await client.smembers(`openai_account_sessions:${accountId}`) + if (sessionHashes.length > 0) { + const pipeline = client.pipeline() + sessionHashes.forEach((hash) => pipeline.del(`${ACCOUNT_SESSION_MAPPING_PREFIX}${hash}`)) + pipeline.del(`openai_account_sessions:${accountId}`) + await pipeline.exec() + } + + logger.info(`Deleted OpenAI account: ${accountId}`) + return true +} + +// 获取所有账户 +async function getAllAccounts() { + const _client = redisClient.getClientSafe() + const accountIds = await redisClient.getAllIdsByIndex( + 'openai:account:index', + `${OPENAI_ACCOUNT_KEY_PREFIX}*`, + /^openai:account:(.+)$/ + ) + const keys = accountIds.map((id) => `${OPENAI_ACCOUNT_KEY_PREFIX}${id}`) + const accounts = [] + const dataList = await redisClient.batchHgetallChunked(keys) + + for (let i = 0; i < keys.length; i++) { + const accountData = dataList[i] + if (accountData && Object.keys(accountData).length > 0) { + const codexUsage = buildCodexUsageSnapshot(accountData) + + // 解密敏感数据(但不返回给前端) + if (accountData.email) { + accountData.email = decrypt(accountData.email) + } + + // 先保存 refreshToken 是否存在的标记 + const hasRefreshTokenFlag = !!accountData.refreshToken + const maskedAccessToken = accountData.accessToken ? '[ENCRYPTED]' : '' + const maskedRefreshToken = accountData.refreshToken ? '[ENCRYPTED]' : '' + const maskedOauth = accountData.openaiOauth ? '[ENCRYPTED]' : '' + + // 屏蔽敏感信息(token等不应该返回给前端) + delete accountData.idToken + delete accountData.accessToken + delete accountData.refreshToken + delete accountData.openaiOauth + delete accountData.codexPrimaryUsedPercent + delete accountData.codexPrimaryResetAfterSeconds + delete accountData.codexPrimaryWindowMinutes + delete accountData.codexSecondaryUsedPercent + delete accountData.codexSecondaryResetAfterSeconds + delete accountData.codexSecondaryWindowMinutes + delete accountData.codexPrimaryOverSecondaryLimitPercent + // 时间戳改由 codexUsage.updatedAt 暴露 + delete accountData.codexUsageUpdatedAt + + // 获取限流状态信息 + const rateLimitInfo = await getAccountRateLimitInfo(accountData.id) + + // 解析代理配置 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + // 如果解析失败,设置为null + accountData.proxy = null + } + } + + const tokenExpiresAt = accountData.expiresAt || null + const subscriptionExpiresAt = + accountData.subscriptionExpiresAt && accountData.subscriptionExpiresAt !== '' + ? accountData.subscriptionExpiresAt + : null + + // 不解密敏感字段,只返回基本信息 + accounts.push({ + ...accountData, + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', + openaiOauth: maskedOauth, + accessToken: maskedAccessToken, + refreshToken: maskedRefreshToken, + + // ✅ 前端显示订阅过期时间(业务字段) + tokenExpiresAt, + subscriptionExpiresAt, + expiresAt: subscriptionExpiresAt, + + // 添加 scopes 字段用于判断认证方式 + // 处理空字符串的情况 + scopes: + accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [], + // 添加 hasRefreshToken 标记 + hasRefreshToken: hasRefreshTokenFlag, + // 添加限流状态信息(统一格式) + rateLimitStatus: rateLimitInfo + ? { + status: rateLimitInfo.status, + isRateLimited: rateLimitInfo.isRateLimited, + rateLimitedAt: rateLimitInfo.rateLimitedAt, + rateLimitResetAt: rateLimitInfo.rateLimitResetAt, + minutesRemaining: rateLimitInfo.minutesRemaining + } + : { + status: 'normal', + isRateLimited: false, + rateLimitedAt: null, + rateLimitResetAt: null, + minutesRemaining: 0 + }, + codexUsage + }) + } + } + + return accounts +} + +// 获取单个账户的概要信息(用于外部展示基本状态) +async function getAccountOverview(accountId) { + const client = redisClient.getClientSafe() + const accountData = await client.hgetall(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`) + + if (!accountData || Object.keys(accountData).length === 0) { + return null + } + + const codexUsage = buildCodexUsageSnapshot(accountData) + const rateLimitInfo = await getAccountRateLimitInfo(accountId) + + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (error) { + accountData.proxy = null + } + } + + const scopes = + accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [] + + return { + id: accountData.id, + accountType: accountData.accountType || 'shared', + platform: accountData.platform || 'openai', + isActive: accountData.isActive === 'true', + schedulable: accountData.schedulable !== 'false', + rateLimitStatus: rateLimitInfo || { + status: 'normal', + isRateLimited: false, + rateLimitedAt: null, + rateLimitResetAt: null, + minutesRemaining: 0 + }, + codexUsage, + scopes + } +} + +// 选择可用账户(支持专属和共享账户) +async function selectAvailableAccount(apiKeyId, sessionHash = null) { + // 首先检查是否有粘性会话 + const client = redisClient.getClientSafe() + if (sessionHash) { + const mappedAccountId = await client.get(`${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`) + + if (mappedAccountId) { + const account = await getAccount(mappedAccountId) + if (account && account.isActive === 'true' && !isTokenExpired(account)) { + logger.debug(`Using sticky session account: ${mappedAccountId}`) + return account + } + } + } + + // 获取 API Key 信息 + const apiKeyData = await client.hgetall(`api_key:${apiKeyId}`) + + // 检查是否绑定了 OpenAI 账户 + if (apiKeyData.openaiAccountId) { + const account = await getAccount(apiKeyData.openaiAccountId) + if (account && account.isActive === 'true') { + // 检查 token 是否过期 + const isExpired = isTokenExpired(account) + + // 记录token使用情况 + logTokenUsage(account.id, account.name, 'openai', account.expiresAt, isExpired) + + if (isExpired) { + await refreshAccountToken(account.id) + return await getAccount(account.id) + } + + // 创建粘性会话映射 + if (sessionHash) { + await client.setex( + `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, + 3600, // 1小时过期 + account.id + ) + // 反向索引:accountId -> sessionHash(用于删除账户时快速清理) + await client.sadd(`openai_account_sessions:${account.id}`, sessionHash) + await client.expire(`openai_account_sessions:${account.id}`, 3600) + } + + return account + } + } + + // 从共享账户池选择 + const sharedAccountIds = await client.smembers(SHARED_OPENAI_ACCOUNTS_KEY) + const availableAccounts = [] + + for (const accountId of sharedAccountIds) { + const account = await getAccount(accountId) + if ( + account && + account.isActive === 'true' && + !isRateLimited(account) && + !isSubscriptionExpired(account) + ) { + availableAccounts.push(account) + } else if (account && isSubscriptionExpired(account)) { + logger.debug( + `⏰ Skipping expired OpenAI account: ${account.name}, expired at ${account.subscriptionExpiresAt}` + ) + } + } + + if (availableAccounts.length === 0) { + throw new Error('No available OpenAI accounts') + } + + // 选择使用最少的账户 + const selectedAccount = availableAccounts.reduce((prev, curr) => { + const prevUsage = parseInt(prev.totalUsage || 0) + const currUsage = parseInt(curr.totalUsage || 0) + return prevUsage <= currUsage ? prev : curr + }) + + // 检查 token 是否过期 + if (isTokenExpired(selectedAccount)) { + await refreshAccountToken(selectedAccount.id) + return await getAccount(selectedAccount.id) + } + + // 创建粘性会话映射 + if (sessionHash) { + await client.setex( + `${ACCOUNT_SESSION_MAPPING_PREFIX}${sessionHash}`, + 3600, // 1小时过期 + selectedAccount.id + ) + await client.sadd(`openai_account_sessions:${selectedAccount.id}`, sessionHash) + await client.expire(`openai_account_sessions:${selectedAccount.id}`, 3600) + } + + return selectedAccount +} + +// 检查账户是否被限流 +function isRateLimited(account) { + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const limitedAt = new Date(account.rateLimitedAt).getTime() + const now = Date.now() + const limitDuration = 60 * 60 * 1000 // 1小时 + + return now < limitedAt + limitDuration + } + return false +} + +// 设置账户限流状态 +async function setAccountRateLimited(accountId, isLimited, resetsInSeconds = null) { + // disableAutoProtection 检查(仅在设置限流时) + if (isLimited) { + const account = await getAccount(accountId) + if ( + account && + (account.disableAutoProtection === true || account.disableAutoProtection === 'true') + ) { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping setAccountRateLimited` + ) + upstreamErrorHelper.recordErrorHistory(accountId, 'openai', 429, 'rate_limit').catch(() => {}) + return + } + } + + const updates = { + rateLimitStatus: isLimited ? 'limited' : 'normal', + rateLimitedAt: isLimited ? new Date().toISOString() : null, + // 限流时停止调度,解除限流时恢复调度 + schedulable: isLimited ? 'false' : 'true' + } + + // 如果提供了重置时间(秒数),计算重置时间戳 + if (isLimited && resetsInSeconds !== null && resetsInSeconds > 0) { + const resetTime = new Date(Date.now() + resetsInSeconds * 1000).toISOString() + updates.rateLimitResetAt = resetTime + logger.info( + `🕐 Account ${accountId} will be reset at ${resetTime} (in ${resetsInSeconds} seconds / ${Math.ceil(resetsInSeconds / 60)} minutes)` + ) + } else if (isLimited) { + // 如果没有提供重置时间,使用默认的60分钟 + const defaultResetSeconds = 60 * 60 // 1小时 + const resetTime = new Date(Date.now() + defaultResetSeconds * 1000).toISOString() + updates.rateLimitResetAt = resetTime + logger.warn( + `⚠️ No reset time provided for account ${accountId}, using default 60 minutes. Reset at ${resetTime}` + ) + } else if (!isLimited) { + updates.rateLimitResetAt = null + } + + await updateAccount(accountId, updates) + logger.info( + `Set rate limit status for OpenAI account ${accountId}: ${updates.rateLimitStatus}, schedulable: ${updates.schedulable}` + ) + + // 如果被限流,发送 Webhook 通知 + if (isLimited) { + try { + const account = await getAccount(accountId) + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai', + status: 'blocked', + errorCode: 'OPENAI_RATE_LIMITED', + reason: resetsInSeconds + ? `Account rate limited (429 error). Reset in ${Math.ceil(resetsInSeconds / 60)} minutes` + : 'Account rate limited (429 error). Estimated reset in 1 hour', + timestamp: new Date().toISOString() + }) + logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} rate limit`) + } catch (webhookError) { + logger.error('Failed to send rate limit webhook notification:', webhookError) + } + } +} + +// 🚫 标记账户为未授权状态(401错误) +async function markAccountUnauthorized(accountId, reason = 'OpenAI账号认证失败(401错误)') { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountUnauthorized` + ) + upstreamErrorHelper.recordErrorHistory(accountId, 'openai', 401, 'auth_error').catch(() => {}) + return + } + + const now = new Date().toISOString() + const currentCount = parseInt(account.unauthorizedCount || '0', 10) + const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 + + const updates = { + status: 'unauthorized', + schedulable: 'false', + errorMessage: reason, + unauthorizedAt: now, + unauthorizedCount: unauthorizedCount.toString() + } + + await updateAccount(accountId, updates) + logger.warn( + `🚫 Marked OpenAI account ${account.name || accountId} as unauthorized due to 401 error` + ) + + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai', + status: 'unauthorized', + errorCode: 'OPENAI_UNAUTHORIZED', + reason, + timestamp: now + }) + logger.info( + `📢 Webhook notification sent for OpenAI account ${account.name} unauthorized state` + ) + } catch (webhookError) { + logger.error('Failed to send unauthorized webhook notification:', webhookError) + } +} + +// 🔄 重置账户所有异常状态 +async function resetAccountStatus(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const updates = { + // 根据是否有有效的 accessToken 来设置 status + status: account.accessToken ? 'active' : 'created', + // 恢复可调度状态 + schedulable: 'true', + // 清除错误相关字段 + errorMessage: null, + rateLimitedAt: null, + rateLimitStatus: 'normal', + rateLimitResetAt: null + } + + await updateAccount(accountId, updates) + logger.info(`✅ Reset all error status for OpenAI account ${accountId}`) + + // 清除临时不可用状态 + await upstreamErrorHelper.clearTempUnavailable(accountId, 'openai').catch(() => {}) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + logger.info(`📢 Webhook notification sent for OpenAI account ${account.name} status reset`) + } catch (webhookError) { + logger.error('Failed to send status reset webhook notification:', webhookError) + } + + return { success: true, message: 'Account status reset successfully' } +} + +// 切换账户调度状态 +async function toggleSchedulable(accountId) { + const account = await getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 切换调度状态 + const newSchedulable = account.schedulable === 'false' ? 'true' : 'false' + + await updateAccount(accountId, { + schedulable: newSchedulable + }) + + logger.info(`Toggled schedulable status for OpenAI account ${accountId}: ${newSchedulable}`) + + return { + success: true, + schedulable: newSchedulable === 'true' + } +} + +// 获取账户限流信息 +async function getAccountRateLimitInfo(accountId) { + const account = await getAccount(accountId) + if (!account) { + return null + } + + const status = account.rateLimitStatus || 'normal' + const rateLimitedAt = account.rateLimitedAt || null + const rateLimitResetAt = account.rateLimitResetAt || null + + if (status === 'limited') { + const now = Date.now() + let remainingTime = 0 + + if (rateLimitResetAt) { + const resetAt = new Date(rateLimitResetAt).getTime() + remainingTime = Math.max(0, resetAt - now) + } else if (rateLimitedAt) { + const limitedAt = new Date(rateLimitedAt).getTime() + const limitDuration = 60 * 60 * 1000 // 默认1小时 + remainingTime = Math.max(0, limitedAt + limitDuration - now) + } + + const minutesRemaining = remainingTime > 0 ? Math.ceil(remainingTime / (60 * 1000)) : 0 + + return { + status, + isRateLimited: minutesRemaining > 0, + rateLimitedAt, + rateLimitResetAt, + minutesRemaining + } + } + + return { + status, + isRateLimited: false, + rateLimitedAt, + rateLimitResetAt, + minutesRemaining: 0 + } +} + +// 更新账户使用统计(tokens参数可选,默认为0,仅更新最后使用时间) +async function updateAccountUsage(accountId, tokens = 0) { + const account = await getAccount(accountId) + if (!account) { + return + } + + const updates = { + lastUsedAt: new Date().toISOString() + } + + // 如果有 tokens 参数且大于0,同时更新使用统计 + if (tokens > 0) { + const totalUsage = parseInt(account.totalUsage || 0) + tokens + updates.totalUsage = totalUsage.toString() + } + + await updateAccount(accountId, updates) +} + +// 为了兼容性,保留recordUsage作为updateAccountUsage的别名 +const recordUsage = updateAccountUsage + +async function updateCodexUsageSnapshot(accountId, usageSnapshot) { + if (!usageSnapshot || typeof usageSnapshot !== 'object') { + return + } + + const fieldMap = { + primaryUsedPercent: 'codexPrimaryUsedPercent', + primaryResetAfterSeconds: 'codexPrimaryResetAfterSeconds', + primaryWindowMinutes: 'codexPrimaryWindowMinutes', + secondaryUsedPercent: 'codexSecondaryUsedPercent', + secondaryResetAfterSeconds: 'codexSecondaryResetAfterSeconds', + secondaryWindowMinutes: 'codexSecondaryWindowMinutes', + primaryOverSecondaryPercent: 'codexPrimaryOverSecondaryLimitPercent' + } + + const updates = {} + let hasPayload = false + + for (const [key, field] of Object.entries(fieldMap)) { + if (usageSnapshot[key] !== undefined && usageSnapshot[key] !== null) { + updates[field] = String(usageSnapshot[key]) + hasPayload = true + } + } + + if (!hasPayload) { + return + } + + updates.codexUsageUpdatedAt = new Date().toISOString() + + const client = redisClient.getClientSafe() + await client.hset(`${OPENAI_ACCOUNT_KEY_PREFIX}${accountId}`, updates) +} + +module.exports = { + createAccount, + getAccount, + getAccountOverview, + updateAccount, + deleteAccount, + getAllAccounts, + selectAvailableAccount, + refreshAccountToken, + isTokenExpired, + setAccountRateLimited, + markAccountUnauthorized, + resetAccountStatus, + toggleSchedulable, + getAccountRateLimitInfo, + updateAccountUsage, + recordUsage, // 别名,指向updateAccountUsage + updateCodexUsageSnapshot, + encrypt, + decrypt, + encryptor // 暴露加密器以便测试和监控 +} diff --git a/src/services/account/openaiResponsesAccountService.js b/src/services/account/openaiResponsesAccountService.js new file mode 100644 index 0000000..484df60 --- /dev/null +++ b/src/services/account/openaiResponsesAccountService.js @@ -0,0 +1,684 @@ +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const config = require('../../../config/config') +const LRUCache = require('../../utils/lruCache') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +class OpenAIResponsesAccountService { + constructor() { + // 加密相关常量 + this.ENCRYPTION_ALGORITHM = 'aes-256-cbc' + this.ENCRYPTION_SALT = 'openai-responses-salt' + + // Redis 键前缀 + this.ACCOUNT_KEY_PREFIX = 'openai_responses_account:' + this.SHARED_ACCOUNTS_KEY = 'shared_openai_responses_accounts' + + // 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算 + this._encryptionKeyCache = null + + // 🔄 解密结果缓存,提高解密性能 + this._decryptCache = new LRUCache(500) + + // 🧹 定期清理缓存(每10分钟) + setInterval( + () => { + this._decryptCache.cleanup() + logger.info( + '🧹 OpenAI-Responses decrypt cache cleanup completed', + this._decryptCache.getStats() + ) + }, + 10 * 60 * 1000 + ) + } + + // 创建账户 + async createAccount(options = {}) { + const { + name = 'OpenAI Responses Account', + description = '', + baseApi = '', // 必填:API 基础地址 + apiKey = '', // 必填:API 密钥 + userAgent = '', // 可选:自定义 User-Agent,空则透传原始请求 + priority = 50, // 调度优先级 (1-100) + proxy = null, + isActive = true, + accountType = 'shared', // 'dedicated' or 'shared' + schedulable = true, // 是否可被调度 + dailyQuota = 0, // 每日额度限制(美元),0表示不限制 + quotaResetTime = '00:00', // 额度重置时间(HH:mm格式) + rateLimitDuration = 60, // 限流时间(分钟) + disableAutoProtection = false, // 是否关闭自动防护(429/401/400/529 不自动禁用) + providerEndpoint = 'responses' // Provider 端点类型:responses | auto + } = options + + // 验证必填字段 + if (!baseApi || !apiKey) { + throw new Error('Base API URL and API Key are required for OpenAI-Responses account') + } + + // 验证 providerEndpoint 枚举值 + const validEndpoints = ['responses', 'auto'] + if (!validEndpoints.includes(providerEndpoint)) { + throw new Error( + `Invalid providerEndpoint: ${providerEndpoint}. Must be one of: ${validEndpoints.join(', ')}` + ) + } + + // 规范化 baseApi(确保不以 / 结尾) + const normalizedBaseApi = baseApi.endsWith('/') ? baseApi.slice(0, -1) : baseApi + + const accountId = uuidv4() + + const accountData = { + id: accountId, + platform: 'openai-responses', + name, + description, + baseApi: normalizedBaseApi, + apiKey: this._encryptSensitiveData(apiKey), + userAgent, + priority: priority.toString(), + proxy: proxy ? JSON.stringify(proxy) : '', + isActive: isActive.toString(), + accountType, + schedulable: schedulable.toString(), + + // ✅ 新增:账户订阅到期时间(业务字段,手动管理) + // 注意:OpenAI-Responses 使用 API Key 认证,没有 OAuth token,因此没有 expiresAt + subscriptionExpiresAt: options.subscriptionExpiresAt || null, + + createdAt: new Date().toISOString(), + lastUsedAt: '', + status: 'active', + errorMessage: '', + // 限流相关 + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitDuration: rateLimitDuration.toString(), + // 额度管理 + dailyQuota: dailyQuota.toString(), + dailyUsage: '0', + lastResetDate: redis.getDateStringInTimezone(), + quotaResetTime, + quotaStoppedAt: '', + disableAutoProtection: disableAutoProtection.toString(), // 关闭自动防护 + providerEndpoint // Provider 端点类型:responses(默认) | auto + } + + // 保存到 Redis + await this._saveAccount(accountId, accountData) + + logger.success(`Created OpenAI-Responses account: ${name} (${accountId})`) + + return { + ...accountData, + apiKey: '***' // 返回时隐藏敏感信息 + } + } + + // 获取账户 + async getAccount(accountId) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + const accountData = await client.hgetall(key) + + if (!accountData || !accountData.id) { + return null + } + + // 解密敏感数据 + accountData.apiKey = this._decryptSensitiveData(accountData.apiKey) + + // 解析 JSON 字段 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch (e) { + accountData.proxy = null + } + } + + return accountData + } + + // 更新账户 + async updateAccount(accountId, updates) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 处理敏感字段加密 + if (updates.apiKey) { + updates.apiKey = this._encryptSensitiveData(updates.apiKey) + } + + // 处理 JSON 字段 + if (updates.proxy !== undefined) { + updates.proxy = updates.proxy ? JSON.stringify(updates.proxy) : '' + } + + // 规范化 baseApi + if (updates.baseApi) { + updates.baseApi = updates.baseApi.endsWith('/') + ? updates.baseApi.slice(0, -1) + : updates.baseApi + } + + // ✅ 直接保存 subscriptionExpiresAt(如果提供) + // OpenAI-Responses 使用 API Key,没有 token 刷新逻辑,不会覆盖此字段 + if (updates.subscriptionExpiresAt !== undefined) { + // 直接保存,不做任何调整 + } + + // 验证 providerEndpoint 枚举值 + if (updates.providerEndpoint !== undefined) { + const validEndpoints = ['responses', 'auto'] + if (!validEndpoints.includes(updates.providerEndpoint)) { + throw new Error( + `Invalid providerEndpoint: ${updates.providerEndpoint}. Must be one of: ${validEndpoints.join(', ')}` + ) + } + } + + // 自动防护开关 + if (updates.disableAutoProtection !== undefined) { + updates.disableAutoProtection = updates.disableAutoProtection.toString() + } + + // 更新 Redis + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + await client.hset(key, updates) + + logger.info(`📝 Updated OpenAI-Responses account: ${account.name}`) + + return { success: true } + } + + // 删除账户 + async deleteAccount(accountId) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 从共享账户列表中移除 + await client.srem(this.SHARED_ACCOUNTS_KEY, accountId) + + // 从索引中移除 + await redis.removeFromIndex('openai_responses_account:index', accountId) + + // 删除账户数据 + await client.del(key) + + logger.info(`🗑️ Deleted OpenAI-Responses account: ${accountId}`) + + return { success: true } + } + + // 获取所有账户 + async getAllAccounts(includeInactive = false) { + const client = redis.getClientSafe() + + // 使用索引获取所有账户ID + const accountIds = await redis.getAllIdsByIndex( + 'openai_responses_account:index', + `${this.ACCOUNT_KEY_PREFIX}*`, + /^openai_responses_account:(.+)$/ + ) + if (accountIds.length === 0) { + return [] + } + + const keys = accountIds.map((id) => `${this.ACCOUNT_KEY_PREFIX}${id}`) + // Pipeline 批量查询所有账户数据 + const pipeline = client.pipeline() + keys.forEach((key) => pipeline.hgetall(key)) + const results = await pipeline.exec() + + const accounts = [] + results.forEach(([err, accountData]) => { + if (err || !accountData || !accountData.id) { + return + } + + // 过滤非活跃账户 + if (!includeInactive && accountData.isActive !== 'true') { + return + } + + // 隐藏敏感信息 + accountData.apiKey = '***' + + // 解析 JSON 字段 + if (accountData.proxy) { + try { + accountData.proxy = JSON.parse(accountData.proxy) + } catch { + accountData.proxy = null + } + } + + // 获取限流状态信息 + const rateLimitInfo = this._getRateLimitInfo(accountData) + accountData.rateLimitStatus = rateLimitInfo.isRateLimited + ? { + isRateLimited: true, + rateLimitedAt: accountData.rateLimitedAt || null, + minutesRemaining: rateLimitInfo.remainingMinutes || 0 + } + : { + isRateLimited: false, + rateLimitedAt: null, + minutesRemaining: 0 + } + + // 转换字段类型 + accountData.schedulable = accountData.schedulable !== 'false' + accountData.isActive = accountData.isActive === 'true' + accountData.expiresAt = accountData.subscriptionExpiresAt || null + accountData.platform = accountData.platform || 'openai-responses' + + accounts.push(accountData) + }) + + return accounts + } + + // 标记账户限流 + async markAccountRateLimited(accountId, duration = null) { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountRateLimited` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'openai-responses', 429, 'rate_limit') + .catch(() => {}) + return + } + + const rateLimitDuration = duration || parseInt(account.rateLimitDuration) || 60 + const now = new Date() + const resetAt = new Date(now.getTime() + rateLimitDuration * 60000) + + await this.updateAccount(accountId, { + rateLimitedAt: now.toISOString(), + rateLimitStatus: 'limited', + rateLimitResetAt: resetAt.toISOString(), + rateLimitDuration: rateLimitDuration.toString(), + status: 'rateLimited', + schedulable: 'false', // 防止被调度 + errorMessage: `Rate limited until ${resetAt.toISOString()}` + }) + + logger.warn( + `⏳ Account ${account.name} marked as rate limited for ${rateLimitDuration} minutes (until ${resetAt.toISOString()})` + ) + } + + // 🚫 标记账户为未授权状态(401错误) + async markAccountUnauthorized(accountId, reason = 'OpenAI Responses账号认证失败(401错误)') { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + // disableAutoProtection 检查 + if (account.disableAutoProtection === true || account.disableAutoProtection === 'true') { + logger.info( + `🛡️ Account ${accountId} has auto-protection disabled, skipping markAccountUnauthorized` + ) + upstreamErrorHelper + .recordErrorHistory(accountId, 'openai-responses', 401, 'auth_error') + .catch(() => {}) + return + } + + const now = new Date().toISOString() + const currentCount = parseInt(account.unauthorizedCount || '0', 10) + const unauthorizedCount = Number.isFinite(currentCount) ? currentCount + 1 : 1 + + await this.updateAccount(accountId, { + status: 'unauthorized', + schedulable: 'false', + errorMessage: reason, + unauthorizedAt: now, + unauthorizedCount: unauthorizedCount.toString() + }) + + logger.warn( + `🚫 OpenAI-Responses account ${account.name || accountId} marked as unauthorized due to 401 error` + ) + + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai', + status: 'unauthorized', + errorCode: 'OPENAI_UNAUTHORIZED', + reason, + timestamp: now + }) + logger.info( + `📢 Webhook notification sent for OpenAI-Responses account ${account.name || accountId} unauthorized state` + ) + } catch (webhookError) { + logger.error('Failed to send unauthorized webhook notification:', webhookError) + } + } + + // 检查并清除过期的限流状态 + async checkAndClearRateLimit(accountId) { + const account = await this.getAccount(accountId) + if (!account || account.rateLimitStatus !== 'limited') { + return false + } + + const now = new Date() + let shouldClear = false + + // 优先使用 rateLimitResetAt 字段 + if (account.rateLimitResetAt) { + const resetAt = new Date(account.rateLimitResetAt) + shouldClear = now >= resetAt + } else { + // 如果没有 rateLimitResetAt,使用旧的逻辑 + const rateLimitedAt = new Date(account.rateLimitedAt) + const rateLimitDuration = parseInt(account.rateLimitDuration) || 60 + shouldClear = now - rateLimitedAt > rateLimitDuration * 60000 + } + + if (shouldClear) { + // 限流已过期,清除状态 + await this.updateAccount(accountId, { + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + status: 'active', + schedulable: 'true', // 恢复调度 + errorMessage: '' + }) + + logger.info(`✅ Rate limit cleared for account ${account.name}`) + return true + } + + return false + } + + // 切换调度状态 + async toggleSchedulable(accountId) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const newSchedulableStatus = account.schedulable === 'true' ? 'false' : 'true' + await this.updateAccount(accountId, { + schedulable: newSchedulableStatus + }) + + logger.info( + `🔄 Toggled schedulable status for account ${account.name}: ${newSchedulableStatus}` + ) + + return { + success: true, + schedulable: newSchedulableStatus === 'true' + } + } + + // 更新使用额度 + async updateUsageQuota(accountId, amount) { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + // 检查是否需要重置额度 + const today = redis.getDateStringInTimezone() + if (account.lastResetDate !== today) { + // 重置额度 + await this.updateAccount(accountId, { + dailyUsage: amount.toString(), + lastResetDate: today, + quotaStoppedAt: '' + }) + } else { + // 累加使用额度 + const currentUsage = parseFloat(account.dailyUsage) || 0 + const newUsage = currentUsage + amount + const dailyQuota = parseFloat(account.dailyQuota) || 0 + + const updates = { + dailyUsage: newUsage.toString() + } + + // 检查是否超出额度 + if (dailyQuota > 0 && newUsage >= dailyQuota) { + updates.status = 'quotaExceeded' + updates.quotaStoppedAt = new Date().toISOString() + updates.errorMessage = `Daily quota exceeded: $${newUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}` + logger.warn(`💸 Account ${account.name} exceeded daily quota`) + } + + await this.updateAccount(accountId, updates) + } + } + + // 更新账户使用统计(记录 token 使用量) + async updateAccountUsage(accountId, tokens = 0) { + const account = await this.getAccount(accountId) + if (!account) { + return + } + + const updates = { + lastUsedAt: new Date().toISOString() + } + + // 如果有 tokens 参数且大于0,同时更新使用统计 + if (tokens > 0) { + const currentTokens = parseInt(account.totalUsedTokens) || 0 + updates.totalUsedTokens = (currentTokens + tokens).toString() + } + + await this.updateAccount(accountId, updates) + } + + // 记录使用量(为了兼容性的别名) + async recordUsage(accountId, tokens = 0) { + return this.updateAccountUsage(accountId, tokens) + } + + // 重置账户状态(清除所有异常状态) + async resetAccountStatus(accountId) { + const account = await this.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + const updates = { + // 根据是否有有效的 apiKey 来设置 status + status: account.apiKey ? 'active' : 'created', + // 恢复可调度状态 + schedulable: 'true', + // 清除错误相关字段 + errorMessage: '', + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + rateLimitDuration: '' + } + + await this.updateAccount(accountId, updates) + logger.info(`✅ Reset all error status for OpenAI-Responses account ${accountId}`) + + // 清除临时不可用状态 + await upstreamErrorHelper.clearTempUnavailable(accountId, 'openai-responses').catch(() => {}) + + // 发送 Webhook 通知 + try { + const webhookNotifier = require('../../utils/webhookNotifier') + await webhookNotifier.sendAccountAnomalyNotification({ + accountId, + accountName: account.name || accountId, + platform: 'openai-responses', + status: 'recovered', + errorCode: 'STATUS_RESET', + reason: 'Account status manually reset', + timestamp: new Date().toISOString() + }) + logger.info( + `📢 Webhook notification sent for OpenAI-Responses account ${account.name} status reset` + ) + } catch (webhookError) { + logger.error('Failed to send status reset webhook notification:', webhookError) + } + + return { success: true, message: 'Account status reset successfully' } + } + + // ⏰ 检查账户订阅是否已过期 + isSubscriptionExpired(account) { + if (!account.subscriptionExpiresAt) { + return false // 未设置过期时间,视为永不过期 + } + + const expiryDate = new Date(account.subscriptionExpiresAt) + const now = new Date() + + if (expiryDate <= now) { + logger.debug( + `⏰ OpenAI-Responses Account ${account.name} (${account.id}) subscription expired at ${account.subscriptionExpiresAt}` + ) + return true + } + + return false + } + + // 获取限流信息 + _getRateLimitInfo(accountData) { + if (accountData.rateLimitStatus !== 'limited') { + return { isRateLimited: false } + } + + const now = new Date() + let willBeAvailableAt + let remainingMinutes + + // 优先使用 rateLimitResetAt 字段 + if (accountData.rateLimitResetAt) { + willBeAvailableAt = new Date(accountData.rateLimitResetAt) + remainingMinutes = Math.max(0, Math.ceil((willBeAvailableAt - now) / 60000)) + } else { + // 如果没有 rateLimitResetAt,使用旧的逻辑 + const rateLimitedAt = new Date(accountData.rateLimitedAt) + const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60 + const elapsedMinutes = Math.floor((now - rateLimitedAt) / 60000) + remainingMinutes = Math.max(0, rateLimitDuration - elapsedMinutes) + willBeAvailableAt = new Date(rateLimitedAt.getTime() + rateLimitDuration * 60000) + } + + return { + isRateLimited: remainingMinutes > 0, + remainingMinutes, + willBeAvailableAt + } + } + + // 加密敏感数据 + _encryptSensitiveData(text) { + if (!text) { + return '' + } + + const key = this._getEncryptionKey() + const iv = crypto.randomBytes(16) + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + + let encrypted = cipher.update(text) + encrypted = Buffer.concat([encrypted, cipher.final()]) + + return `${iv.toString('hex')}:${encrypted.toString('hex')}` + } + + // 解密敏感数据 + _decryptSensitiveData(text) { + if (!text || text === '') { + return '' + } + + // 检查缓存 + const cacheKey = crypto.createHash('sha256').update(text).digest('hex') + const cached = this._decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + + try { + const key = this._getEncryptionKey() + const [ivHex, encryptedHex] = text.split(':') + + const iv = Buffer.from(ivHex, 'hex') + const encryptedText = Buffer.from(encryptedHex, 'hex') + + const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv) + let decrypted = decipher.update(encryptedText) + decrypted = Buffer.concat([decrypted, decipher.final()]) + + const result = decrypted.toString() + + // 存入缓存(5分钟过期) + this._decryptCache.set(cacheKey, result, 5 * 60 * 1000) + + return result + } catch (error) { + logger.error('Decryption error:', error) + return '' + } + } + + // 获取加密密钥 + _getEncryptionKey() { + if (!this._encryptionKeyCache) { + this._encryptionKeyCache = crypto.scryptSync( + config.security.encryptionKey, + this.ENCRYPTION_SALT, + 32 + ) + } + return this._encryptionKeyCache + } + + // 保存账户到 Redis + async _saveAccount(accountId, accountData) { + const client = redis.getClientSafe() + const key = `${this.ACCOUNT_KEY_PREFIX}${accountId}` + + // 保存账户数据 + await client.hset(key, accountData) + + // 添加到索引 + await redis.addToIndex('openai_responses_account:index', accountId) + + // 添加到共享账户列表 + if (accountData.accountType === 'shared') { + await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId) + } + } +} + +module.exports = new OpenAIResponsesAccountService() diff --git a/src/services/accountGroupService.js b/src/services/accountGroupService.js new file mode 100644 index 0000000..c771b00 --- /dev/null +++ b/src/services/accountGroupService.js @@ -0,0 +1,644 @@ +const { v4: uuidv4 } = require('uuid') +const logger = require('../utils/logger') +const redis = require('../models/redis') + +class AccountGroupService { + constructor() { + this.GROUPS_KEY = 'account_groups' + this.GROUP_PREFIX = 'account_group:' + this.GROUP_MEMBERS_PREFIX = 'account_group_members:' + this.REVERSE_INDEX_PREFIX = 'account_groups_reverse:' + this.REVERSE_INDEX_MIGRATED_KEY = 'account_groups_reverse:migrated' + } + + /** + * 确保反向索引存在(启动时自动调用) + * 检查是否已迁移,如果没有则自动回填 + */ + async ensureReverseIndexes() { + try { + const client = redis.getClientSafe() + if (!client) { + return + } + + // 检查是否已迁移 + const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY) + if (migrated === 'true') { + logger.debug('📁 账户分组反向索引已存在,跳过回填') + return + } + + logger.info('📁 开始回填账户分组反向索引...') + + const allGroupIds = await client.smembers(this.GROUPS_KEY) + if (allGroupIds.length === 0) { + await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true') + return + } + + let totalOperations = 0 + + for (const groupId of allGroupIds) { + const group = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`) + if (!group || !group.platform) { + continue + } + + const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + if (members.length === 0) { + continue + } + + const pipeline = client.pipeline() + for (const accountId of members) { + pipeline.sadd(`${this.REVERSE_INDEX_PREFIX}${group.platform}:${accountId}`, groupId) + } + await pipeline.exec() + totalOperations += members.length + } + + await client.set(this.REVERSE_INDEX_MIGRATED_KEY, 'true') + logger.success(`📁 账户分组反向索引回填完成,共 ${totalOperations} 条`) + } catch (error) { + logger.error('❌ 账户分组反向索引回填失败:', error) + } + } + + /** + * 创建账户分组 + * @param {Object} groupData - 分组数据 + * @param {string} groupData.name - 分组名称 + * @param {string} groupData.platform - 平台类型 (claude/gemini/openai) + * @param {string} groupData.description - 分组描述 + * @returns {Object} 创建的分组 + */ + async createGroup(groupData) { + try { + const { name, platform, description = '' } = groupData + + // 验证必填字段 + if (!name || !platform) { + throw new Error('分组名称和平台类型为必填项') + } + + // 验证平台类型 + if (!['claude', 'gemini', 'openai', 'droid'].includes(platform)) { + throw new Error('平台类型必须是 claude、gemini、openai 或 droid') + } + + const client = redis.getClientSafe() + const groupId = uuidv4() + const now = new Date().toISOString() + + const group = { + id: groupId, + name, + platform, + description, + createdAt: now, + updatedAt: now + } + + // 保存分组数据 + await client.hmset(`${this.GROUP_PREFIX}${groupId}`, group) + + // 添加到分组集合 + await client.sadd(this.GROUPS_KEY, groupId) + + logger.success(`创建账户分组成功: ${name} (${platform})`) + + return group + } catch (error) { + logger.error('❌ 创建账户分组失败:', error) + throw error + } + } + + /** + * 更新分组信息 + * @param {string} groupId - 分组ID + * @param {Object} updates - 更新的字段 + * @returns {Object} 更新后的分组 + */ + async updateGroup(groupId, updates) { + try { + const client = redis.getClientSafe() + const groupKey = `${this.GROUP_PREFIX}${groupId}` + + // 检查分组是否存在 + const exists = await client.exists(groupKey) + if (!exists) { + throw new Error('分组不存在') + } + + // 获取现有分组数据 + const existingGroup = await client.hgetall(groupKey) + + // 不允许修改平台类型 + if (updates.platform && updates.platform !== existingGroup.platform) { + throw new Error('不能修改分组的平台类型') + } + + // 准备更新数据 + const updateData = { + ...updates, + updatedAt: new Date().toISOString() + } + + // 移除不允许修改的字段 + delete updateData.id + delete updateData.platform + delete updateData.createdAt + + // 更新分组 + await client.hmset(groupKey, updateData) + + // 返回更新后的完整数据 + const updatedGroup = await client.hgetall(groupKey) + + logger.success(`更新账户分组成功: ${updatedGroup.name}`) + + return updatedGroup + } catch (error) { + logger.error('❌ 更新账户分组失败:', error) + throw error + } + } + + /** + * 删除分组 + * @param {string} groupId - 分组ID + */ + async deleteGroup(groupId) { + try { + const client = redis.getClientSafe() + + // 检查分组是否存在 + const group = await this.getGroup(groupId) + if (!group) { + throw new Error('分组不存在') + } + + // 检查分组是否为空 + const members = await this.getGroupMembers(groupId) + if (members.length > 0) { + throw new Error('分组内还有账户,无法删除') + } + + // 检查是否有API Key绑定此分组 + const boundApiKeys = await this.getApiKeysUsingGroup(groupId) + if (boundApiKeys.length > 0) { + throw new Error('还有API Key使用此分组,无法删除') + } + + // 删除分组数据 + await client.del(`${this.GROUP_PREFIX}${groupId}`) + await client.del(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + + // 从分组集合中移除 + await client.srem(this.GROUPS_KEY, groupId) + + logger.success(`删除账户分组成功: ${group.name}`) + } catch (error) { + logger.error('❌ 删除账户分组失败:', error) + throw error + } + } + + /** + * 获取分组详情 + * @param {string} groupId - 分组ID + * @returns {Object|null} 分组信息 + */ + async getGroup(groupId) { + try { + const client = redis.getClientSafe() + const groupData = await client.hgetall(`${this.GROUP_PREFIX}${groupId}`) + + if (!groupData || Object.keys(groupData).length === 0) { + return null + } + + // 获取成员数量 + const memberCount = await client.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + + return { + ...groupData, + memberCount: memberCount || 0 + } + } catch (error) { + logger.error('❌ 获取分组详情失败:', error) + throw error + } + } + + /** + * 获取所有分组 + * @param {string} platform - 平台筛选 (可选) + * @returns {Array} 分组列表 + */ + async getAllGroups(platform = null) { + try { + const client = redis.getClientSafe() + const groupIds = await client.smembers(this.GROUPS_KEY) + + const groups = [] + for (const groupId of groupIds) { + const group = await this.getGroup(groupId) + if (group) { + // 如果指定了平台,进行筛选 + if (!platform || group.platform === platform) { + groups.push(group) + } + } + } + + // 按创建时间倒序排序 + groups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + return groups + } catch (error) { + logger.error('❌ 获取分组列表失败:', error) + throw error + } + } + + /** + * 添加账户到分组 + * @param {string} accountId - 账户ID + * @param {string} groupId - 分组ID + * @param {string} accountPlatform - 账户平台 + */ + async addAccountToGroup(accountId, groupId, accountPlatform) { + try { + const client = redis.getClientSafe() + + // 获取分组信息 + const group = await this.getGroup(groupId) + if (!group) { + throw new Error('分组不存在') + } + + // 验证平台一致性 (Claude和Claude Console视为同一平台) + const normalizedAccountPlatform = + accountPlatform === 'claude-console' ? 'claude' : accountPlatform + if (normalizedAccountPlatform !== group.platform) { + throw new Error('账户平台与分组平台不匹配') + } + + // 添加到分组成员集合 + await client.sadd(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + + // 维护反向索引 + await client.sadd(`account_groups_reverse:${group.platform}:${accountId}`, groupId) + + logger.success(`添加账户到分组成功: ${accountId} -> ${group.name}`) + } catch (error) { + logger.error('❌ 添加账户到分组失败:', error) + throw error + } + } + + /** + * 从分组移除账户 + * @param {string} accountId - 账户ID + * @param {string} groupId - 分组ID + * @param {string} platform - 平台(可选,如果不传则从分组获取) + */ + async removeAccountFromGroup(accountId, groupId, platform = null) { + try { + const client = redis.getClientSafe() + + // 从分组成员集合中移除 + await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + + // 维护反向索引 + let groupPlatform = platform + if (!groupPlatform) { + const group = await this.getGroup(groupId) + groupPlatform = group?.platform + } + if (groupPlatform) { + await client.srem(`account_groups_reverse:${groupPlatform}:${accountId}`, groupId) + } + + logger.success(`从分组移除账户成功: ${accountId}`) + } catch (error) { + logger.error('❌ 从分组移除账户失败:', error) + throw error + } + } + + /** + * 获取分组成员 + * @param {string} groupId - 分组ID + * @returns {Array} 成员ID列表 + */ + async getGroupMembers(groupId) { + try { + const client = redis.getClientSafe() + const members = await client.smembers(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + return members || [] + } catch (error) { + logger.error('❌ 获取分组成员失败:', error) + throw error + } + } + + /** + * 检查分组是否为空 + * @param {string} groupId - 分组ID + * @returns {boolean} 是否为空 + */ + async isGroupEmpty(groupId) { + try { + const members = await this.getGroupMembers(groupId) + return members.length === 0 + } catch (error) { + logger.error('❌ 检查分组是否为空失败:', error) + throw error + } + } + + /** + * 获取使用指定分组的API Key列表 + * @param {string} groupId - 分组ID + * @returns {Array} API Key列表 + */ + async getApiKeysUsingGroup(groupId) { + try { + const client = redis.getClientSafe() + const groupKey = `group:${groupId}` + + // 获取所有API Key + const apiKeyIds = await client.smembers('api_keys') + const boundApiKeys = [] + + for (const keyId of apiKeyIds) { + const keyData = await client.hgetall(`api_key:${keyId}`) + if ( + keyData && + (keyData.claudeAccountId === groupKey || + keyData.geminiAccountId === groupKey || + keyData.openaiAccountId === groupKey || + keyData.droidAccountId === groupKey) + ) { + boundApiKeys.push({ + id: keyId, + name: keyData.name + }) + } + } + + return boundApiKeys + } catch (error) { + logger.error('❌ 获取使用分组的API Key失败:', error) + throw error + } + } + + /** + * 根据账户ID获取其所属的分组(兼容性方法,返回单个分组) + * @param {string} accountId - 账户ID + * @returns {Object|null} 分组信息 + */ + async getAccountGroup(accountId) { + try { + const client = redis.getClientSafe() + const allGroupIds = await client.smembers(this.GROUPS_KEY) + + for (const groupId of allGroupIds) { + const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + if (isMember) { + return await this.getGroup(groupId) + } + } + + return null + } catch (error) { + logger.error('❌ 获取账户所属分组失败:', error) + throw error + } + } + + /** + * 根据账户ID获取其所属的所有分组 + * @param {string} accountId - 账户ID + * @returns {Array} 分组信息数组 + */ + async getAccountGroups(accountId) { + try { + const client = redis.getClientSafe() + const allGroupIds = await client.smembers(this.GROUPS_KEY) + const memberGroups = [] + + for (const groupId of allGroupIds) { + const isMember = await client.sismember(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + if (isMember) { + const group = await this.getGroup(groupId) + if (group) { + memberGroups.push(group) + } + } + } + + // 按创建时间倒序排序 + memberGroups.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + return memberGroups + } catch (error) { + logger.error('❌ 获取账户所属分组列表失败:', error) + throw error + } + } + + /** + * 批量设置账户的分组 + * @param {string} accountId - 账户ID + * @param {Array} groupIds - 分组ID数组 + * @param {string} accountPlatform - 账户平台 + */ + async setAccountGroups(accountId, groupIds, accountPlatform) { + try { + // 首先移除账户的所有现有分组 + await this.removeAccountFromAllGroups(accountId) + + // 然后添加到新的分组中 + for (const groupId of groupIds) { + await this.addAccountToGroup(accountId, groupId, accountPlatform) + } + + logger.success(`批量设置账户分组成功: ${accountId} -> [${groupIds.join(', ')}]`) + } catch (error) { + logger.error('❌ 批量设置账户分组失败:', error) + throw error + } + } + + /** + * 从所有分组中移除账户 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台(可选,用于清理反向索引) + */ + async removeAccountFromAllGroups(accountId, platform = null) { + try { + const client = redis.getClientSafe() + const allGroupIds = await client.smembers(this.GROUPS_KEY) + + for (const groupId of allGroupIds) { + await client.srem(`${this.GROUP_MEMBERS_PREFIX}${groupId}`, accountId) + } + + // 清理反向索引 + if (platform) { + await client.del(`account_groups_reverse:${platform}:${accountId}`) + } else { + // 如果没有指定平台,清理所有可能的平台 + const platforms = ['claude', 'gemini', 'openai', 'droid'] + const pipeline = client.pipeline() + for (const p of platforms) { + pipeline.del(`account_groups_reverse:${p}:${accountId}`) + } + await pipeline.exec() + } + + logger.success(`从所有分组移除账户成功: ${accountId}`) + } catch (error) { + logger.error('❌ 从所有分组移除账户失败:', error) + throw error + } + } + + /** + * 批量获取多个账户的分组信息(性能优化版本,使用反向索引) + * @param {Array} accountIds - 账户ID数组 + * @param {string} platform - 平台类型 + * @param {Object} options - 选项 + * @param {boolean} options.skipMemberCount - 是否跳过 memberCount(默认 true) + * @returns {Map} accountId -> 分组信息数组的映射 + */ + async batchGetAccountGroupsByIndex(accountIds, platform, options = {}) { + const { skipMemberCount = true } = options + + if (!accountIds || accountIds.length === 0) { + return new Map() + } + + try { + const client = redis.getClientSafe() + + // Pipeline 批量获取所有账户的分组ID + const pipeline = client.pipeline() + for (const accountId of accountIds) { + pipeline.smembers(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`) + } + const groupIdResults = await pipeline.exec() + + // 收集所有需要的分组ID + const uniqueGroupIds = new Set() + const accountGroupIdsMap = new Map() + let hasAnyGroups = false + accountIds.forEach((accountId, i) => { + const [err, groupIds] = groupIdResults[i] + const ids = err ? [] : groupIds || [] + accountGroupIdsMap.set(accountId, ids) + ids.forEach((id) => { + uniqueGroupIds.add(id) + hasAnyGroups = true + }) + }) + + // 如果反向索引全空,回退到原方法(兼容未迁移的数据) + if (!hasAnyGroups) { + const migrated = await client.get(this.REVERSE_INDEX_MIGRATED_KEY) + if (migrated !== 'true') { + logger.debug('📁 Reverse index not migrated, falling back to getAccountGroups') + const result = new Map() + for (const accountId of accountIds) { + try { + const groups = await this.getAccountGroups(accountId) + result.set(accountId, groups) + } catch { + result.set(accountId, []) + } + } + return result + } + } + + // 对于反向索引为空的账户,单独查询并补建索引(处理部分缺失情况) + const emptyIndexAccountIds = [] + for (const accountId of accountIds) { + const ids = accountGroupIdsMap.get(accountId) || [] + if (ids.length === 0) { + emptyIndexAccountIds.push(accountId) + } + } + if (emptyIndexAccountIds.length > 0 && emptyIndexAccountIds.length < accountIds.length) { + // 部分账户索引缺失,逐个查询并补建 + for (const accountId of emptyIndexAccountIds) { + try { + const groups = await this.getAccountGroups(accountId) + if (groups.length > 0) { + const groupIds = groups.map((g) => g.id) + accountGroupIdsMap.set(accountId, groupIds) + groupIds.forEach((id) => uniqueGroupIds.add(id)) + // 异步补建反向索引 + client + .sadd(`${this.REVERSE_INDEX_PREFIX}${platform}:${accountId}`, ...groupIds) + .catch(() => {}) + } + } catch { + // 忽略错误,保持空数组 + } + } + } + + // 批量获取分组详情 + const groupDetailsMap = new Map() + if (uniqueGroupIds.size > 0) { + const detailPipeline = client.pipeline() + const groupIdArray = Array.from(uniqueGroupIds) + for (const groupId of groupIdArray) { + detailPipeline.hgetall(`${this.GROUP_PREFIX}${groupId}`) + if (!skipMemberCount) { + detailPipeline.scard(`${this.GROUP_MEMBERS_PREFIX}${groupId}`) + } + } + const detailResults = await detailPipeline.exec() + + const step = skipMemberCount ? 1 : 2 + for (let i = 0; i < groupIdArray.length; i++) { + const groupId = groupIdArray[i] + const [err1, groupData] = detailResults[i * step] + if (!err1 && groupData && Object.keys(groupData).length > 0) { + const group = { ...groupData } + if (!skipMemberCount) { + const [err2, memberCount] = detailResults[i * step + 1] + group.memberCount = err2 ? 0 : memberCount || 0 + } + groupDetailsMap.set(groupId, group) + } + } + } + + // 构建最终结果 + const result = new Map() + for (const [accountId, groupIds] of accountGroupIdsMap) { + const groups = groupIds + .map((gid) => groupDetailsMap.get(gid)) + .filter(Boolean) + .sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + result.set(accountId, groups) + } + + return result + } catch (error) { + logger.error('❌ 批量获取账户分组失败:', error) + return new Map(accountIds.map((id) => [id, []])) + } + } +} + +module.exports = new AccountGroupService() diff --git a/src/services/accountNameCacheService.js b/src/services/accountNameCacheService.js new file mode 100644 index 0000000..f218b74 --- /dev/null +++ b/src/services/accountNameCacheService.js @@ -0,0 +1,286 @@ +/** + * 账户名称缓存服务 + * 用于加速绑定账号搜索,避免每次搜索都查询所有账户 + */ +const logger = require('../utils/logger') + +class AccountNameCacheService { + constructor() { + // 账户名称缓存:accountId -> { name, platform } + this.accountCache = new Map() + // 账户组名称缓存:groupId -> { name, platform } + this.groupCache = new Map() + // 缓存过期时间 + this.lastRefresh = 0 + this.refreshInterval = 5 * 60 * 1000 // 5分钟 + this.isRefreshing = false + } + + /** + * 刷新缓存(如果过期) + */ + async refreshIfNeeded() { + if (Date.now() - this.lastRefresh < this.refreshInterval) { + return + } + if (this.isRefreshing) { + // 等待正在进行的刷新完成 + let waitCount = 0 + while (this.isRefreshing && waitCount < 50) { + await new Promise((resolve) => setTimeout(resolve, 100)) + waitCount++ + } + return + } + await this.refresh() + } + + /** + * 强制刷新缓存 + */ + async refresh() { + if (this.isRefreshing) { + return + } + this.isRefreshing = true + + try { + const newAccountCache = new Map() + const newGroupCache = new Map() + + // 延迟加载服务,避免循环依赖 + const claudeAccountService = require('./account/claudeAccountService') + const claudeConsoleAccountService = require('./account/claudeConsoleAccountService') + const geminiAccountService = require('./account/geminiAccountService') + const openaiAccountService = require('./account/openaiAccountService') + const azureOpenaiAccountService = require('./account/azureOpenaiAccountService') + const bedrockAccountService = require('./account/bedrockAccountService') + const droidAccountService = require('./account/droidAccountService') + const ccrAccountService = require('./account/ccrAccountService') + const accountGroupService = require('./accountGroupService') + + // 可选服务(可能不存在) + let geminiApiAccountService = null + let openaiResponsesAccountService = null + try { + geminiApiAccountService = require('./account/geminiApiAccountService') + } catch (e) { + // 服务不存在,忽略 + } + try { + openaiResponsesAccountService = require('./account/openaiResponsesAccountService') + } catch (e) { + // 服务不存在,忽略 + } + + // 并行加载所有账户类型 + const results = await Promise.allSettled([ + claudeAccountService.getAllAccounts(), + claudeConsoleAccountService.getAllAccounts(), + geminiAccountService.getAllAccounts(), + geminiApiAccountService?.getAllAccounts() || Promise.resolve([]), + openaiAccountService.getAllAccounts(), + openaiResponsesAccountService?.getAllAccounts() || Promise.resolve([]), + azureOpenaiAccountService.getAllAccounts(), + bedrockAccountService.getAllAccounts(), + droidAccountService.getAllAccounts(), + ccrAccountService.getAllAccounts(), + accountGroupService.getAllGroups() + ]) + + // 提取结果 + const claudeAccounts = results[0].status === 'fulfilled' ? results[0].value : [] + const claudeConsoleAccounts = results[1].status === 'fulfilled' ? results[1].value : [] + const geminiAccounts = results[2].status === 'fulfilled' ? results[2].value : [] + const geminiApiAccounts = results[3].status === 'fulfilled' ? results[3].value : [] + const openaiAccounts = results[4].status === 'fulfilled' ? results[4].value : [] + const openaiResponsesAccounts = results[5].status === 'fulfilled' ? results[5].value : [] + const azureOpenaiAccounts = results[6].status === 'fulfilled' ? results[6].value : [] + const bedrockResult = results[7].status === 'fulfilled' ? results[7].value : { accounts: [] } + const droidAccounts = results[8].status === 'fulfilled' ? results[8].value : [] + const ccrAccounts = results[9].status === 'fulfilled' ? results[9].value : [] + const groups = results[10].status === 'fulfilled' ? results[10].value : [] + + // Bedrock 返回格式特殊处理 + const bedrockAccounts = Array.isArray(bedrockResult) + ? bedrockResult + : bedrockResult.accounts || [] + + // 填充账户缓存的辅助函数 + const addAccounts = (accounts, platform, prefix = '') => { + if (!Array.isArray(accounts)) { + return + } + for (const acc of accounts) { + if (acc && acc.id && acc.name) { + const key = prefix ? `${prefix}${acc.id}` : acc.id + newAccountCache.set(key, { name: acc.name, platform }) + // 同时存储不带前缀的版本,方便查找 + if (prefix) { + newAccountCache.set(acc.id, { name: acc.name, platform }) + } + } + } + } + + addAccounts(claudeAccounts, 'claude') + addAccounts(claudeConsoleAccounts, 'claude-console') + addAccounts(geminiAccounts, 'gemini') + addAccounts(geminiApiAccounts, 'gemini-api', 'api:') + addAccounts(openaiAccounts, 'openai') + addAccounts(openaiResponsesAccounts, 'openai-responses', 'responses:') + addAccounts(azureOpenaiAccounts, 'azure-openai') + addAccounts(bedrockAccounts, 'bedrock') + addAccounts(droidAccounts, 'droid') + addAccounts(ccrAccounts, 'ccr') + + // 填充账户组缓存 + if (Array.isArray(groups)) { + for (const group of groups) { + if (group && group.id && group.name) { + newGroupCache.set(group.id, { name: group.name, platform: group.platform }) + } + } + } + + this.accountCache = newAccountCache + this.groupCache = newGroupCache + this.lastRefresh = Date.now() + + logger.debug( + `账户名称缓存已刷新: ${newAccountCache.size} 个账户, ${newGroupCache.size} 个分组` + ) + } catch (error) { + logger.error('刷新账户名称缓存失败:', error) + } finally { + this.isRefreshing = false + } + } + + /** + * 获取账户显示名称 + * @param {string} accountId - 账户ID(可能带前缀) + * @param {string} _fieldName - 字段名(如 claudeAccountId),保留用于将来扩展 + * @returns {string} 显示名称 + */ + getAccountDisplayName(accountId, _fieldName) { + if (!accountId) { + return null + } + + // 处理账户组 + if (accountId.startsWith('group:')) { + const groupId = accountId.substring(6) + const group = this.groupCache.get(groupId) + if (group) { + return `分组-${group.name}` + } + return `分组-${groupId.substring(0, 8)}` + } + + // 直接查找(包括带前缀的 api:xxx, responses:xxx) + const cached = this.accountCache.get(accountId) + if (cached) { + return cached.name + } + + // 尝试去掉前缀查找 + let realId = accountId + if (accountId.startsWith('api:')) { + realId = accountId.substring(4) + } else if (accountId.startsWith('responses:')) { + realId = accountId.substring(10) + } + + if (realId !== accountId) { + const cached2 = this.accountCache.get(realId) + if (cached2) { + return cached2.name + } + } + + // 未找到,返回 ID 前缀 + return `${accountId.substring(0, 8)}...` + } + + /** + * 获取 API Key 的所有绑定账户显示名称 + * @param {Object} apiKey - API Key 对象 + * @returns {Array<{field: string, platform: string, name: string, accountId: string}>} + */ + getBindingDisplayNames(apiKey) { + const bindings = [] + + const bindingFields = [ + { field: 'claudeAccountId', platform: 'Claude' }, + { field: 'claudeConsoleAccountId', platform: 'Claude Console' }, + { field: 'geminiAccountId', platform: 'Gemini' }, + { field: 'openaiAccountId', platform: 'OpenAI' }, + { field: 'azureOpenaiAccountId', platform: 'Azure OpenAI' }, + { field: 'bedrockAccountId', platform: 'Bedrock' }, + { field: 'droidAccountId', platform: 'Droid' }, + { field: 'ccrAccountId', platform: 'CCR' } + ] + + for (const { field, platform } of bindingFields) { + const accountId = apiKey[field] + if (accountId) { + const name = this.getAccountDisplayName(accountId, field) + bindings.push({ field, platform, name, accountId }) + } + } + + return bindings + } + + /** + * 搜索绑定账号 + * @param {Array} apiKeys - API Key 列表 + * @param {string} keyword - 搜索关键词 + * @returns {Array} 匹配的 API Key 列表 + */ + searchByBindingAccount(apiKeys, keyword) { + const lowerKeyword = keyword.toLowerCase().trim() + if (!lowerKeyword) { + return apiKeys + } + + return apiKeys.filter((key) => { + const bindings = this.getBindingDisplayNames(key) + + // 无绑定时,匹配"共享池" + if (bindings.length === 0) { + return '共享池'.includes(lowerKeyword) || 'shared'.includes(lowerKeyword) + } + + // 匹配任一绑定账户 + return bindings.some((binding) => { + // 匹配账户名称 + if (binding.name && binding.name.toLowerCase().includes(lowerKeyword)) { + return true + } + // 匹配平台名称 + if (binding.platform.toLowerCase().includes(lowerKeyword)) { + return true + } + // 匹配账户 ID + if (binding.accountId.toLowerCase().includes(lowerKeyword)) { + return true + } + return false + }) + }) + } + + /** + * 清除缓存(用于测试或强制刷新) + */ + clearCache() { + this.accountCache.clear() + this.groupCache.clear() + this.lastRefresh = 0 + } +} + +// 单例导出 +module.exports = new AccountNameCacheService() diff --git a/src/services/accountTestSchedulerService.js b/src/services/accountTestSchedulerService.js new file mode 100644 index 0000000..c0a21ce --- /dev/null +++ b/src/services/accountTestSchedulerService.js @@ -0,0 +1,420 @@ +/** + * 账户定时测试调度服务 + * 使用 node-cron 支持 crontab 表达式,为每个账户创建独立的定时任务 + */ + +const cron = require('node-cron') +const redis = require('../models/redis') +const logger = require('../utils/logger') + +class AccountTestSchedulerService { + constructor() { + // 存储每个账户的 cron 任务: Map + this.scheduledTasks = new Map() + // 定期刷新配置的间隔 (毫秒) + this.refreshIntervalMs = 60 * 1000 + this.refreshInterval = null + // 当前正在测试的账户 + this.testingAccounts = new Set() + // 是否已启动 + this.isStarted = false + } + + /** + * 验证 cron 表达式是否有效 + * @param {string} cronExpression - cron 表达式 + * @returns {boolean} + */ + validateCronExpression(cronExpression) { + // 长度检查(防止 DoS) + if (!cronExpression || cronExpression.length > 100) { + return false + } + return cron.validate(cronExpression) + } + + /** + * 启动调度器 + */ + async start() { + if (this.isStarted) { + logger.warn('⚠️ Account test scheduler is already running') + return + } + + this.isStarted = true + logger.info('🚀 Starting account test scheduler service (node-cron mode)') + + // 初始化所有已配置账户的定时任务 + await this._refreshAllTasks() + + // 定期刷新配置,以便动态添加/修改的配置能生效 + this.refreshInterval = setInterval(() => { + this._refreshAllTasks() + }, this.refreshIntervalMs) + + logger.info( + `📅 Account test scheduler started (refreshing configs every ${this.refreshIntervalMs / 1000}s)` + ) + } + + /** + * 停止调度器 + */ + stop() { + if (this.refreshInterval) { + clearInterval(this.refreshInterval) + this.refreshInterval = null + } + + // 停止所有 cron 任务 + for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) { + taskInfo.task.stop() + logger.debug(`🛑 Stopped cron task for ${accountKey}`) + } + this.scheduledTasks.clear() + + this.isStarted = false + logger.info('🛑 Account test scheduler stopped') + } + + /** + * 刷新所有账户的定时任务 + * @private + */ + async _refreshAllTasks() { + try { + const platforms = ['claude', 'gemini', 'openai'] + const activeAccountKeys = new Set() + + // 并行加载所有平台的配置 + const allEnabledAccounts = await Promise.all( + platforms.map((platform) => + redis + .getEnabledTestAccounts(platform) + .then((accounts) => accounts.map((acc) => ({ ...acc, platform }))) + .catch((error) => { + logger.warn(`⚠️ Failed to load test accounts for platform ${platform}:`, error) + return [] + }) + ) + ) + + // 展平平台数据 + const flatAccounts = allEnabledAccounts.flat() + + for (const { accountId, cronExpression, model, platform } of flatAccounts) { + if (!cronExpression) { + logger.warn( + `⚠️ Account ${accountId} (${platform}) has no valid cron expression, skipping` + ) + continue + } + + const accountKey = `${platform}:${accountId}` + activeAccountKeys.add(accountKey) + + // 检查是否需要更新任务 + const existingTask = this.scheduledTasks.get(accountKey) + if (existingTask) { + // 如果 cron 表达式和模型都没变,不需要更新 + if (existingTask.cronExpression === cronExpression && existingTask.model === model) { + continue + } + // 配置变了,停止旧任务 + existingTask.task.stop() + logger.info(`🔄 Updating cron task for ${accountKey}: ${cronExpression}, model: ${model}`) + } else { + logger.info(`➕ Creating cron task for ${accountKey}: ${cronExpression}, model: ${model}`) + } + + // 创建新的 cron 任务 + this._createCronTask(accountId, platform, cronExpression, model) + } + + // 清理已删除或禁用的账户任务 + for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) { + if (!activeAccountKeys.has(accountKey)) { + taskInfo.task.stop() + this.scheduledTasks.delete(accountKey) + logger.info(`➖ Removed cron task for ${accountKey} (disabled or deleted)`) + } + } + } catch (error) { + logger.error('❌ Error refreshing account test tasks:', error) + } + } + + /** + * 为单个账户创建 cron 任务 + * @param {string} accountId + * @param {string} platform + * @param {string} cronExpression + * @param {string} model - 测试使用的模型 + * @private + */ + _createCronTask(accountId, platform, cronExpression, model) { + const accountKey = `${platform}:${accountId}` + + // 验证 cron 表达式 + if (!this.validateCronExpression(cronExpression)) { + logger.error(`❌ Invalid cron expression for ${accountKey}: ${cronExpression}`) + return + } + + const task = cron.schedule( + cronExpression, + async () => { + await this._runAccountTest(accountId, platform, model) + }, + { + scheduled: true, + timezone: process.env.TZ || 'Asia/Shanghai' + } + ) + + this.scheduledTasks.set(accountKey, { + task, + cronExpression, + model, + accountId, + platform + }) + } + + /** + * 执行单个账户测试 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @param {string} model - 测试使用的模型 + * @private + */ + async _runAccountTest(accountId, platform, model) { + const accountKey = `${platform}:${accountId}` + + // 避免重复测试 + if (this.testingAccounts.has(accountKey)) { + logger.debug(`⏳ Account ${accountKey} is already being tested, skipping`) + return + } + + this.testingAccounts.add(accountKey) + + try { + logger.info( + `🧪 Running scheduled test for ${platform} account: ${accountId} (model: ${model})` + ) + + let testResult + + // 根据平台调用对应的测试方法 + switch (platform) { + case 'claude': + testResult = await this._testClaudeAccount(accountId, model) + break + case 'gemini': + testResult = await this._testGeminiAccount(accountId, model) + break + case 'openai': + testResult = await this._testOpenAIAccount(accountId, model) + break + default: + testResult = { + success: false, + error: `Unsupported platform: ${platform}`, + timestamp: new Date().toISOString() + } + } + + // 保存测试结果 + await redis.saveAccountTestResult(accountId, platform, testResult) + + // 更新最后测试时间 + await redis.setAccountLastTestTime(accountId, platform) + + // 记录日志 + if (testResult.success) { + logger.info( + `✅ Scheduled test passed for ${platform} account ${accountId} (${testResult.latencyMs}ms)` + ) + } else { + logger.warn( + `❌ Scheduled test failed for ${platform} account ${accountId}: ${testResult.error}` + ) + } + + return testResult + } catch (error) { + logger.error(`❌ Error testing ${platform} account ${accountId}:`, error) + + const errorResult = { + success: false, + error: error.message, + timestamp: new Date().toISOString() + } + + await redis.saveAccountTestResult(accountId, platform, errorResult) + await redis.setAccountLastTestTime(accountId, platform) + + return errorResult + } finally { + this.testingAccounts.delete(accountKey) + } + } + + /** + * 测试 Claude 账户 + * @param {string} accountId + * @param {string} model - 测试使用的模型 + * @private + */ + async _testClaudeAccount(accountId, model) { + const claudeRelayService = require('./relay/claudeRelayService') + return await claudeRelayService.testAccountConnectionSync(accountId, model) + } + + /** + * 测试 Gemini 账户 + * @param {string} _accountId + * @param {string} _model + * @private + */ + async _testGeminiAccount(_accountId, _model) { + // Gemini 测试暂时返回未实现 + return { + success: false, + error: 'Gemini scheduled test not implemented yet', + timestamp: new Date().toISOString() + } + } + + /** + * 测试 OpenAI 账户 + * @param {string} _accountId + * @param {string} _model + * @private + */ + async _testOpenAIAccount(_accountId, _model) { + // OpenAI 测试暂时返回未实现 + return { + success: false, + error: 'OpenAI scheduled test not implemented yet', + timestamp: new Date().toISOString() + } + } + + /** + * 手动触发账户测试 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @param {string} model - 测试使用的模型 + * @returns {Promise} 测试结果 + */ + async triggerTest(accountId, platform, model = 'claude-sonnet-4-5-20250929') { + logger.info(`🎯 Manual test triggered for ${platform} account: ${accountId} (model: ${model})`) + return await this._runAccountTest(accountId, platform, model) + } + + /** + * 获取账户测试历史 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} 测试历史 + */ + async getTestHistory(accountId, platform) { + return await redis.getAccountTestHistory(accountId, platform) + } + + /** + * 获取账户测试配置 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @returns {Promise} + */ + async getTestConfig(accountId, platform) { + return await redis.getAccountTestConfig(accountId, platform) + } + + /** + * 设置账户测试配置 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 + * @param {Object} testConfig - 测试配置 { enabled: boolean, cronExpression: string, model: string } + * @returns {Promise} + */ + async setTestConfig(accountId, platform, testConfig) { + // 验证 cron 表达式 + if (testConfig.cronExpression && !this.validateCronExpression(testConfig.cronExpression)) { + throw new Error(`Invalid cron expression: ${testConfig.cronExpression}`) + } + + await redis.saveAccountTestConfig(accountId, platform, testConfig) + logger.info( + `📝 Test config updated for ${platform} account ${accountId}: enabled=${testConfig.enabled}, cronExpression=${testConfig.cronExpression}, model=${testConfig.model}` + ) + + // 立即刷新任务,使配置立即生效 + if (this.isStarted) { + await this._refreshAllTasks() + } + } + + /** + * 更新单个账户的定时任务(配置变更时调用) + * @param {string} accountId + * @param {string} platform + */ + async refreshAccountTask(accountId, platform) { + if (!this.isStarted) { + return + } + + const accountKey = `${platform}:${accountId}` + const testConfig = await redis.getAccountTestConfig(accountId, platform) + + // 停止现有任务 + const existingTask = this.scheduledTasks.get(accountKey) + if (existingTask) { + existingTask.task.stop() + this.scheduledTasks.delete(accountKey) + } + + // 如果启用且有有效的 cron 表达式,创建新任务 + if (testConfig?.enabled && testConfig?.cronExpression) { + this._createCronTask(accountId, platform, testConfig.cronExpression, testConfig.model) + logger.info( + `🔄 Refreshed cron task for ${accountKey}: ${testConfig.cronExpression}, model: ${testConfig.model}` + ) + } + } + + /** + * 获取调度器状态 + * @returns {Object} + */ + getStatus() { + const tasks = [] + for (const [accountKey, taskInfo] of this.scheduledTasks.entries()) { + tasks.push({ + accountKey, + accountId: taskInfo.accountId, + platform: taskInfo.platform, + cronExpression: taskInfo.cronExpression, + model: taskInfo.model + }) + } + + return { + running: this.isStarted, + refreshIntervalMs: this.refreshIntervalMs, + scheduledTasksCount: this.scheduledTasks.size, + scheduledTasks: tasks, + currentlyTesting: Array.from(this.testingAccounts) + } + } +} + +// 单例模式 +const accountTestSchedulerService = new AccountTestSchedulerService() + +module.exports = accountTestSchedulerService diff --git a/src/services/anthropicGeminiBridgeService.js b/src/services/anthropicGeminiBridgeService.js new file mode 100644 index 0000000..7200bbe --- /dev/null +++ b/src/services/anthropicGeminiBridgeService.js @@ -0,0 +1,3112 @@ +/** + * ============================================================================ + * Anthropic → Gemini/Antigravity 桥接服务 + * ============================================================================ + * + * 【模块功能】 + * 本模块负责将 Anthropic Claude API 格式的请求转换为 Gemini/Antigravity 格式, + * 并将响应转换回 Anthropic 格式返回给客户端(如 Claude Code)。 + * + * 【支持的后端 (vendor)】 + * - gemini-cli: 原生 Google Gemini API + * - antigravity: Claude 代理层 (CLIProxyAPI),使用 Gemini 格式但有额外约束 + * + * 【核心处理流程】 + * 1. 接收 Anthropic 格式请求 (/v1/messages) + * 2. 标准化消息 (normalizeAnthropicMessages) - 处理 thinking blocks、tool_result 等 + * 3. 转换工具定义 (convertAnthropicToolsToGeminiTools) - 压缩描述、清洗 schema + * 4. 转换消息内容 (convertAnthropicMessagesToGeminiContents) + * 5. 构建 Gemini 请求 (buildGeminiRequestFromAnthropic) + * 6. 发送请求并处理 SSE 流式响应 + * 7. 将 Gemini 响应转换回 Anthropic 格式返回 + * + * 【Antigravity 特殊处理】 + * - 工具描述压缩:限制 400 字符,避免 prompt 超长 + * - Schema description 压缩:限制 200 字符,保留关键约束信息 + * - Thinking signature 校验:防止格式错误导致 400 + * - Tool result 截断:限制 20 万字符 + * - 缺失 tool_result 自动补全:避免 tool_use concurrency 错误 + */ + +const util = require('util') +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') +const logger = require('../utils/logger') +const { getProjectRoot } = require('../utils/projectPaths') +const { createRequestDetailMeta } = require('../utils/requestDetailHelper') +const geminiAccountService = require('./account/geminiAccountService') +const unifiedGeminiScheduler = require('./scheduler/unifiedGeminiScheduler') +const sessionHelper = require('../utils/sessionHelper') +const signatureCache = require('../utils/signatureCache') +const apiKeyService = require('./apiKeyService') +const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const { parseSSELine } = require('../utils/sseParser') +const { sanitizeUpstreamError } = require('../utils/errorSanitizer') +const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner') +const { + dumpAnthropicNonStreamResponse, + dumpAnthropicStreamSummary +} = require('../utils/anthropicResponseDump') +const { + dumpAntigravityStreamEvent, + dumpAntigravityStreamSummary +} = require('../utils/antigravityUpstreamResponseDump') + +// ============================================================================ +// 常量定义 +// ============================================================================ + +// 默认签名 +const THOUGHT_SIGNATURE_FALLBACK = 'skip_thought_signature_validator' + +// 支持的后端类型 +const SUPPORTED_VENDORS = new Set(['gemini-cli', 'antigravity']) +// 需要跳过的系统提醒前缀(Claude 内部消息,不应转发给上游) +const SYSTEM_REMINDER_PREFIX = '' +// 调试:工具定义 dump 相关 +const TOOLS_DUMP_ENV = 'ANTHROPIC_DEBUG_TOOLS_DUMP' +const TOOLS_DUMP_FILENAME = 'anthropic-tools-dump.jsonl' +// 环境变量:工具调用失败时是否回退到文本输出 +const TEXT_TOOL_FALLBACK_ENV = 'ANTHROPIC_TEXT_TOOL_FALLBACK' +// 环境变量:工具报错时是否继续执行(而非中断) +const TOOL_ERROR_CONTINUE_ENV = 'ANTHROPIC_TOOL_ERROR_CONTINUE' +// Antigravity 工具顶级描述的最大字符数(防止 prompt 超长) +const MAX_ANTIGRAVITY_TOOL_DESCRIPTION_CHARS = 400 +// Antigravity 参数 schema description 的最大字符数(保留关键约束信息) +const MAX_ANTIGRAVITY_SCHEMA_DESCRIPTION_CHARS = 200 +// Antigravity:当已经决定要走工具时,避免“只宣布步骤就结束” +const ANTIGRAVITY_TOOL_FOLLOW_THROUGH_PROMPT = + 'When a step requires calling a tool, call the tool immediately in the same turn. Do not stop after announcing the step. Updating todos alone (e.g., TodoWrite) is not enough; you must actually invoke the target MCP tool (browser_*, etc.) before ending the turn.' +// 工具报错时注入的 system prompt,提示模型不要中断 +const TOOL_ERROR_CONTINUE_PROMPT = + 'Tool calls may fail (e.g., missing prerequisites). When a tool result indicates an error, do not stop: briefly explain the cause and continue with an alternative approach or the remaining steps.' +// Antigravity 账号前置注入的系统提示词 +const ANTIGRAVITY_SYSTEM_INSTRUCTION_PREFIX = ` +You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding. +You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question. +The USER will send you requests, which you must always prioritize addressing. Along with each USER request, we will attach additional metadata about their current state, such as what files they have open and where their cursor is. +This information may or may not be relevant to the coding task, it is up for you to decide. + + +- **Proactiveness**. As an agent, you are allowed to be proactive, but only in the course of completing the user's task. For example, if the user asks you to add a new component, you can edit the code, verify build and test statuses, and take any other obvious follow-up actions, such as performing additional research. However, avoid surprising the user. For example, if the user asks HOW to approach something, you should answer their question and instead of jumping into editing a file.` + +// ============================================================================ +// 辅助函数:基础工具 +// ============================================================================ + +/** + * 确保 Antigravity 请求有有效的 projectId + * 如果账户没有配置 projectId,则生成一个临时 ID + */ +function ensureAntigravityProjectId(account) { + if (account.projectId) { + return account.projectId + } + if (account.tempProjectId) { + return account.tempProjectId + } + return `ag-${crypto.randomBytes(8).toString('hex')}` +} + +/** + * 从 Anthropic 消息内容中提取纯文本 + * 支持字符串和 content blocks 数组两种格式 + * @param {string|Array} content - Anthropic 消息内容 + * @returns {string} 提取的文本 + */ +function extractAnthropicText(content) { + if (content === null || content === undefined) { + return '' + } + if (typeof content === 'string') { + return content + } + if (!Array.isArray(content)) { + return '' + } + return content + .filter((part) => part && part.type === 'text') + .map((part) => part.text || '') + .join('') +} + +/** + * 检查文本是否应该跳过(不转发给上游) + * 主要过滤 Claude 内部的 system-reminder 消息 + */ +function shouldSkipText(text) { + if (!text || typeof text !== 'string') { + return true + } + return text.trimStart().startsWith(SYSTEM_REMINDER_PREFIX) +} + +/** + * 构建 Gemini 格式的 system parts + * 将 Anthropic 的 system prompt 转换为 Gemini 的 parts 数组 + * @param {string|Array} system - Anthropic 的 system prompt + * @returns {Array} Gemini 格式的 parts + */ +function buildSystemParts(system) { + const parts = [] + if (!system) { + return parts + } + if (Array.isArray(system)) { + for (const part of system) { + if (!part || part.type !== 'text') { + continue + } + const text = extractAnthropicText(part.text || '') + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + } + return parts + } + const text = extractAnthropicText(system) + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + return parts +} + +/** + * 构建 tool_use ID 到工具名称的映射 + * 用于在处理 tool_result 时查找对应的工具名 + * @param {Array} messages - 消息列表 + * @returns {Map} tool_use_id -> tool_name 的映射 + */ +function buildToolUseIdToNameMap(messages) { + const toolUseIdToName = new Map() + + for (const message of messages || []) { + if (message?.role !== 'assistant') { + continue + } + const content = message?.content + if (!Array.isArray(content)) { + continue + } + for (const part of content) { + if (!part || part.type !== 'tool_use') { + continue + } + if (part.id && part.name) { + toolUseIdToName.set(part.id, part.name) + } + } + } + + return toolUseIdToName +} + +/** + * 标准化工具调用的输入参数 + * 确保输入始终是对象格式 + */ +function normalizeToolUseInput(input) { + if (input === null || input === undefined) { + return {} + } + if (typeof input === 'object') { + return input + } + if (typeof input === 'string') { + const trimmed = input.trim() + if (!trimmed) { + return {} + } + try { + const parsed = JSON.parse(trimmed) + if (parsed && typeof parsed === 'object') { + return parsed + } + } catch (_) { + return {} + } + } + return {} +} + +// Antigravity 工具结果的最大字符数(约 20 万,防止 prompt 超长) +const MAX_ANTIGRAVITY_TOOL_RESULT_CHARS = 200000 + +// ============================================================================ +// 辅助函数:Antigravity 体积压缩 +// 这些函数用于压缩工具描述、schema 等,避免 prompt 超过 Antigravity 的上限 +// ============================================================================ + +/** + * 截断文本并添加截断提示(带换行) + * @param {string} text - 原始文本 + * @param {number} maxChars - 最大字符数 + * @returns {string} 截断后的文本 + */ +function truncateText(text, maxChars) { + if (!text || typeof text !== 'string') { + return '' + } + if (text.length <= maxChars) { + return text + } + return `${text.slice(0, maxChars)}\n...[truncated ${text.length - maxChars} chars]` +} + +/** + * 截断文本并添加截断提示(内联模式,不带换行) + */ +function truncateInlineText(text, maxChars) { + if (!text || typeof text !== 'string') { + return '' + } + if (text.length <= maxChars) { + return text + } + return `${text.slice(0, maxChars)}...[truncated ${text.length - maxChars} chars]` +} + +/** + * 压缩工具顶级描述 + * 取前 6 行,合并为单行,截断到 400 字符 + * 这样可以在保留关键信息的同时大幅减少体积 + * @param {string} description - 原始工具描述 + * @returns {string} 压缩后的描述 + */ +function compactToolDescriptionForAntigravity(description) { + if (!description || typeof description !== 'string') { + return '' + } + const normalized = description.replace(/\r\n/g, '\n').trim() + if (!normalized) { + return '' + } + + const lines = normalized + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + + if (lines.length === 0) { + return '' + } + + const compacted = lines.slice(0, 6).join(' ') + return truncateInlineText(compacted, MAX_ANTIGRAVITY_TOOL_DESCRIPTION_CHARS) +} + +/** + * 压缩 JSON Schema 属性描述 + * 压缩多余空白,截断到 200 字符 + * 这是为了保留关键参数约束(如 ji 工具的 action 只能是 "记忆"/"回忆") + * @param {string} description - 原始描述 + * @returns {string} 压缩后的描述 + */ +function compactSchemaDescriptionForAntigravity(description) { + if (!description || typeof description !== 'string') { + return '' + } + const normalized = description.replace(/\s+/g, ' ').trim() + if (!normalized) { + return '' + } + return truncateInlineText(normalized, MAX_ANTIGRAVITY_SCHEMA_DESCRIPTION_CHARS) +} + +/** + * 递归压缩 JSON Schema 中所有层级的 description 字段 + * 保留并压缩 description(而不是删除),确保关键参数约束信息不丢失 + * @param {Object} schema - JSON Schema 对象 + * @returns {Object} 压缩后的 schema + */ +function compactJsonSchemaDescriptionsForAntigravity(schema) { + if (schema === null || schema === undefined) { + return schema + } + if (typeof schema !== 'object') { + return schema + } + if (Array.isArray(schema)) { + return schema.map((item) => compactJsonSchemaDescriptionsForAntigravity(item)) + } + + const cleaned = {} + for (const [key, value] of Object.entries(schema)) { + if (key === 'description') { + const compacted = compactSchemaDescriptionForAntigravity(value) + if (compacted) { + cleaned.description = compacted + } + continue + } + cleaned[key] = compactJsonSchemaDescriptionsForAntigravity(value) + } + return cleaned +} + +/** + * 清洗 thinking block 的 signature + * 检查格式是否合法(Base64-like token),不合法则返回空串 + * 这是为了避免 "Invalid signature in thinking block" 400 错误 + * @param {string} signature - 原始 signature + * @returns {string} 清洗后的 signature(不合法则为空串) + */ +function sanitizeThoughtSignatureForAntigravity(signature) { + if (!signature || typeof signature !== 'string') { + return '' + } + const trimmed = signature.trim() + if (!trimmed) { + return '' + } + + const compacted = trimmed.replace(/\s+/g, '') + if (compacted.length > 65536) { + return '' + } + + const looksLikeToken = /^[A-Za-z0-9+/_=-]+$/.test(compacted) + if (!looksLikeToken) { + return '' + } + + if (compacted.length < 8) { + return '' + } + + return compacted +} + +/** + * 检测是否是 Antigravity 的 INVALID_ARGUMENT (400) 错误 + * 用于在日志中特殊标记这类错误,方便调试 + * + * @param {Object} sanitized - sanitizeUpstreamError 处理后的错误对象 + * @returns {boolean} 是否是参数无效错误 + */ +function isInvalidAntigravityArgumentError(sanitized) { + if (!sanitized || typeof sanitized !== 'object') { + return false + } + const upstreamType = String(sanitized.upstreamType || '').toUpperCase() + if (upstreamType === 'INVALID_ARGUMENT') { + return true + } + const message = String(sanitized.upstreamMessage || sanitized.message || '') + return /invalid argument/i.test(message) +} + +/** + * 汇总 Antigravity 请求信息用于调试 + * 当发生 400 错误时,输出请求的关键统计信息,帮助定位问题 + * + * @param {Object} requestData - 发送给 Antigravity 的请求数据 + * @returns {Object} 请求摘要信息 + */ +function summarizeAntigravityRequestForDebug(requestData) { + const request = requestData?.request || {} + const contents = Array.isArray(request.contents) ? request.contents : [] + const partStats = { text: 0, thought: 0, functionCall: 0, functionResponse: 0, other: 0 } + let functionResponseIds = 0 + let fallbackSignatureCount = 0 + + for (const message of contents) { + const parts = Array.isArray(message?.parts) ? message.parts : [] + for (const part of parts) { + if (!part || typeof part !== 'object') { + continue + } + if (part.thoughtSignature === THOUGHT_SIGNATURE_FALLBACK) { + fallbackSignatureCount += 1 + } + if (part.thought) { + partStats.thought += 1 + continue + } + if (part.functionCall) { + partStats.functionCall += 1 + continue + } + if (part.functionResponse) { + partStats.functionResponse += 1 + if (part.functionResponse.id) { + functionResponseIds += 1 + } + continue + } + if (typeof part.text === 'string') { + partStats.text += 1 + continue + } + partStats.other += 1 + } + } + + return { + model: requestData?.model, + toolCount: Array.isArray(request.tools) ? request.tools.length : 0, + toolConfigMode: request.toolConfig?.functionCallingConfig?.mode, + thinkingConfig: request.generationConfig?.thinkingConfig, + maxOutputTokens: request.generationConfig?.maxOutputTokens, + contentsCount: contents.length, + partStats, + functionResponseIds, + fallbackSignatureCount + } +} + +/** + * 清洗工具结果的 content blocks + * - 移除 base64 图片(避免体积过大) + * - 截断文本内容到 20 万字符 + * @param {Array} blocks - content blocks 数组 + * @returns {Array} 清洗后的 blocks + */ +function sanitizeToolResultBlocksForAntigravity(blocks) { + const cleaned = [] + let usedChars = 0 + let removedImage = false + + for (const block of blocks) { + if (!block || typeof block !== 'object') { + continue + } + + if ( + block.type === 'image' && + block.source?.type === 'base64' && + typeof block.source?.data === 'string' + ) { + removedImage = true + continue + } + + if (block.type === 'text' && typeof block.text === 'string') { + const remaining = MAX_ANTIGRAVITY_TOOL_RESULT_CHARS - usedChars + if (remaining <= 0) { + break + } + const text = truncateText(block.text, remaining) + cleaned.push({ ...block, text }) + usedChars += text.length + continue + } + + cleaned.push(block) + usedChars += 100 + if (usedChars >= MAX_ANTIGRAVITY_TOOL_RESULT_CHARS) { + break + } + } + + if (removedImage) { + cleaned.push({ + type: 'text', + text: '[image omitted to fit Antigravity prompt limits; use the file path in the previous text block]' + }) + } + + return cleaned +} + +// ============================================================================ +// 核心函数:消息标准化和转换 +// ============================================================================ + +/** + * 标准化工具结果内容 + * 支持字符串和 content blocks 数组两种格式 + * 对 Antigravity 会进行截断和图片移除处理 + */ +function normalizeToolResultContent(content, { vendor = null } = {}) { + if (content === null || content === undefined) { + return '' + } + if (typeof content === 'string') { + if (vendor === 'antigravity') { + return truncateText(content, MAX_ANTIGRAVITY_TOOL_RESULT_CHARS) + } + return content + } + // Claude Code 的 tool_result.content 通常是 content blocks 数组(例如 [{type:"text",text:"..."}])。 + // 为对齐 CLIProxyAPI/Antigravity 的行为,这里优先保留原始 JSON 结构(数组/对象), + // 避免上游将其视为“无效 tool_result”从而触发 tool_use concurrency 400。 + if (Array.isArray(content) || (content && typeof content === 'object')) { + if (vendor === 'antigravity' && Array.isArray(content)) { + return sanitizeToolResultBlocksForAntigravity(content) + } + return content + } + return '' +} + +/** + * 标准化 Anthropic 消息列表 + * 这是关键的预处理函数,处理以下问题: + * + * 1. Antigravity thinking block 顺序调整 + * - Antigravity 要求 thinking blocks 必须在 assistant 消息的最前面 + * - 移除 thinking block 中的 cache_control 字段(上游不接受) + * + * 2. tool_use 后的冗余内容剥离 + * - 移除 tool_use 后的空文本、"(no content)" 等冗余 part + * + * 3. 缺失 tool_result 补全(Antigravity 专用) + * - 检测消息历史中是否有 tool_use 没有对应的 tool_result + * - 自动插入合成的 tool_result(is_error: true) + * - 避免 "tool_use concurrency" 400 错误 + * + * 4. tool_result 和 user 文本拆分 + * - Claude Code 可能把 tool_result 和用户文本混在一个 user message 中 + * - 拆分为两个 message 以符合 Anthropic 规范 + * + * @param {Array} messages - 原始消息列表 + * @param {Object} options - 选项,包含 vendor + * @returns {Array} 标准化后的消息列表 + */ +function normalizeAnthropicMessages(messages, { vendor = null } = {}) { + if (!Array.isArray(messages) || messages.length === 0) { + return messages + } + + const pendingToolUseIds = [] + const isIgnorableTrailingText = (part) => { + if (!part || part.type !== 'text') { + return false + } + if (typeof part.text !== 'string') { + return false + } + const trimmed = part.text.trim() + if (trimmed === '' || trimmed === '(no content)') { + return true + } + if (part.cache_control?.type === 'ephemeral' && trimmed === '(no content)') { + return true + } + return false + } + + const normalizeAssistantThinkingOrderForVendor = (parts) => { + if (vendor !== 'antigravity') { + return parts + } + const thinkingBlocks = [] + const otherBlocks = [] + for (const part of parts) { + if (!part) { + continue + } + if (part.type === 'thinking' || part.type === 'redacted_thinking') { + // 移除 cache_control 字段,上游 API 不接受 thinking block 中包含此字段 + // 错误信息: "thinking.cache_control: Extra inputs are not permitted" + const { cache_control: _cache_control, ...cleanedPart } = part + thinkingBlocks.push(cleanedPart) + continue + } + if (isIgnorableTrailingText(part)) { + continue + } + otherBlocks.push(part) + } + if (thinkingBlocks.length === 0) { + return otherBlocks + } + return [...thinkingBlocks, ...otherBlocks] + } + + const stripNonToolPartsAfterToolUse = (parts) => { + let seenToolUse = false + const cleaned = [] + for (const part of parts) { + if (!part) { + continue + } + if (part.type === 'tool_use') { + seenToolUse = true + cleaned.push(part) + continue + } + if (!seenToolUse) { + cleaned.push(part) + continue + } + if (isIgnorableTrailingText(part)) { + continue + } + } + return cleaned + } + + const normalized = [] + + for (const message of messages) { + if (!message || !Array.isArray(message.content)) { + normalized.push(message) + continue + } + + let parts = message.content.filter(Boolean) + if (message.role === 'assistant') { + parts = normalizeAssistantThinkingOrderForVendor(parts) + } + + if (vendor === 'antigravity' && message.role === 'assistant') { + if (pendingToolUseIds.length > 0) { + normalized.push({ + role: 'user', + content: pendingToolUseIds.map((toolUseId) => ({ + type: 'tool_result', + tool_use_id: toolUseId, + is_error: true, + content: [ + { + type: 'text', + text: '[tool_result missing; tool execution interrupted]' + } + ] + })) + }) + pendingToolUseIds.length = 0 + } + + const stripped = stripNonToolPartsAfterToolUse(parts) + const toolUseIds = stripped + .filter((part) => part?.type === 'tool_use' && typeof part.id === 'string') + .map((part) => part.id) + if (toolUseIds.length > 0) { + pendingToolUseIds.push(...toolUseIds) + } + + normalized.push({ ...message, content: stripped }) + continue + } + + if (vendor === 'antigravity' && message.role === 'user' && pendingToolUseIds.length > 0) { + const toolResults = parts.filter((p) => p.type === 'tool_result') + const toolResultIds = new Set( + toolResults.map((p) => p.tool_use_id).filter((id) => typeof id === 'string') + ) + const missing = pendingToolUseIds.filter((id) => !toolResultIds.has(id)) + if (missing.length > 0) { + const synthetic = missing.map((toolUseId) => ({ + type: 'tool_result', + tool_use_id: toolUseId, + is_error: true, + content: [ + { + type: 'text', + text: '[tool_result missing; tool execution interrupted]' + } + ] + })) + parts = [...toolResults, ...synthetic, ...parts.filter((p) => p.type !== 'tool_result')] + } + pendingToolUseIds.length = 0 + } + + if (message.role !== 'user') { + normalized.push({ ...message, content: parts }) + continue + } + + const toolResults = parts.filter((p) => p.type === 'tool_result') + if (toolResults.length === 0) { + normalized.push({ ...message, content: parts }) + continue + } + + const nonToolResults = parts.filter((p) => p.type !== 'tool_result') + if (nonToolResults.length === 0) { + normalized.push({ ...message, content: toolResults }) + continue + } + + // Claude Code 可能把 tool_result 和下一条用户文本合并在同一个 user message 中。 + // 但上游(Antigravity/Claude)会按 Anthropic 规则校验:tool_use 后的下一条 message + // 必须只包含 tool_result blocks。这里做兼容拆分,避免 400 tool-use concurrency。 + normalized.push({ ...message, content: toolResults }) + normalized.push({ ...message, content: nonToolResults }) + } + + if (vendor === 'antigravity' && pendingToolUseIds.length > 0) { + normalized.push({ + role: 'user', + content: pendingToolUseIds.map((toolUseId) => ({ + type: 'tool_result', + tool_use_id: toolUseId, + is_error: true, + content: [ + { + type: 'text', + text: '[tool_result missing; tool execution interrupted]' + } + ] + })) + }) + pendingToolUseIds.length = 0 + } + + return normalized +} + +// ============================================================================ +// 核心函数:工具定义转换 +// ============================================================================ + +/** + * 将 Anthropic 工具定义转换为 Gemini/Antigravity 格式 + * + * 主要工作: + * 1. 工具描述压缩(Antigravity: 400 字符上限) + * 2. JSON Schema 清洗(移除不支持的字段如 $schema, format 等) + * 3. Schema description 压缩(Antigravity: 200 字符上限,保留关键约束) + * 4. 输出格式差异: + * - Antigravity: 使用 parametersJsonSchema + * - Gemini: 使用 parameters + * + * @param {Array} tools - Anthropic 格式的工具定义数组 + * @param {Object} options - 选项,包含 vendor + * @returns {Array|null} Gemini 格式的工具定义,或 null + */ +function convertAnthropicToolsToGeminiTools(tools, { vendor = null } = {}) { + if (!Array.isArray(tools) || tools.length === 0) { + return null + } + + // 说明:Gemini / Antigravity 对工具 schema 的接受程度不同;这里做“尽可能兼容”的最小清洗,降低 400 概率。 + const sanitizeSchemaForFunctionDeclarations = (schema) => { + const allowedKeys = new Set([ + 'type', + 'properties', + 'required', + 'description', + 'enum', + 'items', + 'anyOf', + 'oneOf', + 'allOf', + 'additionalProperties', + 'minimum', + 'maximum', + 'minItems', + 'maxItems', + 'minLength', + 'maxLength' + ]) + + if (schema === null || schema === undefined) { + return null + } + + // primitives: keep as-is (e.g. type/description/nullable/minimum...) + if (typeof schema !== 'object') { + return schema + } + + if (Array.isArray(schema)) { + return schema + .map((item) => sanitizeSchemaForFunctionDeclarations(item)) + .filter((item) => item !== null && item !== undefined) + } + + const sanitized = {} + for (const [key, value] of Object.entries(schema)) { + // Antigravity/Cloud Code 的 function_declarations.parameters 不接受 $schema / $id 等元字段 + if (key === '$schema' || key === '$id') { + continue + } + // 去除常见的非必要字段,减少上游 schema 校验失败概率 + if (key === 'title' || key === 'default' || key === 'examples' || key === 'example') { + continue + } + // 上游对 JSON Schema "format" 支持不稳定(特别是 format=uri),直接移除以降低 400 概率 + if (key === 'format') { + continue + } + if (!allowedKeys.has(key)) { + continue + } + + if (key === 'properties') { + if (value && typeof value === 'object' && !Array.isArray(value)) { + const props = {} + for (const [propName, propSchema] of Object.entries(value)) { + const sanitizedProp = sanitizeSchemaForFunctionDeclarations(propSchema) + if (sanitizedProp && typeof sanitizedProp === 'object') { + props[propName] = sanitizedProp + } + } + sanitized.properties = props + } + continue + } + + if (key === 'required') { + if (Array.isArray(value)) { + const req = value.filter((item) => typeof item === 'string') + if (req.length > 0) { + sanitized.required = req + } + } + continue + } + + if (key === 'enum') { + if (Array.isArray(value)) { + const en = value.filter( + (item) => + typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean' + ) + if (en.length > 0) { + sanitized.enum = en + } + } + continue + } + + if (key === 'additionalProperties') { + if (typeof value === 'boolean') { + sanitized.additionalProperties = value + } else if (value && typeof value === 'object') { + const ap = sanitizeSchemaForFunctionDeclarations(value) + if (ap && typeof ap === 'object') { + sanitized.additionalProperties = ap + } + } + continue + } + + const sanitizedValue = sanitizeSchemaForFunctionDeclarations(value) + if (sanitizedValue === null || sanitizedValue === undefined) { + continue + } + sanitized[key] = sanitizedValue + } + + // 兜底:确保 schema 至少是一个 object schema + if (!sanitized.type) { + if (sanitized.items) { + sanitized.type = 'array' + } else if (sanitized.properties || sanitized.required || sanitized.additionalProperties) { + sanitized.type = 'object' + } else if (sanitized.enum) { + sanitized.type = 'string' + } else { + sanitized.type = 'object' + sanitized.properties = {} + } + } + + if (sanitized.type === 'object' && !sanitized.properties) { + sanitized.properties = {} + } + + return sanitized + } + + const functionDeclarations = tools + .map((tool) => { + const toolDef = tool?.custom && typeof tool.custom === 'object' ? tool.custom : tool + if (!toolDef || !toolDef.name) { + return null + } + + const toolDescription = + vendor === 'antigravity' + ? compactToolDescriptionForAntigravity(toolDef.description || '') + : toolDef.description || '' + + const schema = + vendor === 'antigravity' + ? compactJsonSchemaDescriptionsForAntigravity( + cleanJsonSchemaForGemini(toolDef.input_schema) + ) + : sanitizeSchemaForFunctionDeclarations(toolDef.input_schema) || { + type: 'object', + properties: {} + } + + const baseDecl = { + name: toolDef.name, + description: toolDescription + } + + // CLIProxyAPI/Antigravity 侧使用 parametersJsonSchema(而不是 parameters)。 + if (vendor === 'antigravity') { + return { ...baseDecl, parametersJsonSchema: schema } + } + return { ...baseDecl, parameters: schema } + }) + .filter(Boolean) + + if (functionDeclarations.length === 0) { + return null + } + + return [ + { + functionDeclarations + } + ] +} + +/** + * 将 Anthropic 的 tool_choice 转换为 Gemini 的 toolConfig + * 映射关系: + * auto → AUTO(模型自决定是否调用工具) + * any → ANY(必须调用某个工具) + * tool → ANY + allowedFunctionNames(指定工具) + * none → NONE(禁止调用工具) + */ +function convertAnthropicToolChoiceToGeminiToolConfig(toolChoice) { + if (!toolChoice || typeof toolChoice !== 'object') { + return null + } + + const { type } = toolChoice + if (!type) { + return null + } + + if (type === 'auto') { + return { functionCallingConfig: { mode: 'AUTO' } } + } + + if (type === 'any') { + return { functionCallingConfig: { mode: 'ANY' } } + } + + if (type === 'tool') { + const { name } = toolChoice + if (!name) { + return { functionCallingConfig: { mode: 'ANY' } } + } + return { + functionCallingConfig: { + mode: 'ANY', + allowedFunctionNames: [name] + } + } + } + + if (type === 'none') { + return { functionCallingConfig: { mode: 'NONE' } } + } + + return null +} + +// ============================================================================ +// 核心函数:消息内容转换 +// ============================================================================ + +/** + * 将 Anthropic 消息转换为 Gemini contents 格式 + * + * 处理的内容类型: + * - text: 纯文本内容 + * - thinking: 思考过程(转换为 Gemini 的 thought part) + * - image: 图片(转换为 inlineData) + * - tool_use: 工具调用(转换为 functionCall) + * - tool_result: 工具结果(转换为 functionResponse) + * + * Antigravity 特殊处理: + * - thinking block 转换为 { thought: true, text, thoughtSignature } + * - signature 清洗和校验(不伪造签名) + * - 空 thinking block 跳过(避免 400 错误) + * - stripThinking 模式:完全剔除 thinking blocks + * + * @param {Array} messages - 标准化后的消息列表 + * @param {Map} toolUseIdToName - tool_use ID 到工具名的映射 + * @param {Object} options - 选项,包含 vendor、stripThinking + * @returns {Array} Gemini 格式的 contents + */ +function convertAnthropicMessagesToGeminiContents( + messages, + toolUseIdToName, + { vendor = null, stripThinking = false, sessionId = null } = {} +) { + const contents = [] + for (const message of messages || []) { + const role = message?.role === 'assistant' ? 'model' : 'user' + + const content = message?.content + const parts = [] + let lastAntigravityThoughtSignature = '' + + if (typeof content === 'string') { + const text = extractAnthropicText(content) + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + } else if (Array.isArray(content)) { + for (const part of content) { + if (!part || !part.type) { + continue + } + + if (part.type === 'text') { + const text = extractAnthropicText(part.text || '') + if (text && !shouldSkipText(text)) { + parts.push({ text }) + } + continue + } + + if (part.type === 'thinking' || part.type === 'redacted_thinking') { + // 当 thinking 未启用时,跳过所有 thinking blocks,避免 Antigravity 400 错误: + // "When thinking is disabled, an assistant message cannot contain thinking" + if (stripThinking) { + continue + } + + const thinkingText = extractAnthropicText(part.thinking || part.text || '') + if (vendor === 'antigravity') { + const hasThinkingText = thinkingText && !shouldSkipText(thinkingText) + // 先尝试使用请求中的签名,如果没有则尝试从缓存恢复 + let signature = sanitizeThoughtSignatureForAntigravity(part.signature) + if (!signature && sessionId && hasThinkingText) { + const cachedSig = signatureCache.getCachedSignature(sessionId, thinkingText) + if (cachedSig) { + signature = cachedSig + logger.debug('[SignatureCache] Restored signature from cache for thinking block') + } + } + const hasSignature = Boolean(signature) + + // Claude Code 有时会发送空的 thinking block(无 thinking / 无 signature)。 + // 传给 Antigravity 会变成仅含 thoughtSignature 的 part,容易触发 INVALID_ARGUMENT。 + if (!hasThinkingText && !hasSignature) { + continue + } + + // Antigravity 会校验 thoughtSignature;缺失/不合法时无法伪造,只能丢弃该块避免 400。 + if (!hasSignature) { + continue + } + + lastAntigravityThoughtSignature = signature + const thoughtPart = { thought: true, thoughtSignature: signature } + if (hasThinkingText) { + thoughtPart.text = thinkingText + } + parts.push(thoughtPart) + } else if (thinkingText && !shouldSkipText(thinkingText)) { + parts.push({ text: thinkingText }) + } + continue + } + + if (part.type === 'image') { + const source = part.source || {} + if (source.type === 'base64' && source.data) { + const mediaType = source.media_type || source.mediaType || 'application/octet-stream' + const inlineData = + vendor === 'antigravity' + ? { mime_type: mediaType, data: source.data } + : { mimeType: mediaType, data: source.data } + parts.push({ inlineData }) + } + continue + } + + if (part.type === 'tool_use') { + if (part.name) { + const toolCallId = typeof part.id === 'string' && part.id ? part.id : undefined + const args = normalizeToolUseInput(part.input) + const functionCall = { + ...(vendor === 'antigravity' && toolCallId ? { id: toolCallId } : {}), + name: part.name, + args + } + + // Antigravity 对历史工具调用的 functionCall 会校验 thoughtSignature; + // Claude Code 侧的签名存放在 thinking block(part.signature),这里需要回填到 functionCall part 上。 + // [大东的绝杀补丁] 再次尝试! + if (vendor === 'antigravity') { + // 如果没有真签名,就用“免检金牌” + const effectiveSignature = + lastAntigravityThoughtSignature || THOUGHT_SIGNATURE_FALLBACK + + // 必须把这个塞进去 + // Antigravity 要求:每个包含 thoughtSignature 的 part 都必须有 thought: true + parts.push({ + thought: true, + thoughtSignature: effectiveSignature, + functionCall + }) + } else { + parts.push({ functionCall }) + } + } + continue + } + + if (part.type === 'tool_result') { + const toolUseId = part.tool_use_id + const toolName = toolUseId ? toolUseIdToName.get(toolUseId) : null + if (!toolName) { + continue + } + + const raw = normalizeToolResultContent(part.content, { vendor }) + + let parsedResponse = null + if (raw && typeof raw === 'string') { + try { + parsedResponse = JSON.parse(raw) + } catch (_) { + parsedResponse = null + } + } + + if (vendor === 'antigravity') { + const toolCallId = typeof toolUseId === 'string' && toolUseId ? toolUseId : undefined + const result = parsedResponse !== null ? parsedResponse : raw || '' + const response = part.is_error === true ? { result, is_error: true } : { result } + + parts.push({ + functionResponse: { + ...(toolCallId ? { id: toolCallId } : {}), + name: toolName, + response + } + }) + } else { + const response = + parsedResponse !== null + ? parsedResponse + : { + content: raw || '', + is_error: part.is_error === true + } + + parts.push({ + functionResponse: { + name: toolName, + response + } + }) + } + } + } + } + + if (parts.length === 0) { + continue + } + + contents.push({ + role, + parts + }) + } + return contents +} + +/** + * 检查是否可以为 Antigravity 启用 thinking 功能 + * + * 规则:查找最后一个 assistant 消息,检查其 thinking block 是否有效 + * - 如果有 thinking 文本或 signature,则可以启用 + * - 如果是空 thinking block(无文本且无 signature),则不能启用 + * + * 这是为了避免 "When thinking is disabled, an assistant message cannot contain thinking" 错误 + * + * @param {Array} messages - 消息列表 + * @returns {boolean} 是否可以启用 thinking + */ +function canEnableAntigravityThinking(messages) { + if (!Array.isArray(messages) || messages.length === 0) { + return true + } + + // Antigravity 会校验历史 thinking blocks 的 signature;缺失/不合法时必须禁用 thinking,避免 400。 + for (const message of messages) { + if (!message || message.role !== 'assistant') { + continue + } + const { content } = message + if (!Array.isArray(content) || content.length === 0) { + continue + } + for (const part of content) { + if (!part || (part.type !== 'thinking' && part.type !== 'redacted_thinking')) { + continue + } + const signature = sanitizeThoughtSignatureForAntigravity(part.signature) + if (!signature) { + return false + } + } + } + + let lastAssistant = null + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] + if (message && message.role === 'assistant') { + lastAssistant = message + break + } + } + if ( + !lastAssistant || + !Array.isArray(lastAssistant.content) || + lastAssistant.content.length === 0 + ) { + return true + } + + const parts = lastAssistant.content.filter(Boolean) + const hasToolBlocks = parts.some( + (part) => part?.type === 'tool_use' || part?.type === 'tool_result' + ) + if (!hasToolBlocks) { + return true + } + + const first = parts[0] + if (!first || (first.type !== 'thinking' && first.type !== 'redacted_thinking')) { + return false + } + + return true +} + +// ============================================================================ +// 核心函数:构建最终请求 +// ============================================================================ + +/** + * 构建 Gemini/Antigravity 请求体 + * 这是整个转换流程的主函数,串联所有转换步骤: + * + * 1. normalizeAnthropicMessages - 消息标准化 + * 2. buildToolUseIdToNameMap - 构建 tool_use ID 映射 + * 3. canEnableAntigravityThinking - 检查 thinking 是否可启用 + * 4. convertAnthropicMessagesToGeminiContents - 转换消息内容 + * 5. buildSystemParts - 构建 system prompt + * 6. convertAnthropicToolsToGeminiTools - 转换工具定义 + * 7. convertAnthropicToolChoiceToGeminiToolConfig - 转换工具选择 + * 8. 构建 generationConfig(温度、maxTokens、thinking 等) + * + * @param {Object} body - Anthropic 请求体 + * @param {string} baseModel - 基础模型名 + * @param {Object} options - 选项,包含 vendor + * @returns {Object} { model, request } Gemini 请求对象 + */ +function buildGeminiRequestFromAnthropic( + body, + baseModel, + { vendor = null, sessionId = null } = {} +) { + const normalizedMessages = normalizeAnthropicMessages(body.messages || [], { vendor }) + const toolUseIdToName = buildToolUseIdToNameMap(normalizedMessages || []) + + // 提前判断是否可以启用 thinking,以便决定是否需要剥离 thinking blocks + let canEnableThinking = false + if (vendor === 'antigravity' && body?.thinking?.type === 'enabled') { + const budgetRaw = Number(body.thinking.budget_tokens) + if (Number.isFinite(budgetRaw)) { + canEnableThinking = canEnableAntigravityThinking(normalizedMessages) + } + } + + const contents = convertAnthropicMessagesToGeminiContents( + normalizedMessages || [], + toolUseIdToName, + { + vendor, + // 当 Antigravity 无法启用 thinking 时,剥离所有 thinking blocks + stripThinking: vendor === 'antigravity' && !canEnableThinking, + sessionId + } + ) + const systemParts = buildSystemParts(body.system) + + if (vendor === 'antigravity' && isEnvEnabled(process.env[TOOL_ERROR_CONTINUE_ENV])) { + systemParts.push({ text: TOOL_ERROR_CONTINUE_PROMPT }) + } + if (vendor === 'antigravity') { + systemParts.push({ text: ANTIGRAVITY_TOOL_FOLLOW_THROUGH_PROMPT }) + } + + const temperature = typeof body.temperature === 'number' ? body.temperature : 1 + const maxTokens = Number.isFinite(body.max_tokens) ? body.max_tokens : 4096 + + const generationConfig = { + temperature, + maxOutputTokens: maxTokens, + candidateCount: 1 + } + + if (typeof body.top_p === 'number') { + generationConfig.topP = body.top_p + } + if (typeof body.top_k === 'number') { + generationConfig.topK = body.top_k + } + + // 使用前面已经计算好的 canEnableThinking 结果 + if (vendor === 'antigravity' && body?.thinking?.type === 'enabled') { + const budgetRaw = Number(body.thinking.budget_tokens) + if (Number.isFinite(budgetRaw)) { + if (canEnableThinking) { + generationConfig.thinkingConfig = { + thinkingBudget: Math.trunc(budgetRaw), + include_thoughts: true + } + } else { + logger.warn( + '⚠️ Antigravity thinking request dropped: last assistant message lacks usable thinking block', + { model: baseModel } + ) + } + } + } + + const geminiRequestBody = { + contents, + generationConfig + } + + // antigravity: 前置注入系统提示词 + if (vendor === 'antigravity') { + const allParts = [{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION_PREFIX }, ...systemParts] + geminiRequestBody.systemInstruction = { role: 'user', parts: allParts } + } else if (systemParts.length > 0) { + geminiRequestBody.systemInstruction = { parts: systemParts } + } + + const geminiTools = convertAnthropicToolsToGeminiTools(body.tools, { vendor }) + if (geminiTools) { + geminiRequestBody.tools = geminiTools + } + + const toolConfig = convertAnthropicToolChoiceToGeminiToolConfig(body.tool_choice) + if (toolConfig) { + geminiRequestBody.toolConfig = toolConfig + } else if (geminiTools) { + // Anthropic 的默认语义是 tools 存在且未设置 tool_choice 时为 auto。 + // Gemini/Antigravity 的 function calling 默认可能不会启用,因此显式设置为 AUTO,避免“永远不产出 tool_use”。 + geminiRequestBody.toolConfig = { functionCallingConfig: { mode: 'AUTO' } } + } + + return { model: baseModel, request: geminiRequestBody } +} + +// ============================================================================ +// 辅助函数:Gemini 响应解析 +// ============================================================================ + +/** + * 从 Gemini 响应中提取文本内容 + * @param {Object} payload - Gemini 响应 payload + * @param {boolean} includeThought - 是否包含 thinking 文本 + * @returns {string} 提取的文本 + */ +function extractGeminiText(payload, { includeThought = false } = {}) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return '' + } + return parts + .filter( + (part) => typeof part?.text === 'string' && part.text && (includeThought || !part.thought) + ) + .map((part) => part.text) + .filter(Boolean) + .join('') +} + +/** + * 从 Gemini 响应中提取 thinking 文本内容 + */ +function extractGeminiThoughtText(payload) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return '' + } + return parts + .filter((part) => part?.thought && typeof part?.text === 'string' && part.text) + .map((part) => part.text) + .filter(Boolean) + .join('') +} + +/** + * 从 Gemini 响应中提取 thinking signature + * 用于在下一轮对话中传回给 Antigravity + */ +function extractGeminiThoughtSignature(payload) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return '' + } + + const resolveSignature = (part) => { + if (!part) { + return '' + } + return part.thoughtSignature || part.thought_signature || part.signature || '' + } + + // 优先:functionCall part 上的 signature(上游可能把签名挂在工具调用 part 上) + for (const part of parts) { + if (!part?.functionCall?.name) { + continue + } + const signature = resolveSignature(part) + if (signature) { + return signature + } + } + + // 回退:thought part 上的 signature + for (const part of parts) { + if (!part?.thought) { + continue + } + const signature = resolveSignature(part) + if (signature) { + return signature + } + } + return '' +} + +/** + * 解析 Gemini 响应的 token 使用情况 + * 计算输出 token 数(包括 candidate + thought tokens) + */ +function resolveUsageOutputTokens(usageMetadata) { + if (!usageMetadata || typeof usageMetadata !== 'object') { + return 0 + } + const promptTokens = usageMetadata.promptTokenCount || 0 + const candidateTokens = usageMetadata.candidatesTokenCount || 0 + const thoughtTokens = usageMetadata.thoughtsTokenCount || 0 + const totalTokens = usageMetadata.totalTokenCount || 0 + + let outputTokens = candidateTokens + thoughtTokens + if (outputTokens === 0 && totalTokens > 0) { + outputTokens = totalTokens - promptTokens + if (outputTokens < 0) { + outputTokens = 0 + } + } + return outputTokens +} + +/** + * 检查环境变量是否启用 + * 支持 true/1/yes/on 等值 + */ +function isEnvEnabled(value) { + if (!value) { + return false + } + const normalized = String(value).trim().toLowerCase() + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on' +} + +/** + * 从文本中提取 Write 工具调用 + * 处理模型在文本中输出 "Write: " 格式的情况 + * 这是一个兜底机制,用于处理 function calling 失败的情况 + */ +function tryExtractWriteToolFromText(text, fallbackCwd) { + if (!text || typeof text !== 'string') { + return null + } + + const lines = text.split(/\r?\n/) + const index = lines.findIndex((line) => /^\s*Write\s*:\s*/i.test(line)) + if (index < 0) { + return null + } + + const header = lines[index] + const rawPath = header.replace(/^\s*Write\s*:\s*/i, '').trim() + if (!rawPath) { + return null + } + + const content = lines.slice(index + 1).join('\n') + const prefixText = lines.slice(0, index).join('\n').trim() + + // Claude Code 的 Write 工具要求绝对路径。若模型给的是相对路径,仅在本地运行代理时可用; + // 这里提供一个可选回退:使用服务端 cwd 解析。 + let filePath = rawPath + if (!path.isAbsolute(filePath) && fallbackCwd) { + filePath = path.resolve(fallbackCwd, filePath) + } + + return { + prefixText: prefixText || '', + tool: { + name: 'Write', + input: { + file_path: filePath, + content: content || '' + } + } + } +} + +function mapGeminiFinishReasonToAnthropicStopReason(finishReason) { + const normalized = (finishReason || '').toString().toUpperCase() + if (normalized === 'MAX_TOKENS') { + return 'max_tokens' + } + return 'end_turn' +} + +/** + * 生成工具调用 ID + * 使用 toolu_ 前缀 + 随机字符串 + */ +function buildToolUseId() { + return `toolu_${crypto.randomBytes(10).toString('hex')}` +} + +/** + * 稳定的 JSON 序列化(键按字母顺序排列) + * 用于生成可比较的 JSON 字符串 + */ +function stableJsonStringify(value) { + if (value === null || value === undefined) { + return 'null' + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableJsonStringify(item)).join(',')}]` + } + if (typeof value === 'object') { + const keys = Object.keys(value).sort() + const pairs = keys.map((key) => `${JSON.stringify(key)}:${stableJsonStringify(value[key])}`) + return `{${pairs.join(',')}}` + } + return JSON.stringify(value) +} + +/** + * 从 Gemini 响应中提取 parts 数组 + */ +function extractGeminiParts(payload) { + const candidate = payload?.candidates?.[0] + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + return [] + } + return parts +} + +// ============================================================================ +// 核心函数:Gemini 响应转换为 Anthropic 格式 +// ============================================================================ + +/** + * 将 Gemini 响应转换为 Anthropic content blocks + * + * 处理的内容类型: + * - text: 纯文本 → { type: "text", text } + * - thought: 思考过程 → { type: "thinking", thinking, signature } + * - functionCall: 工具调用 → { type: "tool_use", id, name, input } + * + * 注意:thinking blocks 会被调整到数组最前面(符合 Anthropic 规范) + */ +function convertGeminiPayloadToAnthropicContent(payload) { + const parts = extractGeminiParts(payload) + const content = [] + let currentText = '' + + const flushText = () => { + if (!currentText) { + return + } + content.push({ type: 'text', text: currentText }) + currentText = '' + } + + const pushThinkingBlock = (thinkingText, signature) => { + const normalizedThinking = typeof thinkingText === 'string' ? thinkingText : '' + const normalizedSignature = typeof signature === 'string' ? signature : '' + if (!normalizedThinking && !normalizedSignature) { + return + } + const block = { type: 'thinking', thinking: normalizedThinking } + if (normalizedSignature) { + block.signature = normalizedSignature + } + content.push(block) + } + + const resolveSignature = (part) => { + if (!part) { + return '' + } + return part.thoughtSignature || part.thought_signature || part.signature || '' + } + + for (const part of parts) { + const isThought = part?.thought === true + if (isThought) { + flushText() + pushThinkingBlock(typeof part?.text === 'string' ? part.text : '', resolveSignature(part)) + continue + } + + if (typeof part?.text === 'string' && part.text) { + currentText += part.text + continue + } + + const functionCall = part?.functionCall + if (functionCall?.name) { + flushText() + + // 上游可能把 thought signature 挂在 functionCall part 上:需要原样传回给客户端, + // 以便下一轮对话能携带 signature。 + const functionCallSignature = resolveSignature(part) + if (functionCallSignature) { + pushThinkingBlock('', functionCallSignature) + } + + const toolUseId = + typeof functionCall.id === 'string' && functionCall.id ? functionCall.id : buildToolUseId() + content.push({ + type: 'tool_use', + id: toolUseId, + name: functionCall.name, + input: functionCall.args || {} + }) + } + } + + flushText() + const thinkingBlocks = content.filter( + (b) => b && (b.type === 'thinking' || b.type === 'redacted_thinking') + ) + if (thinkingBlocks.length > 0) { + const firstType = content?.[0]?.type + if (firstType !== 'thinking' && firstType !== 'redacted_thinking') { + const others = content.filter( + (b) => b && b.type !== 'thinking' && b.type !== 'redacted_thinking' + ) + return [...thinkingBlocks, ...others] + } + } + return content +} + +/** + * 构建 Anthropic 格式的错误响应 + */ +function buildAnthropicError(message) { + return { + type: 'error', + error: { + type: 'api_error', + message: message || 'Upstream error' + } + } +} + +/** + * 判断是否应该在无工具模式下重试 + * 当上游报告 JSON Schema 或工具相关错误时,移除工具定义重试 + */ +function shouldRetryWithoutTools(sanitizedError) { + const message = (sanitizedError?.upstreamMessage || sanitizedError?.message || '').toLowerCase() + if (!message) { + return false + } + return ( + message.includes('json schema is invalid') || + message.includes('invalid json payload') || + message.includes('tools.') || + message.includes('function_declarations') + ) +} + +/** + * 从请求中移除工具定义(用于重试) + */ +function stripToolsFromRequest(requestData) { + if (!requestData || !requestData.request) { + return requestData + } + const nextRequest = { + ...requestData, + request: { + ...requestData.request + } + } + delete nextRequest.request.tools + delete nextRequest.request.toolConfig + return nextRequest +} + +/** + * 写入 Anthropic SSE 事件 + * 将事件和数据以 SSE 格式发送给客户端 + */ +function writeAnthropicSseEvent(res, event, data) { + res.write(`event: ${event}\n`) + res.write(`data: ${JSON.stringify(data)}\n\n`) +} + +// ============================================================================ +// 调试和跟踪函数 +// ============================================================================ + +/** + * 记录工具定义到文件(调试用) + * 只在环境变量 ANTHROPIC_DEBUG_TOOLS_DUMP 启用时生效 + */ +function dumpToolsPayload({ vendor, model, tools, toolChoice }) { + if (!isEnvEnabled(process.env[TOOLS_DUMP_ENV])) { + return + } + if (!Array.isArray(tools) || tools.length === 0) { + return + } + if (vendor !== 'antigravity') { + return + } + + const filePath = path.join(getProjectRoot(), TOOLS_DUMP_FILENAME) + const payload = { + timestamp: new Date().toISOString(), + vendor, + model, + tool_choice: toolChoice || null, + tools + } + + try { + fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, 'utf8') + logger.warn(`🧾 Tools payload dumped to ${filePath}`) + } catch (error) { + logger.warn('Failed to dump tools payload:', error.message) + } +} + +/** + * 更新速率限制计数器 + * 跟踪 token 使用量和成本 + */ +async function applyRateLimitTracking( + rateLimitInfo, + usageSummary, + model, + context = '', + keyId = null, + preCalculatedCost = null +) { + if (!rateLimitInfo) { + return + } + + const label = context ? ` (${context})` : '' + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + rateLimitInfo, + usageSummary, + model, + keyId, + 'gemini', + preCalculatedCost + ) + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${label}: +${totalTokens} tokens`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${label}: +$${totalCost.toFixed(6)}`) + } + } catch (error) { + logger.error(`❌ Failed to update rate limit counters${label}:`, error) + } +} + +// ============================================================================ +// 主入口函数:API 请求处理 +// ============================================================================ + +/** + * 处理 Anthropic 格式的请求并转发到 Gemini/Antigravity + * + * 这是整个模块的主入口,完整流程: + * 1. 验证 vendor 支持 + * 2. 选择可用的 Gemini 账户 + * 3. 模型回退匹配(如果请求的模型不可用) + * 4. 构建 Gemini 请求 (buildGeminiRequestFromAnthropic) + * 5. 发送请求(流式或非流式) + * 6. 处理响应并转换为 Anthropic 格式 + * 7. 如果工具相关错误,尝试移除工具重试 + * 8. 返回结果给客户端 + * + * @param {Object} req - Express 请求对象 + * @param {Object} res - Express 响应对象 + * @param {Object} options - 包含 vendor 和 baseModel + */ +async function handleAnthropicMessagesToGemini(req, res, { vendor, baseModel }) { + if (!SUPPORTED_VENDORS.has(vendor)) { + return res.status(400).json(buildAnthropicError(`Unsupported vendor: ${vendor}`)) + } + + dumpToolsPayload({ + vendor, + model: baseModel, + tools: req.body?.tools || null, + toolChoice: req.body?.tool_choice || null + }) + + const pickFallbackModel = (account, requestedModel) => { + const supportedModels = Array.isArray(account?.supportedModels) ? account.supportedModels : [] + if (supportedModels.length === 0) { + return requestedModel + } + + const normalize = (m) => String(m || '').replace(/^models\//, '') + const requested = normalize(requestedModel) + const normalizedSupported = supportedModels.map(normalize) + + if (normalizedSupported.includes(requested)) { + return requestedModel + } + + // Claude Code 常见探测模型:优先回退到 Opus 4.5(如果账号支持) + const preferred = ['claude-opus-4-5', 'claude-sonnet-4-5-thinking', 'claude-sonnet-4-5'] + for (const candidate of preferred) { + if (normalizedSupported.includes(candidate)) { + return candidate + } + } + + return normalizedSupported[0] + } + + const isStream = req.body?.stream === true + const sessionHash = sessionHelper.generateSessionHash(req.body) + const upstreamSessionId = sessionHash || req.apiKey?.id || null + + let accountSelection + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + baseModel, + { oauthProvider: vendor } + ) + } catch (error) { + logger.error('Failed to select Gemini account (via /v1/messages):', error) + return res + .status(503) + .json(buildAnthropicError(error.message || 'No available Gemini accounts')) + } + + let { accountId } = accountSelection + const { accountType } = accountSelection + if (accountType !== 'gemini') { + return res + .status(400) + .json(buildAnthropicError('Only Gemini OAuth accounts are supported for this vendor')) + } + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(503).json(buildAnthropicError('Gemini OAuth account not found')) + } + + await geminiAccountService.markAccountUsed(account.id) + + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + const client = await geminiAccountService.getOauthClient( + account.accessToken, + account.refreshToken, + proxyConfig, + account.oauthProvider + ) + + let { projectId } = account + if (vendor === 'antigravity') { + projectId = ensureAntigravityProjectId(account) + if (!account.projectId && account.tempProjectId !== projectId) { + await geminiAccountService.updateTempProjectId(account.id, projectId) + account.tempProjectId = projectId + } + } + + const effectiveModel = pickFallbackModel(account, baseModel) + if (effectiveModel !== baseModel) { + logger.warn('⚠️ Requested model not supported by account, falling back', { + requestedModel: baseModel, + effectiveModel, + vendor, + accountId + }) + } + + let requestData = buildGeminiRequestFromAnthropic(req.body, effectiveModel, { + vendor, + sessionId: sessionHash + }) + + // Antigravity 上游对 function calling 的启用/校验更严格:参考实现普遍使用 VALIDATED。 + // 这里仅在 tools 存在且未显式禁用(tool_choice=none)时应用,避免破坏原始语义。 + if ( + vendor === 'antigravity' && + Array.isArray(requestData?.request?.tools) && + requestData.request.tools.length > 0 + ) { + const existingCfg = requestData?.request?.toolConfig?.functionCallingConfig || null + const mode = existingCfg?.mode + if (mode !== 'NONE') { + const nextCfg = { ...(existingCfg || {}), mode: 'VALIDATED' } + requestData = { + ...requestData, + request: { + ...requestData.request, + toolConfig: { functionCallingConfig: nextCfg } + } + } + } + } + + // Antigravity 默认启用 tools(对齐 CLIProxyAPI)。若上游拒绝 schema,会在下方自动重试去掉 tools/toolConfig。 + + const abortController = new AbortController() + req.on('close', () => { + if (!abortController.signal.aborted) { + abortController.abort() + } + }) + + if (!isStream) { + try { + const attemptRequest = async (payload) => { + if (vendor === 'antigravity') { + return await geminiAccountService.generateContentAntigravity( + client, + payload, + null, + projectId, + upstreamSessionId, + proxyConfig + ) + } + return await geminiAccountService.generateContent( + client, + payload, + null, + projectId, + upstreamSessionId, + proxyConfig + ) + } + + let rawResponse + try { + rawResponse = await attemptRequest(requestData) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + if (shouldRetryWithoutTools(sanitized) && requestData.request?.tools) { + logger.warn('⚠️ Tool schema rejected by upstream, retrying without tools', { + vendor, + accountId + }) + rawResponse = await attemptRequest(stripToolsFromRequest(requestData)) + } else if ( + // [429 账户切换] 检测到 Antigravity 配额耗尽错误时,尝试切换账户重试 + vendor === 'antigravity' && + sanitized.statusCode === 429 && + (sanitized.message?.toLowerCase()?.includes('exhausted') || + sanitized.upstreamMessage?.toLowerCase()?.includes('exhausted') || + sanitized.message?.toLowerCase()?.includes('capacity')) + ) { + logger.warn( + '⚠️ Antigravity 429 quota exhausted (non-stream), switching account and retrying', + { + vendor, + accountId, + model: effectiveModel + } + ) + // 删除当前会话映射,让调度器选择其他账户 + if (sessionHash) { + await unifiedGeminiScheduler._deleteSessionMapping(sessionHash) + } + // 重新选择账户 + try { + const newAccountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + effectiveModel, + { oauthProvider: vendor } + ) + const newAccountId = newAccountSelection.accountId + const newClient = await geminiAccountService.getGeminiClient(newAccountId) + if (!newClient) { + throw new Error('Failed to get new Gemini client for retry') + } + logger.info( + `🔄 Retrying non-stream with new account: ${newAccountId} (was: ${accountId})` + ) + // 用新账户的 client 重试 + rawResponse = + vendor === 'antigravity' + ? await geminiAccountService.generateContentAntigravity( + newClient, + requestData, + null, + projectId, + upstreamSessionId, + proxyConfig + ) + : await geminiAccountService.generateContent( + newClient, + requestData, + null, + projectId, + upstreamSessionId, + proxyConfig + ) + // 更新 accountId 以便后续使用记录 + accountId = newAccountId + } catch (retryError) { + logger.error('❌ Failed to retry non-stream with new account:', retryError) + throw error // 抛出原始错误 + } + } else { + throw error + } + } + + const payload = rawResponse?.response || rawResponse + let content = convertGeminiPayloadToAnthropicContent(payload) + let hasToolUse = content.some((block) => block.type === 'tool_use') + + // Antigravity 某些模型可能不会返回 functionCall(导致永远没有 tool_use),但会把 “Write: xxx” 以纯文本形式输出。 + // 可选回退:解析该文本并合成标准 tool_use,交给 claude-cli 去执行。 + if (!hasToolUse && isEnvEnabled(process.env[TEXT_TOOL_FALLBACK_ENV])) { + const fullText = extractGeminiText(payload) + const extracted = tryExtractWriteToolFromText(fullText, process.cwd()) + if (extracted?.tool) { + const toolUseId = buildToolUseId() + const blocks = [] + if (extracted.prefixText) { + blocks.push({ type: 'text', text: extracted.prefixText }) + } + blocks.push({ + type: 'tool_use', + id: toolUseId, + name: extracted.tool.name, + input: extracted.tool.input + }) + content = blocks + hasToolUse = true + logger.warn('⚠️ Synthesized tool_use from plain text Write directive', { + vendor, + accountId, + tool: extracted.tool.name + }) + } + } + + const usageMetadata = payload?.usageMetadata || {} + const inputTokens = usageMetadata.promptTokenCount || 0 + const outputTokens = resolveUsageOutputTokens(usageMetadata) + const finishReason = payload?.candidates?.[0]?.finishReason + + const stopReason = hasToolUse + ? 'tool_use' + : mapGeminiFinishReasonToAnthropicStopReason(finishReason) + + if (req.apiKey?.id && (inputTokens > 0 || outputTokens > 0)) { + const bridgeCosts = await apiKeyService.recordUsage( + req.apiKey.id, + inputTokens, + outputTokens, + 0, + 0, + effectiveModel, + accountId, + 'gemini', + null, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: false, + statusCode: 200 + }) + ) + await applyRateLimitTracking( + req.rateLimitInfo, + { inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 }, + effectiveModel, + 'anthropic-messages', + req.apiKey?.id, + bridgeCosts + ) + } + + const responseBody = { + id: `msg_${crypto.randomBytes(12).toString('hex')}`, + type: 'message', + role: 'assistant', + model: req.body.model || effectiveModel, + content, + stop_reason: stopReason, + stop_sequence: null, + usage: { + input_tokens: inputTokens, + output_tokens: outputTokens + } + } + + dumpAnthropicNonStreamResponse(req, 200, responseBody, { + vendor, + accountId, + effectiveModel, + forcedVendor: vendor + }) + + return res.status(200).json(responseBody) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + logger.error('Upstream Gemini error (via /v1/messages):', sanitized) + dumpAnthropicNonStreamResponse( + req, + sanitized.statusCode || 502, + buildAnthropicError(sanitized.upstreamMessage || sanitized.message), + { vendor, accountId, effectiveModel, forcedVendor: vendor, upstreamError: sanitized } + ) + return res + .status(sanitized.statusCode || 502) + .json(buildAnthropicError(sanitized.upstreamMessage || sanitized.message)) + } + } + + const messageId = `msg_${crypto.randomBytes(12).toString('hex')}` + const responseModel = req.body.model || effectiveModel + + try { + const startStream = async (payload) => { + if (vendor === 'antigravity') { + return await geminiAccountService.generateContentStreamAntigravity( + client, + payload, + null, + projectId, + upstreamSessionId, + abortController.signal, + proxyConfig + ) + } + return await geminiAccountService.generateContentStream( + client, + payload, + null, + projectId, + upstreamSessionId, + abortController.signal, + proxyConfig + ) + } + + let streamResponse + try { + streamResponse = await startStream(requestData) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + if (shouldRetryWithoutTools(sanitized) && requestData.request?.tools) { + logger.warn('⚠️ Tool schema rejected by upstream, retrying stream without tools', { + vendor, + accountId + }) + streamResponse = await startStream(stripToolsFromRequest(requestData)) + } else if ( + // [429 账户切换] 检测到 Antigravity 配额耗尽错误时,尝试切换账户重试 + vendor === 'antigravity' && + sanitized.statusCode === 429 && + (sanitized.message?.toLowerCase()?.includes('exhausted') || + sanitized.upstreamMessage?.toLowerCase()?.includes('exhausted') || + sanitized.message?.toLowerCase()?.includes('capacity')) + ) { + logger.warn('⚠️ Antigravity 429 quota exhausted, switching account and retrying', { + vendor, + accountId, + model: effectiveModel + }) + // 删除当前会话映射,让调度器选择其他账户 + if (sessionHash) { + await unifiedGeminiScheduler._deleteSessionMapping(sessionHash) + } + // 重新选择账户 + try { + const newAccountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + effectiveModel, + { oauthProvider: vendor } + ) + const newAccountId = newAccountSelection.accountId + const newClient = await geminiAccountService.getGeminiClient(newAccountId) + if (!newClient) { + throw new Error('Failed to get new Gemini client for retry') + } + logger.info(`🔄 Retrying with new account: ${newAccountId} (was: ${accountId})`) + // 用新账户的 client 重试 + streamResponse = + vendor === 'antigravity' + ? await geminiAccountService.generateContentStreamAntigravity( + newClient, + requestData, + null, + projectId, + upstreamSessionId, + abortController.signal, + proxyConfig + ) + : await geminiAccountService.generateContentStream( + newClient, + requestData, + null, + projectId, + upstreamSessionId, + abortController.signal, + proxyConfig + ) + // 更新 accountId 以便后续使用记录 + accountId = newAccountId + } catch (retryError) { + logger.error('❌ Failed to retry with new account:', retryError) + throw error // 抛出原始错误 + } + } else { + throw error + } + } + + // 仅在上游流成功建立后再开始向客户端发送 SSE。 + // 这样如果上游在握手阶段直接返回 4xx/5xx(例如 schema 400 或配额 429), + // 我们可以返回真实 HTTP 状态码,而不是先 200 再在 SSE 内发 error 事件。 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + writeAnthropicSseEvent(res, 'message_start', { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + model: responseModel, + content: [], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0 + } + } + }) + + const isAntigravityVendor = vendor === 'antigravity' + const wantsThinkingBlockFirst = + isAntigravityVendor && + requestData?.request?.generationConfig?.thinkingConfig?.include_thoughts === true + + // ======================================================================== + // [大东的 2.0 补丁 - 修复版] 活跃度看门狗 (Watchdog) + // ======================================================================== + let activityTimeout = null + const STREAM_ACTIVITY_TIMEOUT_MS = 90000 // 90秒无数据视为卡死 + + const resetActivityTimeout = () => { + if (activityTimeout) { + clearTimeout(activityTimeout) + } + activityTimeout = setTimeout(() => { + if (finished) { + return + } + + // 🛑【关键修改】先锁门!防止 abort() 触发的 onError 再次写入 res + finished = true + + logger.warn('⚠️ Upstream stream zombie detected (no data for 45s). Forcing termination.', { + requestId: req.requestId + }) + + if (!abortController.signal.aborted) { + abortController.abort() + } + + writeAnthropicSseEvent(res, 'error', { + type: 'error', + error: { + type: 'overloaded_error', + message: 'Upstream stream timed out (zombie connection). Please try again.' + } + }) + res.end() + }, STREAM_ACTIVITY_TIMEOUT_MS) + } + + // 🔥【这里!】一定要加这句来启动它! + resetActivityTimeout() + // ======================================================================== + + let buffer = '' + let emittedText = '' + let emittedThinking = '' + let emittedThoughtSignature = '' + let finished = false + let usageMetadata = null + let finishReason = null + let emittedAnyToolUse = false + let sseEventIndex = 0 + const emittedToolCallKeys = new Set() + const emittedToolUseNames = new Set() + const pendingToolCallsById = new Map() + + let currentIndex = wantsThinkingBlockFirst ? 0 : -1 + let currentBlockType = wantsThinkingBlockFirst ? 'thinking' : null + + const startTextBlock = (index) => { + writeAnthropicSseEvent(res, 'content_block_start', { + type: 'content_block_start', + index, + content_block: { type: 'text', text: '' } + }) + } + + const stopCurrentBlock = () => { + writeAnthropicSseEvent(res, 'content_block_stop', { + type: 'content_block_stop', + index: currentIndex + }) + } + + const startThinkingBlock = (index) => { + writeAnthropicSseEvent(res, 'content_block_start', { + type: 'content_block_start', + index, + content_block: { type: 'thinking', thinking: '' } + }) + } + + if (wantsThinkingBlockFirst) { + startThinkingBlock(0) + } + + const switchBlockType = (nextType) => { + if (currentBlockType === nextType) { + return + } + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + currentIndex += 1 + currentBlockType = nextType + if (nextType === 'text') { + startTextBlock(currentIndex) + } else if (nextType === 'thinking') { + startThinkingBlock(currentIndex) + } + } + + const canStartThinkingBlock = (_hasSignature = false) => { + // Antigravity 特殊处理:某些情况下不应启动 thinking block + if (isAntigravityVendor) { + // 如果 wantsThinkingBlockFirst 且已发送过工具调用,不应再启动 thinking + if (wantsThinkingBlockFirst && emittedAnyToolUse) { + return false + } + // [移除规则2] 签名可能在后续 chunk 中到达,不应提前阻止 thinking 启动 + } + if (currentIndex < 0) { + return true + } + if (currentBlockType === 'thinking') { + return true + } + if (emittedThinking || emittedThoughtSignature) { + return true + } + return false + } + + const emitToolUseBlock = (name, args, id = null) => { + const toolUseId = typeof id === 'string' && id ? id : buildToolUseId() + const jsonArgs = stableJsonStringify(args || {}) + + if (name) { + emittedToolUseNames.add(name) + } + currentIndex += 1 + const toolIndex = currentIndex + + writeAnthropicSseEvent(res, 'content_block_start', { + type: 'content_block_start', + index: toolIndex, + content_block: { type: 'tool_use', id: toolUseId, name, input: {} } + }) + + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: toolIndex, + delta: { type: 'input_json_delta', partial_json: jsonArgs } + }) + + writeAnthropicSseEvent(res, 'content_block_stop', { + type: 'content_block_stop', + index: toolIndex + }) + emittedAnyToolUse = true + currentBlockType = null + } + + const resolveFunctionCallArgs = (functionCall) => { + if (!functionCall || typeof functionCall !== 'object') { + return { args: null, json: '', canContinue: false } + } + const canContinue = + functionCall.willContinue === true || + functionCall.will_continue === true || + functionCall.continue === true || + functionCall.willContinue === 'true' || + functionCall.will_continue === 'true' + + const raw = + functionCall.args !== undefined + ? functionCall.args + : functionCall.partialArgs !== undefined + ? functionCall.partialArgs + : functionCall.partial_args !== undefined + ? functionCall.partial_args + : functionCall.argsJson !== undefined + ? functionCall.argsJson + : functionCall.args_json !== undefined + ? functionCall.args_json + : '' + + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + return { args: raw, json: '', canContinue } + } + + const json = + typeof raw === 'string' ? raw : raw === null || raw === undefined ? '' : String(raw) + if (!json) { + return { args: null, json: '', canContinue } + } + + try { + const parsed = JSON.parse(json) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { args: parsed, json: '', canContinue } + } + } catch (_) { + // ignore: treat as partial JSON string + } + + return { args: null, json, canContinue } + } + + const flushPendingToolCallById = (id, { force = false } = {}) => { + const pending = pendingToolCallsById.get(id) + if (!pending) { + return + } + if (!pending.name) { + return + } + if (!pending.args && pending.argsJson) { + try { + const parsed = JSON.parse(pending.argsJson) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + pending.args = parsed + pending.argsJson = '' + } + } catch (_) { + // keep buffering + } + } + if (!pending.args) { + if (!force) { + return + } + pending.args = {} + } + + const toolKey = `id:${id}` + if (emittedToolCallKeys.has(toolKey)) { + pendingToolCallsById.delete(id) + return + } + emittedToolCallKeys.add(toolKey) + + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + currentBlockType = 'tool_use' + emitToolUseBlock(pending.name, pending.args, id) + pendingToolCallsById.delete(id) + } + + const finalize = async () => { + if (finished) { + return + } + finished = true + + // 若存在未完成的工具调用(例如 args 分段但上游提前结束),尽力 flush,避免客户端卡死。 + for (const id of pendingToolCallsById.keys()) { + flushPendingToolCallById(id, { force: true }) + } + + // 上游可能在没有 finishReason 的情况下静默结束(例如 browser_snapshot 输出过大被截断)。 + // 这种情况下主动向客户端发送错误,避免长时间挂起。 + if (!finishReason) { + logger.warn( + '⚠️ Upstream stream ended without finishReason; sending overloaded_error to client', + { + requestId: req.requestId, + model: effectiveModel, + hasToolCalls: emittedAnyToolUse + } + ) + + writeAnthropicSseEvent(res, 'error', { + type: 'error', + error: { + type: 'overloaded_error', + message: + 'Upstream connection interrupted unexpectedly (missing finish reason). Please retry.' + } + }) + + // 记录摘要便于排查 + dumpAnthropicStreamSummary(req, { + vendor, + accountId, + effectiveModel, + responseModel, + stop_reason: 'error', + tool_use_names: Array.from(emittedToolUseNames).filter(Boolean), + text_preview: emittedText ? emittedText.slice(0, 800) : '', + usage: { input_tokens: 0, output_tokens: 0 } + }) + + if (vendor === 'antigravity') { + dumpAntigravityStreamSummary({ + requestId: req.requestId, + model: effectiveModel, + totalEvents: sseEventIndex, + finishReason: null, + hasThinking: Boolean(emittedThinking || emittedThoughtSignature), + hasToolCalls: emittedAnyToolUse, + toolCallNames: Array.from(emittedToolUseNames).filter(Boolean), + usage: { input_tokens: 0, output_tokens: 0 }, + textPreview: emittedText ? emittedText.slice(0, 500) : '', + error: 'missing_finish_reason' + }).catch(() => {}) + } + + res.end() + return + } + + const inputTokens = usageMetadata?.promptTokenCount || 0 + const outputTokens = resolveUsageOutputTokens(usageMetadata) + + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + + writeAnthropicSseEvent(res, 'message_delta', { + type: 'message_delta', + delta: { + stop_reason: emittedAnyToolUse + ? 'tool_use' + : mapGeminiFinishReasonToAnthropicStopReason(finishReason), + stop_sequence: null + }, + usage: { + output_tokens: outputTokens + } + }) + + writeAnthropicSseEvent(res, 'message_stop', { type: 'message_stop' }) + res.end() + + dumpAnthropicStreamSummary(req, { + vendor, + accountId, + effectiveModel, + responseModel, + stop_reason: emittedAnyToolUse + ? 'tool_use' + : mapGeminiFinishReasonToAnthropicStopReason(finishReason), + tool_use_names: Array.from(emittedToolUseNames).filter(Boolean), + text_preview: emittedText ? emittedText.slice(0, 800) : '', + usage: { input_tokens: inputTokens, output_tokens: outputTokens } + }) + + // 记录 Antigravity 上游流摘要用于调试 + if (vendor === 'antigravity') { + dumpAntigravityStreamSummary({ + requestId: req.requestId, + model: effectiveModel, + totalEvents: sseEventIndex, + finishReason, + hasThinking: Boolean(emittedThinking || emittedThoughtSignature), + hasToolCalls: emittedAnyToolUse, + toolCallNames: Array.from(emittedToolUseNames).filter(Boolean), + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + textPreview: emittedText ? emittedText.slice(0, 500) : '' + }).catch(() => {}) + } + + if (req.apiKey?.id && (inputTokens > 0 || outputTokens > 0)) { + const bridgeStreamCosts = await apiKeyService.recordUsage( + req.apiKey.id, + inputTokens, + outputTokens, + 0, + 0, + effectiveModel, + accountId, + 'gemini', + null, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: true, + statusCode: res.statusCode + }) + ) + await applyRateLimitTracking( + req.rateLimitInfo, + { inputTokens, outputTokens, cacheCreateTokens: 0, cacheReadTokens: 0 }, + effectiveModel, + 'anthropic-messages-stream', + req.apiKey?.id, + bridgeStreamCosts + ) + } + } + + streamResponse.on('data', (chunk) => { + resetActivityTimeout() // <--- 【新增】收到数据了,重置倒计时! + + if (finished) { + return + } + + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) { + continue + } + + const parsed = parseSSELine(line) + if (parsed.type === 'control') { + continue + } + if (parsed.type !== 'data' || !parsed.data) { + continue + } + + const payload = parsed.data?.response || parsed.data + + // 记录上游 SSE 事件用于调试 + if (vendor === 'antigravity') { + sseEventIndex += 1 + dumpAntigravityStreamEvent({ + requestId: req.requestId, + eventIndex: sseEventIndex, + eventType: parsed.type, + data: payload + }).catch(() => {}) + } + + const { usageMetadata: currentUsageMetadata, candidates } = payload || {} + if (currentUsageMetadata) { + usageMetadata = currentUsageMetadata + } + + const [candidate] = Array.isArray(candidates) ? candidates : [] + const { finishReason: currentFinishReason } = candidate || {} + if (currentFinishReason) { + finishReason = currentFinishReason + } + + const parts = extractGeminiParts(payload) + const rawThoughtSignature = extractGeminiThoughtSignature(payload) + // Antigravity 专用净化:确保签名格式符合 API 要求 + const thoughtSignature = isAntigravityVendor + ? sanitizeThoughtSignatureForAntigravity(rawThoughtSignature) + : rawThoughtSignature + const fullThoughtForToolOrdering = extractGeminiThoughtText(payload) + + if (wantsThinkingBlockFirst) { + // 关键:确保 thinking/signature 在 tool_use 之前输出,避免出现 tool_use 后紧跟 thinking(signature) + // 导致下一轮请求的 thinking 校验/工具调用校验失败(Antigravity 会返回 400)。 + if (thoughtSignature && canStartThinkingBlock()) { + let delta = '' + if (thoughtSignature.startsWith(emittedThoughtSignature)) { + delta = thoughtSignature.slice(emittedThoughtSignature.length) + } else if (thoughtSignature !== emittedThoughtSignature) { + delta = thoughtSignature + } + if (delta) { + switchBlockType('thinking') + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'signature_delta', signature: delta } + }) + emittedThoughtSignature = thoughtSignature + } + } + + if (fullThoughtForToolOrdering && canStartThinkingBlock()) { + let delta = '' + if (fullThoughtForToolOrdering.startsWith(emittedThinking)) { + delta = fullThoughtForToolOrdering.slice(emittedThinking.length) + } else { + delta = fullThoughtForToolOrdering + } + if (delta) { + switchBlockType('thinking') + emittedThinking = fullThoughtForToolOrdering + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'thinking_delta', thinking: delta } + }) + } + } + } + for (const part of parts) { + const functionCall = part?.functionCall + if (!functionCall?.name) { + continue + } + + const id = typeof functionCall.id === 'string' && functionCall.id ? functionCall.id : null + const { args, json, canContinue } = resolveFunctionCallArgs(functionCall) + + // 若没有 id(无法聚合多段 args),只在拿到可用 args 时才 emit + if (!id) { + const finalArgs = args || {} + const toolKey = `${functionCall.name}:${stableJsonStringify(finalArgs)}` + if (emittedToolCallKeys.has(toolKey)) { + continue + } + emittedToolCallKeys.add(toolKey) + + if (currentBlockType === 'text' || currentBlockType === 'thinking') { + stopCurrentBlock() + } + currentBlockType = 'tool_use' + emitToolUseBlock(functionCall.name, finalArgs, null) + continue + } + + const pending = pendingToolCallsById.get(id) || { + id, + name: functionCall.name, + args: null, + argsJson: '' + } + pending.name = functionCall.name + if (args) { + pending.args = args + pending.argsJson = '' + } else if (json) { + pending.argsJson += json + } + pendingToolCallsById.set(id, pending) + + // 能确定“本次已完整”时再 emit;否则继续等待后续 SSE 事件补全 args。 + if (!canContinue) { + flushPendingToolCallById(id) + } + } + + if (thoughtSignature && canStartThinkingBlock(true)) { + let delta = '' + if (thoughtSignature.startsWith(emittedThoughtSignature)) { + delta = thoughtSignature.slice(emittedThoughtSignature.length) + } else if (thoughtSignature !== emittedThoughtSignature) { + delta = thoughtSignature + } + if (delta) { + switchBlockType('thinking') + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'signature_delta', signature: delta } + }) + emittedThoughtSignature = thoughtSignature + } + } + + const fullThought = extractGeminiThoughtText(payload) + if ( + fullThought && + canStartThinkingBlock(Boolean(thoughtSignature || emittedThoughtSignature)) + ) { + let delta = '' + if (fullThought.startsWith(emittedThinking)) { + delta = fullThought.slice(emittedThinking.length) + } else { + delta = fullThought + } + if (delta) { + switchBlockType('thinking') + emittedThinking = fullThought + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'thinking_delta', thinking: delta } + }) + // [签名缓存] 当 thinking 内容和签名都有时,缓存供后续请求使用 + if (isAntigravityVendor && sessionHash && emittedThoughtSignature) { + signatureCache.cacheSignature(sessionHash, fullThought, emittedThoughtSignature) + } + } + } + + const fullText = extractGeminiText(payload) + if (fullText) { + let delta = '' + if (fullText.startsWith(emittedText)) { + delta = fullText.slice(emittedText.length) + } else { + delta = fullText + } + if (delta) { + switchBlockType('text') + emittedText = fullText + writeAnthropicSseEvent(res, 'content_block_delta', { + type: 'content_block_delta', + index: currentIndex, + delta: { type: 'text_delta', text: delta } + }) + } + } + } + }) + + streamResponse.on('end', () => { + if (activityTimeout) { + clearTimeout(activityTimeout) + } // <--- 【新增】正常结束,取消报警 + + finalize().catch((e) => logger.error('Failed to finalize Anthropic SSE response:', e)) + }) + + streamResponse.on('error', (error) => { + if (activityTimeout) { + clearTimeout(activityTimeout) + } // <--- 【新增】报错了,取消报警 + + if (finished) { + return + } + const sanitized = sanitizeUpstreamError(error) + logger.error('Upstream Gemini stream error (via /v1/messages):', sanitized) + writeAnthropicSseEvent( + res, + 'error', + buildAnthropicError(sanitized.upstreamMessage || sanitized.message) + ) + res.end() + }) + + return undefined + } catch (error) { + // ============================================================ + // [大东修复 3.0] 彻底防止 JSON 循环引用导致服务崩溃 + // ============================================================ + + // 1. 使用 util.inspect 安全地将错误对象转为字符串,不使用 JSON.stringify + const safeErrorDetails = util.inspect(error, { + showHidden: false, + depth: 2, + colors: false, + breakLength: Infinity + }) + + // 2. 打印安全日志,绝对不会崩 + logger.error(`❌ [Critical] Failed to start Gemini stream. 错误详情:\n${safeErrorDetails}`) + + const sanitized = sanitizeUpstreamError(error) + + // 3. 特殊处理 Antigravity 的参数错误 (400),输出详细请求信息便于调试 + if ( + vendor === 'antigravity' && + effectiveModel.includes('claude') && + isInvalidAntigravityArgumentError(sanitized) + ) { + logger.warn('⚠️ Antigravity Claude invalid argument detected', { + requestId: req.requestId, + ...summarizeAntigravityRequestForDebug(requestData), + statusCode: sanitized.statusCode, + upstreamType: sanitized.upstreamType, + upstreamMessage: sanitized.upstreamMessage || sanitized.message + }) + } + + // 4. 确保返回 JSON 响应给客户端 (让客户端知道出错了并重试) + if (!res.headersSent) { + // 记录非流式响应日志 + dumpAnthropicNonStreamResponse( + req, + sanitized.statusCode || 502, + buildAnthropicError(sanitized.upstreamMessage || sanitized.message), + { vendor, accountId, effectiveModel, forcedVendor: vendor, upstreamError: sanitized } + ) + + return res + .status(sanitized.statusCode || 502) + .json(buildAnthropicError(sanitized.upstreamMessage || sanitized.message)) + } + + // 5. 如果头已经发了,走 SSE 发送错误 + writeAnthropicSseEvent( + res, + 'error', + buildAnthropicError(sanitized.upstreamMessage || sanitized.message) + ) + res.end() + return undefined + } +} + +async function handleAnthropicCountTokensToGemini(req, res, { vendor }) { + if (!SUPPORTED_VENDORS.has(vendor)) { + return res.status(400).json(buildAnthropicError(`Unsupported vendor: ${vendor}`)) + } + + const sessionHash = sessionHelper.generateSessionHash(req.body) + + const model = (req.body?.model || '').trim() + if (!model) { + return res.status(400).json(buildAnthropicError('Missing model')) + } + + let accountSelection + try { + accountSelection = await unifiedGeminiScheduler.selectAccountForApiKey( + req.apiKey, + sessionHash, + model, + { oauthProvider: vendor } + ) + } catch (error) { + logger.error('Failed to select Gemini account (count_tokens):', error) + return res + .status(503) + .json(buildAnthropicError(error.message || 'No available Gemini accounts')) + } + + const { accountId, accountType } = accountSelection + if (accountType !== 'gemini') { + return res + .status(400) + .json(buildAnthropicError('Only Gemini OAuth accounts are supported for this vendor')) + } + + const account = await geminiAccountService.getAccount(accountId) + if (!account) { + return res.status(503).json(buildAnthropicError('Gemini OAuth account not found')) + } + + await geminiAccountService.markAccountUsed(account.id) + + let proxyConfig = null + if (account.proxy) { + try { + proxyConfig = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy + } catch (e) { + logger.warn('Failed to parse proxy configuration:', e) + } + } + + const client = await geminiAccountService.getOauthClient( + account.accessToken, + account.refreshToken, + proxyConfig, + account.oauthProvider + ) + + const normalizedMessages = normalizeAnthropicMessages(req.body.messages || [], { vendor }) + const toolUseIdToName = buildToolUseIdToNameMap(normalizedMessages || []) + + let canEnableThinking = false + if (vendor === 'antigravity' && req.body?.thinking?.type === 'enabled') { + const budgetRaw = Number(req.body.thinking.budget_tokens) + if (Number.isFinite(budgetRaw)) { + canEnableThinking = canEnableAntigravityThinking(normalizedMessages) + } + } + + const contents = convertAnthropicMessagesToGeminiContents( + normalizedMessages || [], + toolUseIdToName, + { + vendor, + stripThinking: vendor === 'antigravity' && !canEnableThinking, + sessionId: sessionHash + } + ) + + try { + const countResult = + vendor === 'antigravity' + ? await geminiAccountService.countTokensAntigravity(client, contents, model, proxyConfig) + : await geminiAccountService.countTokens(client, contents, model, proxyConfig) + + const totalTokens = countResult?.totalTokens || 0 + return res.status(200).json({ input_tokens: totalTokens }) + } catch (error) { + const sanitized = sanitizeUpstreamError(error) + logger.error('Upstream token count error (via /v1/messages/count_tokens):', sanitized) + return res + .status(sanitized.statusCode || 502) + .json(buildAnthropicError(sanitized.upstreamMessage || sanitized.message)) + } +} + +// ============================================================================ +// 模块导出 +// ============================================================================ + +module.exports = { + // 主入口:处理 /v1/messages 请求 + handleAnthropicMessagesToGemini, + // 辅助入口:处理 /v1/messages/count_tokens 请求 + handleAnthropicCountTokensToGemini +} diff --git a/src/services/antigravityClient.js b/src/services/antigravityClient.js new file mode 100644 index 0000000..dcdf4f1 --- /dev/null +++ b/src/services/antigravityClient.js @@ -0,0 +1,595 @@ +const axios = require('axios') +const https = require('https') +const { v4: uuidv4 } = require('uuid') + +const ProxyHelper = require('../utils/proxyHelper') +const logger = require('../utils/logger') +const { + mapAntigravityUpstreamModel, + normalizeAntigravityModelInput, + getAntigravityModelMetadata +} = require('../utils/antigravityModel') +const { cleanJsonSchemaForGemini } = require('../utils/geminiSchemaCleaner') +const { dumpAntigravityUpstreamRequest } = require('../utils/antigravityUpstreamDump') + +const keepAliveAgent = new https.Agent({ + keepAlive: true, + keepAliveMsecs: 30000, + timeout: 120000, + maxSockets: 100, + maxFreeSockets: 10 +}) + +function getAntigravityApiUrl() { + return process.env.ANTIGRAVITY_API_URL || 'https://daily-cloudcode-pa.sandbox.googleapis.com' +} + +function normalizeBaseUrl(url) { + const str = String(url || '').trim() + return str.endsWith('/') ? str.slice(0, -1) : str +} + +function getAntigravityApiUrlCandidates() { + const configured = normalizeBaseUrl(getAntigravityApiUrl()) + const daily = 'https://daily-cloudcode-pa.sandbox.googleapis.com' + const prod = 'https://cloudcode-pa.googleapis.com' + + // 若显式配置了自定义 base url,则只使用该地址(不做 fallback,避免意外路由到别的环境)。 + if (process.env.ANTIGRAVITY_API_URL) { + return [configured] + } + + // 默认行为:优先 daily(与旧逻辑一致),失败时再尝试 prod(对齐 CLIProxyAPI)。 + if (configured === normalizeBaseUrl(daily)) { + return [configured, prod] + } + if (configured === normalizeBaseUrl(prod)) { + return [configured, daily] + } + + return [configured, prod, daily].filter(Boolean) +} + +function getAntigravityHeaders(accessToken, baseUrl) { + const resolvedBaseUrl = baseUrl || getAntigravityApiUrl() + let host = 'daily-cloudcode-pa.sandbox.googleapis.com' + try { + host = new URL(resolvedBaseUrl).host || host + } catch (e) { + // ignore + } + + return { + Host: host, + 'User-Agent': process.env.ANTIGRAVITY_USER_AGENT || 'antigravity/1.15.8 windows/amd64', + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'Accept-Encoding': 'gzip', + requestType: 'agent' + } +} + +function generateAntigravityProjectId() { + return `ag-${uuidv4().replace(/-/g, '').slice(0, 16)}` +} + +function generateAntigravitySessionId() { + return `sess-${uuidv4()}` +} + +function resolveAntigravityProjectId(projectId, requestData) { + const candidate = projectId || requestData?.project || requestData?.projectId || null + return candidate || generateAntigravityProjectId() +} + +function resolveAntigravitySessionId(sessionId, requestData) { + const candidate = + sessionId || requestData?.request?.sessionId || requestData?.request?.session_id || null + return candidate || generateAntigravitySessionId() +} + +function buildAntigravityEnvelope({ requestData, projectId, sessionId, userPromptId }) { + const model = mapAntigravityUpstreamModel(requestData?.model) + const resolvedProjectId = resolveAntigravityProjectId(projectId, requestData) + const resolvedSessionId = resolveAntigravitySessionId(sessionId, requestData) + const requestPayload = { + ...(requestData?.request || {}) + } + + if (requestPayload.session_id !== undefined) { + delete requestPayload.session_id + } + requestPayload.sessionId = resolvedSessionId + + const envelope = { + project: resolvedProjectId, + requestId: `req-${uuidv4()}`, + model, + userAgent: 'antigravity', + request: { + ...requestPayload + } + } + + if (userPromptId) { + envelope.user_prompt_id = userPromptId + envelope.userPromptId = userPromptId + } + + normalizeAntigravityEnvelope(envelope) + return { model, envelope } +} + +function normalizeAntigravityThinking(model, requestPayload) { + if (!requestPayload || typeof requestPayload !== 'object') { + return + } + + const { generationConfig } = requestPayload + if (!generationConfig || typeof generationConfig !== 'object') { + return + } + const { thinkingConfig } = generationConfig + if (!thinkingConfig || typeof thinkingConfig !== 'object') { + return + } + + const normalizedModel = normalizeAntigravityModelInput(model) + if (thinkingConfig.thinkingLevel && !normalizedModel.startsWith('gemini-3-')) { + delete thinkingConfig.thinkingLevel + } + + const metadata = getAntigravityModelMetadata(normalizedModel) + if (metadata && !metadata.thinking) { + delete generationConfig.thinkingConfig + return + } + if (!metadata || !metadata.thinking) { + return + } + + const budgetRaw = Number(thinkingConfig.thinkingBudget) + if (!Number.isFinite(budgetRaw)) { + return + } + let budget = Math.trunc(budgetRaw) + + const minBudget = Number.isFinite(metadata.thinking.min) ? metadata.thinking.min : null + const maxBudget = Number.isFinite(metadata.thinking.max) ? metadata.thinking.max : null + + if (maxBudget !== null && budget > maxBudget) { + budget = maxBudget + } + + let effectiveMax = Number.isFinite(generationConfig.maxOutputTokens) + ? generationConfig.maxOutputTokens + : null + let setDefaultMax = false + if (!effectiveMax && metadata.maxCompletionTokens) { + effectiveMax = metadata.maxCompletionTokens + setDefaultMax = true + } + + if (effectiveMax && budget >= effectiveMax) { + budget = Math.max(0, effectiveMax - 1) + } + + if (minBudget !== null && budget >= 0 && budget < minBudget) { + delete generationConfig.thinkingConfig + return + } + + thinkingConfig.thinkingBudget = budget + if (setDefaultMax) { + generationConfig.maxOutputTokens = effectiveMax + } +} + +function normalizeAntigravityEnvelope(envelope) { + if (!envelope || typeof envelope !== 'object') { + return + } + const model = String(envelope.model || '') + const requestPayload = envelope.request + if (!requestPayload || typeof requestPayload !== 'object') { + return + } + + if (requestPayload.safetySettings !== undefined) { + delete requestPayload.safetySettings + } + + // 对齐 CLIProxyAPI:有 tools 时默认启用 VALIDATED(除非显式 NONE) + if (Array.isArray(requestPayload.tools) && requestPayload.tools.length > 0) { + const existing = requestPayload?.toolConfig?.functionCallingConfig || null + if (existing?.mode !== 'NONE') { + const nextCfg = { ...(existing || {}), mode: 'VALIDATED' } + requestPayload.toolConfig = { functionCallingConfig: nextCfg } + } + } + + // 对齐 CLIProxyAPI:非 Claude 模型移除 maxOutputTokens(Antigravity 环境不稳定) + normalizeAntigravityThinking(model, requestPayload) + if (!model.includes('claude')) { + if (requestPayload.generationConfig && typeof requestPayload.generationConfig === 'object') { + delete requestPayload.generationConfig.maxOutputTokens + } + return + } + + // Claude 模型:parametersJsonSchema -> parameters + schema 清洗(避免 $schema / additionalProperties 等触发 400) + if (!Array.isArray(requestPayload.tools)) { + return + } + + for (const tool of requestPayload.tools) { + if (!tool || typeof tool !== 'object') { + continue + } + const decls = Array.isArray(tool.functionDeclarations) + ? tool.functionDeclarations + : Array.isArray(tool.function_declarations) + ? tool.function_declarations + : null + + if (!decls) { + continue + } + + for (const decl of decls) { + if (!decl || typeof decl !== 'object') { + continue + } + let schema = + decl.parametersJsonSchema !== undefined ? decl.parametersJsonSchema : decl.parameters + if (typeof schema === 'string' && schema) { + try { + schema = JSON.parse(schema) + } catch (_) { + schema = null + } + } + + decl.parameters = cleanJsonSchemaForGemini(schema) + delete decl.parametersJsonSchema + } + } +} + +async function request({ + accessToken, + proxyConfig = null, + requestData, + projectId = null, + sessionId = null, + userPromptId = null, + stream = false, + signal = null, + params = null, + timeoutMs = null +}) { + const { model, envelope } = buildAntigravityEnvelope({ + requestData, + projectId, + sessionId, + userPromptId + }) + + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + let endpoints = getAntigravityApiUrlCandidates() + + // Claude 模型在 sandbox(daily) 环境下对 tool_use/tool_result 的兼容性不稳定,优先走 prod。 + // 保持可配置优先:若用户显式设置了 ANTIGRAVITY_API_URL,则不改变顺序。 + if (!process.env.ANTIGRAVITY_API_URL && String(model).includes('claude')) { + const prodHost = 'cloudcode-pa.googleapis.com' + const dailyHost = 'daily-cloudcode-pa.sandbox.googleapis.com' + const ordered = [] + for (const u of endpoints) { + if (String(u).includes(prodHost)) { + ordered.push(u) + } + } + for (const u of endpoints) { + if (!String(u).includes(prodHost)) { + ordered.push(u) + } + } + // 去重并保持 prod -> daily 的稳定顺序 + endpoints = Array.from(new Set(ordered)).sort((a, b) => { + const av = String(a) + const bv = String(b) + const aScore = av.includes(prodHost) ? 0 : av.includes(dailyHost) ? 1 : 2 + const bScore = bv.includes(prodHost) ? 0 : bv.includes(dailyHost) ? 1 : 2 + return aScore - bScore + }) + } + + const isRetryable = (error) => { + // 处理网络层面的连接重置或超时(常见于长请求被中间节点切断) + if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') { + return true + } + + const status = error?.response?.status + if (status === 429) { + return true + } + + // 400/404 的 “model unavailable / not found” 在不同环境间可能表现不同,允许 fallback。 + if (status === 400 || status === 404) { + const data = error?.response?.data + const safeToString = (value) => { + if (typeof value === 'string') { + return value + } + if (value === null || value === undefined) { + return '' + } + // axios responseType=stream 时,data 可能是 stream(存在循环引用),不能 JSON.stringify + if (typeof value === 'object' && typeof value.pipe === 'function') { + return '' + } + if (Buffer.isBuffer(value)) { + try { + return value.toString('utf8') + } catch (_) { + return '' + } + } + if (typeof value === 'object') { + try { + return JSON.stringify(value) + } catch (_) { + return '' + } + } + return String(value) + } + + const text = safeToString(data) + const msg = (text || '').toLowerCase() + return ( + msg.includes('requested model is currently unavailable') || + msg.includes('tool_use') || + msg.includes('tool_result') || + msg.includes('requested entity was not found') || + msg.includes('not found') + ) + } + + return false + } + + let lastError = null + let retriedAfterDelay = false + + const attemptRequest = async () => { + for (let index = 0; index < endpoints.length; index += 1) { + const baseUrl = endpoints[index] + const url = `${baseUrl}/v1internal:${stream ? 'streamGenerateContent' : 'generateContent'}` + + const axiosConfig = { + url, + method: 'POST', + ...(params ? { params } : {}), + headers: getAntigravityHeaders(accessToken, baseUrl), + data: envelope, + timeout: stream ? 0 : timeoutMs || 600000, + ...(stream ? { responseType: 'stream' } : {}) + } + + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + if (index === 0) { + logger.info( + `🌐 Using proxy for Antigravity ${stream ? 'streamGenerateContent' : 'generateContent'}: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } + } else { + axiosConfig.httpsAgent = keepAliveAgent + } + + if (signal) { + axiosConfig.signal = signal + } + + try { + dumpAntigravityUpstreamRequest({ + requestId: envelope.requestId, + model, + stream, + url, + baseUrl, + params: axiosConfig.params || null, + headers: axiosConfig.headers, + envelope + }).catch(() => {}) + const response = await axios(axiosConfig) + return { model, response } + } catch (error) { + lastError = error + const status = error?.response?.status || null + + const hasNext = index + 1 < endpoints.length + if (hasNext && isRetryable(error)) { + logger.warn('⚠️ Antigravity upstream error, retrying with fallback baseUrl', { + status, + from: baseUrl, + to: endpoints[index + 1], + model + }) + continue + } + throw error + } + } + + throw lastError || new Error('Antigravity request failed') + } + + try { + return await attemptRequest() + } catch (error) { + // 如果是 429 RESOURCE_EXHAUSTED 且尚未重试过,等待 2 秒后重试一次 + const status = error?.response?.status + if (status === 429 && !retriedAfterDelay && !signal?.aborted) { + const data = error?.response?.data + + // 安全地将 data 转为字符串,避免 stream 对象导致循环引用崩溃 + const safeDataToString = (value) => { + if (typeof value === 'string') { + return value + } + if (value === null || value === undefined) { + return '' + } + // stream 对象存在循环引用,不能 JSON.stringify + if (typeof value === 'object' && typeof value.pipe === 'function') { + return '' + } + if (Buffer.isBuffer(value)) { + try { + return value.toString('utf8') + } catch (_) { + return '' + } + } + if (typeof value === 'object') { + try { + return JSON.stringify(value) + } catch (_) { + return '' + } + } + return String(value) + } + + const msg = safeDataToString(data) + if ( + msg.toLowerCase().includes('resource_exhausted') || + msg.toLowerCase().includes('no capacity') + ) { + retriedAfterDelay = true + logger.warn('⏳ Antigravity 429 RESOURCE_EXHAUSTED, waiting 2s before retry', { model }) + await new Promise((resolve) => setTimeout(resolve, 2000)) + return await attemptRequest() + } + } + throw error + } +} + +async function fetchAvailableModels({ accessToken, proxyConfig = null, timeoutMs = 30000 }) { + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + const endpoints = getAntigravityApiUrlCandidates() + + let lastError = null + for (let index = 0; index < endpoints.length; index += 1) { + const baseUrl = endpoints[index] + const url = `${baseUrl}/v1internal:fetchAvailableModels` + + const axiosConfig = { + url, + method: 'POST', + headers: getAntigravityHeaders(accessToken, baseUrl), + data: {}, + timeout: timeoutMs + } + + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + if (index === 0) { + logger.info( + `🌐 Using proxy for Antigravity fetchAvailableModels: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } + } else { + axiosConfig.httpsAgent = keepAliveAgent + } + + try { + const response = await axios(axiosConfig) + return response.data + } catch (error) { + lastError = error + const status = error?.response?.status + const hasNext = index + 1 < endpoints.length + if (hasNext && (status === 429 || status === 404)) { + continue + } + throw error + } + } + + throw lastError || new Error('Antigravity fetchAvailableModels failed') +} + +async function countTokens({ + accessToken, + proxyConfig = null, + contents, + model, + timeoutMs = 30000 +}) { + const upstreamModel = mapAntigravityUpstreamModel(model) + + const proxyAgent = ProxyHelper.createProxyAgent(proxyConfig) + const endpoints = getAntigravityApiUrlCandidates() + + let lastError = null + for (let index = 0; index < endpoints.length; index += 1) { + const baseUrl = endpoints[index] + const url = `${baseUrl}/v1internal:countTokens` + const axiosConfig = { + url, + method: 'POST', + headers: getAntigravityHeaders(accessToken, baseUrl), + data: { + request: { + model: `models/${upstreamModel}`, + contents + } + }, + timeout: timeoutMs + } + + if (proxyAgent) { + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + if (index === 0) { + logger.info( + `🌐 Using proxy for Antigravity countTokens: ${ProxyHelper.getProxyDescription(proxyConfig)}` + ) + } + } else { + axiosConfig.httpsAgent = keepAliveAgent + } + + try { + const response = await axios(axiosConfig) + return response.data + } catch (error) { + lastError = error + const status = error?.response?.status + const hasNext = index + 1 < endpoints.length + if (hasNext && (status === 429 || status === 404)) { + continue + } + throw error + } + } + + throw lastError || new Error('Antigravity countTokens failed') +} + +module.exports = { + getAntigravityApiUrl, + getAntigravityApiUrlCandidates, + getAntigravityHeaders, + buildAntigravityEnvelope, + request, + fetchAvailableModels, + countTokens +} diff --git a/src/services/apiKeyIndexService.js b/src/services/apiKeyIndexService.js new file mode 100644 index 0000000..1a5963a --- /dev/null +++ b/src/services/apiKeyIndexService.js @@ -0,0 +1,654 @@ +/** + * API Key 索引服务 + * 维护 Sorted Set 索引以支持高效分页查询 + */ + +const { randomUUID } = require('crypto') +const logger = require('../utils/logger') + +class ApiKeyIndexService { + constructor() { + this.redis = null + this.INDEX_VERSION_KEY = 'apikey:index:version' + this.CURRENT_VERSION = 2 // 版本升级,触发重建 + this.isBuilding = false + this.buildProgress = { current: 0, total: 0 } + + // 索引键名 + this.INDEX_KEYS = { + CREATED_AT: 'apikey:idx:createdAt', + LAST_USED_AT: 'apikey:idx:lastUsedAt', + NAME: 'apikey:idx:name', + ACTIVE_SET: 'apikey:set:active', + DELETED_SET: 'apikey:set:deleted', + ALL_SET: 'apikey:idx:all', + TAGS_ALL: 'apikey:tags:all' // 所有标签的集合 + } + } + + /** + * 初始化服务 + */ + init(redis) { + this.redis = redis + return this + } + + /** + * 启动时检查并重建索引 + */ + async checkAndRebuild() { + if (!this.redis) { + logger.warn('⚠️ ApiKeyIndexService: Redis not initialized') + return + } + + try { + const client = this.redis.getClientSafe() + const version = await client.get(this.INDEX_VERSION_KEY) + + // 始终检查并回填 hash_map(幂等操作,确保升级兼容) + this.rebuildHashMap().catch((err) => { + logger.error('❌ API Key hash_map 回填失败:', err) + }) + + if (parseInt(version) >= this.CURRENT_VERSION) { + logger.info('✅ API Key 索引已是最新版本') + return + } + + // 后台异步重建,不阻塞启动 + this.rebuildIndexes().catch((err) => { + logger.error('❌ API Key 索引重建失败:', err) + }) + } catch (error) { + logger.error('❌ 检查 API Key 索引版本失败:', error) + } + } + + /** + * 回填 apikey:hash_map(升级兼容) + * 扫描所有 API Key,确保 hash -> keyId 映射存在 + */ + async rebuildHashMap() { + if (!this.redis) { + return + } + + try { + const client = this.redis.getClientSafe() + const keyIds = await this.redis.scanApiKeyIds() + + let rebuilt = 0 + const BATCH_SIZE = 100 + + for (let i = 0; i < keyIds.length; i += BATCH_SIZE) { + const batch = keyIds.slice(i, i + BATCH_SIZE) + const pipeline = client.pipeline() + + // 批量获取 API Key 数据 + for (const keyId of batch) { + pipeline.hgetall(`apikey:${keyId}`) + } + const results = await pipeline.exec() + + // 检查并回填缺失的映射 + const fillPipeline = client.pipeline() + let needFill = false + + for (let j = 0; j < batch.length; j++) { + const keyData = results[j]?.[1] + if (keyData && keyData.apiKey) { + // keyData.apiKey 存储的是哈希值 + const exists = await client.hexists('apikey:hash_map', keyData.apiKey) + if (!exists) { + fillPipeline.hset('apikey:hash_map', keyData.apiKey, batch[j]) + rebuilt++ + needFill = true + } + } + } + + if (needFill) { + await fillPipeline.exec() + } + } + + if (rebuilt > 0) { + logger.info(`🔧 回填了 ${rebuilt} 个 API Key 到 hash_map`) + } + } catch (error) { + logger.error('❌ 回填 hash_map 失败:', error) + throw error + } + } + + /** + * 检查索引是否可用 + */ + async isIndexReady() { + if (!this.redis || this.isBuilding) { + return false + } + + try { + const client = this.redis.getClientSafe() + const version = await client.get(this.INDEX_VERSION_KEY) + return parseInt(version) >= this.CURRENT_VERSION + } catch { + return false + } + } + + /** + * 重建所有索引 + */ + async rebuildIndexes() { + if (this.isBuilding) { + logger.warn('⚠️ API Key 索引正在重建中,跳过') + return + } + + this.isBuilding = true + const startTime = Date.now() + + try { + const client = this.redis.getClientSafe() + logger.info('🔨 开始重建 API Key 索引...') + + // 0. 先删除版本号,让 _checkIndexReady 返回 false,查询回退到 SCAN + await client.del(this.INDEX_VERSION_KEY) + + // 1. 清除旧索引 + const indexKeys = Object.values(this.INDEX_KEYS) + for (const key of indexKeys) { + await client.del(key) + } + // 清除标签索引(用 SCAN 避免阻塞) + let cursor = '0' + do { + const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'apikey:tag:*', 'COUNT', 100) + cursor = newCursor + if (keys.length > 0) { + await client.del(...keys) + } + } while (cursor !== '0') + + // 2. 扫描所有 API Key + const keyIds = await this.redis.scanApiKeyIds() + this.buildProgress = { current: 0, total: keyIds.length } + + logger.info(`📊 发现 ${keyIds.length} 个 API Key,开始建立索引...`) + + // 3. 批量处理(每批 500 个) + const BATCH_SIZE = 500 + for (let i = 0; i < keyIds.length; i += BATCH_SIZE) { + const batch = keyIds.slice(i, i + BATCH_SIZE) + const apiKeys = await this.redis.batchGetApiKeys(batch) + + const pipeline = client.pipeline() + + for (const apiKey of apiKeys) { + if (!apiKey || !apiKey.id) { + continue + } + + const keyId = apiKey.id + const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : 0 + const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0 + const name = (apiKey.name || '').toLowerCase() + const isActive = apiKey.isActive === true || apiKey.isActive === 'true' + const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true' + + // 创建时间索引 + pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId) + + // 最后使用时间索引 + pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId) + + // 名称索引(用于排序,存储格式:name\0keyId) + pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`) + + // 全部集合 + pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId) + + // 状态集合 + if (isDeleted) { + pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId) + } else if (isActive) { + pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId) + } + + // 标签索引 + const tags = Array.isArray(apiKey.tags) ? apiKey.tags : [] + for (const tag of tags) { + if (tag && typeof tag === 'string') { + pipeline.sadd(`apikey:tag:${tag}`, keyId) + pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag) // 维护标签集合 + } + } + } + + await pipeline.exec() + this.buildProgress.current = Math.min(i + BATCH_SIZE, keyIds.length) + + // 每批次后短暂让出 CPU + await new Promise((resolve) => setTimeout(resolve, 10)) + } + + // 4. 更新版本号 + await client.set(this.INDEX_VERSION_KEY, this.CURRENT_VERSION) + + const duration = ((Date.now() - startTime) / 1000).toFixed(2) + logger.success(`✅ API Key 索引重建完成,共 ${keyIds.length} 条,耗时 ${duration}s`) + } catch (error) { + logger.error('❌ API Key 索引重建失败:', error) + throw error + } finally { + this.isBuilding = false + } + } + + /** + * 添加单个 API Key 到索引 + */ + async addToIndex(apiKey) { + if (!this.redis || !apiKey || !apiKey.id) { + return + } + + try { + const client = this.redis.getClientSafe() + const keyId = apiKey.id + const createdAt = apiKey.createdAt ? new Date(apiKey.createdAt).getTime() : Date.now() + const lastUsedAt = apiKey.lastUsedAt ? new Date(apiKey.lastUsedAt).getTime() : 0 + const name = (apiKey.name || '').toLowerCase() + const isActive = apiKey.isActive === true || apiKey.isActive === 'true' + const isDeleted = apiKey.isDeleted === true || apiKey.isDeleted === 'true' + + const pipeline = client.pipeline() + + pipeline.zadd(this.INDEX_KEYS.CREATED_AT, createdAt, keyId) + pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId) + pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${name}\x00${keyId}`) + pipeline.sadd(this.INDEX_KEYS.ALL_SET, keyId) + + if (isDeleted) { + pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId) + pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId) + } else if (isActive) { + pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId) + pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId) + } else { + pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId) + pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId) + } + + // 标签索引 + const tags = Array.isArray(apiKey.tags) ? apiKey.tags : [] + for (const tag of tags) { + if (tag && typeof tag === 'string') { + pipeline.sadd(`apikey:tag:${tag}`, keyId) + pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag) + } + } + + await pipeline.exec() + } catch (error) { + logger.error(`❌ 添加 API Key ${apiKey.id} 到索引失败:`, error) + } + } + + /** + * 更新索引(状态、名称、标签变化时调用) + */ + async updateIndex(keyId, updates, oldData = {}) { + if (!this.redis || !keyId) { + return + } + + try { + const client = this.redis.getClientSafe() + const pipeline = client.pipeline() + + // 更新名称索引 + if (updates.name !== undefined) { + const oldName = (oldData.name || '').toLowerCase() + const newName = (updates.name || '').toLowerCase() + if (oldName !== newName) { + pipeline.zrem(this.INDEX_KEYS.NAME, `${oldName}\x00${keyId}`) + pipeline.zadd(this.INDEX_KEYS.NAME, 0, `${newName}\x00${keyId}`) + } + } + + // 更新最后使用时间索引 + if (updates.lastUsedAt !== undefined) { + const lastUsedAt = updates.lastUsedAt ? new Date(updates.lastUsedAt).getTime() : 0 + pipeline.zadd(this.INDEX_KEYS.LAST_USED_AT, lastUsedAt, keyId) + } + + // 更新状态集合 + if (updates.isActive !== undefined || updates.isDeleted !== undefined) { + const isActive = updates.isActive ?? oldData.isActive + const isDeleted = updates.isDeleted ?? oldData.isDeleted + + if (isDeleted === true || isDeleted === 'true') { + pipeline.sadd(this.INDEX_KEYS.DELETED_SET, keyId) + pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId) + } else if (isActive === true || isActive === 'true') { + pipeline.sadd(this.INDEX_KEYS.ACTIVE_SET, keyId) + pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId) + } else { + pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId) + pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId) + } + } + + // 更新标签索引 + const removedTags = [] + if (updates.tags !== undefined) { + const oldTags = Array.isArray(oldData.tags) ? oldData.tags : [] + const newTags = Array.isArray(updates.tags) ? updates.tags : [] + + // 移除旧标签 + for (const tag of oldTags) { + if (tag && !newTags.includes(tag)) { + pipeline.srem(`apikey:tag:${tag}`, keyId) + removedTags.push(tag) + } + } + // 添加新标签 + for (const tag of newTags) { + if (tag && typeof tag === 'string') { + pipeline.sadd(`apikey:tag:${tag}`, keyId) + pipeline.sadd(this.INDEX_KEYS.TAGS_ALL, tag) + } + } + } + + await pipeline.exec() + + // 检查被移除的标签集合是否为空,为空则从 tags:all 移除 + for (const tag of removedTags) { + const count = await client.scard(`apikey:tag:${tag}`) + if (count === 0) { + await client.srem(this.INDEX_KEYS.TAGS_ALL, tag) + } + } + } catch (error) { + logger.error(`❌ 更新 API Key ${keyId} 索引失败:`, error) + } + } + + /** + * 从索引中移除 API Key + */ + async removeFromIndex(keyId, oldData = {}) { + if (!this.redis || !keyId) { + return + } + + try { + const client = this.redis.getClientSafe() + const pipeline = client.pipeline() + + const name = (oldData.name || '').toLowerCase() + + pipeline.zrem(this.INDEX_KEYS.CREATED_AT, keyId) + pipeline.zrem(this.INDEX_KEYS.LAST_USED_AT, keyId) + pipeline.zrem(this.INDEX_KEYS.NAME, `${name}\x00${keyId}`) + pipeline.srem(this.INDEX_KEYS.ALL_SET, keyId) + pipeline.srem(this.INDEX_KEYS.ACTIVE_SET, keyId) + pipeline.srem(this.INDEX_KEYS.DELETED_SET, keyId) + + // 移除标签索引 + const tags = Array.isArray(oldData.tags) ? oldData.tags : [] + for (const tag of tags) { + if (tag) { + pipeline.srem(`apikey:tag:${tag}`, keyId) + } + } + + await pipeline.exec() + + // 检查标签集合是否为空,为空则从 tags:all 移除 + for (const tag of tags) { + if (tag) { + const count = await client.scard(`apikey:tag:${tag}`) + if (count === 0) { + await client.srem(this.INDEX_KEYS.TAGS_ALL, tag) + } + } + } + } catch (error) { + logger.error(`❌ 从索引移除 API Key ${keyId} 失败:`, error) + } + } + + /** + * 使用索引进行分页查询 + * 使用 ZINTERSTORE 优化,避免全量拉回内存 + */ + async queryWithIndex(options = {}) { + const { + page = 1, + pageSize = 20, + sortBy = 'createdAt', + sortOrder = 'desc', + isActive, + tag, + excludeDeleted = true + } = options + + const client = this.redis.getClientSafe() + const tempSets = [] + + try { + // 1. 构建筛选集合 + let filterSet = this.INDEX_KEYS.ALL_SET + + // 状态筛选 + if (isActive === true || isActive === 'true') { + // 筛选活跃的 + filterSet = this.INDEX_KEYS.ACTIVE_SET + } else if (isActive === false || isActive === 'false') { + // 筛选未激活的 = ALL - ACTIVE (- DELETED if excludeDeleted) + const tempKey = `apikey:tmp:inactive:${randomUUID()}` + if (excludeDeleted) { + await client.sdiffstore( + tempKey, + this.INDEX_KEYS.ALL_SET, + this.INDEX_KEYS.ACTIVE_SET, + this.INDEX_KEYS.DELETED_SET + ) + } else { + await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.ACTIVE_SET) + } + await client.expire(tempKey, 60) + filterSet = tempKey + tempSets.push(tempKey) + } else if (excludeDeleted) { + // 排除已删除:ALL - DELETED + const tempKey = `apikey:tmp:notdeleted:${randomUUID()}` + await client.sdiffstore(tempKey, this.INDEX_KEYS.ALL_SET, this.INDEX_KEYS.DELETED_SET) + await client.expire(tempKey, 60) + filterSet = tempKey + tempSets.push(tempKey) + } + + // 标签筛选 + if (tag) { + const tagSet = `apikey:tag:${tag}` + const tempKey = `apikey:tmp:tag:${randomUUID()}` + await client.sinterstore(tempKey, filterSet, tagSet) + await client.expire(tempKey, 60) + filterSet = tempKey + tempSets.push(tempKey) + } + + // 2. 获取筛选后的 keyId 集合 + const filterMembers = await client.smembers(filterSet) + if (filterMembers.length === 0) { + // 没有匹配的数据 + return { + items: [], + pagination: { page: 1, pageSize, total: 0, totalPages: 1 }, + availableTags: await this._getAvailableTags(client) + } + } + + // 3. 排序 + let sortedKeyIds + + if (sortBy === 'name') { + // 优化:只拉筛选后 keyId 的 name 字段,避免全量扫描 name 索引 + const pipeline = client.pipeline() + for (const keyId of filterMembers) { + pipeline.hget(`apikey:${keyId}`, 'name') + } + const results = await pipeline.exec() + + // 组装并排序 + const items = filterMembers.map((keyId, i) => ({ + keyId, + name: (results[i]?.[1] || '').toLowerCase() + })) + items.sort((a, b) => + sortOrder === 'desc' ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name) + ) + sortedKeyIds = items.map((item) => item.keyId) + } else { + // createdAt / lastUsedAt 索引成员是 keyId,可以用 ZINTERSTORE + const sortIndex = this._getSortIndex(sortBy) + const tempSortedKey = `apikey:tmp:sorted:${randomUUID()}` + tempSets.push(tempSortedKey) + + // 将 filterSet 转换为 Sorted Set(所有分数为 0) + const filterZsetKey = `apikey:tmp:filter:${randomUUID()}` + tempSets.push(filterZsetKey) + + const zaddArgs = [] + for (const member of filterMembers) { + zaddArgs.push(0, member) + } + await client.zadd(filterZsetKey, ...zaddArgs) + await client.expire(filterZsetKey, 60) + + // ZINTERSTORE:取交集,使用排序索引的分数(WEIGHTS 0 1) + await client.zinterstore(tempSortedKey, 2, filterZsetKey, sortIndex, 'WEIGHTS', 0, 1) + await client.expire(tempSortedKey, 60) + + // 获取排序后的 keyId + sortedKeyIds = + sortOrder === 'desc' + ? await client.zrevrange(tempSortedKey, 0, -1) + : await client.zrange(tempSortedKey, 0, -1) + } + + // 4. 分页 + const total = sortedKeyIds.length + const totalPages = Math.max(Math.ceil(total / pageSize), 1) + const validPage = Math.min(Math.max(1, page), totalPages) + const start = (validPage - 1) * pageSize + const pageKeyIds = sortedKeyIds.slice(start, start + pageSize) + + // 5. 获取数据 + const items = await this.redis.batchGetApiKeys(pageKeyIds) + + // 6. 获取所有标签 + const availableTags = await this._getAvailableTags(client) + + return { + items, + pagination: { + page: validPage, + pageSize, + total, + totalPages + }, + availableTags + } + } finally { + // 7. 清理临时集合 + for (const tempKey of tempSets) { + client.del(tempKey).catch(() => {}) + } + } + } + + /** + * 获取排序索引键名 + */ + _getSortIndex(sortBy) { + switch (sortBy) { + case 'createdAt': + return this.INDEX_KEYS.CREATED_AT + case 'lastUsedAt': + return this.INDEX_KEYS.LAST_USED_AT + case 'name': + return this.INDEX_KEYS.NAME + default: + return this.INDEX_KEYS.CREATED_AT + } + } + + /** + * 获取所有可用标签(从 tags:all 集合) + */ + async _getAvailableTags(client) { + try { + const tags = await client.smembers(this.INDEX_KEYS.TAGS_ALL) + return tags.sort() + } catch { + return [] + } + } + + /** + * 更新 lastUsedAt 索引(供 recordUsage 调用) + */ + async updateLastUsedAt(keyId, lastUsedAt) { + if (!this.redis || !keyId) { + return + } + + try { + const client = this.redis.getClientSafe() + const timestamp = lastUsedAt ? new Date(lastUsedAt).getTime() : Date.now() + await client.zadd(this.INDEX_KEYS.LAST_USED_AT, timestamp, keyId) + } catch (error) { + logger.error(`❌ 更新 API Key ${keyId} lastUsedAt 索引失败:`, error) + } + } + + /** + * 获取索引状态 + */ + async getStatus() { + if (!this.redis) { + return { ready: false, building: false } + } + + try { + const client = this.redis.getClientSafe() + const version = await client.get(this.INDEX_VERSION_KEY) + const totalCount = await client.scard(this.INDEX_KEYS.ALL_SET) + + return { + ready: parseInt(version) >= this.CURRENT_VERSION, + building: this.isBuilding, + progress: this.buildProgress, + version: parseInt(version) || 0, + currentVersion: this.CURRENT_VERSION, + totalIndexed: totalCount + } + } catch { + return { ready: false, building: this.isBuilding } + } + } +} + +// 单例 +const apiKeyIndexService = new ApiKeyIndexService() + +module.exports = apiKeyIndexService diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js new file mode 100644 index 0000000..45cea6c --- /dev/null +++ b/src/services/apiKeyService.js @@ -0,0 +1,2864 @@ +const crypto = require('crypto') +const { v4: uuidv4 } = require('uuid') +const config = require('../../config/config') +const redis = require('../models/redis') +const logger = require('../utils/logger') +const serviceRatesService = require('./serviceRatesService') +const requestDetailService = require('./requestDetailService') +const { isClaudeFamilyModel } = require('../utils/modelHelper') +const { finalizeRequestDetailMeta } = require('../utils/requestDetailHelper') +const requestBodyRuleService = require('./requestBodyRuleService') + +const ACCOUNT_TYPE_CONFIG = { + claude: { prefix: 'claude:account:' }, + 'claude-console': { prefix: 'claude_console_account:' }, + openai: { prefix: 'openai:account:' }, + 'openai-responses': { prefix: 'openai_responses_account:' }, + 'azure-openai': { prefix: 'azure_openai:account:' }, + gemini: { prefix: 'gemini_account:' }, + 'gemini-api': { prefix: 'gemini_api_account:' }, + droid: { prefix: 'droid:account:' } +} + +const ACCOUNT_TYPE_PRIORITY = [ + 'openai', + 'openai-responses', + 'azure-openai', + 'claude', + 'claude-console', + 'gemini', + 'gemini-api', + 'droid' +] + +const ACCOUNT_CATEGORY_MAP = { + claude: 'claude', + 'claude-console': 'claude', + openai: 'openai', + 'openai-responses': 'openai', + 'azure-openai': 'openai', + gemini: 'gemini', + 'gemini-api': 'gemini', + droid: 'droid' +} + +/** + * 规范化权限数据,兼容旧格式(字符串)和新格式(数组) + * @param {string|array} permissions - 权限数据 + * @returns {array} - 权限数组,空数组表示全部服务 + */ +function normalizePermissions(permissions) { + if (!permissions) { + return [] // 空 = 全部服务 + } + if (Array.isArray(permissions)) { + return permissions + } + // 尝试解析 JSON 字符串(新格式存储) + if (typeof permissions === 'string') { + if (permissions.startsWith('[')) { + try { + const parsed = JSON.parse(permissions) + if (Array.isArray(parsed)) { + return parsed + } + } catch (e) { + // 解析失败,继续处理为普通字符串 + } + } + // 旧格式 'all' 转为空数组 + if (permissions === 'all') { + return [] + } + // 兼容逗号分隔格式(修复历史错误数据,如 "claude,openai") + if (permissions.includes(',')) { + return permissions + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + } + // 旧单个字符串转为数组 + return [permissions] + } + return [] +} + +/** + * 检查是否有访问特定服务的权限 + * @param {string|array} permissions - 权限数据 + * @param {string} service - 服务名称(claude/gemini/openai/droid) + * @returns {boolean} - 是否有权限 + */ +function hasPermission(permissions, service) { + const perms = normalizePermissions(permissions) + return perms.length === 0 || perms.includes(service) // 空数组 = 全部服务 +} + +function normalizeAccountTypeKey(type) { + if (!type) { + return null + } + const lower = String(type).toLowerCase() + if (lower === 'claude_console') { + return 'claude-console' + } + if (lower === 'openai_responses' || lower === 'openai-response' || lower === 'openai-responses') { + return 'openai-responses' + } + if (lower === 'azure_openai' || lower === 'azureopenai' || lower === 'azure-openai') { + return 'azure-openai' + } + if (lower === 'gemini_api' || lower === 'gemini-api') { + return 'gemini-api' + } + return lower +} + +function sanitizeAccountIdForType(accountId, accountType) { + if (!accountId || typeof accountId !== 'string') { + return accountId + } + if (accountType === 'openai-responses') { + return accountId.replace(/^responses:/, '') + } + if (accountType === 'gemini-api') { + return accountId.replace(/^api:/, '') + } + return accountId +} + +function parseBooleanWithDefault(value, defaultValue = false) { + if (value === undefined || value === null || value === '') { + return defaultValue + } + + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'string') { + return value === 'true' + } + + return Boolean(value) +} + +function parseOpenAIResponsesPayloadRules(rawRules) { + if (rawRules === undefined || rawRules === null || rawRules === '') { + return [] + } + + let parsedRules = rawRules + if (typeof rawRules === 'string') { + try { + parsedRules = JSON.parse(rawRules) + } catch (error) { + return [] + } + } + + if (!Array.isArray(parsedRules)) { + return [] + } + + return parsedRules.map((rule) => requestBodyRuleService.normalizeRule(rule)).filter(Boolean) +} + +class ApiKeyService { + constructor() { + this.prefix = config.security.apiKeyPrefix + } + + // 🔑 生成新的API Key + async generateApiKey(options = {}) { + const { + name = 'Unnamed Key', + description = '', + tokenLimit = 0, // 默认为0,不再使用token限制 + expiresAt = null, + claudeAccountId = null, + claudeConsoleAccountId = null, + geminiAccountId = null, + openaiAccountId = null, + azureOpenaiAccountId = null, + bedrockAccountId = null, // 添加 Bedrock 账号ID支持 + droidAccountId = null, + permissions = [], // 数组格式,空数组表示全部服务,如 ['claude', 'gemini'] + isActive = true, + concurrencyLimit = 0, + rateLimitWindow = null, + rateLimitRequests = null, + rateLimitCost = null, // 新增:速率限制费用字段 + enableModelRestriction = false, + restrictedModels = [], + enableClientRestriction = false, + allowedClients = [], + dailyCostLimit = 0, + totalCostLimit = 0, + weeklyOpusCostLimit = 0, + tags = [], + activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能) + activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days' + expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活) + icon = '', // 新增:图标(base64编码) + serviceRates = {}, // API Key 级别服务倍率覆盖 + weeklyResetDay = 1, // 周费用重置日 (1=周一 ... 7=周日) + weeklyResetHour = 0, // 周费用重置时 (0-23) + enableOpenAIResponsesCodexAdaptation = true, + enableOpenAIResponsesPayloadRules = false, + openaiResponsesPayloadRules = [] + } = options + + const payloadRulesValidation = requestBodyRuleService.validateAndNormalizeRules( + openaiResponsesPayloadRules + ) + if (!payloadRulesValidation.valid) { + throw new Error(payloadRulesValidation.error) + } + + // 生成简单的API Key (64字符十六进制) + const apiKey = `${this.prefix}${this._generateSecretKey()}` + const keyId = uuidv4() + const hashedKey = this._hashApiKey(apiKey) + + // 处理 permissions + const _permissionsValue = permissions + + const keyData = { + id: keyId, + name, + description, + apiKey: hashedKey, + tokenLimit: String(tokenLimit ?? 0), + concurrencyLimit: String(concurrencyLimit ?? 0), + rateLimitWindow: String(rateLimitWindow ?? 0), + rateLimitRequests: String(rateLimitRequests ?? 0), + rateLimitCost: String(rateLimitCost ?? 0), // 新增:速率限制费用字段 + isActive: String(isActive), + claudeAccountId: claudeAccountId || '', + claudeConsoleAccountId: claudeConsoleAccountId || '', + geminiAccountId: geminiAccountId || '', + openaiAccountId: openaiAccountId || '', + azureOpenaiAccountId: azureOpenaiAccountId || '', + bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID + droidAccountId: droidAccountId || '', + permissions: JSON.stringify(normalizePermissions(permissions)), + enableModelRestriction: String(enableModelRestriction), + restrictedModels: JSON.stringify(restrictedModels || []), + enableClientRestriction: String(enableClientRestriction || false), + allowedClients: JSON.stringify(allowedClients || []), + dailyCostLimit: String(dailyCostLimit || 0), + totalCostLimit: String(totalCostLimit || 0), + weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), + tags: JSON.stringify(tags || []), + activationDays: String(activationDays || 0), // 新增:激活后有效天数 + activationUnit: activationUnit || 'days', // 新增:激活时间单位 + expirationMode: expirationMode || 'fixed', // 新增:过期模式 + isActivated: expirationMode === 'fixed' ? 'true' : 'false', // 根据模式决定激活状态 + activatedAt: expirationMode === 'fixed' ? new Date().toISOString() : '', // 激活时间 + createdAt: new Date().toISOString(), + lastUsedAt: '', + expiresAt: expirationMode === 'fixed' ? expiresAt || '' : '', // 固定模式才设置过期时间 + createdBy: options.createdBy || 'admin', + userId: options.userId || '', + userUsername: options.userUsername || '', + icon: icon || '', // 新增:图标(base64编码) + serviceRates: JSON.stringify(serviceRates || {}), // API Key 级别服务倍率 + weeklyResetDay: String(weeklyResetDay || 1), // 周费用重置日 (1-7) + weeklyResetHour: String(weeklyResetHour || 0), // 周费用重置时 (0-23) + enableOpenAIResponsesCodexAdaptation: String(enableOpenAIResponsesCodexAdaptation !== false), + enableOpenAIResponsesPayloadRules: String(enableOpenAIResponsesPayloadRules === true), + openaiResponsesPayloadRules: JSON.stringify(payloadRulesValidation.rules) + } + + // 保存API Key数据并建立哈希映射 + await redis.setApiKey(keyId, keyData, hashedKey) + + // 同步添加到费用排序索引 + try { + const costRankService = require('./costRankService') + await costRankService.addKeyToIndexes(keyId) + } catch (err) { + logger.warn(`Failed to add key ${keyId} to cost rank indexes:`, err.message) + } + + // 同步添加到 API Key 索引(用于分页查询优化) + try { + const apiKeyIndexService = require('./apiKeyIndexService') + await apiKeyIndexService.addToIndex({ + id: keyId, + name: keyData.name, + createdAt: keyData.createdAt, + lastUsedAt: keyData.lastUsedAt, + isActive: keyData.isActive === 'true', + isDeleted: false, + tags: JSON.parse(keyData.tags || '[]') + }) + } catch (err) { + logger.warn(`Failed to add key ${keyId} to API Key index:`, err.message) + } + + logger.success(`🔑 Generated new API key: ${name} (${keyId})`) + + return { + id: keyId, + apiKey, // 只在创建时返回完整的key + name: keyData.name, + description: keyData.description, + tokenLimit: parseInt(keyData.tokenLimit), + concurrencyLimit: parseInt(keyData.concurrencyLimit), + rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), + rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), + rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段 + isActive: keyData.isActive === 'true', + claudeAccountId: keyData.claudeAccountId, + claudeConsoleAccountId: keyData.claudeConsoleAccountId, + geminiAccountId: keyData.geminiAccountId, + openaiAccountId: keyData.openaiAccountId, + azureOpenaiAccountId: keyData.azureOpenaiAccountId, + bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID + droidAccountId: keyData.droidAccountId, + permissions: normalizePermissions(keyData.permissions), + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels: JSON.parse(keyData.restrictedModels), + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients: JSON.parse(keyData.allowedClients || '[]'), + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), + weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), + tags: JSON.parse(keyData.tags || '[]'), + activationDays: parseInt(keyData.activationDays || 0), + activationUnit: keyData.activationUnit || 'days', + expirationMode: keyData.expirationMode || 'fixed', + isActivated: keyData.isActivated === 'true', + activatedAt: keyData.activatedAt, + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, + createdBy: keyData.createdBy, + serviceRates: JSON.parse(keyData.serviceRates || '{}'), // API Key 级别服务倍率 + enableOpenAIResponsesCodexAdaptation: parseBooleanWithDefault( + keyData.enableOpenAIResponsesCodexAdaptation, + true + ), + enableOpenAIResponsesPayloadRules: parseBooleanWithDefault( + keyData.enableOpenAIResponsesPayloadRules, + false + ), + openaiResponsesPayloadRules: parseOpenAIResponsesPayloadRules( + keyData.openaiResponsesPayloadRules + ) + } + } + + // 🔍 验证API Key + async validateApiKey(apiKey) { + try { + if (!apiKey || !apiKey.startsWith(this.prefix)) { + return { valid: false, error: 'Invalid API key format' } + } + + // 计算API Key的哈希值 + const hashedKey = this._hashApiKey(apiKey) + + // 通过哈希值直接查找API Key(性能优化) + const keyData = await redis.findApiKeyByHash(hashedKey) + + if (!keyData) { + // ⚠️ 警告:映射表查找失败,可能是竞态条件或映射表损坏 + logger.warn( + `⚠️ API key not found in hash map: ${hashedKey.substring(0, 16)}... (possible race condition or corrupted hash map)` + ) + return { valid: false, error: 'API key not found' } + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + return { valid: false, error: 'API key is disabled' } + } + + // 处理激活逻辑(仅在 activation 模式下) + if (keyData.expirationMode === 'activation' && keyData.isActivated !== 'true') { + // 首次使用,需要激活 + const now = new Date() + const activationPeriod = parseInt(keyData.activationDays || 30) // 默认30 + const activationUnit = keyData.activationUnit || 'days' // 默认天 + + // 根据单位计算过期时间 + let milliseconds + if (activationUnit === 'hours') { + milliseconds = activationPeriod * 60 * 60 * 1000 // 小时转毫秒 + } else { + milliseconds = activationPeriod * 24 * 60 * 60 * 1000 // 天转毫秒 + } + + const expiresAt = new Date(now.getTime() + milliseconds) + + // 更新激活状态和过期时间 + keyData.isActivated = 'true' + keyData.activatedAt = now.toISOString() + keyData.expiresAt = expiresAt.toISOString() + keyData.lastUsedAt = now.toISOString() + + // 保存到Redis + await redis.setApiKey(keyData.id, keyData) + + logger.success( + `🔓 API key activated: ${keyData.id} (${ + keyData.name + }), will expire in ${activationPeriod} ${activationUnit} at ${expiresAt.toISOString()}` + ) + } + + // 检查是否过期 + if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) { + return { valid: false, error: 'API key has expired' } + } + + // 如果API Key属于某个用户,检查用户是否被禁用 + if (keyData.userId) { + try { + const userService = require('./userService') + const user = await userService.getUserById(keyData.userId, false) + if (!user || !user.isActive) { + return { valid: false, error: 'User account is disabled' } + } + } catch (error) { + logger.error('❌ Error checking user status during API key validation:', error) + return { valid: false, error: 'Unable to validate user status' } + } + } + + // 按需获取费用统计(仅在有限制时查询,减少 Redis 调用) + const dailyCostLimit = parseFloat(keyData.dailyCostLimit || 0) + const totalCostLimit = parseFloat(keyData.totalCostLimit || 0) + const weeklyOpusCostLimit = parseFloat(keyData.weeklyOpusCostLimit || 0) + + const costQueries = [] + if (dailyCostLimit > 0) { + costQueries.push(redis.getDailyCost(keyData.id).then((v) => ({ dailyCost: v || 0 }))) + } + if (totalCostLimit > 0) { + costQueries.push(redis.getCostStats(keyData.id).then((v) => ({ totalCost: v?.total || 0 }))) + } + if (weeklyOpusCostLimit > 0) { + const resetDay = parseInt(keyData.weeklyResetDay || 1) + const resetHour = parseInt(keyData.weeklyResetHour || 0) + costQueries.push( + redis + .getWeeklyOpusCost(keyData.id, resetDay, resetHour) + .then((v) => ({ weeklyOpusCost: v || 0 })) + ) + } + + const costData = + costQueries.length > 0 ? Object.assign({}, ...(await Promise.all(costQueries))) : {} + + // 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时) + // 注意:lastUsedAt的更新已移至recordUsage方法中 + + logger.api(`🔓 API key validated successfully: ${keyData.id}`) + + // 解析限制模型数据 + let restrictedModels = [] + try { + restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] + } catch (e) { + restrictedModels = [] + } + + // 解析允许的客户端 + let allowedClients = [] + try { + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] + } catch (e) { + allowedClients = [] + } + + // 解析标签 + let tags = [] + try { + tags = keyData.tags ? JSON.parse(keyData.tags) : [] + } catch (e) { + tags = [] + } + + // 解析 serviceRates + let serviceRates = {} + try { + serviceRates = keyData.serviceRates ? JSON.parse(keyData.serviceRates) : {} + } catch (e) { + // 解析失败使用默认值 + } + + const openaiResponsesPayloadRules = parseOpenAIResponsesPayloadRules( + keyData.openaiResponsesPayloadRules + ) + const enableOpenAIResponsesCodexAdaptation = parseBooleanWithDefault( + keyData.enableOpenAIResponsesCodexAdaptation, + true + ) + const enableOpenAIResponsesPayloadRules = parseBooleanWithDefault( + keyData.enableOpenAIResponsesPayloadRules, + false + ) + + return { + valid: true, + keyData: { + id: keyData.id, + name: keyData.name, + description: keyData.description, + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, + claudeAccountId: keyData.claudeAccountId, + claudeConsoleAccountId: keyData.claudeConsoleAccountId, + geminiAccountId: keyData.geminiAccountId, + openaiAccountId: keyData.openaiAccountId, + azureOpenaiAccountId: keyData.azureOpenaiAccountId, + bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID + droidAccountId: keyData.droidAccountId, + permissions: normalizePermissions(keyData.permissions), + tokenLimit: parseInt(keyData.tokenLimit), + concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), + rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), + rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), + rateLimitCost: parseFloat(keyData.rateLimitCost || 0), // 新增:速率限制费用字段 + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels, + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients, + dailyCostLimit, + totalCostLimit, + weeklyOpusCostLimit, + dailyCost: costData.dailyCost || 0, + totalCost: costData.totalCost || 0, + weeklyOpusCost: costData.weeklyOpusCost || 0, + weeklyResetDay: parseInt(keyData.weeklyResetDay || 1), + weeklyResetHour: parseInt(keyData.weeklyResetHour || 0), + tags, + serviceRates, + enableOpenAIResponsesCodexAdaptation, + enableOpenAIResponsesPayloadRules, + openaiResponsesPayloadRules + } + } + } catch (error) { + logger.error('❌ API key validation error:', error) + return { valid: false, error: 'Internal validation error' } + } + } + + // 🔍 验证API Key(仅用于统计查询,不触发激活) + async validateApiKeyForStats(apiKey) { + try { + if (!apiKey || !apiKey.startsWith(this.prefix)) { + return { valid: false, error: 'Invalid API key format' } + } + + // 计算API Key的哈希值 + const hashedKey = this._hashApiKey(apiKey) + + // 通过哈希值直接查找API Key(性能优化) + const keyData = await redis.findApiKeyByHash(hashedKey) + + if (!keyData) { + return { valid: false, error: 'API key not found' } + } + + // 检查是否激活 + if (keyData.isActive !== 'true') { + const keyName = keyData.name || 'Unknown' + return { valid: false, error: `API Key "${keyName}" 已被禁用`, keyName } + } + + // 注意:这里不处理激活逻辑,保持 API Key 的未激活状态 + + // 检查是否过期(仅对已激活的 Key 检查) + if ( + keyData.isActivated === 'true' && + keyData.expiresAt && + new Date() > new Date(keyData.expiresAt) + ) { + const keyName = keyData.name || 'Unknown' + return { valid: false, error: `API Key "${keyName}" 已过期`, keyName } + } + + // 如果API Key属于某个用户,检查用户是否被禁用 + if (keyData.userId) { + try { + const userService = require('./userService') + const user = await userService.getUserById(keyData.userId, false) + if (!user || !user.isActive) { + return { valid: false, error: 'User account is disabled' } + } + } catch (userError) { + // 如果用户服务出错,记录但不影响API Key验证 + logger.warn(`Failed to check user status for API key ${keyData.id}:`, userError) + } + } + + // 获取当日费用 + const [dailyCost, costStats] = await Promise.all([ + redis.getDailyCost(keyData.id), + redis.getCostStats(keyData.id) + ]) + + // 获取使用统计 + const usage = await redis.getUsageStats(keyData.id) + + // 解析限制模型数据 + let restrictedModels = [] + try { + restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : [] + } catch (e) { + restrictedModels = [] + } + + // 解析允许的客户端 + let allowedClients = [] + try { + allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : [] + } catch (e) { + allowedClients = [] + } + + // 解析标签 + let tags = [] + try { + tags = keyData.tags ? JSON.parse(keyData.tags) : [] + } catch (e) { + tags = [] + } + + const openaiResponsesPayloadRules = parseOpenAIResponsesPayloadRules( + keyData.openaiResponsesPayloadRules + ) + const enableOpenAIResponsesCodexAdaptation = parseBooleanWithDefault( + keyData.enableOpenAIResponsesCodexAdaptation, + true + ) + const enableOpenAIResponsesPayloadRules = parseBooleanWithDefault( + keyData.enableOpenAIResponsesPayloadRules, + false + ) + + return { + valid: true, + keyData: { + id: keyData.id, + name: keyData.name, + description: keyData.description, + createdAt: keyData.createdAt, + expiresAt: keyData.expiresAt, + // 添加激活相关字段 + expirationMode: keyData.expirationMode || 'fixed', + isActivated: keyData.isActivated === 'true', + activationDays: parseInt(keyData.activationDays || 0), + activationUnit: keyData.activationUnit || 'days', + activatedAt: keyData.activatedAt || null, + claudeAccountId: keyData.claudeAccountId, + claudeConsoleAccountId: keyData.claudeConsoleAccountId, + geminiAccountId: keyData.geminiAccountId, + openaiAccountId: keyData.openaiAccountId, + azureOpenaiAccountId: keyData.azureOpenaiAccountId, + bedrockAccountId: keyData.bedrockAccountId, + droidAccountId: keyData.droidAccountId, + permissions: normalizePermissions(keyData.permissions), + tokenLimit: parseInt(keyData.tokenLimit), + concurrencyLimit: parseInt(keyData.concurrencyLimit || 0), + rateLimitWindow: parseInt(keyData.rateLimitWindow || 0), + rateLimitRequests: parseInt(keyData.rateLimitRequests || 0), + rateLimitCost: parseFloat(keyData.rateLimitCost || 0), + enableModelRestriction: keyData.enableModelRestriction === 'true', + restrictedModels, + enableClientRestriction: keyData.enableClientRestriction === 'true', + allowedClients, + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), + weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), + dailyCost: dailyCost || 0, + totalCost: costStats?.total || 0, + weeklyOpusCost: + (await redis.getWeeklyOpusCost( + keyData.id, + parseInt(keyData.weeklyResetDay || 1), + parseInt(keyData.weeklyResetHour || 0) + )) || 0, + tags, + usage, + enableOpenAIResponsesCodexAdaptation, + enableOpenAIResponsesPayloadRules, + openaiResponsesPayloadRules + } + } + } catch (error) { + logger.error('❌ API key validation error (stats):', error) + return { valid: false, error: 'Internal validation error' } + } + } + + // 🏷️ 获取所有标签(合并索引和全局集合) + async getAllTags() { + const indexTags = await redis.scanAllApiKeyTags() + const globalTags = await redis.getGlobalTags() + // 过滤空值和空格 + return [ + ...new Set([...indexTags, ...globalTags].map((t) => (t ? t.trim() : '')).filter((t) => t)) + ].sort() + } + + // 🏷️ 创建新标签 + async createTag(tagName) { + const existingTags = await this.getAllTags() + if (existingTags.includes(tagName)) { + return { success: false, error: '标签已存在' } + } + await redis.addTag(tagName) + return { success: true } + } + + // 🏷️ 获取标签详情(含使用数量) + async getTagsWithCount() { + const apiKeys = await redis.getAllApiKeys() + const tagCounts = new Map() + + // 统计 API Key 上的标签(trim 后统计) + for (const key of apiKeys) { + if (key.isDeleted === 'true') { + continue + } + let tags = [] + try { + const parsed = key.tags ? JSON.parse(key.tags) : [] + tags = Array.isArray(parsed) ? parsed : [] + } catch { + tags = [] + } + for (const tag of tags) { + if (typeof tag === 'string') { + const trimmed = tag.trim() + if (trimmed) { + tagCounts.set(trimmed, (tagCounts.get(trimmed) || 0) + 1) + } + } + } + } + + // 直接获取全局标签集合(避免重复扫描) + const globalTags = await redis.getGlobalTags() + for (const tag of globalTags) { + const trimmed = tag ? tag.trim() : '' + if (trimmed && !tagCounts.has(trimmed)) { + tagCounts.set(trimmed, 0) + } + } + + return Array.from(tagCounts.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + } + + // 🏷️ 从所有 API Key 中移除指定标签 + async removeTagFromAllKeys(tagName) { + const normalizedName = (tagName || '').trim() + if (!normalizedName) { + return { affectedCount: 0 } + } + + const apiKeys = await redis.getAllApiKeys() + let affectedCount = 0 + + for (const key of apiKeys) { + if (key.isDeleted === 'true') { + continue + } + let tags = [] + try { + const parsed = key.tags ? JSON.parse(key.tags) : [] + tags = Array.isArray(parsed) ? parsed : [] + } catch { + tags = [] + } + + // 匹配时 trim 比较,过滤非字符串 + const strTags = tags.filter((t) => typeof t === 'string') + if (strTags.some((t) => t.trim() === normalizedName)) { + const newTags = strTags.filter((t) => t.trim() !== normalizedName) + await this.updateApiKey(key.id, { tags: newTags }) + affectedCount++ + } + } + + // 同时从全局标签集合删除 + await redis.removeTag(normalizedName) + await redis.removeTag(tagName) // 也删除原始值(可能带空格) + + return { affectedCount } + } + + // 🏷️ 重命名标签 + async renameTag(oldName, newName) { + if (!newName || !newName.trim()) { + return { affectedCount: 0, error: '新标签名不能为空' } + } + + const normalizedOld = (oldName || '').trim() + const normalizedNew = newName.trim() + + if (!normalizedOld) { + return { affectedCount: 0, error: '旧标签名不能为空' } + } + + const apiKeys = await redis.getAllApiKeys() + let affectedCount = 0 + let foundInKeys = false + + for (const key of apiKeys) { + if (key.isDeleted === 'true') { + continue + } + let tags = [] + try { + const parsed = key.tags ? JSON.parse(key.tags) : [] + tags = Array.isArray(parsed) ? parsed : [] + } catch { + tags = [] + } + + // 匹配时 trim 比较,过滤非字符串 + const strTags = tags.filter((t) => typeof t === 'string') + if (strTags.some((t) => t.trim() === normalizedOld)) { + foundInKeys = true + const newTags = [ + ...new Set(strTags.map((t) => (t.trim() === normalizedOld ? normalizedNew : t))) + ] + await this.updateApiKey(key.id, { tags: newTags }) + affectedCount++ + } + } + + // 检查全局集合是否有该标签 + const globalTags = await redis.getGlobalTags() + const foundInGlobal = globalTags.some( + (t) => typeof t === 'string' && t.trim() === normalizedOld + ) + + if (!foundInKeys && !foundInGlobal) { + return { affectedCount: 0, error: '标签不存在' } + } + + // 同时更新全局标签集合(删旧加新) + await redis.removeTag(normalizedOld) + await redis.removeTag(oldName) // 也删除原始值 + await redis.addTag(normalizedNew) + + return { affectedCount } + } + + // 📋 获取所有API Keys + async getAllApiKeys(includeDeleted = false) { + try { + let apiKeys = await redis.getAllApiKeys() + const client = redis.getClientSafe() + const accountInfoCache = new Map() + + // 默认过滤掉已删除的API Keys + if (!includeDeleted) { + apiKeys = apiKeys.filter((key) => key.isDeleted !== 'true') + } + + // 为每个key添加使用统计和当前并发数 + for (const key of apiKeys) { + key.usage = await redis.getUsageStats(key.id) + const costStats = await redis.getCostStats(key.id) + // 为前端兼容性:把费用信息同步到 usage 对象里 + if (key.usage && costStats) { + key.usage.total = key.usage.total || {} + key.usage.total.cost = costStats.total + key.usage.totalCost = costStats.total + } + key.totalCost = costStats ? costStats.total : 0 + key.tokenLimit = parseInt(key.tokenLimit) + key.concurrencyLimit = parseInt(key.concurrencyLimit || 0) + key.rateLimitWindow = parseInt(key.rateLimitWindow || 0) + key.rateLimitRequests = parseInt(key.rateLimitRequests || 0) + key.rateLimitCost = parseFloat(key.rateLimitCost || 0) // 新增:速率限制费用字段 + key.currentConcurrency = await redis.getConcurrency(key.id) + key.isActive = key.isActive === 'true' + key.enableModelRestriction = key.enableModelRestriction === 'true' + key.enableClientRestriction = key.enableClientRestriction === 'true' + key.enableOpenAIResponsesCodexAdaptation = parseBooleanWithDefault( + key.enableOpenAIResponsesCodexAdaptation, + true + ) + key.enableOpenAIResponsesPayloadRules = parseBooleanWithDefault( + key.enableOpenAIResponsesPayloadRules, + false + ) + key.permissions = normalizePermissions(key.permissions) + key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) + key.totalCostLimit = parseFloat(key.totalCostLimit || 0) + key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) + key.dailyCost = (await redis.getDailyCost(key.id)) || 0 + key.weeklyOpusCost = + (await redis.getWeeklyOpusCost( + key.id, + parseInt(key.weeklyResetDay || 1), + parseInt(key.weeklyResetHour || 0) + )) || 0 + key.activationDays = parseInt(key.activationDays || 0) + key.activationUnit = key.activationUnit || 'days' + key.expirationMode = key.expirationMode || 'fixed' + key.isActivated = key.isActivated === 'true' + key.activatedAt = key.activatedAt || null + + // 获取当前时间窗口的请求次数、Token使用量和费用 + if (key.rateLimitWindow > 0) { + const requestCountKey = `rate_limit:requests:${key.id}` + const tokenCountKey = `rate_limit:tokens:${key.id}` + const costCountKey = `rate_limit:cost:${key.id}` // 新增:费用计数器 + const windowStartKey = `rate_limit:window_start:${key.id}` + + key.currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0') + key.currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0') + key.currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:当前窗口费用 + + // 获取窗口开始时间和计算剩余时间 + const windowStart = await client.get(windowStartKey) + if (windowStart) { + const now = Date.now() + const windowStartTime = parseInt(windowStart) + const windowDuration = key.rateLimitWindow * 60 * 1000 // 转换为毫秒 + const windowEndTime = windowStartTime + windowDuration + + // 如果窗口还有效 + if (now < windowEndTime) { + key.windowStartTime = windowStartTime + key.windowEndTime = windowEndTime + key.windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000)) + } else { + // 窗口已过期,下次请求会重置 + key.windowStartTime = null + key.windowEndTime = null + key.windowRemainingSeconds = 0 + // 重置计数为0,因为窗口已过期 + key.currentWindowRequests = 0 + key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 + } + } else { + // 窗口还未开始(没有任何请求) + key.windowStartTime = null + key.windowEndTime = null + key.windowRemainingSeconds = null + } + } else { + key.currentWindowRequests = 0 + key.currentWindowTokens = 0 + key.currentWindowCost = 0 // 新增:重置费用 + key.windowStartTime = null + key.windowEndTime = null + key.windowRemainingSeconds = null + } + + try { + key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [] + } catch (e) { + key.restrictedModels = [] + } + try { + key.allowedClients = key.allowedClients ? JSON.parse(key.allowedClients) : [] + } catch (e) { + key.allowedClients = [] + } + try { + key.tags = key.tags ? JSON.parse(key.tags) : [] + } catch (e) { + key.tags = [] + } + key.openaiResponsesPayloadRules = parseOpenAIResponsesPayloadRules( + key.openaiResponsesPayloadRules + ) + // 不暴露已弃用字段 + if (Object.prototype.hasOwnProperty.call(key, 'ccrAccountId')) { + delete key.ccrAccountId + } + + let lastUsageRecord = null + try { + const usageRecords = await redis.getUsageRecords(key.id, 1) + if (Array.isArray(usageRecords) && usageRecords.length > 0) { + lastUsageRecord = usageRecords[0] + } + } catch (error) { + logger.debug(`加载 API Key ${key.id} 的使用记录失败:`, error) + } + + if (lastUsageRecord && (lastUsageRecord.accountId || lastUsageRecord.accountType)) { + const resolvedAccount = await this._resolveLastUsageAccount( + key, + lastUsageRecord, + accountInfoCache, + client + ) + + if (resolvedAccount) { + key.lastUsage = { + accountId: resolvedAccount.accountId, + rawAccountId: lastUsageRecord.accountId || resolvedAccount.accountId, + accountType: resolvedAccount.accountType, + accountCategory: resolvedAccount.accountCategory, + accountName: resolvedAccount.accountName, + recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null + } + } else { + key.lastUsage = { + accountId: null, + rawAccountId: lastUsageRecord.accountId || null, + accountType: 'deleted', + accountCategory: 'deleted', + accountName: '已删除', + recordedAt: lastUsageRecord.timestamp || key.lastUsedAt || null + } + } + } else { + key.lastUsage = null + } + + delete key.apiKey // 不返回哈希后的key + } + + return apiKeys + } catch (error) { + logger.error('❌ Failed to get API keys:', error) + throw error + } + } + + /** + * 🚀 快速获取所有 API Keys(使用 Pipeline 批量操作,性能优化版) + * 适用于 dashboard、usage-costs 等需要大量 API Key 数据的场景 + * @param {boolean} includeDeleted - 是否包含已删除的 API Keys + * @returns {Promise} API Keys 列表 + */ + async getAllApiKeysFast(includeDeleted = false) { + try { + // 1. 使用 SCAN 获取所有 API Key IDs + const keyIds = await redis.scanApiKeyIds() + if (keyIds.length === 0) { + return [] + } + + // 2. 批量获取基础数据 + let apiKeys = await redis.batchGetApiKeys(keyIds) + + // 3. 过滤已删除的 + if (!includeDeleted) { + apiKeys = apiKeys.filter((key) => !key.isDeleted) + } + + // 4. 批量获取统计数据(单次 Pipeline) + const activeKeyIds = apiKeys.map((k) => k.id) + const statsMap = await redis.batchGetApiKeyStats(activeKeyIds) + + // 5. 合并数据 + for (const key of apiKeys) { + const stats = statsMap.get(key.id) || {} + + // 处理 usage 数据 + const usageTotal = stats.usageTotal || {} + const usageDaily = stats.usageDaily || {} + const usageMonthly = stats.usageMonthly || {} + + // 计算平均 RPM/TPM + const createdAt = stats.createdAt ? new Date(stats.createdAt) : new Date() + const daysSinceCreated = Math.max( + 1, + Math.ceil((Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24)) + ) + const totalMinutes = daysSinceCreated * 24 * 60 + // 兼容旧数据格式:优先读 totalXxx,fallback 到 xxx + const totalRequests = parseInt(usageTotal.totalRequests || usageTotal.requests) || 0 + const totalTokens = parseInt(usageTotal.totalTokens || usageTotal.tokens) || 0 + let inputTokens = parseInt(usageTotal.totalInputTokens || usageTotal.inputTokens) || 0 + let outputTokens = parseInt(usageTotal.totalOutputTokens || usageTotal.outputTokens) || 0 + let cacheCreateTokens = + parseInt(usageTotal.totalCacheCreateTokens || usageTotal.cacheCreateTokens) || 0 + let cacheReadTokens = + parseInt(usageTotal.totalCacheReadTokens || usageTotal.cacheReadTokens) || 0 + + // 旧数据兼容:没有 input/output 分离时做 30/70 拆分 + const totalFromSeparate = inputTokens + outputTokens + if (totalFromSeparate === 0 && totalTokens > 0) { + inputTokens = Math.round(totalTokens * 0.3) + outputTokens = Math.round(totalTokens * 0.7) + cacheCreateTokens = 0 + cacheReadTokens = 0 + } + + // allTokens:优先读存储值,否则计算,最后 fallback 到 totalTokens + const allTokens = + parseInt(usageTotal.totalAllTokens || usageTotal.allTokens) || + inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens || + totalTokens + + key.usage = { + total: { + requests: totalRequests, + tokens: allTokens, // 与 getUsageStats 语义一致:包含 cache 的总 tokens + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + allTokens, + cost: stats.costStats?.total || 0 + }, + daily: { + requests: parseInt(usageDaily.totalRequests || usageDaily.requests) || 0, + tokens: parseInt(usageDaily.totalTokens || usageDaily.tokens) || 0 + }, + monthly: { + requests: parseInt(usageMonthly.totalRequests || usageMonthly.requests) || 0, + tokens: parseInt(usageMonthly.totalTokens || usageMonthly.tokens) || 0 + }, + averages: { + rpm: Math.round((totalRequests / totalMinutes) * 100) / 100, + tpm: Math.round((totalTokens / totalMinutes) * 100) / 100 + }, + totalCost: stats.costStats?.total || 0 + } + + // 费用统计 + key.totalCost = stats.costStats?.total || 0 + key.dailyCost = stats.dailyCost || 0 + key.weeklyOpusCost = stats.weeklyOpusCost || 0 + + // 并发 + key.currentConcurrency = stats.concurrency || 0 + + // 类型转换 + key.tokenLimit = parseInt(key.tokenLimit) || 0 + key.concurrencyLimit = parseInt(key.concurrencyLimit) || 0 + key.rateLimitWindow = parseInt(key.rateLimitWindow) || 0 + key.rateLimitRequests = parseInt(key.rateLimitRequests) || 0 + key.rateLimitCost = parseFloat(key.rateLimitCost) || 0 + key.dailyCostLimit = parseFloat(key.dailyCostLimit) || 0 + key.totalCostLimit = parseFloat(key.totalCostLimit) || 0 + key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit) || 0 + key.activationDays = parseInt(key.activationDays) || 0 + key.isActive = key.isActive === 'true' || key.isActive === true + key.enableModelRestriction = + key.enableModelRestriction === 'true' || key.enableModelRestriction === true + key.enableClientRestriction = + key.enableClientRestriction === 'true' || key.enableClientRestriction === true + key.enableOpenAIResponsesCodexAdaptation = parseBooleanWithDefault( + key.enableOpenAIResponsesCodexAdaptation, + true + ) + key.enableOpenAIResponsesPayloadRules = parseBooleanWithDefault( + key.enableOpenAIResponsesPayloadRules, + false + ) + key.isActivated = key.isActivated === 'true' || key.isActivated === true + key.permissions = key.permissions || 'all' + key.activationUnit = key.activationUnit || 'days' + key.expirationMode = key.expirationMode || 'fixed' + key.activatedAt = key.activatedAt || null + + // Rate limit 窗口数据 + if (key.rateLimitWindow > 0) { + const rl = stats.rateLimit || {} + key.currentWindowRequests = rl.requests || 0 + key.currentWindowTokens = rl.tokens || 0 + key.currentWindowCost = rl.cost || 0 + + if (rl.windowStart) { + const now = Date.now() + const windowDuration = key.rateLimitWindow * 60 * 1000 + const windowEndTime = rl.windowStart + windowDuration + + if (now < windowEndTime) { + key.windowStartTime = rl.windowStart + key.windowEndTime = windowEndTime + key.windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000)) + } else { + key.windowStartTime = null + key.windowEndTime = null + key.windowRemainingSeconds = 0 + key.currentWindowRequests = 0 + key.currentWindowTokens = 0 + key.currentWindowCost = 0 + } + } else { + key.windowStartTime = null + key.windowEndTime = null + key.windowRemainingSeconds = null + } + } else { + key.currentWindowRequests = 0 + key.currentWindowTokens = 0 + key.currentWindowCost = 0 + key.windowStartTime = null + key.windowEndTime = null + key.windowRemainingSeconds = null + } + + // JSON 字段解析(兼容已解析的数组和未解析的字符串) + if (Array.isArray(key.restrictedModels)) { + // 已解析,保持不变 + } else if (key.restrictedModels) { + try { + key.restrictedModels = JSON.parse(key.restrictedModels) + } catch { + key.restrictedModels = [] + } + } else { + key.restrictedModels = [] + } + if (Array.isArray(key.allowedClients)) { + // 已解析,保持不变 + } else if (key.allowedClients) { + try { + key.allowedClients = JSON.parse(key.allowedClients) + } catch { + key.allowedClients = [] + } + } else { + key.allowedClients = [] + } + if (Array.isArray(key.tags)) { + // 已解析,保持不变 + } else if (key.tags) { + try { + key.tags = JSON.parse(key.tags) + } catch { + key.tags = [] + } + } else { + key.tags = [] + } + if (Array.isArray(key.openaiResponsesPayloadRules)) { + // 已解析,保持不变 + } else if (key.openaiResponsesPayloadRules) { + key.openaiResponsesPayloadRules = parseOpenAIResponsesPayloadRules( + key.openaiResponsesPayloadRules + ) + } else { + key.openaiResponsesPayloadRules = [] + } + + // 生成掩码key后再清理敏感字段 + if (key.apiKey) { + key.maskedKey = `${this.prefix}****${key.apiKey.slice(-4)}` + } + delete key.apiKey + delete key.ccrAccountId + + // 不获取 lastUsage(太慢),设为 null + key.lastUsage = null + } + + return apiKeys + } catch (error) { + logger.error('❌ Failed to get API keys (fast):', error) + throw error + } + } + + /** + * 获取所有 API Keys 的轻量版本(仅绑定字段,用于计算绑定数) + * @returns {Promise} 包含绑定字段的 API Keys 列表 + */ + async getAllApiKeysLite() { + try { + const client = redis.getClientSafe() + const keyIds = await redis.scanApiKeyIds() + + if (keyIds.length === 0) { + return [] + } + + // Pipeline 只获取绑定相关字段 + const pipeline = client.pipeline() + for (const keyId of keyIds) { + pipeline.hmget( + `apikey:${keyId}`, + 'claudeAccountId', + 'geminiAccountId', + 'openaiAccountId', + 'droidAccountId', + 'isDeleted' + ) + } + const results = await pipeline.exec() + + return keyIds + .map((id, i) => { + const [err, fields] = results[i] + if (err) { + return null + } + return { + id, + claudeAccountId: fields[0] || null, + geminiAccountId: fields[1] || null, + openaiAccountId: fields[2] || null, + droidAccountId: fields[3] || null, + isDeleted: fields[4] === 'true' + } + }) + .filter((k) => k && !k.isDeleted) + } catch (error) { + logger.error('❌ Failed to get API keys (lite):', error) + return [] + } + } + + // 📝 更新API Key + async updateApiKey(keyId, updates) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 允许更新的字段 + const allowedUpdates = [ + 'name', + 'description', + 'tokenLimit', + 'concurrencyLimit', + 'rateLimitWindow', + 'rateLimitRequests', + 'rateLimitCost', // 新增:速率限制费用字段 + 'isActive', + 'claudeAccountId', + 'claudeConsoleAccountId', + 'geminiAccountId', + 'openaiAccountId', + 'azureOpenaiAccountId', + 'bedrockAccountId', // 添加 Bedrock 账号ID + 'droidAccountId', + 'permissions', + 'expiresAt', + 'activationDays', // 新增:激活后有效天数 + 'activationUnit', // 新增:激活时间单位 + 'expirationMode', // 新增:过期模式 + 'isActivated', // 新增:是否已激活 + 'activatedAt', // 新增:激活时间 + 'enableModelRestriction', + 'restrictedModels', + 'enableClientRestriction', + 'allowedClients', + 'dailyCostLimit', + 'totalCostLimit', + 'weeklyOpusCostLimit', + 'tags', + 'userId', // 新增:用户ID(所有者变更) + 'userUsername', // 新增:用户名(所有者变更) + 'createdBy', // 新增:创建者(所有者变更) + 'serviceRates', // API Key 级别服务倍率 + 'weeklyResetDay', // 周费用重置日 (1-7) + 'weeklyResetHour', // 周费用重置时 (0-23) + 'enableOpenAIResponsesCodexAdaptation', + 'enableOpenAIResponsesPayloadRules', + 'openaiResponsesPayloadRules' + ] + const updatedData = { ...keyData } + + for (const [field, value] of Object.entries(updates)) { + if (allowedUpdates.includes(field)) { + if ( + field === 'restrictedModels' || + field === 'allowedClients' || + field === 'tags' || + field === 'serviceRates' || + field === 'openaiResponsesPayloadRules' + ) { + // 特殊处理数组/对象字段 + updatedData[field] = JSON.stringify(value || (field === 'serviceRates' ? {} : [])) + } else if (field === 'permissions') { + // 权限字段:规范化后JSON序列化,与createApiKey保持一致 + updatedData[field] = JSON.stringify(normalizePermissions(value)) + } else if ( + field === 'enableModelRestriction' || + field === 'enableClientRestriction' || + field === 'isActivated' || + field === 'enableOpenAIResponsesCodexAdaptation' || + field === 'enableOpenAIResponsesPayloadRules' + ) { + // 布尔值转字符串 + updatedData[field] = String(value) + } else if (field === 'expiresAt' || field === 'activatedAt') { + // 日期字段保持原样,不要toString() + updatedData[field] = value || '' + } else { + updatedData[field] = (value !== null && value !== undefined ? value : '').toString() + } + } + } + + updatedData.updatedAt = new Date().toISOString() + + // 传递hashedKey以确保映射表一致性 + // keyData.apiKey 存储的就是 hashedKey(见generateApiKey第123行) + await redis.setApiKey(keyId, updatedData, keyData.apiKey) + + // 同步更新 API Key 索引 + try { + const apiKeyIndexService = require('./apiKeyIndexService') + await apiKeyIndexService.updateIndex(keyId, updates, { + name: keyData.name, + isActive: keyData.isActive === 'true', + isDeleted: keyData.isDeleted === 'true', + tags: JSON.parse(keyData.tags || '[]') + }) + } catch (err) { + logger.warn(`Failed to update API Key index for ${keyId}:`, err.message) + } + + logger.success(`📝 Updated API key: ${keyId}, hashMap updated`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to update API key:', error) + throw error + } + } + + // 🗑️ 软删除API Key (保留使用统计) + async deleteApiKey(keyId, deletedBy = 'system', deletedByType = 'system') { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 标记为已删除,保留所有数据和统计信息 + const updatedData = { + ...keyData, + isDeleted: 'true', + deletedAt: new Date().toISOString(), + deletedBy, + deletedByType, // 'user', 'admin', 'system' + isActive: 'false' // 同时禁用 + } + + await redis.setApiKey(keyId, updatedData) + + // 从哈希映射中移除(这样就不能再使用这个key进行API调用) + if (keyData.apiKey) { + await redis.deleteApiKeyHash(keyData.apiKey) + } + + // 从费用排序索引中移除 + try { + const costRankService = require('./costRankService') + await costRankService.removeKeyFromIndexes(keyId) + } catch (err) { + logger.warn(`Failed to remove key ${keyId} from cost rank indexes:`, err.message) + } + + // 更新 API Key 索引(标记为已删除) + try { + const apiKeyIndexService = require('./apiKeyIndexService') + await apiKeyIndexService.updateIndex( + keyId, + { isDeleted: true, isActive: false }, + { + name: keyData.name, + isActive: keyData.isActive === 'true', + isDeleted: false, + tags: JSON.parse(keyData.tags || '[]') + } + ) + } catch (err) { + logger.warn(`Failed to update API Key index for deleted key ${keyId}:`, err.message) + } + + logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to delete API key:', error) + throw error + } + } + + // 🔄 恢复已删除的API Key + async restoreApiKey(keyId, restoredBy = 'system', restoredByType = 'system') { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 检查是否确实是已删除的key + if (keyData.isDeleted !== 'true') { + throw new Error('API key is not deleted') + } + + // 准备更新的数据 + const updatedData = { ...keyData } + updatedData.isActive = 'true' + updatedData.restoredAt = new Date().toISOString() + updatedData.restoredBy = restoredBy + updatedData.restoredByType = restoredByType + + // 从更新的数据中移除删除相关的字段 + delete updatedData.isDeleted + delete updatedData.deletedAt + delete updatedData.deletedBy + delete updatedData.deletedByType + + // 保存更新后的数据 + await redis.setApiKey(keyId, updatedData) + + // 使用Redis的hdel命令删除不需要的字段 + const keyName = `apikey:${keyId}` + await redis.client.hdel(keyName, 'isDeleted', 'deletedAt', 'deletedBy', 'deletedByType') + + // 重新建立哈希映射(恢复API Key的使用能力) + if (keyData.apiKey) { + await redis.setApiKeyHash(keyData.apiKey, { + id: keyId, + name: keyData.name, + isActive: 'true' + }) + } + + // 重新添加到费用排序索引 + try { + const costRankService = require('./costRankService') + await costRankService.addKeyToIndexes(keyId) + } catch (err) { + logger.warn(`Failed to add restored key ${keyId} to cost rank indexes:`, err.message) + } + + // 更新 API Key 索引(恢复为活跃状态) + try { + const apiKeyIndexService = require('./apiKeyIndexService') + await apiKeyIndexService.updateIndex( + keyId, + { isDeleted: false, isActive: true }, + { + name: keyData.name, + isActive: false, + isDeleted: true, + tags: JSON.parse(keyData.tags || '[]') + } + ) + } catch (err) { + logger.warn(`Failed to update API Key index for restored key ${keyId}:`, err.message) + } + + logger.success(`Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`) + + return { success: true, apiKey: updatedData } + } catch (error) { + logger.error('❌ Failed to restore API key:', error) + throw error + } + } + + // 🗑️ 彻底删除API Key(物理删除) + async permanentDeleteApiKey(keyId) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 确保只能彻底删除已经软删除的key + if (keyData.isDeleted !== 'true') { + throw new Error('只能彻底删除已经删除的API Key') + } + + // 删除所有相关的使用统计数据 + const today = new Date().toISOString().split('T')[0] + const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0] + + // 删除每日统计 + await redis.client.del(`usage:daily:${today}:${keyId}`) + await redis.client.del(`usage:daily:${yesterday}:${keyId}`) + + // 删除月度统计 + const currentMonth = today.substring(0, 7) + await redis.client.del(`usage:monthly:${currentMonth}:${keyId}`) + + // 删除所有相关的统计键(通过模式匹配) + const usageKeys = await redis.scanKeys(`usage:*:${keyId}*`) + if (usageKeys.length > 0) { + await redis.batchDelChunked(usageKeys) + } + + // 从 API Key 索引中移除 + try { + const apiKeyIndexService = require('./apiKeyIndexService') + await apiKeyIndexService.removeFromIndex(keyId, { + name: keyData.name, + tags: JSON.parse(keyData.tags || '[]') + }) + } catch (err) { + logger.warn(`Failed to remove key ${keyId} from API Key index:`, err.message) + } + + // 删除API Key本身 + await redis.deleteApiKey(keyId) + + logger.success(`🗑️ Permanently deleted API key: ${keyId}`) + + return { success: true } + } catch (error) { + logger.error('❌ Failed to permanently delete API key:', error) + throw error + } + } + + // 🧹 清空所有已删除的API Keys + async clearAllDeletedApiKeys() { + try { + const allKeys = await this.getAllApiKeysFast(true) + const deletedKeys = allKeys.filter((key) => key.isDeleted === true) + + let successCount = 0 + let failedCount = 0 + const errors = [] + + for (const key of deletedKeys) { + try { + await this.permanentDeleteApiKey(key.id) + successCount++ + } catch (error) { + failedCount++ + errors.push({ + keyId: key.id, + keyName: key.name, + error: error.message + }) + } + } + + logger.success(`🧹 Cleared deleted API keys: ${successCount} success, ${failedCount} failed`) + + return { + success: true, + total: deletedKeys.length, + successCount, + failedCount, + errors + } + } catch (error) { + logger.error('❌ Failed to clear all deleted API keys:', error) + throw error + } + } + + // 📊 记录使用情况(支持缓存token和账户级别统计,应用服务倍率) + async recordUsage( + keyId, + inputTokens = 0, + outputTokens = 0, + cacheCreateTokens = 0, + cacheReadTokens = 0, + model = 'unknown', + accountId = null, + accountType = null, + serviceTier = null, + requestMeta = null + ) { + try { + const finalizedRequestMeta = finalizeRequestDetailMeta(requestMeta) + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 计算费用 + const CostCalculator = require('../utils/costCalculator') + const costInfo = CostCalculator.calculateCost( + { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }, + model, + serviceTier + ) + + // 检查是否为 1M 上下文请求 + let isLongContextRequest = false + if (model && model.includes('[1m]')) { + const totalInputTokens = inputTokens + cacheCreateTokens + cacheReadTokens + isLongContextRequest = totalInputTokens > 200000 + } + + // 计算费用(应用服务倍率) + const realCost = costInfo.costs.total + let ratedCost = realCost + if (realCost > 0) { + const service = serviceRatesService.getService(accountType, model) + ratedCost = await this.calculateRatedCost(keyId, service, realCost) + } + + // 记录API Key级别的使用统计(包含费用) + await redis.incrementTokenUsage( + keyId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + 0, // ephemeral5mTokens - 暂时为0,后续处理 + 0, // ephemeral1hTokens - 暂时为0,后续处理 + isLongContextRequest, + realCost, + ratedCost + ) + + // 记录费用统计到每日/每月汇总 + if (realCost > 0) { + await redis.incrementDailyCost(keyId, ratedCost, realCost) + logger.database( + `💰 Recorded cost for ${keyId}: rated=$${ratedCost.toFixed(6)}, real=$${realCost.toFixed(6)}, model: ${model}` + ) + + // 记录 Opus 周费用(如果适用) + await this.recordOpusCost(keyId, ratedCost, realCost, model, accountType) + } else { + logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`) + } + + // 获取API Key数据以确定关联的账户 + const keyData = await redis.getApiKey(keyId) + if (keyData && Object.keys(keyData).length > 0) { + // 更新最后使用时间 + const lastUsedAt = new Date().toISOString() + keyData.lastUsedAt = lastUsedAt + await redis.setApiKey(keyId, keyData) + + // 同步更新 lastUsedAt 索引 + try { + const apiKeyIndexService = require('./apiKeyIndexService') + await apiKeyIndexService.updateLastUsedAt(keyId, lastUsedAt) + } catch (err) { + // 索引更新失败不影响主流程 + } + + // 记录账户级别的使用统计(只统计实际处理请求的账户) + if (accountId) { + await redis.incrementAccountUsage( + accountId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + 0, // ephemeral5mTokens - recordUsage 不含详细缓存数据 + 0, // ephemeral1hTokens - recordUsage 不含详细缓存数据 + model, + isLongContextRequest + ) + logger.database( + `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` + ) + } else { + logger.debug( + '⚠️ No accountId provided for usage recording, skipping account-level statistics' + ) + } + } + + // 记录单次请求的使用详情(同时保存真实成本和倍率成本) + const usageRecord = { + timestamp: new Date().toISOString(), + model, + accountId: accountId || null, + accountType: accountType || null, + requestId: finalizedRequestMeta?.requestId || null, + endpoint: finalizedRequestMeta?.endpoint || null, + method: finalizedRequestMeta?.method || null, + statusCode: finalizedRequestMeta?.statusCode || null, + stream: finalizedRequestMeta?.stream === true, + durationMs: finalizedRequestMeta?.durationMs ?? null, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens, + cost: Number(ratedCost.toFixed(6)), + realCost: Number(realCost.toFixed(6)), + costBreakdown: costInfo?.costs || undefined, + realCostBreakdown: costInfo?.costs || undefined, + isLongContext: isLongContextRequest + } + + await redis.addUsageRecord(keyId, usageRecord) + this._captureRequestDetail(keyId, usageRecord, finalizedRequestMeta).catch((captureError) => { + logger.warn(`⚠️ Failed to schedule request detail capture: ${captureError.message}`) + }) + + const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] + if (cacheCreateTokens > 0) { + logParts.push(`Cache Create: ${cacheCreateTokens}`) + } + if (cacheReadTokens > 0) { + logParts.push(`Cache Read: ${cacheReadTokens}`) + } + logParts.push(`Total: ${totalTokens} tokens`) + + logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`) + + return { realCost, ratedCost } + } catch (error) { + logger.error('❌ Failed to record usage:', error) + return { realCost: 0, ratedCost: 0 } + } + } + + // 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户,支持自定义重置周期) + // ratedCost: 倍率后的成本(用于限额校验) + // realCost: 真实成本(用于对账),如果不传则等于 ratedCost + async recordOpusCost(keyId, ratedCost, realCost, model, accountType) { + try { + // 判断是否为 Claude 系列模型(包含 Bedrock 格式等) + if (!isClaudeFamilyModel(model)) { + return + } + + // 判断是否为 claude-official、claude-console 或 ccr 账户 + const opusAccountTypes = ['claude-official', 'claude-console', 'ccr'] + if (!accountType || !opusAccountTypes.includes(accountType)) { + logger.debug(`⚠️ Skipping Opus cost recording for non-Claude account type: ${accountType}`) + return // 不是 claude 账户,直接返回 + } + + // 获取 key 的重置配置 + const keyData = await redis.getApiKey(keyId) + const resetDay = parseInt(keyData?.weeklyResetDay || 1) + const resetHour = parseInt(keyData?.weeklyResetHour || 0) + + // 记录 Opus 周费用(倍率成本和真实成本) + await redis.incrementWeeklyOpusCost(keyId, ratedCost, realCost, resetDay, resetHour) + logger.database( + `💰 Recorded Opus weekly cost for ${keyId}: rated=$${ratedCost.toFixed(6)}, real=$${realCost.toFixed(6)}, model: ${model}` + ) + } catch (error) { + logger.error('❌ Failed to record Opus weekly cost:', error) + } + } + + // 📊 记录使用情况(新版本,支持详细的缓存类型) + async recordUsageWithDetails( + keyId, + usageObject, + model = 'unknown', + accountId = null, + accountType = null, + requestMeta = null + ) { + try { + const finalizedRequestMeta = finalizeRequestDetailMeta(requestMeta) + // 提取 token 数量 + const inputTokens = usageObject.input_tokens || 0 + const outputTokens = usageObject.output_tokens || 0 + const cacheCreateTokens = usageObject.cache_creation_input_tokens || 0 + const cacheReadTokens = usageObject.cache_read_input_tokens || 0 + + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + // 计算费用统一走 CostCalculator,缺少动态价格时使用内置 unknown fallback。 + let costInfo = { + totalCost: 0, + inputCost: 0, + outputCost: 0, + cacheCreateCost: 0, + cacheReadCost: 0, + ephemeral5mCost: 0, + ephemeral1hCost: 0, + isLongContextRequest: false, + usedFallbackPricing: false, + pricingSource: null + } + try { + const CostCalculator = require('../utils/costCalculator') + const calculatedCost = CostCalculator.calculateCost(usageObject, model) + const costs = calculatedCost?.costs || {} + const totalCost = Number(costs.total ?? calculatedCost?.totalCost ?? 0) + + if (!Number.isFinite(totalCost)) { + throw new Error(`Invalid cost calculation result for model ${model}`) + } + + costInfo = { + totalCost, + inputCost: Number(costs.input ?? calculatedCost?.inputCost ?? 0) || 0, + outputCost: Number(costs.output ?? calculatedCost?.outputCost ?? 0) || 0, + cacheCreateCost: + Number(costs.cacheCreate ?? costs.cacheWrite ?? calculatedCost?.cacheCreateCost ?? 0) || + 0, + cacheReadCost: Number(costs.cacheRead ?? calculatedCost?.cacheReadCost ?? 0) || 0, + ephemeral5mCost: Number(costs.ephemeral5m ?? calculatedCost?.ephemeral5mCost ?? 0) || 0, + ephemeral1hCost: Number(costs.ephemeral1h ?? calculatedCost?.ephemeral1hCost ?? 0) || 0, + isLongContextRequest: + calculatedCost?.isLongContextRequest === true || + calculatedCost?.debug?.isLongContextRequest === true, + usedFallbackPricing: calculatedCost?.debug?.usedFallbackPricing === true, + pricingSource: + calculatedCost?.debug?.pricingSource || + (calculatedCost?.usingDynamicPricing ? 'dynamic' : 'unknown-fallback') + } + } catch (pricingError) { + logger.error(`❌ Failed to calculate cost for model ${model}:`, pricingError) + logger.error(` Usage object:`, JSON.stringify(usageObject)) + } + + // 提取详细的缓存创建数据 + let ephemeral5mTokens = 0 + let ephemeral1hTokens = 0 + + if (usageObject.cache_creation && typeof usageObject.cache_creation === 'object') { + ephemeral5mTokens = usageObject.cache_creation.ephemeral_5m_input_tokens || 0 + ephemeral1hTokens = usageObject.cache_creation.ephemeral_1h_input_tokens || 0 + } + + // 计算费用(应用服务倍率)- 需要在 incrementTokenUsage 之前计算 + const realCostWithDetails = costInfo.totalCost || 0 + let ratedCostWithDetails = realCostWithDetails + if (realCostWithDetails > 0) { + const service = serviceRatesService.getService(accountType, model) + ratedCostWithDetails = await this.calculateRatedCost(keyId, service, realCostWithDetails) + } + + // 记录API Key级别的使用统计(包含费用) + await redis.incrementTokenUsage( + keyId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + model, + ephemeral5mTokens, + ephemeral1hTokens, + costInfo.isLongContextRequest || false, + realCostWithDetails, + ratedCostWithDetails + ) + + // 记录费用到每日/每月汇总 + if (realCostWithDetails > 0) { + // 记录倍率成本和真实成本 + await redis.incrementDailyCost(keyId, ratedCostWithDetails, realCostWithDetails) + logger.database( + `💰 Recorded cost for ${keyId}: rated=$${ratedCostWithDetails.toFixed(6)}, real=$${realCostWithDetails.toFixed(6)}, model: ${model}` + ) + + // 记录 Opus 周费用(如果适用,也应用倍率) + await this.recordOpusCost( + keyId, + ratedCostWithDetails, + realCostWithDetails, + model, + accountType + ) + + // 记录详细的缓存费用(如果有) + if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) { + logger.database( + `💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed( + 6 + )}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}` + ) + } + } else { + // 如果有 token 使用但费用为 0,记录警告 + if (totalTokens > 0) { + logger.warn( + `⚠️ No cost recorded for ${keyId} - zero cost for model: ${model} (tokens: ${totalTokens})` + ) + logger.warn(` This may indicate a pricing issue or model not found in pricing data`) + } else { + logger.debug(`💰 No cost recorded for ${keyId} - zero tokens for model: ${model}`) + } + } + + // 获取API Key数据以确定关联的账户 + const keyData = await redis.getApiKey(keyId) + if (keyData && Object.keys(keyData).length > 0) { + // 更新最后使用时间 + const lastUsedAt = new Date().toISOString() + keyData.lastUsedAt = lastUsedAt + await redis.setApiKey(keyId, keyData) + + // 同步更新 lastUsedAt 索引 + try { + const apiKeyIndexService = require('./apiKeyIndexService') + await apiKeyIndexService.updateLastUsedAt(keyId, lastUsedAt) + } catch (err) { + // 索引更新失败不影响主流程 + } + + // 记录账户级别的使用统计(只统计实际处理请求的账户) + if (accountId) { + await redis.incrementAccountUsage( + accountId, + totalTokens, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + ephemeral5mTokens, + ephemeral1hTokens, + model, + costInfo.isLongContextRequest || false + ) + logger.database( + `📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})` + ) + } else { + logger.debug( + '⚠️ No accountId provided for usage recording, skipping account-level statistics' + ) + } + } + + const usageRecord = { + timestamp: new Date().toISOString(), + model, + accountId: accountId || null, + accountType: accountType || null, + requestId: finalizedRequestMeta?.requestId || null, + endpoint: finalizedRequestMeta?.endpoint || null, + method: finalizedRequestMeta?.method || null, + statusCode: finalizedRequestMeta?.statusCode || null, + stream: finalizedRequestMeta?.stream === true, + durationMs: finalizedRequestMeta?.durationMs ?? null, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + ephemeral5mTokens, + ephemeral1hTokens, + totalTokens, + cost: Number(ratedCostWithDetails.toFixed(6)), + realCost: Number(realCostWithDetails.toFixed(6)), + costBreakdown: { + input: costInfo.inputCost || 0, + output: costInfo.outputCost || 0, + cacheCreate: costInfo.cacheCreateCost || 0, + cacheRead: costInfo.cacheReadCost || 0, + ephemeral5m: costInfo.ephemeral5mCost || 0, + ephemeral1h: costInfo.ephemeral1hCost || 0, + total: realCostWithDetails + }, + realCostBreakdown: { + input: costInfo.inputCost || 0, + output: costInfo.outputCost || 0, + cacheCreate: costInfo.cacheCreateCost || 0, + cacheRead: costInfo.cacheReadCost || 0, + ephemeral5m: costInfo.ephemeral5mCost || 0, + ephemeral1h: costInfo.ephemeral1hCost || 0, + total: realCostWithDetails + }, + pricingSource: costInfo.pricingSource || null, + usedFallbackPricing: costInfo.usedFallbackPricing === true, + isLongContext: costInfo.isLongContextRequest || false + } + + await redis.addUsageRecord(keyId, usageRecord) + this._captureRequestDetail(keyId, usageRecord, finalizedRequestMeta).catch((captureError) => { + logger.warn(`⚠️ Failed to schedule request detail capture: ${captureError.message}`) + }) + + const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] + if (cacheCreateTokens > 0) { + logParts.push(`Cache Create: ${cacheCreateTokens}`) + + // 如果有详细的缓存创建数据,也记录它们 + if (usageObject.cache_creation) { + const { ephemeral_5m_input_tokens, ephemeral_1h_input_tokens } = + usageObject.cache_creation + if (ephemeral_5m_input_tokens > 0) { + logParts.push(`5m: ${ephemeral_5m_input_tokens}`) + } + if (ephemeral_1h_input_tokens > 0) { + logParts.push(`1h: ${ephemeral_1h_input_tokens}`) + } + } + } + if (cacheReadTokens > 0) { + logParts.push(`Cache Read: ${cacheReadTokens}`) + } + logParts.push(`Total: ${totalTokens} tokens`) + + logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`) + + // 🔔 发布计费事件到消息队列(异步非阻塞) + this._publishBillingEvent({ + keyId, + keyName: keyData?.name, + userId: keyData?.userId, + model, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + ephemeral5mTokens, + ephemeral1hTokens, + totalTokens, + cost: costInfo.totalCost || 0, + costBreakdown: { + input: costInfo.inputCost || 0, + output: costInfo.outputCost || 0, + cacheCreate: costInfo.cacheCreateCost || 0, + cacheRead: costInfo.cacheReadCost || 0, + ephemeral5m: costInfo.ephemeral5mCost || 0, + ephemeral1h: costInfo.ephemeral1hCost || 0 + }, + accountId, + accountType, + isLongContext: costInfo.isLongContextRequest || false, + requestTimestamp: usageRecord.timestamp + }).catch((err) => { + // 发布失败不影响主流程,只记录错误 + logger.warn('⚠️ Failed to publish billing event:', err.message) + }) + + return { realCost: realCostWithDetails, ratedCost: ratedCostWithDetails } + } catch (error) { + logger.error('❌ Failed to record usage:', error) + return { realCost: 0, ratedCost: 0 } + } + } + + async _captureRequestDetail(keyId, usageRecord, requestMeta = null) { + if (!usageRecord) { + return + } + + await requestDetailService.captureRequestDetail({ + requestId: requestMeta?.requestId || usageRecord.requestId || null, + timestamp: usageRecord.timestamp, + requestStartedAt: requestMeta?.requestStartedAt || null, + endpoint: requestMeta?.endpoint || usageRecord.endpoint || null, + method: requestMeta?.method || usageRecord.method || null, + statusCode: requestMeta?.statusCode ?? usageRecord.statusCode ?? 200, + stream: requestMeta?.stream === true || usageRecord.stream === true, + durationMs: requestMeta?.durationMs ?? usageRecord.durationMs ?? null, + requestBody: requestMeta?.requestBody, + apiKeyId: keyId, + accountId: usageRecord.accountId || null, + accountType: usageRecord.accountType || null, + model: usageRecord.model || 'unknown', + inputTokens: usageRecord.inputTokens || 0, + outputTokens: usageRecord.outputTokens || 0, + cacheReadTokens: usageRecord.cacheReadTokens || 0, + cacheCreateTokens: usageRecord.cacheCreateTokens || 0, + totalTokens: usageRecord.totalTokens || 0, + cost: usageRecord.cost || 0, + realCost: usageRecord.realCost || usageRecord.cost || 0, + costBreakdown: usageRecord.costBreakdown || null, + realCostBreakdown: usageRecord.realCostBreakdown || usageRecord.costBreakdown || null, + pricingSource: usageRecord.pricingSource || null, + usedFallbackPricing: usageRecord.usedFallbackPricing === true, + isLongContextRequest: + usageRecord.isLongContext === true || usageRecord.isLongContextRequest === true + }) + } + + async _fetchAccountInfo(accountId, accountType, cache, client) { + if (!client || !accountId || !accountType) { + return null + } + + const cacheKey = `${accountType}:${accountId}` + if (cache.has(cacheKey)) { + return cache.get(cacheKey) + } + + const accountConfig = ACCOUNT_TYPE_CONFIG[accountType] + if (!accountConfig) { + cache.set(cacheKey, null) + return null + } + + const redisKey = `${accountConfig.prefix}${accountId}` + let accountData = null + try { + accountData = await client.hgetall(redisKey) + } catch (error) { + logger.debug(`加载账号信息失败 ${redisKey}:`, error) + } + + if (accountData && Object.keys(accountData).length > 0) { + const displayName = + accountData.name || + accountData.displayName || + accountData.email || + accountData.username || + accountData.description || + accountId + + const info = { id: accountId, name: displayName } + cache.set(cacheKey, info) + return info + } + + cache.set(cacheKey, null) + return null + } + + async _resolveAccountByUsageRecord(usageRecord, cache, client) { + if (!usageRecord || !client) { + return null + } + + const rawAccountId = usageRecord.accountId || null + const rawAccountType = normalizeAccountTypeKey(usageRecord.accountType) + const modelName = usageRecord.model || usageRecord.actualModel || usageRecord.service || null + + if (!rawAccountId && !rawAccountType) { + return null + } + + const candidateIds = new Set() + if (rawAccountId) { + candidateIds.add(rawAccountId) + if (typeof rawAccountId === 'string' && rawAccountId.startsWith('responses:')) { + candidateIds.add(rawAccountId.replace(/^responses:/, '')) + } + if (typeof rawAccountId === 'string' && rawAccountId.startsWith('api:')) { + candidateIds.add(rawAccountId.replace(/^api:/, '')) + } + } + + if (candidateIds.size === 0) { + return null + } + + const typeCandidates = [] + const pushType = (type) => { + const normalized = normalizeAccountTypeKey(type) + if (normalized && ACCOUNT_TYPE_CONFIG[normalized] && !typeCandidates.includes(normalized)) { + typeCandidates.push(normalized) + } + } + + pushType(rawAccountType) + + if (modelName) { + const lowerModel = modelName.toLowerCase() + if (lowerModel.includes('gpt') || lowerModel.includes('openai')) { + pushType('openai') + pushType('openai-responses') + pushType('azure-openai') + } else if (lowerModel.includes('gemini')) { + pushType('gemini') + pushType('gemini-api') + } else if (lowerModel.includes('claude') || lowerModel.includes('anthropic')) { + pushType('claude') + pushType('claude-console') + } else if (lowerModel.includes('droid')) { + pushType('droid') + } + } + + ACCOUNT_TYPE_PRIORITY.forEach(pushType) + + for (const type of typeCandidates) { + const accountConfig = ACCOUNT_TYPE_CONFIG[type] + if (!accountConfig) { + continue + } + + for (const candidateId of candidateIds) { + const normalizedId = sanitizeAccountIdForType(candidateId, type) + const accountInfo = await this._fetchAccountInfo(normalizedId, type, cache, client) + if (accountInfo) { + return { + accountId: normalizedId, + accountName: accountInfo.name, + accountType: type, + accountCategory: ACCOUNT_CATEGORY_MAP[type] || 'other', + rawAccountId: rawAccountId || normalizedId + } + } + } + } + + return null + } + + async _resolveLastUsageAccount(apiKey, usageRecord, cache, client) { + return await this._resolveAccountByUsageRecord(usageRecord, cache, client) + } + + // 🔔 发布计费事件(内部方法) + async _publishBillingEvent(eventData) { + try { + const billingEventPublisher = require('./billingEventPublisher') + await billingEventPublisher.publishBillingEvent(eventData) + } catch (error) { + // 静默失败,不影响主流程 + logger.debug('Failed to publish billing event:', error.message) + } + } + + // 🔐 生成密钥 + _generateSecretKey() { + return crypto.randomBytes(32).toString('hex') + } + + // 🔒 哈希API Key + _hashApiKey(apiKey) { + return crypto + .createHash('sha256') + .update(apiKey + config.security.encryptionKey) + .digest('hex') + } + + // 📈 获取使用统计 + async getUsageStats(keyId, options = {}) { + const usageStats = await redis.getUsageStats(keyId) + + // options 可能是字符串(兼容旧接口),仅当为对象时才解析 + const optionObject = + options && typeof options === 'object' && !Array.isArray(options) ? options : {} + + if (optionObject.includeRecords === false) { + return usageStats + } + + const recordLimit = optionObject.recordLimit || 20 + const recentRecords = await redis.getUsageRecords(keyId, recordLimit) + + // API 兼容:同时输出 costBreakdown 和 realCostBreakdown + const compatibleRecords = recentRecords.map((record) => { + const breakdown = record.realCostBreakdown || record.costBreakdown + return { + ...record, + costBreakdown: breakdown, + realCostBreakdown: breakdown + } + }) + + return { + ...usageStats, + recentRecords: compatibleRecords + } + } + + // 📊 获取账户使用统计 + async getAccountUsageStats(accountId) { + return await redis.getAccountUsageStats(accountId) + } + + // 📈 获取所有账户使用统计 + async getAllAccountsUsageStats() { + return await redis.getAllAccountsUsageStats() + } + + // === 用户相关方法 === + + // 🔑 创建API Key(支持用户) + async createApiKey(options = {}) { + return await this.generateApiKey(options) + } + + // 👤 获取用户的API Keys + async getUserApiKeys(userId, includeDeleted = false) { + try { + const allKeys = await this.getAllApiKeysFast(includeDeleted) + let userKeys = allKeys.filter((key) => key.userId === userId) + + // 默认过滤掉已删除的API Keys(Fast版本返回布尔值) + if (!includeDeleted) { + userKeys = userKeys.filter((key) => !key.isDeleted) + } + + // Populate usage stats for each user's API key (same as getAllApiKeys does) + const userKeysWithUsage = [] + for (const key of userKeys) { + const usage = await redis.getUsageStats(key.id) + const dailyCost = (await redis.getDailyCost(key.id)) || 0 + const costStats = await redis.getCostStats(key.id) + + userKeysWithUsage.push({ + id: key.id, + name: key.name, + description: key.description, + key: key.maskedKey || null, // Fast版本已提供maskedKey + tokenLimit: parseInt(key.tokenLimit || 0), + isActive: key.isActive === true, // Fast版本返回布尔值 + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + expiresAt: key.expiresAt, + usage, + dailyCost, + totalCost: costStats.total, + dailyCostLimit: parseFloat(key.dailyCostLimit || 0), + totalCostLimit: parseFloat(key.totalCostLimit || 0), + userId: key.userId, + userUsername: key.userUsername, + createdBy: key.createdBy, + droidAccountId: key.droidAccountId, + // Include deletion fields for deleted keys + isDeleted: key.isDeleted, + deletedAt: key.deletedAt, + deletedBy: key.deletedBy, + deletedByType: key.deletedByType + }) + } + + return userKeysWithUsage + } catch (error) { + logger.error('❌ Failed to get user API keys:', error) + return [] + } + } + + // 🔍 通过ID获取API Key(检查权限) + async getApiKeyById(keyId, userId = null) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData) { + return null + } + + // 如果指定了用户ID,检查权限 + if (userId && keyData.userId !== userId) { + return null + } + + return { + id: keyData.id, + name: keyData.name, + description: keyData.description, + key: keyData.apiKey, + tokenLimit: parseInt(keyData.tokenLimit || 0), + isActive: keyData.isActive === 'true', + createdAt: keyData.createdAt, + lastUsedAt: keyData.lastUsedAt, + expiresAt: keyData.expiresAt, + userId: keyData.userId, + userUsername: keyData.userUsername, + createdBy: keyData.createdBy, + permissions: normalizePermissions(keyData.permissions), + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), + // 所有平台账户绑定字段 + claudeAccountId: keyData.claudeAccountId, + claudeConsoleAccountId: keyData.claudeConsoleAccountId, + geminiAccountId: keyData.geminiAccountId, + openaiAccountId: keyData.openaiAccountId, + bedrockAccountId: keyData.bedrockAccountId, + droidAccountId: keyData.droidAccountId, + azureOpenaiAccountId: keyData.azureOpenaiAccountId, + ccrAccountId: keyData.ccrAccountId, + enableOpenAIResponsesCodexAdaptation: parseBooleanWithDefault( + keyData.enableOpenAIResponsesCodexAdaptation, + true + ), + enableOpenAIResponsesPayloadRules: parseBooleanWithDefault( + keyData.enableOpenAIResponsesPayloadRules, + false + ), + openaiResponsesPayloadRules: parseOpenAIResponsesPayloadRules( + keyData.openaiResponsesPayloadRules + ) + } + } catch (error) { + logger.error('❌ Failed to get API key by ID:', error) + return null + } + } + + // 🔄 重新生成API Key + async regenerateApiKey(keyId) { + try { + const existingKey = await redis.getApiKey(keyId) + if (!existingKey) { + throw new Error('API key not found') + } + + // 生成新的key + const newApiKey = `${this.prefix}${this._generateSecretKey()}` + const newHashedKey = this._hashApiKey(newApiKey) + + // 删除旧的哈希映射 + const oldHashedKey = existingKey.apiKey + await redis.deleteApiKeyHash(oldHashedKey) + + // 更新key数据 + const updatedKeyData = { + ...existingKey, + apiKey: newHashedKey, + updatedAt: new Date().toISOString() + } + + // 保存新数据并建立新的哈希映射 + await redis.setApiKey(keyId, updatedKeyData, newHashedKey) + + logger.info(`🔄 Regenerated API key: ${existingKey.name} (${keyId})`) + + return { + id: keyId, + name: existingKey.name, + key: newApiKey, // 返回完整的新key + updatedAt: updatedKeyData.updatedAt + } + } catch (error) { + logger.error('❌ Failed to regenerate API key:', error) + throw error + } + } + + // 🗑️ 硬删除API Key (完全移除) + async hardDeleteApiKey(keyId) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData) { + throw new Error('API key not found') + } + + // 删除key数据和哈希映射 + await redis.deleteApiKey(keyId) + await redis.deleteApiKeyHash(keyData.apiKey) + + logger.info(`🗑️ Deleted API key: ${keyData.name} (${keyId})`) + return true + } catch (error) { + logger.error('❌ Failed to delete API key:', error) + throw error + } + } + + // 🚫 禁用用户的所有API Keys + async disableUserApiKeys(userId) { + try { + const userKeys = await this.getUserApiKeys(userId) + let disabledCount = 0 + + for (const key of userKeys) { + if (key.isActive) { + await this.updateApiKey(key.id, { isActive: false }) + disabledCount++ + } + } + + logger.info(`🚫 Disabled ${disabledCount} API keys for user: ${userId}`) + return { count: disabledCount } + } catch (error) { + logger.error('❌ Failed to disable user API keys:', error) + throw error + } + } + + // 📊 获取聚合使用统计(支持多个API Key) + async getAggregatedUsageStats(keyIds, options = {}) { + try { + if (!Array.isArray(keyIds)) { + keyIds = [keyIds] + } + + const { period: _period = 'week', model: _model } = options + const stats = { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + + // 汇总所有API Key的统计数据 + for (const keyId of keyIds) { + const keyStats = await redis.getUsageStats(keyId) + const costStats = await redis.getCostStats(keyId) + if (keyStats && keyStats.total) { + stats.totalRequests += keyStats.total.requests || 0 + stats.totalInputTokens += keyStats.total.inputTokens || 0 + stats.totalOutputTokens += keyStats.total.outputTokens || 0 + stats.totalCost += costStats?.total || 0 + } + } + + // TODO: 实现日期范围和模型统计 + // 这里可以根据需要添加更详细的统计逻辑 + + return stats + } catch (error) { + logger.error('❌ Failed to get usage stats:', error) + return { + totalRequests: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + dailyStats: [], + modelStats: [] + } + } + } + + // 🔓 解绑账号从所有API Keys + async unbindAccountFromAllKeys(accountId, accountType) { + try { + // 账号类型与字段的映射关系 + const fieldMap = { + claude: 'claudeAccountId', + 'claude-console': 'claudeConsoleAccountId', + gemini: 'geminiAccountId', + 'gemini-api': 'geminiAccountId', // 特殊处理,带 api: 前缀 + openai: 'openaiAccountId', + 'openai-responses': 'openaiAccountId', // 特殊处理,带 responses: 前缀 + azure_openai: 'azureOpenaiAccountId', + bedrock: 'bedrockAccountId', + droid: 'droidAccountId', + ccr: null // CCR 账号没有对应的 API Key 字段 + } + + const field = fieldMap[accountType] + if (!field) { + logger.info(`账号类型 ${accountType} 不需要解绑 API Key`) + return 0 + } + + // 获取所有API Keys + const allKeys = await this.getAllApiKeysFast() + + // 筛选绑定到此账号的 API Keys + let boundKeys = [] + if (accountType === 'openai-responses') { + // OpenAI-Responses 特殊处理:查找 openaiAccountId 字段中带 responses: 前缀的 + boundKeys = allKeys.filter((key) => key.openaiAccountId === `responses:${accountId}`) + } else if (accountType === 'gemini-api') { + // Gemini-API 特殊处理:查找 geminiAccountId 字段中带 api: 前缀的 + boundKeys = allKeys.filter((key) => key.geminiAccountId === `api:${accountId}`) + } else { + // 其他账号类型正常匹配 + boundKeys = allKeys.filter((key) => key[field] === accountId) + } + + // 批量解绑 + for (const key of boundKeys) { + const updates = {} + if (accountType === 'openai-responses') { + updates.openaiAccountId = null + } else if (accountType === 'gemini-api') { + updates.geminiAccountId = null + } else if (accountType === 'claude-console') { + updates.claudeConsoleAccountId = null + } else { + updates[field] = null + } + + await this.updateApiKey(key.id, updates) + logger.info( + `✅ 自动解绑 API Key ${key.id} (${key.name}) 从 ${accountType} 账号 ${accountId}` + ) + } + + if (boundKeys.length > 0) { + logger.success( + `🔓 成功解绑 ${boundKeys.length} 个 API Key 从 ${accountType} 账号 ${accountId}` + ) + } + + return boundKeys.length + } catch (error) { + logger.error(`❌ 解绑 API Keys 失败 (${accountType} 账号 ${accountId}):`, error) + return 0 + } + } + + // 🧹 清理过期的API Keys + async cleanupExpiredKeys() { + try { + const apiKeys = await this.getAllApiKeysFast() + const now = new Date() + let cleanedCount = 0 + + for (const key of apiKeys) { + // 检查是否已过期且仍处于激活状态(Fast版本返回布尔值) + if (key.expiresAt && new Date(key.expiresAt) < now && key.isActive === true) { + // 将过期的 API Key 标记为禁用状态,而不是直接删除 + await this.updateApiKey(key.id, { isActive: false }) + logger.info(`🔒 API Key ${key.id} (${key.name}) has expired and been disabled`) + cleanedCount++ + } + } + + if (cleanedCount > 0) { + logger.success(`🧹 Disabled ${cleanedCount} expired API keys`) + } + + return cleanedCount + } catch (error) { + logger.error('❌ Failed to cleanup expired keys:', error) + return 0 + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // 服务倍率和费用限制相关方法 + // ═══════════════════════════════════════════════════════════════════════════ + + /** + * 计算应用倍率后的费用 + * 公式:消费计费 = 真实消费 × 全局倍率 × Key 倍率 + * @param {string} keyId - API Key ID + * @param {string} service - 服务类型 + * @param {number} realCost - 真实成本(USD) + * @returns {Promise} 应用倍率后的费用 + */ + async calculateRatedCost(keyId, service, realCost) { + try { + // 获取全局倍率 + const globalRate = await serviceRatesService.getServiceRate(service) + + // 获取 Key 倍率 + const keyData = await redis.getApiKey(keyId) + let keyRates = {} + try { + keyRates = JSON.parse(keyData?.serviceRates || '{}') + } catch (e) { + keyRates = {} + } + const keyRate = keyRates[service] ?? 1.0 + + // 相乘计算 + return realCost * globalRate * keyRate + } catch (error) { + logger.error('❌ Failed to calculate rated cost:', error) + // 出错时返回原始费用 + return realCost + } + } + + /** + * 增加 API Key 费用限制(用于核销额度卡) + * @param {string} keyId - API Key ID + * @param {number} amount - 要增加的金额(USD) + * @returns {Promise} { success: boolean, newTotalCostLimit: number } + */ + async addTotalCostLimit(keyId, amount) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + const currentLimit = parseFloat(keyData.totalCostLimit || 0) + const newLimit = currentLimit + amount + + await redis.client.hset(`apikey:${keyId}`, 'totalCostLimit', String(newLimit)) + + logger.success(`💰 Added $${amount} to key ${keyId}, new limit: $${newLimit}`) + + return { success: true, previousLimit: currentLimit, newTotalCostLimit: newLimit } + } catch (error) { + logger.error('❌ Failed to add total cost limit:', error) + throw error + } + } + + /** + * 减少 API Key 费用限制(用于撤销核销) + * @param {string} keyId - API Key ID + * @param {number} amount - 要减少的金额(USD) + * @returns {Promise} { success: boolean, newTotalCostLimit: number, actualDeducted: number } + */ + async deductTotalCostLimit(keyId, amount) { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + const currentLimit = parseFloat(keyData.totalCostLimit || 0) + const costStats = await redis.getCostStats(keyId) + const currentUsed = costStats?.total || 0 + + // 不能扣到比已使用的还少 + const minLimit = currentUsed + const actualDeducted = Math.min(amount, currentLimit - minLimit) + const newLimit = Math.max(currentLimit - amount, minLimit) + + await redis.client.hset(`apikey:${keyId}`, 'totalCostLimit', String(newLimit)) + + logger.success(`💸 Deducted $${actualDeducted} from key ${keyId}, new limit: $${newLimit}`) + + return { + success: true, + previousLimit: currentLimit, + newTotalCostLimit: newLimit, + actualDeducted + } + } catch (error) { + logger.error('❌ Failed to deduct total cost limit:', error) + throw error + } + } + + /** + * 延长 API Key 有效期(用于核销时间卡) + * @param {string} keyId - API Key ID + * @param {number} amount - 时间数量 + * @param {string} unit - 时间单位 'hours' | 'days' | 'months' + * @returns {Promise} { success: boolean, newExpiresAt: string } + */ + async extendExpiry(keyId, amount, unit = 'days') { + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API key not found') + } + + // 计算新的过期时间 + let baseDate = keyData.expiresAt ? new Date(keyData.expiresAt) : new Date() + // 如果已过期,从当前时间开始计算 + if (baseDate < new Date()) { + baseDate = new Date() + } + + let milliseconds + switch (unit) { + case 'hours': + milliseconds = amount * 60 * 60 * 1000 + break + case 'months': + // 简化处理:1个月 = 30天 + milliseconds = amount * 30 * 24 * 60 * 60 * 1000 + break + case 'days': + default: + milliseconds = amount * 24 * 60 * 60 * 1000 + } + + const newExpiresAt = new Date(baseDate.getTime() + milliseconds).toISOString() + + await this.updateApiKey(keyId, { expiresAt: newExpiresAt }) + + logger.success( + `⏰ Extended key ${keyId} expiry by ${amount} ${unit}, new expiry: ${newExpiresAt}` + ) + + return { success: true, previousExpiresAt: keyData.expiresAt, newExpiresAt } + } catch (error) { + logger.error('❌ Failed to extend expiry:', error) + throw error + } + } +} + +// 导出实例和单独的方法 +const apiKeyService = new ApiKeyService() + +// 为了方便其他服务调用,导出 recordUsage 方法 +apiKeyService.recordUsageMetrics = apiKeyService.recordUsage.bind(apiKeyService) + +// 导出权限辅助函数供路由使用 +apiKeyService.hasPermission = hasPermission +apiKeyService.normalizePermissions = normalizePermissions + +module.exports = apiKeyService diff --git a/src/services/balanceProviders/baseBalanceProvider.js b/src/services/balanceProviders/baseBalanceProvider.js new file mode 100644 index 0000000..ececd2e --- /dev/null +++ b/src/services/balanceProviders/baseBalanceProvider.js @@ -0,0 +1,133 @@ +const axios = require('axios') +const logger = require('../../utils/logger') +const ProxyHelper = require('../../utils/proxyHelper') + +/** + * Provider 抽象基类 + * 各平台 Provider 需继承并实现 queryBalance(account) + */ +class BaseBalanceProvider { + constructor(platform) { + this.platform = platform + this.logger = logger + } + + /** + * 查询余额(抽象方法) + * @param {object} account - 账户对象 + * @returns {Promise} + * 形如: + * { + * balance: number|null, + * currency?: string, + * quota?: { daily, used, remaining, resetAt, percentage, unlimited? }, + * queryMethod?: 'api'|'field'|'local', + * rawData?: any + * } + */ + async queryBalance(_account) { + throw new Error('queryBalance 方法必须由子类实现') + } + + /** + * 通用 HTTP 请求方法(支持代理) + * @param {string} url + * @param {object} options + * @param {object} account + */ + async makeRequest(url, options = {}, account = {}) { + const config = { + url, + method: options.method || 'GET', + headers: options.headers || {}, + timeout: options.timeout || 15000, + data: options.data, + params: options.params, + responseType: options.responseType + } + + const proxyConfig = account.proxyConfig || account.proxy + if (proxyConfig) { + const agent = ProxyHelper.createProxyAgent(proxyConfig) + if (agent) { + config.httpAgent = agent + config.httpsAgent = agent + config.proxy = false + } + } + + try { + const response = await axios(config) + return { + success: true, + data: response.data, + status: response.status, + headers: response.headers + } + } catch (error) { + const status = error.response?.status + const message = error.response?.data?.message || error.message || '请求失败' + this.logger.debug(`余额 Provider HTTP 请求失败: ${url} (${this.platform})`, { + status, + message + }) + return { success: false, status, error: message } + } + } + + /** + * 从账户字段读取 dailyQuota / dailyUsage(通用降级方案) + * 注意:部分平台 dailyUsage 字段可能不是实时值,最终以 AccountBalanceService 的本地统计为准 + */ + readQuotaFromFields(account) { + const dailyQuota = Number(account?.dailyQuota || 0) + const dailyUsage = Number(account?.dailyUsage || 0) + + // 无限制 + if (!Number.isFinite(dailyQuota) || dailyQuota <= 0) { + return { + balance: null, + currency: 'USD', + quota: { + daily: Infinity, + used: Number.isFinite(dailyUsage) ? dailyUsage : 0, + remaining: Infinity, + percentage: 0, + unlimited: true + }, + queryMethod: 'field' + } + } + + const used = Number.isFinite(dailyUsage) ? dailyUsage : 0 + const remaining = Math.max(0, dailyQuota - used) + const percentage = dailyQuota > 0 ? (used / dailyQuota) * 100 : 0 + + return { + balance: remaining, + currency: 'USD', + quota: { + daily: dailyQuota, + used, + remaining, + percentage: Math.round(percentage * 100) / 100 + }, + queryMethod: 'field' + } + } + + parseCurrency(data) { + return data?.currency || data?.Currency || 'USD' + } + + async safeExecute(fn, fallbackValue = null) { + try { + return await fn() + } catch (error) { + this.logger.error(`余额 Provider 执行失败: ${this.platform}`, error) + return fallbackValue + } + } +} + +module.exports = BaseBalanceProvider diff --git a/src/services/balanceProviders/claudeBalanceProvider.js b/src/services/balanceProviders/claudeBalanceProvider.js new file mode 100644 index 0000000..8978302 --- /dev/null +++ b/src/services/balanceProviders/claudeBalanceProvider.js @@ -0,0 +1,30 @@ +const BaseBalanceProvider = require('./baseBalanceProvider') +const claudeAccountService = require('../claudeAccountService') + +class ClaudeBalanceProvider extends BaseBalanceProvider { + constructor() { + super('claude') + } + + /** + * Claude(OAuth):优先尝试获取 OAuth usage(用于配额/使用信息),不强行提供余额金额 + */ + async queryBalance(account) { + this.logger.debug(`查询 Claude 余额(OAuth usage): ${account?.id}`) + + // 仅 OAuth 账户可用;失败时降级 + const usageData = await claudeAccountService.fetchOAuthUsage(account.id).catch(() => null) + if (!usageData) { + return { balance: null, currency: 'USD', queryMethod: 'local' } + } + + return { + balance: null, + currency: 'USD', + queryMethod: 'api', + rawData: usageData + } + } +} + +module.exports = ClaudeBalanceProvider diff --git a/src/services/balanceProviders/claudeConsoleBalanceProvider.js b/src/services/balanceProviders/claudeConsoleBalanceProvider.js new file mode 100644 index 0000000..f544104 --- /dev/null +++ b/src/services/balanceProviders/claudeConsoleBalanceProvider.js @@ -0,0 +1,14 @@ +const BaseBalanceProvider = require('./baseBalanceProvider') + +class ClaudeConsoleBalanceProvider extends BaseBalanceProvider { + constructor() { + super('claude-console') + } + + async queryBalance(account) { + this.logger.debug(`查询 Claude Console 余额(字段): ${account?.id}`) + return this.readQuotaFromFields(account) + } +} + +module.exports = ClaudeConsoleBalanceProvider diff --git a/src/services/balanceProviders/geminiBalanceProvider.js b/src/services/balanceProviders/geminiBalanceProvider.js new file mode 100644 index 0000000..0f7fb78 --- /dev/null +++ b/src/services/balanceProviders/geminiBalanceProvider.js @@ -0,0 +1,250 @@ +const BaseBalanceProvider = require('./baseBalanceProvider') +const antigravityClient = require('../antigravityClient') +const geminiAccountService = require('../geminiAccountService') + +const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' + +function clamp01(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null + } + if (value < 0) { + return 0 + } + if (value > 1) { + return 1 + } + return value +} + +function round2(value) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null + } + return Math.round(value * 100) / 100 +} + +function normalizeQuotaCategory(displayName, modelId) { + const name = String(displayName || '') + const id = String(modelId || '') + + if (name.includes('Gemini') && name.includes('Pro')) { + return 'Gemini Pro' + } + if (name.includes('Gemini') && name.includes('Flash')) { + return 'Gemini Flash' + } + if (name.includes('Gemini') && name.toLowerCase().includes('image')) { + return 'Gemini Image' + } + + if (name.includes('Claude') || name.includes('GPT-OSS')) { + return 'Claude' + } + + if (id.startsWith('gemini-3-pro-') || id.startsWith('gemini-2.5-pro')) { + return 'Gemini Pro' + } + if (id.startsWith('gemini-3-flash') || id.startsWith('gemini-2.5-flash')) { + return 'Gemini Flash' + } + if (id.includes('image')) { + return 'Gemini Image' + } + if (id.includes('claude') || id.includes('gpt-oss')) { + return 'Claude' + } + + return name || id || 'Unknown' +} + +function buildAntigravityQuota(modelsResponse) { + const models = modelsResponse && typeof modelsResponse === 'object' ? modelsResponse.models : null + + if (!models || typeof models !== 'object') { + return null + } + + const parseRemainingFraction = (quotaInfo) => { + if (!quotaInfo || typeof quotaInfo !== 'object') { + return null + } + + const raw = + quotaInfo.remainingFraction ?? + quotaInfo.remaining_fraction ?? + quotaInfo.remaining ?? + undefined + + const num = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN + if (!Number.isFinite(num)) { + return null + } + + return clamp01(num) + } + + const allowedCategories = new Set(['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']) + const fixedOrder = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image'] + + const categoryMap = new Map() + + for (const [modelId, modelDataRaw] of Object.entries(models)) { + if (!modelDataRaw || typeof modelDataRaw !== 'object') { + continue + } + + const displayName = modelDataRaw.displayName || modelDataRaw.display_name || modelId + const quotaInfo = modelDataRaw.quotaInfo || modelDataRaw.quota_info || null + + const remainingFraction = parseRemainingFraction(quotaInfo) + if (remainingFraction === null) { + continue + } + + const remainingPercent = round2(remainingFraction * 100) + const usedPercent = round2(100 - remainingPercent) + const resetAt = quotaInfo?.resetTime || quotaInfo?.reset_time || null + + const category = normalizeQuotaCategory(displayName, modelId) + if (!allowedCategories.has(category)) { + continue + } + const entry = { + category, + modelId, + displayName: String(displayName || modelId || category), + remainingPercent, + usedPercent, + resetAt: typeof resetAt === 'string' && resetAt.trim() ? resetAt : null + } + + const existing = categoryMap.get(category) + if (!existing || entry.remainingPercent < existing.remainingPercent) { + categoryMap.set(category, entry) + } + } + + const buckets = fixedOrder.map((category) => { + const existing = categoryMap.get(category) || null + if (existing) { + return existing + } + return { + category, + modelId: '', + displayName: category, + remainingPercent: null, + usedPercent: null, + resetAt: null + } + }) + + if (buckets.length === 0) { + return null + } + + const critical = buckets + .filter((item) => item.remainingPercent !== null) + .reduce((min, item) => { + if (!min) { + return item + } + return (item.remainingPercent ?? 0) < (min.remainingPercent ?? 0) ? item : min + }, null) + + if (!critical) { + return null + } + + return { + balance: null, + currency: 'USD', + quota: { + type: 'antigravity', + total: 100, + used: critical.usedPercent, + remaining: critical.remainingPercent, + percentage: critical.usedPercent, + resetAt: critical.resetAt, + buckets: buckets.map((item) => ({ + category: item.category, + remaining: item.remainingPercent, + used: item.usedPercent, + percentage: item.usedPercent, + resetAt: item.resetAt + })) + }, + queryMethod: 'api', + rawData: { + modelsCount: Object.keys(models).length, + bucketCount: buckets.length + } + } +} + +class GeminiBalanceProvider extends BaseBalanceProvider { + constructor() { + super('gemini') + } + + async queryBalance(account) { + const oauthProvider = account?.oauthProvider + if (oauthProvider !== OAUTH_PROVIDER_ANTIGRAVITY) { + if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) { + return this.readQuotaFromFields(account) + } + return { balance: null, currency: 'USD', queryMethod: 'local' } + } + + const accessToken = String(account?.accessToken || '').trim() + const refreshToken = String(account?.refreshToken || '').trim() + const proxyConfig = account?.proxyConfig || account?.proxy || null + + if (!accessToken) { + throw new Error('Antigravity 账户缺少 accessToken') + } + + const fetch = async (token) => + await antigravityClient.fetchAvailableModels({ + accessToken: token, + proxyConfig + }) + + let data + try { + data = await fetch(accessToken) + } catch (error) { + const status = error?.response?.status + if ((status === 401 || status === 403) && refreshToken) { + const refreshed = await geminiAccountService.refreshAccessToken( + refreshToken, + proxyConfig, + OAUTH_PROVIDER_ANTIGRAVITY + ) + const nextToken = String(refreshed?.access_token || '').trim() + if (!nextToken) { + throw error + } + data = await fetch(nextToken) + } else { + throw error + } + } + + const mapped = buildAntigravityQuota(data) + if (!mapped) { + return { + balance: null, + currency: 'USD', + quota: null, + queryMethod: 'api', + rawData: data || null + } + } + + return mapped + } +} + +module.exports = GeminiBalanceProvider diff --git a/src/services/balanceProviders/genericBalanceProvider.js b/src/services/balanceProviders/genericBalanceProvider.js new file mode 100644 index 0000000..6b3efe2 --- /dev/null +++ b/src/services/balanceProviders/genericBalanceProvider.js @@ -0,0 +1,23 @@ +const BaseBalanceProvider = require('./baseBalanceProvider') + +class GenericBalanceProvider extends BaseBalanceProvider { + constructor(platform) { + super(platform) + } + + async queryBalance(account) { + this.logger.debug(`${this.platform} 暂无专用余额 API,实现降级策略`) + + if (account && Object.prototype.hasOwnProperty.call(account, 'dailyQuota')) { + return this.readQuotaFromFields(account) + } + + return { + balance: null, + currency: 'USD', + queryMethod: 'local' + } + } +} + +module.exports = GenericBalanceProvider diff --git a/src/services/balanceProviders/index.js b/src/services/balanceProviders/index.js new file mode 100644 index 0000000..47806f1 --- /dev/null +++ b/src/services/balanceProviders/index.js @@ -0,0 +1,25 @@ +const ClaudeBalanceProvider = require('./claudeBalanceProvider') +const ClaudeConsoleBalanceProvider = require('./claudeConsoleBalanceProvider') +const OpenAIResponsesBalanceProvider = require('./openaiResponsesBalanceProvider') +const GenericBalanceProvider = require('./genericBalanceProvider') +const GeminiBalanceProvider = require('./geminiBalanceProvider') + +function registerAllProviders(balanceService) { + // Claude + balanceService.registerProvider('claude', new ClaudeBalanceProvider()) + balanceService.registerProvider('claude-console', new ClaudeConsoleBalanceProvider()) + + // OpenAI / Codex + balanceService.registerProvider('openai-responses', new OpenAIResponsesBalanceProvider()) + balanceService.registerProvider('openai', new GenericBalanceProvider('openai')) + balanceService.registerProvider('azure_openai', new GenericBalanceProvider('azure_openai')) + + // 其他平台(降级) + balanceService.registerProvider('gemini', new GeminiBalanceProvider()) + balanceService.registerProvider('gemini-api', new GenericBalanceProvider('gemini-api')) + balanceService.registerProvider('bedrock', new GenericBalanceProvider('bedrock')) + balanceService.registerProvider('droid', new GenericBalanceProvider('droid')) + balanceService.registerProvider('ccr', new GenericBalanceProvider('ccr')) +} + +module.exports = { registerAllProviders } diff --git a/src/services/balanceProviders/openaiResponsesBalanceProvider.js b/src/services/balanceProviders/openaiResponsesBalanceProvider.js new file mode 100644 index 0000000..9ff8433 --- /dev/null +++ b/src/services/balanceProviders/openaiResponsesBalanceProvider.js @@ -0,0 +1,54 @@ +const BaseBalanceProvider = require('./baseBalanceProvider') + +class OpenAIResponsesBalanceProvider extends BaseBalanceProvider { + constructor() { + super('openai-responses') + } + + /** + * OpenAI-Responses: + * - 优先使用 dailyQuota 字段(如果配置了额度) + * - 可选:尝试调用兼容 API(不同服务商实现不一,失败自动降级) + */ + async queryBalance(account) { + this.logger.debug(`查询 OpenAI Responses 余额: ${account?.id}`) + + // 配置了额度时直接返回(字段法) + if (account?.dailyQuota && Number(account.dailyQuota) > 0) { + return this.readQuotaFromFields(account) + } + + // 尝试调用 usage 接口(兼容性不保证) + if (account?.apiKey && account?.baseApi) { + const baseApi = String(account.baseApi).replace(/\/$/, '') + const response = await this.makeRequest( + `${baseApi}/v1/usage`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${account.apiKey}`, + 'Content-Type': 'application/json' + } + }, + account + ) + + if (response.success) { + return { + balance: null, + currency: this.parseCurrency(response.data), + queryMethod: 'api', + rawData: response.data + } + } + } + + return { + balance: null, + currency: 'USD', + queryMethod: 'local' + } + } +} + +module.exports = OpenAIResponsesBalanceProvider diff --git a/src/services/balanceScriptService.js b/src/services/balanceScriptService.js new file mode 100644 index 0000000..3d348d3 --- /dev/null +++ b/src/services/balanceScriptService.js @@ -0,0 +1,210 @@ +const vm = require('vm') +const axios = require('axios') +const { isBalanceScriptEnabled } = require('../utils/featureFlags') + +/** + * SSRF防护:检查URL是否访问内网或敏感地址 + * @param {string} url - 要检查的URL + * @returns {boolean} - true表示URL安全 + */ +function isUrlSafe(url) { + try { + const parsed = new URL(url) + const hostname = parsed.hostname.toLowerCase() + + // 禁止的协议 + if (!['http:', 'https:'].includes(parsed.protocol)) { + return false + } + + // 禁止访问localhost和私有IP + const privatePatterns = [ + /^localhost$/i, + /^127\./, + /^10\./, + /^172\.(1[6-9]|2[0-9]|3[0-1])\./, + /^192\.168\./, + /^169\.254\./, // AWS metadata + /^0\./, // 0.0.0.0 + /^::1$/, + /^fc00:/i, + /^fe80:/i, + /\.local$/i, + /\.internal$/i, + /\.localhost$/i + ] + + for (const pattern of privatePatterns) { + if (pattern.test(hostname)) { + return false + } + } + + return true + } catch { + return false + } +} + +/** + * 可配置脚本余额查询执行器 + * - 脚本格式:({ request: {...}, extractor: function(response){...} }) + * - 模板变量:{{baseUrl}}, {{apiKey}}, {{token}}, {{accountId}}, {{platform}}, {{extra}} + */ +class BalanceScriptService { + /** + * 执行脚本:返回标准余额结构 + 原始响应 + * @param {object} options + * - scriptBody: string + * - variables: Record + * - timeoutSeconds: number + */ + async execute(options = {}) { + if (!isBalanceScriptEnabled()) { + const error = new Error('余额脚本功能已禁用(可通过 BALANCE_SCRIPT_ENABLED=true 启用)') + error.code = 'BALANCE_SCRIPT_DISABLED' + throw error + } + + const scriptBody = options.scriptBody?.trim() + if (!scriptBody) { + throw new Error('脚本内容为空') + } + + const timeoutMs = Math.max(1, (options.timeoutSeconds || 10) * 1000) + const sandbox = { + console, + Math, + Date + } + + let scriptResult + try { + const wrapped = scriptBody.startsWith('(') ? scriptBody : `(${scriptBody})` + const script = new vm.Script(wrapped) + scriptResult = script.runInNewContext(sandbox, { timeout: timeoutMs }) + } catch (error) { + throw new Error(`脚本解析失败: ${error.message}`) + } + + if (!scriptResult || typeof scriptResult !== 'object') { + throw new Error('脚本返回格式无效(需返回 { request, extractor })') + } + + const variables = options.variables || {} + const request = this.applyTemplates(scriptResult.request || {}, variables) + const { extractor } = scriptResult + + if (!request?.url || typeof request.url !== 'string') { + throw new Error('脚本 request.url 不能为空') + } + + // SSRF防护:验证URL安全性 + if (!isUrlSafe(request.url)) { + throw new Error('脚本 request.url 不安全:禁止访问内网地址、localhost或使用非HTTP(S)协议') + } + + if (typeof extractor !== 'function') { + throw new Error('脚本 extractor 必须是函数') + } + + const axiosConfig = { + url: request.url, + method: (request.method || 'GET').toUpperCase(), + headers: request.headers || {}, + timeout: timeoutMs + } + + if (request.params) { + axiosConfig.params = request.params + } + if (request.body || request.data) { + axiosConfig.data = request.body || request.data + } + + let httpResponse + try { + httpResponse = await axios(axiosConfig) + } catch (error) { + const { response } = error || {} + const { status, data } = response || {} + throw new Error( + `请求失败: ${status || ''} ${error.message}${data ? ` | ${JSON.stringify(data)}` : ''}` + ) + } + + const responseData = httpResponse?.data + + let extracted = {} + try { + extracted = extractor(responseData) || {} + } catch (error) { + throw new Error(`extractor 执行失败: ${error.message}`) + } + + const mapped = this.mapExtractorResult(extracted, responseData) + return { + mapped, + extracted, + response: { + status: httpResponse?.status, + headers: httpResponse?.headers, + data: responseData + } + } + } + + applyTemplates(value, variables) { + if (typeof value === 'string') { + return value.replace(/{{(\w+)}}/g, (_, key) => { + const trimmed = key.trim() + return variables[trimmed] !== undefined ? String(variables[trimmed]) : '' + }) + } + if (Array.isArray(value)) { + return value.map((item) => this.applyTemplates(item, variables)) + } + if (value && typeof value === 'object') { + const result = {} + Object.keys(value).forEach((k) => { + result[k] = this.applyTemplates(value[k], variables) + }) + return result + } + return value + } + + mapExtractorResult(result = {}, responseData) { + const isValid = result.isValid !== false + const remaining = Number(result.remaining) + const total = Number(result.total) + const used = Number(result.used) + const currency = result.unit || 'USD' + + const quota = + Number.isFinite(total) || Number.isFinite(used) + ? { + total: Number.isFinite(total) ? total : null, + used: Number.isFinite(used) ? used : null, + remaining: Number.isFinite(remaining) ? remaining : null, + percentage: + Number.isFinite(total) && total > 0 && Number.isFinite(used) + ? (used / total) * 100 + : null + } + : null + + return { + status: isValid ? 'success' : 'error', + errorMessage: isValid ? '' : result.invalidMessage || '套餐无效', + balance: Number.isFinite(remaining) ? remaining : null, + currency, + quota, + planName: result.planName || null, + extra: result.extra || null, + rawData: responseData || result.raw + } + } +} + +module.exports = new BalanceScriptService() diff --git a/src/services/billingEventPublisher.js b/src/services/billingEventPublisher.js new file mode 100644 index 0000000..82631d4 --- /dev/null +++ b/src/services/billingEventPublisher.js @@ -0,0 +1,224 @@ +const redis = require('../models/redis') +const logger = require('../utils/logger') + +/** + * 计费事件发布器 - 使用 Redis Stream 解耦计费系统 + * + * 设计原则: + * 1. 异步非阻塞: 发布失败不影响主流程 + * 2. 结构化数据: 使用标准化的事件格式 + * 3. 可追溯性: 每个事件包含完整上下文 + */ +class BillingEventPublisher { + constructor() { + this.streamKey = 'billing:events' + this.maxLength = 100000 // 保留最近 10 万条事件 + this.enabled = process.env.BILLING_EVENTS_ENABLED !== 'false' // 默认开启 + } + + /** + * 发布计费事件 + * @param {Object} eventData - 事件数据 + * @returns {Promise} - 事件ID 或 null + */ + async publishBillingEvent(eventData) { + if (!this.enabled) { + logger.debug('📭 Billing events disabled, skipping publish') + return null + } + + try { + const client = redis.getClientSafe() + + // 构建标准化事件 + const event = { + // 事件元数据 + eventId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + eventType: 'usage.recorded', + timestamp: new Date().toISOString(), + version: '1.0', + + // 核心计费数据 + apiKey: { + id: eventData.keyId, + name: eventData.keyName || null, + userId: eventData.userId || null + }, + + // 使用量详情 + usage: { + model: eventData.model, + inputTokens: eventData.inputTokens || 0, + outputTokens: eventData.outputTokens || 0, + cacheCreateTokens: eventData.cacheCreateTokens || 0, + cacheReadTokens: eventData.cacheReadTokens || 0, + ephemeral5mTokens: eventData.ephemeral5mTokens || 0, + ephemeral1hTokens: eventData.ephemeral1hTokens || 0, + totalTokens: eventData.totalTokens || 0 + }, + + // 费用详情 + cost: { + total: eventData.cost || 0, + currency: 'USD', + breakdown: { + input: eventData.costBreakdown?.input || 0, + output: eventData.costBreakdown?.output || 0, + cacheCreate: eventData.costBreakdown?.cacheCreate || 0, + cacheRead: eventData.costBreakdown?.cacheRead || 0, + ephemeral5m: eventData.costBreakdown?.ephemeral5m || 0, + ephemeral1h: eventData.costBreakdown?.ephemeral1h || 0 + } + }, + + // 账户信息 + account: { + id: eventData.accountId || null, + type: eventData.accountType || null + }, + + // 请求上下文 + context: { + isLongContext: eventData.isLongContext || false, + requestTimestamp: eventData.requestTimestamp || new Date().toISOString() + } + } + + // 使用 XADD 发布事件到 Stream + // MAXLEN ~ 10000: 近似截断,保持性能 + const messageId = await client.xadd( + this.streamKey, + 'MAXLEN', + '~', + this.maxLength, + '*', // 自动生成消息ID + 'data', + JSON.stringify(event) + ) + + logger.debug( + `📤 Published billing event: ${messageId} | Key: ${eventData.keyId} | Cost: $${event.cost.total.toFixed(6)}` + ) + + return messageId + } catch (error) { + // ⚠️ 发布失败不影响主流程,只记录错误 + logger.error('❌ Failed to publish billing event:', error) + return null + } + } + + /** + * 批量发布计费事件(优化性能) + * @param {Array} events - 事件数组 + * @returns {Promise} - 成功发布的事件数 + */ + async publishBatchBillingEvents(events) { + if (!this.enabled || !events || events.length === 0) { + return 0 + } + + try { + const client = redis.getClientSafe() + const pipeline = client.pipeline() + + events.forEach((eventData) => { + const event = { + eventId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + eventType: 'usage.recorded', + timestamp: new Date().toISOString(), + version: '1.0', + apiKey: { + id: eventData.keyId, + name: eventData.keyName || null + }, + usage: { + model: eventData.model, + inputTokens: eventData.inputTokens || 0, + outputTokens: eventData.outputTokens || 0, + totalTokens: eventData.totalTokens || 0 + }, + cost: { + total: eventData.cost || 0, + currency: 'USD' + } + } + + pipeline.xadd( + this.streamKey, + 'MAXLEN', + '~', + this.maxLength, + '*', + 'data', + JSON.stringify(event) + ) + }) + + const results = await pipeline.exec() + const successCount = results.filter((r) => r[0] === null).length + + logger.info(`📤 Batch published ${successCount}/${events.length} billing events`) + return successCount + } catch (error) { + logger.error('❌ Failed to batch publish billing events:', error) + return 0 + } + } + + /** + * 获取 Stream 信息(用于监控) + * @returns {Promise} + */ + async getStreamInfo() { + try { + const client = redis.getClientSafe() + const info = await client.xinfo('STREAM', this.streamKey) + + // 解析 Redis XINFO 返回的数组格式 + const result = {} + for (let i = 0; i < info.length; i += 2) { + result[info[i]] = info[i + 1] + } + + return { + length: result.length || 0, + firstEntry: result['first-entry'] || null, + lastEntry: result['last-entry'] || null, + groups: result.groups || 0 + } + } catch (error) { + if (error.message.includes('no such key')) { + return { length: 0, groups: 0 } + } + logger.error('❌ Failed to get stream info:', error) + return null + } + } + + /** + * 创建消费者组(供外部计费系统使用) + * @param {string} groupName - 消费者组名称 + * @returns {Promise} + */ + async createConsumerGroup(groupName = 'billing-system') { + try { + const client = redis.getClientSafe() + + // MKSTREAM: 如果 stream 不存在则创建 + await client.xgroup('CREATE', this.streamKey, groupName, '0', 'MKSTREAM') + + logger.success(`Created consumer group: ${groupName}`) + return true + } catch (error) { + if (error.message.includes('BUSYGROUP')) { + logger.debug(`Consumer group ${groupName} already exists`) + return true + } + logger.error(`❌ Failed to create consumer group ${groupName}:`, error) + return false + } + } +} + +module.exports = new BillingEventPublisher() diff --git a/src/services/claudeCodeHeadersService.js b/src/services/claudeCodeHeadersService.js new file mode 100644 index 0000000..8990e31 --- /dev/null +++ b/src/services/claudeCodeHeadersService.js @@ -0,0 +1,241 @@ +/** + * Claude Code Headers 管理服务 + * 负责存储和管理不同账号使用的 Claude Code headers + */ + +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { + getCachedConfig, + setCachedConfig, + deleteCachedConfig +} = require('../utils/performanceOptimizer') + +class ClaudeCodeHeadersService { + constructor() { + this.defaultHeaders = { + 'x-stainless-retry-count': '0', + 'x-stainless-timeout': '60', + 'x-stainless-lang': 'js', + 'x-stainless-package-version': '0.55.1', + 'x-stainless-os': 'Windows', + 'x-stainless-arch': 'x64', + 'x-stainless-runtime': 'node', + 'x-stainless-runtime-version': 'v20.19.2', + 'anthropic-dangerous-direct-browser-access': 'true', + 'x-app': 'cli', + 'user-agent': 'claude-cli/1.0.57 (external, cli)', + 'accept-language': '*', + 'sec-fetch-mode': 'cors' + } + + // 需要捕获的 Claude Code 特定 headers + this.claudeCodeHeaderKeys = [ + 'x-stainless-retry-count', + 'x-stainless-timeout', + 'x-stainless-lang', + 'x-stainless-package-version', + 'x-stainless-os', + 'x-stainless-arch', + 'x-stainless-runtime', + 'x-stainless-runtime-version', + 'anthropic-dangerous-direct-browser-access', + 'x-app', + 'user-agent', + 'accept-language', + 'sec-fetch-mode' + // 注意:不捕获 accept-encoding,避免存储客户端的 zstd 等不支持的编码 + ] + + // Headers 缓存 TTL(60秒) + this.headersCacheTtl = 60000 + } + + /** + * 从 user-agent 中提取版本号 + */ + extractVersionFromUserAgent(userAgent) { + if (!userAgent) { + return null + } + const match = userAgent.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i) + return match ? match[1] : null + } + + /** + * 比较版本号 + * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal + */ + compareVersions(v1, v2) { + if (!v1 || !v2) { + return 0 + } + + const parts1 = v1.split('.').map(Number) + const parts2 = v2.split('.').map(Number) + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const p1 = parts1[i] || 0 + const p2 = parts2[i] || 0 + + if (p1 > p2) { + return 1 + } + if (p1 < p2) { + return -1 + } + } + + return 0 + } + + /** + * 从客户端 headers 中提取 Claude Code 相关的 headers + */ + extractClaudeCodeHeaders(clientHeaders) { + const headers = {} + + // 转换所有 header keys 为小写进行比较 + const lowerCaseHeaders = {} + Object.keys(clientHeaders || {}).forEach((key) => { + lowerCaseHeaders[key.toLowerCase()] = clientHeaders[key] + }) + + // 提取需要的 headers + this.claudeCodeHeaderKeys.forEach((key) => { + const lowerKey = key.toLowerCase() + if (lowerCaseHeaders[lowerKey]) { + headers[key] = lowerCaseHeaders[lowerKey] + } + }) + + return headers + } + + /** + * 存储账号的 Claude Code headers + */ + async storeAccountHeaders(accountId, clientHeaders) { + try { + const extractedHeaders = this.extractClaudeCodeHeaders(clientHeaders) + + // 检查是否有 user-agent + const userAgent = extractedHeaders['user-agent'] + if (!userAgent || !/^claude-cli\/[\d.]+\s+\(/i.test(userAgent)) { + // 不是 Claude Code 的请求,不存储 + return + } + + const version = this.extractVersionFromUserAgent(userAgent) + if (!version) { + logger.warn(`⚠️ Failed to extract version from user-agent: ${userAgent}`) + return + } + + // 获取当前存储的 headers + const key = `claude_code_headers:${accountId}` + const currentData = await redis.getClient().get(key) + + if (currentData) { + const current = JSON.parse(currentData) + const currentVersion = this.extractVersionFromUserAgent(current.headers['user-agent']) + + // 只有新版本更高时才更新 + if (this.compareVersions(version, currentVersion) <= 0) { + return + } + } + + // 存储新的 headers + const data = { + headers: extractedHeaders, + version, + updatedAt: new Date().toISOString() + } + + await redis.getClient().setex(key, 86400 * 7, JSON.stringify(data)) // 7天过期 + + // 更新内存缓存,避免延迟 + setCachedConfig(key, extractedHeaders, this.headersCacheTtl) + + logger.info(`✅ Stored Claude Code headers for account ${accountId}, version: ${version}`) + } catch (error) { + logger.error(`❌ Failed to store Claude Code headers for account ${accountId}:`, error) + } + } + + /** + * 获取账号的 Claude Code headers(带内存缓存) + */ + async getAccountHeaders(accountId) { + const cacheKey = `claude_code_headers:${accountId}` + + // 检查内存缓存 + const cached = getCachedConfig(cacheKey) + if (cached) { + return cached + } + + try { + const data = await redis.getClient().get(cacheKey) + + if (data) { + const parsed = JSON.parse(data) + logger.debug( + `📋 Retrieved Claude Code headers for account ${accountId}, version: ${parsed.version}` + ) + // 缓存到内存 + setCachedConfig(cacheKey, parsed.headers, this.headersCacheTtl) + return parsed.headers + } + + // 返回默认 headers + logger.debug(`📋 Using default Claude Code headers for account ${accountId}`) + return this.defaultHeaders + } catch (error) { + logger.error(`❌ Failed to get Claude Code headers for account ${accountId}:`, error) + return this.defaultHeaders + } + } + + /** + * 清除账号的 Claude Code headers + */ + async clearAccountHeaders(accountId) { + try { + const cacheKey = `claude_code_headers:${accountId}` + await redis.getClient().del(cacheKey) + // 删除内存缓存 + deleteCachedConfig(cacheKey) + logger.info(`🗑️ Cleared Claude Code headers for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to clear Claude Code headers for account ${accountId}:`, error) + } + } + + /** + * 获取所有账号的 headers 信息(使用 scanKeys 替代 keys) + */ + async getAllAccountHeaders() { + try { + const pattern = 'claude_code_headers:*' + const keys = await redis.scanKeys(pattern) + + const results = {} + for (const key of keys) { + const accountId = key.replace('claude_code_headers:', '') + const data = await redis.getClient().get(key) + if (data) { + results[accountId] = JSON.parse(data) + } + } + + return results + } catch (error) { + logger.error('❌ Failed to get all account headers:', error) + return {} + } + } +} + +module.exports = new ClaudeCodeHeadersService() diff --git a/src/services/claudeRelayConfigService.js b/src/services/claudeRelayConfigService.js new file mode 100644 index 0000000..f668af0 --- /dev/null +++ b/src/services/claudeRelayConfigService.js @@ -0,0 +1,453 @@ +/** + * Claude 转发配置服务 + * 管理全局 Claude Code 限制和会话绑定配置 + */ + +const redis = require('../models/redis') +const logger = require('../utils/logger') +const metadataUserIdHelper = require('../utils/metadataUserIdHelper') + +const CONFIG_KEY = 'claude_relay_config' +const SESSION_BINDING_PREFIX = 'original_session_binding:' + +// 默认配置 +const DEFAULT_CONFIG = { + claudeCodeOnlyEnabled: false, + globalSessionBindingEnabled: false, + sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。', + sessionBindingTtlDays: 1, // 会话绑定 TTL(天),默认1天(支持 /clear 场景,避免 Redis 累积) + // 用户消息队列配置 + userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭) + userMessageQueueDelayMs: 200, // 请求间隔(毫秒) + userMessageQueueTimeoutMs: 60000, // 队列等待超时(毫秒) + userMessageQueueLockTtlMs: 120000, // 锁TTL(毫秒) + // 并发请求排队配置 + concurrentRequestQueueEnabled: false, // 是否启用并发请求排队(默认关闭) + concurrentRequestQueueMaxSize: 3, // 固定最小排队数(默认3) + concurrentRequestQueueMaxSizeMultiplier: 0, // 并发数的倍数(默认0,仅使用固定值) + concurrentRequestQueueTimeoutMs: 10000, // 排队超时(毫秒,默认10秒) + concurrentRequestQueueMaxRedisFailCount: 5, // 连续 Redis 失败阈值(默认5次) + requestDetailCaptureEnabled: false, // 是否启用请求明细采集 + requestDetailRetentionHours: 6, // 请求明细保留时间(小时) + requestDetailBodyPreviewEnabled: false, // 是否保存请求体预览快照 + // 排队健康检查配置 + concurrentRequestQueueHealthCheckEnabled: true, // 是否启用排队健康检查(默认开启) + concurrentRequestQueueHealthThreshold: 0.8, // 健康检查阈值(P90 >= 超时 × 阈值时拒绝新请求) + updatedAt: null, + updatedBy: null +} + +// 内存缓存(避免频繁 Redis 查询) +let configCache = null +let configCacheTime = 0 +const CONFIG_CACHE_TTL = 60000 // 1分钟缓存 + +class ClaudeRelayConfigService { + /** + * 从 metadata.user_id 中提取原始 sessionId + * @param {Object} requestBody - 请求体 + * @returns {string|null} 原始 sessionId 或 null + */ + extractOriginalSessionId(requestBody) { + if (!requestBody?.metadata?.user_id) { + return null + } + return metadataUserIdHelper.extractSessionId(requestBody.metadata.user_id) + } + + /** + * 获取配置(带缓存) + * @returns {Promise} 配置对象 + */ + async getConfig() { + try { + // 检查缓存 + if (configCache && Date.now() - configCacheTime < CONFIG_CACHE_TTL) { + return configCache + } + + const client = redis.getClient() + if (!client) { + logger.warn('⚠️ Redis not connected, using default config') + return { ...DEFAULT_CONFIG } + } + + const data = await client.get(CONFIG_KEY) + + if (data) { + configCache = { ...DEFAULT_CONFIG, ...JSON.parse(data) } + } else { + configCache = { ...DEFAULT_CONFIG } + } + + configCacheTime = Date.now() + return configCache + } catch (error) { + logger.error('❌ Failed to get Claude relay config:', error) + return { ...DEFAULT_CONFIG } + } + } + + /** + * 更新配置 + * @param {Object} newConfig - 新配置 + * @param {string} updatedBy - 更新者 + * @returns {Promise} 更新后的配置 + */ + async updateConfig(newConfig, updatedBy) { + try { + const client = redis.getClientSafe() + const currentConfig = await this.getConfig() + + const updatedConfig = { + ...currentConfig, + ...newConfig, + updatedAt: new Date().toISOString(), + updatedBy + } + + await client.set(CONFIG_KEY, JSON.stringify(updatedConfig)) + + // 更新缓存 + configCache = updatedConfig + configCacheTime = Date.now() + + logger.info(`✅ Claude relay config updated by ${updatedBy}:`, { + claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled, + globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled, + concurrentRequestQueueEnabled: updatedConfig.concurrentRequestQueueEnabled + }) + + return updatedConfig + } catch (error) { + logger.error('❌ Failed to update Claude relay config:', error) + throw error + } + } + + /** + * 检查是否启用全局 Claude Code 限制 + * @returns {Promise} + */ + async isClaudeCodeOnlyEnabled() { + const cfg = await this.getConfig() + return cfg.claudeCodeOnlyEnabled === true + } + + /** + * 检查是否启用全局会话绑定 + * @returns {Promise} + */ + async isGlobalSessionBindingEnabled() { + const cfg = await this.getConfig() + return cfg.globalSessionBindingEnabled === true + } + + /** + * 获取会话绑定错误信息 + * @returns {Promise} + */ + async getSessionBindingErrorMessage() { + const cfg = await this.getConfig() + return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage + } + + /** + * 获取原始会话绑定 + * @param {string} originalSessionId - 原始会话ID + * @returns {Promise} 绑定信息或 null + */ + async getOriginalSessionBinding(originalSessionId) { + if (!originalSessionId) { + return null + } + + try { + const client = redis.getClient() + if (!client) { + return null + } + + const key = `${SESSION_BINDING_PREFIX}${originalSessionId}` + const data = await client.get(key) + + if (data) { + return JSON.parse(data) + } + return null + } catch (error) { + logger.error(`❌ Failed to get session binding for ${originalSessionId}:`, error) + return null + } + } + + /** + * 设置原始会话绑定 + * @param {string} originalSessionId - 原始会话ID + * @param {string} accountId - 账户ID + * @param {string} accountType - 账户类型 + * @returns {Promise} 绑定信息 + */ + async setOriginalSessionBinding(originalSessionId, accountId, accountType) { + if (!originalSessionId || !accountId || !accountType) { + throw new Error('Invalid parameters for session binding') + } + + try { + const client = redis.getClientSafe() + const key = `${SESSION_BINDING_PREFIX}${originalSessionId}` + + const binding = { + accountId, + accountType, + createdAt: new Date().toISOString(), + lastUsedAt: new Date().toISOString() + } + + // 使用配置的 TTL(默认30天) + const cfg = await this.getConfig() + const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays + const ttlSeconds = Math.floor(ttlDays * 24 * 3600) + + await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds) + + logger.info( + `🔗 Session binding created: ${originalSessionId} -> ${accountId} (${accountType})` + ) + + return binding + } catch (error) { + logger.error(`❌ Failed to set session binding for ${originalSessionId}:`, error) + throw error + } + } + + /** + * 更新会话绑定的最后使用时间(续期) + * @param {string} originalSessionId - 原始会话ID + */ + async touchOriginalSessionBinding(originalSessionId) { + if (!originalSessionId) { + return + } + + try { + const binding = await this.getOriginalSessionBinding(originalSessionId) + if (!binding) { + return + } + + binding.lastUsedAt = new Date().toISOString() + + const client = redis.getClientSafe() + const key = `${SESSION_BINDING_PREFIX}${originalSessionId}` + + // 使用配置的 TTL(默认30天) + const cfg = await this.getConfig() + const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays + const ttlSeconds = Math.floor(ttlDays * 24 * 3600) + + await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds) + } catch (error) { + logger.warn(`⚠️ Failed to touch session binding for ${originalSessionId}:`, error) + } + } + + /** + * 检查原始会话是否已绑定 + * @param {string} originalSessionId - 原始会话ID + * @returns {Promise} + */ + async isOriginalSessionBound(originalSessionId) { + const binding = await this.getOriginalSessionBinding(originalSessionId) + return binding !== null + } + + /** + * 验证绑定的账户是否可用 + * @param {Object} binding - 绑定信息 + * @returns {Promise} + */ + async validateBoundAccount(binding) { + if (!binding || !binding.accountId || !binding.accountType) { + return false + } + + try { + const { accountType } = binding + const { accountId } = binding + + let accountService + switch (accountType) { + case 'claude-official': + accountService = require('./account/claudeAccountService') + break + case 'claude-console': + accountService = require('./account/claudeConsoleAccountService') + break + case 'bedrock': + accountService = require('./account/bedrockAccountService') + break + case 'ccr': + accountService = require('./account/ccrAccountService') + break + default: + logger.warn(`Unknown account type for validation: ${accountType}`) + return false + } + + const account = await accountService.getAccount(accountId) + + // getAccount() 直接返回账户数据对象或 null,不是 { success, data } 格式 + if (!account) { + logger.warn(`Session binding account not found: ${accountId} (${accountType})`) + return false + } + + const accountData = account + + // 检查账户是否激活 + if (accountData.isActive === false || accountData.isActive === 'false') { + logger.warn( + `Session binding account not active: ${accountId} (${accountType}), isActive: ${accountData.isActive}` + ) + return false + } + + // 检查账户状态(如果存在) + if (accountData.status && accountData.status === 'error') { + logger.warn( + `Session binding account has error status: ${accountId} (${accountType}), status: ${accountData.status}` + ) + return false + } + + return true + } catch (error) { + logger.error(`❌ Failed to validate bound account ${binding.accountId}:`, error) + return false + } + } + + /** + * 验证新会话请求 + * @param {Object} _requestBody - 请求体(预留参数,当前未使用) + * @param {string} originalSessionId - 原始会话ID + * @returns {Promise} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean } + */ + async validateNewSession(_requestBody, originalSessionId) { + const cfg = await this.getConfig() + + if (!cfg.globalSessionBindingEnabled) { + return { valid: true } + } + + // 如果没有 sessionId,跳过验证(可能是非 Claude Code 客户端) + if (!originalSessionId) { + return { valid: true } + } + + const existingBinding = await this.getOriginalSessionBinding(originalSessionId) + + // 如果会话已存在绑定 + if (existingBinding) { + // ⚠️ 只有 claude-official 类型账户受全局会话绑定限制 + // 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度 + if (existingBinding.accountType !== 'claude-official') { + logger.info( + `🔗 Session binding ignored for non-official account type: ${existingBinding.accountType}` + ) + return { valid: true } + } + + const accountValid = await this.validateBoundAccount(existingBinding) + + if (!accountValid) { + return { + valid: false, + error: cfg.sessionBindingErrorMessage, + code: 'SESSION_BINDING_INVALID' + } + } + + // 续期 + await this.touchOriginalSessionBinding(originalSessionId) + + // 已有绑定,允许继续(这是正常的会话延续) + return { valid: true, binding: existingBinding } + } + + // 没有绑定,是新会话 + // 注意:messages.length 检查在此处无法执行,因为我们不知道最终会调度到哪种账户类型 + // 绑定会在调度后创建,仅针对 claude-official 账户 + return { valid: true, isNewSession: true } + } + + /** + * 删除原始会话绑定 + * @param {string} originalSessionId - 原始会话ID + */ + async deleteOriginalSessionBinding(originalSessionId) { + if (!originalSessionId) { + return + } + + try { + const client = redis.getClient() + if (!client) { + return + } + + const key = `${SESSION_BINDING_PREFIX}${originalSessionId}` + await client.del(key) + logger.info(`🗑️ Session binding deleted: ${originalSessionId}`) + } catch (error) { + logger.error(`❌ Failed to delete session binding for ${originalSessionId}:`, error) + } + } + + /** + * 获取会话绑定统计 + * @returns {Promise} + */ + async getSessionBindingStats() { + try { + const client = redis.getClient() + if (!client) { + return { totalBindings: 0 } + } + + let cursor = '0' + let count = 0 + + do { + const [newCursor, keys] = await client.scan( + cursor, + 'MATCH', + `${SESSION_BINDING_PREFIX}*`, + 'COUNT', + 100 + ) + cursor = newCursor + count += keys.length + } while (cursor !== '0') + + return { + totalBindings: count + } + } catch (error) { + logger.error('❌ Failed to get session binding stats:', error) + return { totalBindings: 0 } + } + } + + /** + * 清除配置缓存(用于测试或强制刷新) + */ + clearCache() { + configCache = null + configCacheTime = 0 + } +} + +module.exports = new ClaudeRelayConfigService() diff --git a/src/services/codexToOpenAI.js b/src/services/codexToOpenAI.js new file mode 100644 index 0000000..e040c31 --- /dev/null +++ b/src/services/codexToOpenAI.js @@ -0,0 +1,717 @@ +/** + * Codex Responses API → OpenAI Chat Completions 格式转换器 + * 将 Codex/OpenAI Responses API 的 SSE 事件转为标准 chat.completion / chat.completion.chunk 格式 + */ + +class CodexToOpenAIConverter { + constructor() { + // 工具名缩短映射(buildRequestFromOpenAI 填充,响应转换时逆向恢复) + this._reverseToolNameMap = {} + } + + createStreamState() { + return { + responseId: '', + createdAt: 0, + model: '', + functionCallIndex: -1, + hasReceivedArgumentsDelta: false, + hasToolCallAnnounced: false, + roleSent: false + } + } + + /** + * 流式转换: 单个已解析的 SSE 事件 → OpenAI chunk SSE 字符串数组 + * @param {Object} eventData - 已解析的 SSE JSON 对象 + * @param {string} model - 请求模型名 + * @param {Object} state - createStreamState() 的返回值 + * @returns {string[]} "data: {...}\n\n" 字符串数组(可能为空) + */ + convertStreamChunk(eventData, model, state) { + const { type } = eventData + if (!type) { + return [] + } + + switch (type) { + case 'response.created': + return this._handleResponseCreated(eventData, state) + + case 'response.reasoning_summary_text.delta': + return this._emitChunk(state, model, { reasoning_content: eventData.delta }) + + case 'response.reasoning_summary_text.done': + // done 事件仅为结束信号,delta 已通过 .delta 事件发送,不再注入内容 + return [] + + case 'response.output_text.delta': + return this._emitChunk(state, model, { content: eventData.delta }) + + case 'response.output_item.added': + return this._handleOutputItemAdded(eventData, model, state) + + case 'response.function_call_arguments.delta': + return this._handleArgumentsDelta(eventData, model, state) + + case 'response.function_call_arguments.done': + return this._handleArgumentsDone(eventData, model, state) + + case 'response.output_item.done': + return this._handleOutputItemDone(eventData, model, state) + + case 'response.completed': + return this._handleResponseCompleted(eventData, model, state) + + case 'response.failed': + case 'response.incomplete': + return this._handleResponseError(eventData, model, state) + + case 'error': + return this._handleStreamError(eventData, model, state) + + default: + return [] + } + } + + /** + * 非流式转换: Codex 完整响应 → OpenAI chat.completion + * @param {Object} responseData - Codex 响应对象或 response.completed 事件 + * @param {string} model - 请求模型名 + * @returns {Object} + */ + convertResponse(responseData, model) { + // 自动检测:response.completed 事件包装 vs 直接响应对象 + const resp = responseData.type === 'response.completed' ? responseData.response : responseData + + const message = { role: 'assistant', content: null } + const toolCalls = [] + + const output = resp.output || [] + for (const item of output) { + if (item.type === 'reasoning') { + const summaryTexts = (item.summary || []) + .filter((s) => s.type === 'summary_text') + .map((s) => s.text) + if (summaryTexts.length > 0) { + message.reasoning_content = (message.reasoning_content || '') + summaryTexts.join('') + } + } else if (item.type === 'message') { + const contentTexts = (item.content || []) + .filter((c) => c.type === 'output_text') + .map((c) => c.text) + if (contentTexts.length > 0) { + message.content = (message.content || '') + contentTexts.join('') + } + } else if (item.type === 'function_call') { + toolCalls.push({ + id: item.call_id || item.id, + type: 'function', + function: { + name: this._restoreToolName(item.name), + arguments: item.arguments || '{}' + } + }) + } + } + + if (toolCalls.length > 0) { + message.tool_calls = toolCalls + } + + // response.failed → 返回 error 结构(与流式 _handleResponseError 一致) + if (resp.status === 'failed') { + const err = resp.error || {} + return { + error: { + message: err.message || 'Response failed', + type: err.type || 'server_error', + code: err.code || null + } + } + } + + const finishReason = toolCalls.length > 0 ? 'tool_calls' : this._mapResponseStatus(resp) + + const result = { + id: resp.id || `chatcmpl-${Date.now()}`, + object: 'chat.completion', + created: this._parseCreatedAt(resp.created_at), + model: resp.model || model, + choices: [{ index: 0, message, finish_reason: finishReason }] + } + + const usage = this._mapUsage(resp.usage) + if (usage) { + result.usage = usage + } + + return result + } + + // --- 内部方法:流式事件处理 --- + + _handleResponseCreated(eventData, state) { + const resp = eventData.response || {} + state.responseId = resp.id || '' + if (resp.created_at) { + state.createdAt = this._parseCreatedAt(resp.created_at) + } + state.model = resp.model || '' + return [] + } + + _handleOutputItemAdded(eventData, model, state) { + const { item } = eventData + if (!item || item.type !== 'function_call') { + return [] + } + + state.functionCallIndex++ + state.hasReceivedArgumentsDelta = false + state.hasToolCallAnnounced = true + + return this._emitChunk(state, model, { + tool_calls: [ + { + index: state.functionCallIndex, + id: item.call_id || item.id, + type: 'function', + function: { name: this._restoreToolName(item.name), arguments: '' } + } + ] + }) + } + + _handleArgumentsDelta(eventData, model, state) { + state.hasReceivedArgumentsDelta = true + return this._emitChunk(state, model, { + tool_calls: [ + { + index: state.functionCallIndex, + function: { arguments: eventData.delta } + } + ] + }) + } + + _handleArgumentsDone(eventData, model, state) { + // 如果已收到增量 delta,done 不需要再输出 + if (state.hasReceivedArgumentsDelta) { + return [] + } + + // 没有收到 delta,一次性输出完整参数 + return this._emitChunk(state, model, { + tool_calls: [ + { + index: state.functionCallIndex, + function: { arguments: eventData.arguments || '{}' } + } + ] + }) + } + + _handleOutputItemDone(eventData, model, state) { + const { item } = eventData + if (!item || item.type !== 'function_call') { + return [] + } + + // 如果已经通过 output_item.added 通知过,不重复输出 + if (state.hasToolCallAnnounced) { + state.hasToolCallAnnounced = false + return [] + } + + // Fallback:未收到 added 事件,输出完整 tool call + state.functionCallIndex++ + return this._emitChunk(state, model, { + tool_calls: [ + { + index: state.functionCallIndex, + id: item.call_id || item.id, + type: 'function', + function: { + name: this._restoreToolName(item.name), + arguments: item.arguments || '{}' + } + } + ] + }) + } + + _handleResponseCompleted(eventData, model, state) { + const resp = eventData.response || {} + const chunk = this._makeChunk(state, model) + + if (state.functionCallIndex >= 0) { + chunk.choices[0].finish_reason = 'tool_calls' + } else { + chunk.choices[0].finish_reason = this._mapResponseStatus(resp) + } + + const usage = this._mapUsage(resp.usage) + if (usage) { + chunk.usage = usage + } + + return [`data: ${JSON.stringify(chunk)}\n\n`] + } + + _handleResponseError(eventData, model, state) { + const resp = eventData.response || {} + const results = [] + + // response.failed → 转为 error SSE 事件(保留错误语义) + if (resp.status === 'failed') { + const err = resp.error || {} + results.push( + `data: ${JSON.stringify({ + error: { + message: err.message || 'Response failed', + type: err.type || 'server_error', + code: err.code || null + } + })}\n\n` + ) + } + + // response.incomplete 及其他非 failed 状态 → 带 finish_reason 的终止 chunk + if (resp.status !== 'failed') { + const chunk = this._makeChunk(state, model) + if (state.functionCallIndex >= 0) { + chunk.choices[0].finish_reason = 'tool_calls' + } else { + chunk.choices[0].finish_reason = this._mapResponseStatus(resp) + } + const usage = this._mapUsage(resp.usage) + if (usage) { + chunk.usage = usage + } + results.push(`data: ${JSON.stringify(chunk)}\n\n`) + } + + return results + } + + _handleStreamError(eventData) { + // type: "error" → 转为 OpenAI 格式的 error SSE 事件 + const errorObj = { + error: { + message: eventData.message || 'Unknown error', + type: 'server_error', + code: eventData.code || null + } + } + return [`data: ${JSON.stringify(errorObj)}\n\n`] + } + + // --- 工具方法 --- + + _emitChunk(state, model, delta) { + const chunk = this._makeChunk(state, model) + if (!state.roleSent) { + delta.role = 'assistant' + state.roleSent = true + } + chunk.choices[0].delta = delta + return [`data: ${JSON.stringify(chunk)}\n\n`] + } + + _makeChunk(state, model) { + return { + id: state.responseId || `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: state.createdAt || Math.floor(Date.now() / 1000), + model: state.model || model, + choices: [{ index: 0, delta: {}, finish_reason: null }] + } + } + + _mapResponseStatus(resp) { + const { status } = resp + if (!status || status === 'completed') { + return 'stop' + } + if (status === 'incomplete') { + const reason = resp.incomplete_details?.reason + if (reason === 'max_output_tokens') { + return 'length' + } + if (reason === 'content_filter') { + return 'content_filter' + } + return 'length' + } + // failed, cancelled, etc. + return 'stop' + } + + _parseCreatedAt(createdAt) { + if (!createdAt) { + return Math.floor(Date.now() / 1000) + } + if (typeof createdAt === 'number') { + return createdAt + } + const ts = Math.floor(new Date(createdAt).getTime() / 1000) + return isNaN(ts) ? Math.floor(Date.now() / 1000) : ts + } + + _mapUsage(usage) { + if (!usage) { + return undefined + } + const result = { + prompt_tokens: usage.input_tokens || 0, + completion_tokens: usage.output_tokens || 0, + total_tokens: usage.total_tokens || 0 + } + if (usage.input_tokens_details?.cached_tokens > 0) { + result.prompt_tokens_details = { cached_tokens: usage.input_tokens_details.cached_tokens } + } + if (usage.output_tokens_details?.reasoning_tokens > 0) { + result.completion_tokens_details = { + reasoning_tokens: usage.output_tokens_details.reasoning_tokens + } + } + return result + } + + // ============================================= + // 请求转换: Chat Completions → Responses API + // ============================================= + // ============================================= + + /** + * 将 OpenAI Chat Completions 请求体转为 Responses API 格式 + * @param {Object} chatBody - Chat Completions 格式请求体 + * @returns {Object} Responses API 格式请求体 + */ + buildRequestFromOpenAI(chatBody) { + const result = {} + + if (chatBody.model) { + result.model = chatBody.model + } + if (chatBody.stream !== undefined) { + result.stream = chatBody.stream + } + + // messages → input(instructions 由调用方设置,此处只转换消息到 input) + const input = [] + + for (const msg of chatBody.messages || []) { + switch (msg.role) { + case 'system': + case 'developer': + input.push({ + type: 'message', + role: 'developer', + content: this._wrapContent(msg.content, 'user') + }) + break + + case 'user': + input.push({ + type: 'message', + role: 'user', + content: this._wrapContent(msg.content, 'user') + }) + break + + case 'assistant': + if (msg.content) { + input.push({ + type: 'message', + role: 'assistant', + content: this._wrapContent(msg.content, 'assistant') + }) + } + if (msg.tool_calls && msg.tool_calls.length > 0) { + for (const tc of msg.tool_calls) { + if (tc.type === 'function') { + input.push({ + type: 'function_call', + call_id: tc.id, + name: this._shortenToolName(tc.function?.name || ''), + arguments: + typeof tc.function?.arguments === 'string' + ? tc.function.arguments + : JSON.stringify(tc.function?.arguments ?? {}) + }) + } + } + } + break + + case 'tool': + input.push({ + type: 'function_call_output', + call_id: msg.tool_call_id, + output: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) + }) + break + } + } + + result.input = input + + // temperature/top_p/max_output_tokens 不透传,与上游 Codex API 行为保持一致 + + // reasoning 配置 + result.reasoning = { + effort: chatBody.reasoning_effort || 'medium', + summary: 'auto' + } + + // 固定值 + result.parallel_tool_calls = true + result.include = ['reasoning.encrypted_content'] + result.store = false + + // 收集所有工具名(tools + assistant.tool_calls),统一构建缩短映射 + const allToolNames = new Set() + if (chatBody.tools) { + for (const t of chatBody.tools) { + if (t.type === 'function' && t.function?.name) { + allToolNames.add(t.function.name) + } + } + } + for (const msg of chatBody.messages || []) { + if (msg.role === 'assistant' && msg.tool_calls) { + for (const tc of msg.tool_calls) { + if (tc.type === 'function' && tc.function?.name) { + allToolNames.add(tc.function.name) + } + } + } + } + if (allToolNames.size > 0) { + this._toolNameMap = this._buildShortNameMap([...allToolNames]) + this._reverseToolNameMap = {} + for (const [orig, short] of Object.entries(this._toolNameMap)) { + if (orig !== short) { + this._reverseToolNameMap[short] = orig + } + } + } + + // tools 展平 + if (chatBody.tools && chatBody.tools.length > 0) { + result.tools = this._convertTools(chatBody.tools) + } + + // tool_choice + if (chatBody.tool_choice !== undefined) { + result.tool_choice = this._convertToolChoice(chatBody.tool_choice) + } + + // response_format → text.format + if (chatBody.response_format) { + const text = this._convertResponseFormat(chatBody.response_format) + if (text && Object.keys(text).length > 0) { + result.text = text + } + } + + // session 字段透传(handleResponses 用于 session hash) + if (chatBody.session_id) { + result.session_id = chatBody.session_id + } + if (chatBody.conversation_id) { + result.conversation_id = chatBody.conversation_id + } + if (chatBody.prompt_cache_key) { + result.prompt_cache_key = chatBody.prompt_cache_key + } + + return result + } + + // --- 请求转换辅助方法 --- + + _extractTextContent(content) { + if (typeof content === 'string') { + return content + } + if (Array.isArray(content)) { + return content + .filter((c) => c.type === 'text') + .map((c) => c.text) + .join('') + } + return String(content || '') + } + + _wrapContent(content, role) { + const textType = role === 'assistant' ? 'output_text' : 'input_text' + if (typeof content === 'string') { + return [{ type: textType, text: content }] + } + if (Array.isArray(content)) { + return content + .map((item) => { + switch (item.type) { + case 'text': + return { type: textType, text: item.text } + case 'image_url': + return { + type: 'input_image', + image_url: item.image_url?.url || item.image_url + } + default: + return item + } + }) + .filter(Boolean) + } + return [{ type: textType, text: String(content || '') }] + } + + _convertTools(tools) { + return tools + .filter((t) => t && t.type) + .map((t) => { + // 非 function 工具(web_search、code_interpreter 等)原样透传 + if (t.type !== 'function') { + return t + } + // function 工具展平:去除 function wrapper + if (!t.function) { + return null + } + const tool = { type: 'function', name: this._shortenToolName(t.function.name) } + if (t.function.description) { + tool.description = t.function.description + } + if (t.function.parameters) { + tool.parameters = t.function.parameters + } + if (t.function.strict !== undefined) { + tool.strict = t.function.strict + } + return tool + }) + .filter(Boolean) + } + + _convertToolChoice(tc) { + if (typeof tc === 'string') { + return tc + } + if (tc && typeof tc === 'object') { + if (tc.type === 'function' && tc.function?.name) { + return { type: 'function', name: this._shortenToolName(tc.function.name) } + } + return tc + } + return tc + } + + _convertResponseFormat(rf) { + if (!rf || !rf.type) { + return {} + } + if (rf.type === 'text') { + return { format: { type: 'text' } } + } + if (rf.type === 'json_schema' && rf.json_schema) { + const format = { type: 'json_schema' } + if (rf.json_schema.name) { + format.name = rf.json_schema.name + } + if (rf.json_schema.strict !== undefined) { + format.strict = rf.json_schema.strict + } + if (rf.json_schema.schema) { + format.schema = rf.json_schema.schema + } + return { format } + } + return {} + } + + /** + * 工具名缩短:优先使用唯一化 map,无 map 时做简单截断 + */ + _shortenToolName(name) { + if (!name) { + return name + } + if (this._toolNameMap && this._toolNameMap[name]) { + return this._toolNameMap[name] + } + const LIMIT = 64 + if (name.length <= LIMIT) { + return name + } + if (name.startsWith('mcp__')) { + const idx = name.lastIndexOf('__') + if (idx > 0) { + const candidate = `mcp__${name.slice(idx + 2)}` + return candidate.length > LIMIT ? candidate.slice(0, LIMIT) : candidate + } + } + return name.slice(0, LIMIT) + } + + /** + * 构建工具名缩短映射(保证唯一) + * 构建唯一缩短名映射,处理碰撞 + * @returns {Object} { originalName: shortName } + */ + _buildShortNameMap(names) { + const LIMIT = 64 + const used = new Set() + const map = {} + + const baseCandidate = (n) => { + if (n.length <= LIMIT) { + return n + } + if (n.startsWith('mcp__')) { + const idx = n.lastIndexOf('__') + if (idx > 0) { + const cand = `mcp__${n.slice(idx + 2)}` + return cand.length > LIMIT ? cand.slice(0, LIMIT) : cand + } + } + return n.slice(0, LIMIT) + } + + const makeUnique = (cand) => { + if (!used.has(cand)) { + return cand + } + const base = cand + for (let i = 1; ; i++) { + const suffix = `_${i}` + const allowed = LIMIT - suffix.length + const tmp = (base.length > allowed ? base.slice(0, allowed) : base) + suffix + if (!used.has(tmp)) { + return tmp + } + } + } + + for (const n of names) { + const short = makeUnique(baseCandidate(n)) + used.add(short) + map[n] = short + } + return map + } + + /** + * 逆向恢复工具名(用于响应转换) + */ + _restoreToolName(shortName) { + return this._reverseToolNameMap[shortName] || shortName + } +} + +module.exports = CodexToOpenAIConverter diff --git a/src/services/costInitService.js b/src/services/costInitService.js new file mode 100644 index 0000000..a926d2c --- /dev/null +++ b/src/services/costInitService.js @@ -0,0 +1,358 @@ +const redis = require('../models/redis') +const CostCalculator = require('../utils/costCalculator') +const logger = require('../utils/logger') + +// HMGET 需要的字段 +const USAGE_FIELDS = [ + 'totalInputTokens', + 'inputTokens', + 'totalOutputTokens', + 'outputTokens', + 'totalCacheCreateTokens', + 'cacheCreateTokens', + 'totalCacheReadTokens', + 'cacheReadTokens', + 'ephemeral5mTokens', + 'ephemeral1hTokens', + 'totalEphemeral5mTokens', + 'totalEphemeral1hTokens' +] + +class CostInitService { + /** + * 带并发限制的并行执行 + */ + async parallelLimit(items, fn, concurrency = 20) { + let index = 0 + const results = [] + + async function worker() { + while (index < items.length) { + const currentIndex = index++ + try { + results[currentIndex] = await fn(items[currentIndex], currentIndex) + } catch (error) { + results[currentIndex] = { error } + } + } + } + + await Promise.all(Array(Math.min(concurrency, items.length)).fill().map(worker)) + return results + } + + /** + * 使用 SCAN 获取匹配的 keys(带去重) + */ + async scanKeysWithDedup(client, pattern, count = 500) { + const seen = new Set() + const allKeys = [] + let cursor = '0' + + do { + const [newCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', count) + cursor = newCursor + + for (const key of keys) { + if (!seen.has(key)) { + seen.add(key) + allKeys.push(key) + } + } + } while (cursor !== '0') + + return allKeys + } + + /** + * 初始化所有API Key的费用数据 + * 扫描历史使用记录并计算费用 + */ + async initializeAllCosts() { + try { + logger.info('💰 Starting cost initialization for all API Keys...') + + // 用 scanApiKeyIds 获取 ID,然后过滤已删除的 + const allKeyIds = await redis.scanApiKeyIds() + const client = redis.getClientSafe() + + // 批量检查 isDeleted 状态,过滤已删除的 key + const FILTER_BATCH = 100 + const apiKeyIds = [] + + for (let i = 0; i < allKeyIds.length; i += FILTER_BATCH) { + const batch = allKeyIds.slice(i, i + FILTER_BATCH) + const pipeline = client.pipeline() + + for (const keyId of batch) { + pipeline.hget(`apikey:${keyId}`, 'isDeleted') + } + + const results = await pipeline.exec() + + for (let j = 0; j < results.length; j++) { + const [err, isDeleted] = results[j] + if (!err && isDeleted !== 'true') { + apiKeyIds.push(batch[j]) + } + } + } + + logger.info( + `💰 Found ${apiKeyIds.length} active API Keys to process (filtered ${allKeyIds.length - apiKeyIds.length} deleted)` + ) + + let processedCount = 0 + let errorCount = 0 + + // 优化6: 并行处理 + 并发限制 + await this.parallelLimit( + apiKeyIds, + async (apiKeyId) => { + try { + await this.initializeApiKeyCosts(apiKeyId, client) + processedCount++ + + if (processedCount % 100 === 0) { + logger.info(`💰 Processed ${processedCount}/${apiKeyIds.length} API Keys...`) + } + } catch (error) { + errorCount++ + logger.error(`❌ Failed to initialize costs for API Key ${apiKeyId}:`, error) + } + }, + 20 // 并发数 + ) + + logger.success( + `💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}` + ) + return { processed: processedCount, errors: errorCount } + } catch (error) { + logger.error('❌ Failed to initialize costs:', error) + throw error + } + } + + /** + * 初始化单个API Key的费用数据 + */ + async initializeApiKeyCosts(apiKeyId, client) { + // 优化4: 使用 SCAN 获取 keys(带去重) + const modelKeys = await this.scanKeysWithDedup(client, `usage:${apiKeyId}:model:*:*:*`) + + if (modelKeys.length === 0) { + return + } + + // 优化5: 使用 Pipeline + HMGET 批量获取数据 + const BATCH_SIZE = 100 + const allData = [] + + for (let i = 0; i < modelKeys.length; i += BATCH_SIZE) { + const batch = modelKeys.slice(i, i + BATCH_SIZE) + const pipeline = client.pipeline() + + for (const key of batch) { + pipeline.hmget(key, ...USAGE_FIELDS) + } + + const results = await pipeline.exec() + + for (let j = 0; j < results.length; j++) { + const [err, values] = results[j] + if (err) { + continue + } + + // 将数组转换为对象 + const data = {} + let hasData = false + for (let k = 0; k < USAGE_FIELDS.length; k++) { + if (values[k] !== null) { + data[USAGE_FIELDS[k]] = values[k] + hasData = true + } + } + + if (hasData) { + allData.push({ key: batch[j], data }) + } + } + } + + // 按日期分组统计 + const dailyCosts = new Map() + const monthlyCosts = new Map() + const hourlyCosts = new Map() + + for (const { key, data } of allData) { + const match = key.match( + /usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/ + ) + if (!match) { + continue + } + + const [, , period, model, dateStr] = match + + const usage = { + input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0, + output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0, + cache_creation_input_tokens: + parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0, + cache_read_input_tokens: + parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + } + + // 添加 cache_creation 子对象以支持精确 ephemeral 定价 + const eph5m = parseInt(data.totalEphemeral5mTokens) || parseInt(data.ephemeral5mTokens) || 0 + const eph1h = parseInt(data.totalEphemeral1hTokens) || parseInt(data.ephemeral1hTokens) || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + const costResult = CostCalculator.calculateCost(usage, model) + const cost = costResult.costs.total + + if (period === 'daily') { + dailyCosts.set(dateStr, (dailyCosts.get(dateStr) || 0) + cost) + } else if (period === 'monthly') { + monthlyCosts.set(dateStr, (monthlyCosts.get(dateStr) || 0) + cost) + } else if (period === 'hourly') { + hourlyCosts.set(dateStr, (hourlyCosts.get(dateStr) || 0) + cost) + } + } + + // 使用 SET NX EX 只补缺失的键,不覆盖已存在的 + const pipeline = client.pipeline() + + // 写入每日费用(只补缺失) + for (const [date, cost] of dailyCosts) { + const key = `usage:cost:daily:${apiKeyId}:${date}` + pipeline.set(key, cost.toString(), 'EX', 86400 * 30, 'NX') + } + + // 写入每月费用(只补缺失) + for (const [month, cost] of monthlyCosts) { + const key = `usage:cost:monthly:${apiKeyId}:${month}` + pipeline.set(key, cost.toString(), 'EX', 86400 * 90, 'NX') + } + + // 写入每小时费用(只补缺失) + for (const [hour, cost] of hourlyCosts) { + const key = `usage:cost:hourly:${apiKeyId}:${hour}` + pipeline.set(key, cost.toString(), 'EX', 86400 * 7, 'NX') + } + + // 计算总费用 + let totalCost = 0 + for (const cost of dailyCosts.values()) { + totalCost += cost + } + + // 写入总费用(只补缺失) + if (totalCost > 0) { + const totalKey = `usage:cost:total:${apiKeyId}` + const existingTotal = await client.get(totalKey) + + if (!existingTotal || parseFloat(existingTotal) === 0) { + pipeline.set(totalKey, totalCost.toString()) + logger.info(`💰 Initialized total cost for API Key ${apiKeyId}: $${totalCost.toFixed(6)}`) + } else { + const existing = parseFloat(existingTotal) + if (totalCost > existing * 1.1) { + logger.warn( + `💰 Total cost mismatch for API Key ${apiKeyId}: existing=$${existing.toFixed(6)}, calculated=$${totalCost.toFixed(6)} (from last 30 days). Keeping existing value.` + ) + } + } + } + + await pipeline.exec() + + logger.debug( + `💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}` + ) + } + + /** + * 检查是否需要初始化费用数据 + * 使用 SCAN 代替 KEYS,正确处理 cursor + */ + async needsInitialization() { + try { + const client = redis.getClientSafe() + + // 正确循环 SCAN 检查是否有任何费用数据 + let cursor = '0' + let hasCostData = false + + do { + const [newCursor, keys] = await client.scan(cursor, 'MATCH', 'usage:cost:*', 'COUNT', 100) + cursor = newCursor + if (keys.length > 0) { + hasCostData = true + break + } + } while (cursor !== '0') + + if (!hasCostData) { + logger.info('💰 No cost data found, initialization needed') + return true + } + + // 抽样检查使用数据是否有对应的费用数据 + cursor = '0' + let samplesChecked = 0 + const maxSamples = 10 + + do { + const [newCursor, usageKeys] = await client.scan( + cursor, + 'MATCH', + 'usage:*:model:daily:*:*', + 'COUNT', + 100 + ) + cursor = newCursor + + for (const usageKey of usageKeys) { + if (samplesChecked >= maxSamples) { + break + } + + const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) + if (match) { + const [, keyId, , date] = match + const costKey = `usage:cost:daily:${keyId}:${date}` + const hasCost = await client.exists(costKey) + + if (!hasCost) { + logger.info( + `💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed` + ) + return true + } + samplesChecked++ + } + } + + if (samplesChecked >= maxSamples) { + break + } + } while (cursor !== '0') + + logger.info('💰 Cost data appears to be up to date') + return false + } catch (error) { + logger.error('❌ Failed to check initialization status:', error) + return false + } + } +} + +module.exports = new CostInitService() diff --git a/src/services/costRankService.js b/src/services/costRankService.js new file mode 100644 index 0000000..edd9acd --- /dev/null +++ b/src/services/costRankService.js @@ -0,0 +1,606 @@ +/** + * 费用排序索引服务 + * + * 为 API Keys 提供按费用排序的功能,使用 Redis Sorted Set 预计算排序索引 + * 支持 today/7days/30days/all 四种固定时间范围的预计算索引 + * 支持 custom 时间范围的实时计算 + * + * 设计原则: + * - 只计算未删除的 API Key + * - 使用原子操作避免竞态条件 + * - 提供增量更新接口供 API Key 创建/删除时调用 + */ + +const redis = require('../models/redis') +const logger = require('../utils/logger') + +// ============================================================================ +// 常量配置 +// ============================================================================ + +/** 时间范围更新间隔配置(省资源模式) */ +const UPDATE_INTERVALS = { + today: 10 * 60 * 1000, // 10分钟 + '7days': 30 * 60 * 1000, // 30分钟 + '30days': 60 * 60 * 1000, // 1小时 + all: 2 * 60 * 60 * 1000 // 2小时 +} + +/** 支持的时间范围列表 */ +const VALID_TIME_RANGES = ['today', '7days', '30days', 'all'] + +/** 分布式锁超时时间(秒) */ +const LOCK_TTL = 300 + +/** 批处理大小 */ +const BATCH_SIZE = 100 + +// ============================================================================ +// Redis Key 生成器(集中管理 key 格式) +// ============================================================================ + +const RedisKeys = { + /** 费用排序索引 Sorted Set */ + rankKey: (timeRange) => `cost_rank:${timeRange}`, + + /** 临时索引 key(用于原子替换) */ + tempRankKey: (timeRange) => `cost_rank:${timeRange}:temp:${Date.now()}`, + + /** 索引元数据 Hash */ + metaKey: (timeRange) => `cost_rank_meta:${timeRange}`, + + /** 更新锁 */ + lockKey: (timeRange) => `cost_rank_lock:${timeRange}`, + + /** 每日费用 */ + dailyCost: (keyId, date) => `usage:cost:daily:${keyId}:${date}`, + + /** 总费用 */ + totalCost: (keyId) => `usage:cost:total:${keyId}` +} + +// ============================================================================ +// CostRankService 类 +// ============================================================================ + +class CostRankService { + constructor() { + this.timers = {} + this.isInitialized = false + } + + // -------------------------------------------------------------------------- + // 生命周期管理 + // -------------------------------------------------------------------------- + + /** + * 初始化服务:启动定时任务 + * 幂等设计:多次调用只会初始化一次 + */ + async initialize() { + // 先清理可能存在的旧定时器(支持热重载) + this._clearAllTimers() + + if (this.isInitialized) { + logger.warn('CostRankService already initialized, re-initializing...') + } + + logger.info('🔄 Initializing CostRankService...') + + try { + // 启动时立即更新所有索引(异步,不阻塞启动) + this.updateAllRanks().catch((err) => { + logger.error('Failed to initialize cost ranks:', err) + }) + + // 设置定时更新 + for (const [timeRange, interval] of Object.entries(UPDATE_INTERVALS)) { + this.timers[timeRange] = setInterval(() => { + this.updateRank(timeRange).catch((err) => { + logger.error(`Failed to update cost rank for ${timeRange}:`, err) + }) + }, interval) + } + + this.isInitialized = true + logger.success('CostRankService initialized') + } catch (error) { + logger.error('❌ Failed to initialize CostRankService:', error) + throw error + } + } + + /** + * 关闭服务:清理定时器 + */ + shutdown() { + this._clearAllTimers() + this.isInitialized = false + logger.info('CostRankService shutdown') + } + + /** + * 清理所有定时器 + * @private + */ + _clearAllTimers() { + for (const timer of Object.values(this.timers)) { + clearInterval(timer) + } + this.timers = {} + } + + // -------------------------------------------------------------------------- + // 索引更新(全量) + // -------------------------------------------------------------------------- + + /** + * 更新所有时间范围的索引 + */ + async updateAllRanks() { + for (const timeRange of VALID_TIME_RANGES) { + try { + await this.updateRank(timeRange) + } catch (error) { + logger.error(`Failed to update rank for ${timeRange}:`, error) + } + } + } + + /** + * 更新指定时间范围的排序索引 + * @param {string} timeRange - 时间范围 + */ + async updateRank(timeRange) { + const client = redis.getClient() + if (!client) { + logger.warn('Redis client not available, skipping cost rank update') + return + } + + const lockKey = RedisKeys.lockKey(timeRange) + const rankKey = RedisKeys.rankKey(timeRange) + const metaKey = RedisKeys.metaKey(timeRange) + + // 获取分布式锁 + const acquired = await client.set(lockKey, '1', 'NX', 'EX', LOCK_TTL) + if (!acquired) { + logger.debug(`Skipping ${timeRange} rank update - another update in progress`) + return + } + + const startTime = Date.now() + + try { + // 标记为更新中 + await client.hset(metaKey, 'status', 'updating') + + // 1. 获取所有未删除的 API Key IDs + const keyIds = await this._getActiveApiKeyIds() + + if (keyIds.length === 0) { + // 无数据时清空索引 + await client.del(rankKey) + await this._updateMeta(client, metaKey, startTime, 0) + return + } + + // 2. 计算日期范围 + const dateRange = this._getDateRange(timeRange) + + // 3. 分批计算费用 + const costs = await this._calculateCostsInBatches(keyIds, dateRange) + + // 4. 原子更新索引(使用临时 key + RENAME 避免竞态条件) + await this._atomicUpdateIndex(client, rankKey, costs) + + // 5. 更新元数据 + await this._updateMeta(client, metaKey, startTime, keyIds.length) + + logger.info( + `📊 Updated cost rank for ${timeRange}: ${keyIds.length} keys in ${Date.now() - startTime}ms` + ) + } catch (error) { + await client.hset(metaKey, 'status', 'failed') + logger.error(`Failed to update cost rank for ${timeRange}:`, error) + throw error + } finally { + await client.del(lockKey) + } + } + + /** + * 原子更新索引(避免竞态条件) + * @private + */ + async _atomicUpdateIndex(client, rankKey, costs) { + if (costs.size === 0) { + await client.del(rankKey) + return + } + + // 使用临时 key 构建新索引 + const tempKey = `${rankKey}:temp:${Date.now()}` + + try { + // 构建 ZADD 参数 + const members = [] + costs.forEach((cost, keyId) => { + members.push(cost, keyId) + }) + + // 写入临时 key + await client.zadd(tempKey, ...members) + + // 原子替换(RENAME 是原子操作) + await client.rename(tempKey, rankKey) + } catch (error) { + // 清理临时 key + await client.del(tempKey).catch(() => {}) + throw error + } + } + + /** + * 更新元数据 + * @private + */ + async _updateMeta(client, metaKey, startTime, keyCount) { + await client.hmset(metaKey, { + lastUpdate: new Date().toISOString(), + keyCount: keyCount.toString(), + status: 'ready', + updateDuration: (Date.now() - startTime).toString() + }) + } + + // -------------------------------------------------------------------------- + // 索引增量更新(供外部调用) + // -------------------------------------------------------------------------- + + /** + * 添加 API Key 到所有索引(创建 API Key 时调用) + * @param {string} keyId - API Key ID + */ + async addKeyToIndexes(keyId) { + const client = redis.getClient() + if (!client) { + return + } + + try { + const pipeline = client.pipeline() + + // 将新 Key 添加到所有索引,初始分数为 0 + for (const timeRange of VALID_TIME_RANGES) { + pipeline.zadd(RedisKeys.rankKey(timeRange), 0, keyId) + } + + await pipeline.exec() + logger.debug(`Added key ${keyId} to cost rank indexes`) + } catch (error) { + logger.error(`Failed to add key ${keyId} to cost rank indexes:`, error) + } + } + + /** + * 从所有索引中移除 API Key(删除 API Key 时调用) + * @param {string} keyId - API Key ID + */ + async removeKeyFromIndexes(keyId) { + const client = redis.getClient() + if (!client) { + return + } + + try { + const pipeline = client.pipeline() + + // 从所有索引中移除 + for (const timeRange of VALID_TIME_RANGES) { + pipeline.zrem(RedisKeys.rankKey(timeRange), keyId) + } + + await pipeline.exec() + logger.debug(`Removed key ${keyId} from cost rank indexes`) + } catch (error) { + logger.error(`Failed to remove key ${keyId} from cost rank indexes:`, error) + } + } + + // -------------------------------------------------------------------------- + // 查询接口 + // -------------------------------------------------------------------------- + + /** + * 获取排序后的 keyId 列表 + * @param {string} timeRange - 时间范围 + * @param {string} sortOrder - 排序方向 'asc' | 'desc' + * @param {number} offset - 偏移量 + * @param {number} limit - 限制数量,-1 表示全部 + * @returns {Promise} keyId 列表 + */ + async getSortedKeyIds(timeRange, sortOrder = 'desc', offset = 0, limit = -1) { + const client = redis.getClient() + if (!client) { + throw new Error('Redis client not available') + } + + const rankKey = RedisKeys.rankKey(timeRange) + const end = limit === -1 ? -1 : offset + limit - 1 + + if (sortOrder === 'desc') { + return await client.zrevrange(rankKey, offset, end) + } else { + return await client.zrange(rankKey, offset, end) + } + } + + /** + * 获取 Key 的费用分数 + * @param {string} timeRange - 时间范围 + * @param {string} keyId - API Key ID + * @returns {Promise} 费用 + */ + async getKeyCost(timeRange, keyId) { + const client = redis.getClient() + if (!client) { + return 0 + } + + const score = await client.zscore(RedisKeys.rankKey(timeRange), keyId) + return score ? parseFloat(score) : 0 + } + + /** + * 批量获取多个 Key 的费用分数 + * @param {string} timeRange - 时间范围 + * @param {string[]} keyIds - API Key ID 列表 + * @returns {Promise>} keyId -> cost + */ + async getBatchKeyCosts(timeRange, keyIds) { + const client = redis.getClient() + if (!client || keyIds.length === 0) { + return new Map() + } + + const rankKey = RedisKeys.rankKey(timeRange) + const costs = new Map() + + const pipeline = client.pipeline() + keyIds.forEach((keyId) => { + pipeline.zscore(rankKey, keyId) + }) + const results = await pipeline.exec() + + keyIds.forEach((keyId, index) => { + const [err, score] = results[index] + costs.set(keyId, err || !score ? 0 : parseFloat(score)) + }) + + return costs + } + + /** + * 获取所有排序索引的状态 + * @returns {Promise} 各时间范围的状态 + */ + async getRankStatus() { + const client = redis.getClient() + if (!client) { + return {} + } + + // 使用 Pipeline 批量获取 + const pipeline = client.pipeline() + for (const timeRange of VALID_TIME_RANGES) { + pipeline.hgetall(RedisKeys.metaKey(timeRange)) + } + const results = await pipeline.exec() + + const status = {} + VALID_TIME_RANGES.forEach((timeRange, i) => { + const [err, meta] = results[i] + if (err || !meta) { + status[timeRange] = { + lastUpdate: null, + keyCount: 0, + status: 'unknown', + updateDuration: 0 + } + } else { + status[timeRange] = { + lastUpdate: meta.lastUpdate || null, + keyCount: parseInt(meta.keyCount || 0), + status: meta.status || 'unknown', + updateDuration: parseInt(meta.updateDuration || 0) + } + } + }) + + return status + } + + /** + * 强制刷新指定时间范围的索引 + * @param {string} timeRange - 时间范围,不传则刷新全部 + */ + async forceRefresh(timeRange = null) { + if (timeRange) { + await this.updateRank(timeRange) + } else { + await this.updateAllRanks() + } + } + + // -------------------------------------------------------------------------- + // Custom 时间范围实时计算 + // -------------------------------------------------------------------------- + + /** + * 计算 custom 时间范围的费用(实时计算,排除已删除的 Key) + * @param {string} startDate - 开始日期 YYYY-MM-DD + * @param {string} endDate - 结束日期 YYYY-MM-DD + * @returns {Promise>} keyId -> cost + */ + async calculateCustomRangeCosts(startDate, endDate) { + const client = redis.getClient() + if (!client) { + throw new Error('Redis client not available') + } + + logger.info(`📊 Calculating custom range costs: ${startDate} to ${endDate}`) + const startTime = Date.now() + + // 1. 获取所有未删除的 API Key IDs + const keyIds = await this._getActiveApiKeyIds() + + if (keyIds.length === 0) { + return new Map() + } + + // 2. 分批计算费用 + const costs = await this._calculateCostsInBatches(keyIds, { startDate, endDate }) + + const duration = Date.now() - startTime + logger.info(`📊 Custom range costs calculated: ${keyIds.length} keys in ${duration}ms`) + + return costs + } + + // -------------------------------------------------------------------------- + // 私有辅助方法 + // -------------------------------------------------------------------------- + + /** + * 获取所有未删除的 API Key IDs + * @private + * @returns {Promise} + */ + async _getActiveApiKeyIds() { + // 使用现有的 scanApiKeyIds 获取所有 ID + const allKeyIds = await redis.scanApiKeyIds() + + if (allKeyIds.length === 0) { + return [] + } + + // 批量获取 API Key 数据,过滤已删除的 + const allKeys = await redis.batchGetApiKeys(allKeyIds) + + return allKeys.filter((k) => !k.isDeleted).map((k) => k.id) + } + + /** + * 分批计算费用 + * @private + */ + async _calculateCostsInBatches(keyIds, dateRange) { + const costs = new Map() + + for (let i = 0; i < keyIds.length; i += BATCH_SIZE) { + const batch = keyIds.slice(i, i + BATCH_SIZE) + const batchCosts = await this._calculateBatchCosts(batch, dateRange) + batchCosts.forEach((cost, keyId) => costs.set(keyId, cost)) + } + + return costs + } + + /** + * 批量计算费用 + * @private + */ + async _calculateBatchCosts(keyIds, dateRange) { + const client = redis.getClient() + const costs = new Map() + + if (dateRange.useTotal) { + // 'all' 时间范围:直接读取 total cost + const pipeline = client.pipeline() + keyIds.forEach((keyId) => { + pipeline.get(RedisKeys.totalCost(keyId)) + }) + const results = await pipeline.exec() + + keyIds.forEach((keyId, index) => { + const [err, value] = results[index] + costs.set(keyId, err ? 0 : parseFloat(value || 0)) + }) + } else { + // 特定日期范围:汇总每日费用 + const dates = this._getDatesBetween(dateRange.startDate, dateRange.endDate) + + const pipeline = client.pipeline() + keyIds.forEach((keyId) => { + dates.forEach((date) => { + pipeline.get(RedisKeys.dailyCost(keyId, date)) + }) + }) + const results = await pipeline.exec() + + let resultIndex = 0 + keyIds.forEach((keyId) => { + let totalCost = 0 + dates.forEach(() => { + const [err, value] = results[resultIndex++] + if (!err && value) { + totalCost += parseFloat(value) + } + }) + costs.set(keyId, totalCost) + }) + } + + return costs + } + + /** + * 获取日期范围配置 + * @private + */ + _getDateRange(timeRange) { + const now = new Date() + const today = redis.getDateStringInTimezone(now) + + switch (timeRange) { + case 'today': + return { startDate: today, endDate: today } + case '7days': { + const d7 = new Date(now) + d7.setDate(d7.getDate() - 6) + return { startDate: redis.getDateStringInTimezone(d7), endDate: today } + } + case '30days': { + const d30 = new Date(now) + d30.setDate(d30.getDate() - 29) + return { startDate: redis.getDateStringInTimezone(d30), endDate: today } + } + case 'all': + return { useTotal: true } + default: + throw new Error(`Invalid time range: ${timeRange}`) + } + } + + /** + * 获取两个日期之间的所有日期 + * @private + */ + _getDatesBetween(startDate, endDate) { + const dates = [] + const current = new Date(startDate) + const end = new Date(endDate) + + while (current <= end) { + dates.push( + `${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}-${String(current.getDate()).padStart(2, '0')}` + ) + current.setDate(current.getDate() + 1) + } + + return dates + } +} + +module.exports = new CostRankService() diff --git a/src/services/geminiToOpenAI.js b/src/services/geminiToOpenAI.js new file mode 100644 index 0000000..82a60ee --- /dev/null +++ b/src/services/geminiToOpenAI.js @@ -0,0 +1,392 @@ +/** + * Gemini 响应格式 → OpenAI Chat Completions 格式转换器 + * 将 Gemini API 的原生响应转为标准 OpenAI chat.completion / chat.completion.chunk 格式 + */ + +class GeminiToOpenAIConverter { + createStreamState() { + return { + buffer: '', + id: `chatcmpl-${Date.now()}`, + created: Math.floor(Date.now() / 1000), + functionIndex: 0, + candidatesWithFunctionCalls: new Set(), + roleSent: false + } + } + + /** + * 流式转换: 拦截 res.write 的 chunk → OpenAI SSE 字符串 + * @param {Buffer|string} rawChunk + * @param {string} model + * @param {Object} state - createStreamState() 返回的状态 + * @returns {string|null} 转换后的 SSE 字符串,null 表示跳过 + */ + convertStreamChunk(rawChunk, model, state) { + const str = (typeof rawChunk === 'string' ? rawChunk : rawChunk.toString()).replace( + /\r\n/g, + '\n' + ) + + // 心跳透传:仅当 buffer 为空时才透传空白 + // buffer 有数据时需要继续处理(空白可能是 SSE 事件的 \n\n 分隔符) + if (!str.trim() && !state.buffer) { + return str + } + + state.buffer += str + let output = '' + + // 按 \n\n 分割完整 SSE 事件 + let idx + while ((idx = state.buffer.indexOf('\n\n')) !== -1) { + const event = state.buffer.slice(0, idx) + state.buffer = state.buffer.slice(idx + 2) + + if (!event.trim()) { + continue + } + + const lines = event.split('\n') + for (const line of lines) { + if (!line.startsWith('data: ')) { + continue + } + + const jsonStr = line.slice(6).trim() + if (!jsonStr) { + continue + } + + // [DONE] 消费(由 res.end patch 统一发送) + if (jsonStr === '[DONE]') { + continue + } + + let geminiData + try { + geminiData = JSON.parse(jsonStr) + } catch (e) { + // 解析失败透传 + output += `data: ${jsonStr}\n\n` + continue + } + + // 错误事件透传 + if (geminiData.error) { + output += `data: ${jsonStr}\n\n` + continue + } + + const chunks = this._convertGeminiChunkToOpenAI(geminiData, model, state) + for (const c of chunks) { + output += `data: ${JSON.stringify(c)}\n\n` + } + } + } + + return output || null + } + + /** + * 非流式转换: Gemini JSON → OpenAI chat.completion + * @param {Object} geminiData - { candidates, usageMetadata, modelVersion, responseId } + * @param {string} model + * @returns {Object} + */ + convertResponse(geminiData, model) { + // 兼容 v1internal 包裹格式 { response: { candidates: [...] } } + const data = geminiData.response || geminiData + const candidates = data.candidates || [] + const choices = candidates.map((candidate, i) => { + const parts = candidate.content?.parts || [] + const textParts = [] + const thoughtParts = [] + const toolCalls = [] + const images = [] + let fnIndex = 0 + + for (const part of parts) { + if ( + part.thoughtSignature && + !part.text && + !part.functionCall && + !part.inlineData && + !part.inline_data + ) { + continue + } + + if (part.functionCall) { + toolCalls.push({ + id: `${part.functionCall.name}-${Date.now()}-${fnIndex}`, + type: 'function', + function: { + name: part.functionCall.name, + arguments: JSON.stringify(part.functionCall.args || {}) + } + }) + fnIndex++ + } else if (part.text !== undefined) { + if (part.thought) { + thoughtParts.push(part.text) + } else { + textParts.push(part.text) + } + } else if (part.inlineData || part.inline_data) { + const inlineData = part.inlineData || part.inline_data + const imgData = inlineData.data + if (imgData) { + const mimeType = inlineData.mimeType || inlineData.mime_type || 'image/png' + images.push({ + type: 'image_url', + index: images.length, + image_url: { url: `data:${mimeType};base64,${imgData}` } + }) + } + } + } + + const message = { role: 'assistant' } + if (textParts.length > 0) { + message.content = textParts.join('') + } else { + message.content = null + } + if (thoughtParts.length > 0) { + message.reasoning_content = thoughtParts.join('') + } + if (toolCalls.length > 0) { + message.tool_calls = toolCalls + } + if (images.length > 0) { + message.images = images + } + + let finishReason = 'stop' + if (toolCalls.length > 0) { + finishReason = 'tool_calls' + } else if (candidate.finishReason) { + finishReason = this._mapFinishReason(candidate.finishReason) + } + + return { + index: candidate.index !== undefined ? candidate.index : i, + message, + finish_reason: finishReason + } + }) + + const result = { + id: data.responseId || `chatcmpl-${Date.now()}`, + object: 'chat.completion', + created: this._parseCreateTime(data.createTime), + model: data.modelVersion || model, + choices + } + + const usage = this._mapUsage(data.usageMetadata) + if (usage) { + result.usage = usage + } + + return result + } + + // --- 内部方法 --- + + _convertGeminiChunkToOpenAI(geminiData, model, state) { + // 兼容 v1internal 包裹格式 { response: { candidates: [...] } } + const data = geminiData.response || geminiData + + // 更新元数据 + if (data.responseId) { + state.id = data.responseId + } + if (data.modelVersion) { + state.model = data.modelVersion + } + if (data.createTime) { + const ts = this._parseCreateTime(data.createTime) + if (ts !== Math.floor(Date.now() / 1000)) { + state.created = ts + } + } + + const candidates = data.candidates || [] + if (candidates.length === 0 && data.usageMetadata) { + // 仅 usage 的最终 chunk + const chunk = this._makeChunk(state, model) + chunk.choices[0].finish_reason = 'stop' + chunk.usage = this._mapUsage(data.usageMetadata) + return [chunk] + } + + const results = [] + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i] + const candidateIndex = candidate.index !== undefined ? candidate.index : i + const parts = candidate.content?.parts || [] + + for (const part of parts) { + if ( + part.thoughtSignature && + !part.text && + !part.functionCall && + !part.inlineData && + !part.inline_data + ) { + continue + } + + const chunk = this._makeChunk(state, model) + chunk.choices[0].index = candidateIndex + + if (part.functionCall) { + state.candidatesWithFunctionCalls.add(candidateIndex) + chunk.choices[0].delta = { + tool_calls: [ + { + index: state.functionIndex, + id: `${part.functionCall.name}-${Date.now()}-${state.functionIndex}`, + type: 'function', + function: { + name: part.functionCall.name, + arguments: JSON.stringify(part.functionCall.args || {}) + } + } + ] + } + this._injectRole(state, chunk.choices[0].delta) + state.functionIndex++ + results.push(chunk) + } else if (part.text !== undefined) { + if (part.thought) { + chunk.choices[0].delta = { reasoning_content: part.text } + } else { + chunk.choices[0].delta = { content: part.text } + } + this._injectRole(state, chunk.choices[0].delta) + results.push(chunk) + } else if (part.inlineData || part.inline_data) { + const inlineData = part.inlineData || part.inline_data + const imgData = inlineData.data + if (imgData) { + const mimeType = inlineData.mimeType || inlineData.mime_type || 'image/png' + chunk.choices[0].delta = { + images: [ + { + type: 'image_url', + index: 0, + image_url: { url: `data:${mimeType};base64,${imgData}` } + } + ] + } + this._injectRole(state, chunk.choices[0].delta) + results.push(chunk) + } + } + } + + // finish_reason + if (candidate.finishReason) { + const chunk = this._makeChunk(state, model) + chunk.choices[0].index = candidateIndex + + if (state.candidatesWithFunctionCalls.has(candidateIndex)) { + chunk.choices[0].finish_reason = 'tool_calls' + } else { + chunk.choices[0].finish_reason = this._mapFinishReason(candidate.finishReason) + } + + if (data.usageMetadata) { + chunk.usage = this._mapUsage(data.usageMetadata) + } + + results.push(chunk) + } + } + + return results + } + + _mapFinishReason(geminiReason) { + // 按 Gemini FinishReason 官方枚举完整映射 + const fr = geminiReason.toLowerCase() + if (fr === 'stop') { + return 'stop' + } + if (fr === 'max_tokens') { + return 'length' + } + // 工具调用异常 → stop(调用失败不等于内容过滤) + if ( + fr === 'malformed_function_call' || + fr === 'too_many_tool_calls' || + fr === 'unexpected_tool_call' + ) { + return 'stop' + } + // 内容策略/安全拦截 → content_filter + if ( + fr === 'safety' || + fr === 'recitation' || + fr === 'blocklist' || + fr === 'prohibited_content' || + fr === 'spii' || + fr === 'image_safety' || + fr === 'language' + ) { + return 'content_filter' + } + // FINISH_REASON_UNSPECIFIED, OTHER, 未知值 → stop + return 'stop' + } + + _makeChunk(state, model) { + return { + id: state.id, + object: 'chat.completion.chunk', + created: state.created, + model: state.model || model, + choices: [{ index: 0, delta: {}, finish_reason: null }] + } + } + + _injectRole(state, delta) { + if (!state.roleSent) { + delta.role = 'assistant' + state.roleSent = true + } + } + + _parseCreateTime(createTime) { + if (!createTime) { + return Math.floor(Date.now() / 1000) + } + // Gemini 官方文档:createTime 为 RFC3339 格式字符串 + const ts = Math.floor(new Date(createTime).getTime() / 1000) + return isNaN(ts) ? Math.floor(Date.now() / 1000) : ts + } + + _mapUsage(meta) { + if (!meta) { + return undefined + } + const completionTokens = (meta.candidatesTokenCount || 0) + (meta.thoughtsTokenCount || 0) + const result = { + prompt_tokens: meta.promptTokenCount || 0, + completion_tokens: completionTokens, + total_tokens: meta.totalTokenCount || 0 + } + if (meta.thoughtsTokenCount > 0) { + result.completion_tokens_details = { reasoning_tokens: meta.thoughtsTokenCount } + } + if (meta.cachedContentTokenCount > 0) { + result.prompt_tokens_details = { cached_tokens: meta.cachedContentTokenCount } + } + return result + } +} + +module.exports = GeminiToOpenAIConverter diff --git a/src/services/ldapService.js b/src/services/ldapService.js new file mode 100644 index 0000000..86fdb88 --- /dev/null +++ b/src/services/ldapService.js @@ -0,0 +1,753 @@ +const ldap = require('ldapjs') +const logger = require('../utils/logger') +const config = require('../../config/config') +const userService = require('./userService') + +class LdapService { + constructor() { + this.config = config.ldap || {} + this.client = null + + // 验证配置 - 只有在 LDAP 配置存在且启用时才验证 + if (this.config && this.config.enabled) { + this.validateConfiguration() + } + } + + // 🔍 验证LDAP配置 + validateConfiguration() { + const errors = [] + + if (!this.config.server) { + errors.push('LDAP server configuration is missing') + } else { + if (!this.config.server.url || typeof this.config.server.url !== 'string') { + errors.push('LDAP server URL is not configured or invalid') + } + + if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') { + errors.push('LDAP bind DN is not configured or invalid') + } + + if ( + !this.config.server.bindCredentials || + typeof this.config.server.bindCredentials !== 'string' + ) { + errors.push('LDAP bind credentials are not configured or invalid') + } + + if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') { + errors.push('LDAP search base is not configured or invalid') + } + + if (!this.config.server.searchFilter || typeof this.config.server.searchFilter !== 'string') { + errors.push('LDAP search filter is not configured or invalid') + } + } + + if (errors.length > 0) { + logger.error('❌ LDAP configuration validation failed:', errors) + // Don't throw error during initialization, just log warnings + logger.warn('⚠️ LDAP authentication may not work properly due to configuration errors') + } else { + logger.info('✅ LDAP configuration validation passed') + } + } + + // 🔍 提取LDAP条目的DN + extractDN(ldapEntry) { + if (!ldapEntry) { + return null + } + + // Try different ways to get the DN + let dn = null + + // Method 1: Direct dn property + if (ldapEntry.dn) { + ;({ dn } = ldapEntry) + } + // Method 2: objectName property (common in some LDAP implementations) + else if (ldapEntry.objectName) { + dn = ldapEntry.objectName + } + // Method 3: distinguishedName property + else if (ldapEntry.distinguishedName) { + dn = ldapEntry.distinguishedName + } + // Method 4: Check if the entry itself is a DN string + else if (typeof ldapEntry === 'string' && ldapEntry.includes('=')) { + dn = ldapEntry + } + + // Convert DN to string if it's an object + if (dn && typeof dn === 'object') { + if (dn.toString && typeof dn.toString === 'function') { + dn = dn.toString() + } else if (dn.dn && typeof dn.dn === 'string') { + ;({ dn } = dn) + } + } + + // Validate the DN format + if (typeof dn === 'string' && dn.trim() !== '' && dn.includes('=')) { + return dn.trim() + } + + return null + } + + // 🌐 从DN中提取域名,用于Windows AD UPN格式认证 + extractDomainFromDN(dnString) { + try { + if (!dnString || typeof dnString !== 'string') { + return null + } + + // 提取所有DC组件:DC=test,DC=demo,DC=com + const dcMatches = dnString.match(/DC=([^,]+)/gi) + if (!dcMatches || dcMatches.length === 0) { + return null + } + + // 提取DC值并连接成域名 + const domainParts = dcMatches.map((match) => { + const value = match.replace(/DC=/i, '').trim() + return value + }) + + if (domainParts.length > 0) { + const domain = domainParts.join('.') + logger.debug(`🌐 从DN提取域名: ${domain}`) + return domain + } + + return null + } catch (error) { + logger.debug('⚠️ 域名提取失败:', error.message) + return null + } + } + + // 🔗 创建LDAP客户端连接 + createClient() { + try { + const clientOptions = { + url: this.config.server.url, + timeout: this.config.server.timeout, + connectTimeout: this.config.server.connectTimeout, + reconnect: true + } + + // 如果使用 LDAPS (SSL/TLS),添加 TLS 选项 + if (this.config.server.url.toLowerCase().startsWith('ldaps://')) { + const tlsOptions = {} + + // 证书验证设置 + if (this.config.server.tls) { + if (typeof this.config.server.tls.rejectUnauthorized === 'boolean') { + tlsOptions.rejectUnauthorized = this.config.server.tls.rejectUnauthorized + } + + // CA 证书 + if (this.config.server.tls.ca) { + tlsOptions.ca = this.config.server.tls.ca + } + + // 客户端证书和私钥 (双向认证) + if (this.config.server.tls.cert) { + tlsOptions.cert = this.config.server.tls.cert + } + + if (this.config.server.tls.key) { + tlsOptions.key = this.config.server.tls.key + } + + // 服务器名称 (SNI) + if (this.config.server.tls.servername) { + tlsOptions.servername = this.config.server.tls.servername + } + } + + clientOptions.tlsOptions = tlsOptions + + logger.debug('🔒 Creating LDAPS client with TLS options:', { + url: this.config.server.url, + rejectUnauthorized: tlsOptions.rejectUnauthorized, + hasCA: !!tlsOptions.ca, + hasCert: !!tlsOptions.cert, + hasKey: !!tlsOptions.key, + servername: tlsOptions.servername + }) + } + + const client = ldap.createClient(clientOptions) + + // 设置错误处理 + client.on('error', (err) => { + if (err.code === 'CERT_HAS_EXPIRED' || err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') { + logger.error('🔒 LDAP TLS certificate error:', { + code: err.code, + message: err.message, + hint: 'Consider setting LDAP_TLS_REJECT_UNAUTHORIZED=false for self-signed certificates' + }) + } else { + logger.error('🔌 LDAP client error:', err) + } + }) + + client.on('connect', () => { + if (this.config.server.url.toLowerCase().startsWith('ldaps://')) { + logger.info('🔒 LDAPS client connected successfully') + } else { + logger.info('🔗 LDAP client connected successfully') + } + }) + + client.on('connectTimeout', () => { + logger.warn('⏱️ LDAP connection timeout') + }) + + return client + } catch (error) { + logger.error('❌ Failed to create LDAP client:', error) + throw error + } + } + + // 🔒 绑定LDAP连接(管理员认证) + async bindClient(client) { + return new Promise((resolve, reject) => { + // 验证绑定凭据 + const { bindDN } = this.config.server + const { bindCredentials } = this.config.server + + if (!bindDN || typeof bindDN !== 'string') { + const error = new Error('LDAP bind DN is not configured or invalid') + logger.error('❌ LDAP configuration error:', error.message) + reject(error) + return + } + + if (!bindCredentials || typeof bindCredentials !== 'string') { + const error = new Error('LDAP bind credentials are not configured or invalid') + logger.error('❌ LDAP configuration error:', error.message) + reject(error) + return + } + + client.bind(bindDN, bindCredentials, (err) => { + if (err) { + logger.error('❌ LDAP bind failed:', err) + reject(err) + } else { + logger.debug('🔑 LDAP bind successful') + resolve() + } + }) + }) + } + + // 🔍 搜索用户 + async searchUser(client, username) { + return new Promise((resolve, reject) => { + // 防止LDAP注入:转义特殊字符 + // 根据RFC 4515,需要转义的特殊字符:* ( ) \ NUL + const escapedUsername = username + .replace(/\\/g, '\\5c') // 反斜杠必须先转义 + .replace(/\*/g, '\\2a') // 星号 + .replace(/\(/g, '\\28') // 左括号 + .replace(/\)/g, '\\29') // 右括号 + .replace(/\0/g, '\\00') // NUL字符 + .replace(/\//g, '\\2f') // 斜杠 + + const searchFilter = this.config.server.searchFilter.replace('{{username}}', escapedUsername) + const searchOptions = { + scope: 'sub', + filter: searchFilter, + attributes: this.config.server.searchAttributes + } + + logger.debug(`🔍 Searching for user: ${username} with filter: ${searchFilter}`) + + const entries = [] + + client.search(this.config.server.searchBase, searchOptions, (err, res) => { + if (err) { + logger.error('❌ LDAP search error:', err) + reject(err) + return + } + + res.on('searchEntry', (entry) => { + logger.debug('🔍 LDAP search entry received:', { + dn: entry.dn, + objectName: entry.objectName, + type: typeof entry.dn, + entryType: typeof entry, + hasAttributes: !!entry.attributes, + attributeCount: entry.attributes ? entry.attributes.length : 0 + }) + entries.push(entry) + }) + + res.on('searchReference', (referral) => { + logger.debug('🔗 LDAP search referral:', referral.uris) + }) + + res.on('error', (error) => { + logger.error('❌ LDAP search result error:', error) + reject(error) + }) + + res.on('end', (result) => { + logger.debug( + `✅ LDAP search completed. Status: ${result.status}, Found ${entries.length} entries` + ) + + if (entries.length === 0) { + resolve(null) + } else { + // Log the structure of the first entry for debugging + if (entries[0]) { + logger.debug('🔍 Full LDAP entry structure:', { + entryType: typeof entries[0], + entryConstructor: entries[0].constructor?.name, + entryKeys: Object.keys(entries[0]), + entryStringified: JSON.stringify(entries[0], null, 2).substring(0, 500) + }) + } + + if (entries.length === 1) { + resolve(entries[0]) + } else { + logger.warn(`⚠️ Multiple LDAP entries found for username: ${username}`) + resolve(entries[0]) // 使用第一个结果 + } + } + }) + }) + }) + } + + // 🔐 验证用户密码 + async authenticateUser(userDN, password) { + return new Promise((resolve, reject) => { + // 验证输入参数 + if (!userDN || typeof userDN !== 'string') { + const error = new Error('User DN is not provided or invalid') + logger.error('❌ LDAP authentication error:', error.message) + reject(error) + return + } + + if (!password || typeof password !== 'string') { + logger.debug(`🚫 Invalid or empty password for DN: ${userDN}`) + resolve(false) + return + } + + const authClient = this.createClient() + + authClient.bind(userDN, password, (err) => { + authClient.unbind() // 立即关闭认证客户端 + + if (err) { + if (err.name === 'InvalidCredentialsError') { + logger.debug(`🚫 Invalid credentials for DN: ${userDN}`) + resolve(false) + } else { + logger.error('❌ LDAP authentication error:', err) + reject(err) + } + } else { + logger.debug(`✅ Authentication successful for DN: ${userDN}`) + resolve(true) + } + }) + }) + } + + // 🔐 Windows AD兼容认证 - 在DN认证失败时尝试多种格式 + async tryWindowsADAuthentication(username, password) { + if (!username || !password) { + return false + } + + // 从searchBase提取域名 + const domain = this.extractDomainFromDN(this.config.server.searchBase) + + const adFormats = [] + + if (domain) { + // UPN格式(Windows AD标准) + adFormats.push(`${username}@${domain}`) + + // 如果域名有多个部分,也尝试简化版本 + const domainParts = domain.split('.') + if (domainParts.length > 1) { + adFormats.push(`${username}@${domainParts.slice(-2).join('.')}`) // 只取后两部分 + } + + // 域\用户名格式 + const firstDomainPart = domainParts[0] + if (firstDomainPart) { + adFormats.push(`${firstDomainPart}\\${username}`) + adFormats.push(`${firstDomainPart.toUpperCase()}\\${username}`) + } + } + + // 纯用户名(最后尝试) + adFormats.push(username) + + logger.info(`🔄 尝试 ${adFormats.length} 种Windows AD认证格式...`) + + for (const format of adFormats) { + try { + logger.info(`🔍 尝试格式: ${format}`) + const result = await this.tryDirectBind(format, password) + if (result) { + logger.info(`✅ Windows AD认证成功: ${format}`) + return true + } + logger.debug(`❌ 认证失败: ${format}`) + } catch (error) { + logger.debug(`认证异常 ${format}:`, error.message) + } + } + + logger.info(`🚫 所有Windows AD格式认证都失败了`) + return false + } + + // 🔐 直接尝试绑定认证的辅助方法 + async tryDirectBind(identifier, password) { + return new Promise((resolve, reject) => { + const authClient = this.createClient() + + authClient.bind(identifier, password, (err) => { + authClient.unbind() + + if (err) { + if (err.name === 'InvalidCredentialsError') { + resolve(false) + } else { + reject(err) + } + } else { + resolve(true) + } + }) + }) + } + + // 📝 提取用户信息 + extractUserInfo(ldapEntry, username) { + try { + const attributes = ldapEntry.attributes || [] + const userInfo = { username } + + // 创建属性映射 + const attrMap = {} + attributes.forEach((attr) => { + const name = attr.type || attr.name + const values = Array.isArray(attr.values) ? attr.values : [attr.values] + attrMap[name] = values.length === 1 ? values[0] : values + }) + + // 根据配置映射用户属性 + const mapping = this.config.userMapping + + userInfo.displayName = attrMap[mapping.displayName] || username + userInfo.email = attrMap[mapping.email] || '' + userInfo.firstName = attrMap[mapping.firstName] || '' + userInfo.lastName = attrMap[mapping.lastName] || '' + + // 如果没有displayName,尝试组合firstName和lastName + if (!userInfo.displayName || userInfo.displayName === username) { + if (userInfo.firstName || userInfo.lastName) { + userInfo.displayName = `${userInfo.firstName || ''} ${userInfo.lastName || ''}`.trim() + } + } + + logger.debug('📋 Extracted user info:', { + username: userInfo.username, + displayName: userInfo.displayName, + email: userInfo.email + }) + + return userInfo + } catch (error) { + logger.error('❌ Error extracting user info:', error) + return { username } + } + } + + // 🔍 验证和清理用户名 + validateAndSanitizeUsername(username) { + if (!username || typeof username !== 'string' || username.trim() === '') { + throw new Error('Username is required and must be a non-empty string') + } + + const trimmedUsername = username.trim() + + // 用户名只能包含字母、数字、下划线和连字符 + const usernameRegex = /^[a-zA-Z0-9_-]+$/ + if (!usernameRegex.test(trimmedUsername)) { + throw new Error('Username can only contain letters, numbers, underscores, and hyphens') + } + + // 长度限制 (防止过长的输入) + if (trimmedUsername.length > 64) { + throw new Error('Username cannot exceed 64 characters') + } + + // 不能以连字符开头或结尾 + if (trimmedUsername.startsWith('-') || trimmedUsername.endsWith('-')) { + throw new Error('Username cannot start or end with a hyphen') + } + + return trimmedUsername + } + + // 🔐 主要的登录验证方法 + async authenticateUserCredentials(username, password) { + if (!this.config.enabled) { + throw new Error('LDAP authentication is not enabled') + } + + // 验证和清理用户名 (防止LDAP注入) + const sanitizedUsername = this.validateAndSanitizeUsername(username) + + if (!password || typeof password !== 'string' || password.trim() === '') { + throw new Error('Password is required and must be a non-empty string') + } + + // 验证LDAP服务器配置 + if (!this.config.server || !this.config.server.url) { + throw new Error('LDAP server URL is not configured') + } + + if (!this.config.server.bindDN || typeof this.config.server.bindDN !== 'string') { + throw new Error('LDAP bind DN is not configured') + } + + if ( + !this.config.server.bindCredentials || + typeof this.config.server.bindCredentials !== 'string' + ) { + throw new Error('LDAP bind credentials are not configured') + } + + if (!this.config.server.searchBase || typeof this.config.server.searchBase !== 'string') { + throw new Error('LDAP search base is not configured') + } + + const client = this.createClient() + + try { + // 1. 使用管理员凭据绑定 + await this.bindClient(client) + + // 2. 搜索用户 (使用已验证的用户名) + const ldapEntry = await this.searchUser(client, sanitizedUsername) + if (!ldapEntry) { + logger.info(`🚫 User not found in LDAP: ${sanitizedUsername}`) + return { success: false, message: 'Invalid username or password' } + } + + // 3. 获取用户DN + logger.debug('🔍 LDAP entry details for DN extraction:', { + hasEntry: !!ldapEntry, + entryType: typeof ldapEntry, + entryKeys: Object.keys(ldapEntry || {}), + dn: ldapEntry.dn, + objectName: ldapEntry.objectName, + dnType: typeof ldapEntry.dn, + objectNameType: typeof ldapEntry.objectName + }) + + // Use the helper method to extract DN + const userDN = this.extractDN(ldapEntry) + + logger.debug(`👤 Extracted user DN: ${userDN} (type: ${typeof userDN})`) + + // 验证用户DN + if (!userDN) { + logger.error(`❌ Invalid or missing DN for user: ${sanitizedUsername}`, { + ldapEntryDn: ldapEntry.dn, + ldapEntryObjectName: ldapEntry.objectName, + ldapEntryType: typeof ldapEntry, + extractedDN: userDN + }) + return { success: false, message: 'Authentication service error' } + } + + // 4. 验证用户密码 - 支持传统LDAP和Windows AD + let isPasswordValid = false + + // 首先尝试传统的DN认证(保持原有LDAP逻辑) + try { + isPasswordValid = await this.authenticateUser(userDN, password) + if (isPasswordValid) { + logger.info(`✅ DN authentication successful for user: ${sanitizedUsername}`) + } + } catch (error) { + logger.debug( + `DN authentication failed for user: ${sanitizedUsername}, error: ${error.message}` + ) + } + + // 如果DN认证失败,尝试Windows AD多格式认证 + if (!isPasswordValid) { + logger.debug(`🔄 Trying Windows AD authentication formats for user: ${sanitizedUsername}`) + isPasswordValid = await this.tryWindowsADAuthentication(sanitizedUsername, password) + if (isPasswordValid) { + logger.info(`✅ Windows AD authentication successful for user: ${sanitizedUsername}`) + } + } + + if (!isPasswordValid) { + logger.info(`🚫 All authentication methods failed for user: ${sanitizedUsername}`) + return { success: false, message: 'Invalid username or password' } + } + + // 5. 提取用户信息 + const userInfo = this.extractUserInfo(ldapEntry, sanitizedUsername) + + // 6. 创建或更新本地用户 + const user = await userService.createOrUpdateUser(userInfo) + + // 7. 检查用户是否被禁用 + if (!user.isActive) { + logger.security( + `🔒 Disabled user LDAP login attempt: ${sanitizedUsername} from LDAP authentication` + ) + return { + success: false, + message: 'Your account has been disabled. Please contact administrator.' + } + } + + // 8. 记录登录 + await userService.recordUserLogin(user.id) + + // 9. 创建用户会话 + const sessionToken = await userService.createUserSession(user.id) + + logger.info(`✅ LDAP authentication successful for user: ${sanitizedUsername}`) + + return { + success: true, + user, + sessionToken, + message: 'Authentication successful' + } + } catch (error) { + // 记录详细错误供调试,但不向用户暴露 + logger.error('❌ LDAP authentication error:', { + username: sanitizedUsername, + error: error.message, + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined + }) + + // 返回通用错误消息,避免信息泄露 + // 不要尝试解析具体的错误信息,因为不同LDAP服务器返回的格式不同 + return { + success: false, + message: 'Authentication service unavailable' + } + } finally { + // 确保客户端连接被关闭 + if (client) { + client.unbind((err) => { + if (err) { + logger.debug('Error unbinding LDAP client:', err) + } + }) + } + } + } + + // 🔍 测试LDAP连接 + async testConnection() { + if (!this.config.enabled) { + return { success: false, message: 'LDAP is not enabled' } + } + + const client = this.createClient() + + try { + await this.bindClient(client) + + return { + success: true, + message: 'LDAP connection successful', + server: this.config.server.url, + searchBase: this.config.server.searchBase + } + } catch (error) { + logger.error('❌ LDAP connection test failed:', { + error: error.message, + server: this.config.server.url, + stack: process.env.NODE_ENV === 'development' ? error.stack : undefined + }) + + // 提供通用错误消息,避免泄露系统细节 + let userMessage = 'LDAP connection failed' + + // 对于某些已知错误类型,提供有用但不泄露细节的信息 + if (error.code === 'ECONNREFUSED') { + userMessage = 'Unable to connect to LDAP server' + } else if (error.code === 'ETIMEDOUT') { + userMessage = 'LDAP server connection timeout' + } else if (error.name === 'InvalidCredentialsError') { + userMessage = 'LDAP bind credentials are invalid' + } + + return { + success: false, + message: userMessage, + server: this.config.server.url.replace(/:[^:]*@/, ':***@') // 隐藏密码部分 + } + } finally { + if (client) { + client.unbind((err) => { + if (err) { + logger.debug('Error unbinding test LDAP client:', err) + } + }) + } + } + } + + // 📊 获取LDAP配置信息(不包含敏感信息) + getConfigInfo() { + const configInfo = { + enabled: this.config.enabled, + server: { + url: this.config.server.url, + searchBase: this.config.server.searchBase, + searchFilter: this.config.server.searchFilter, + timeout: this.config.server.timeout, + connectTimeout: this.config.server.connectTimeout + }, + userMapping: this.config.userMapping + } + + // 添加 TLS 配置信息(不包含敏感数据) + if (this.config.server.url.toLowerCase().startsWith('ldaps://') && this.config.server.tls) { + configInfo.server.tls = { + rejectUnauthorized: this.config.server.tls.rejectUnauthorized, + hasCA: !!this.config.server.tls.ca, + hasCert: !!this.config.server.tls.cert, + hasKey: !!this.config.server.tls.key, + servername: this.config.server.tls.servername + } + } + + return configInfo + } +} + +module.exports = new LdapService() diff --git a/src/services/modelService.js b/src/services/modelService.js new file mode 100644 index 0000000..3ac47ec --- /dev/null +++ b/src/services/modelService.js @@ -0,0 +1,156 @@ +const logger = require('../utils/logger') + +/** + * 模型服务 + * 管理系统支持的 AI 模型列表 + * 与 pricingService 独立,专注于"支持哪些模型"而不是"如何计费" + */ +class ModelService { + constructor() { + this.supportedModels = this.getDefaultModels() + } + + /** + * 初始化模型服务 + */ + async initialize() { + const totalModels = Object.values(this.supportedModels).reduce( + (sum, config) => sum + config.models.length, + 0 + ) + logger.success(`Model service initialized with ${totalModels} models`) + } + + /** + * 获取支持的模型配置 + */ + getDefaultModels() { + return { + claude: { + provider: 'anthropic', + description: 'Claude models from Anthropic', + models: [ + 'claude-opus-4-5-20251101', + 'claude-haiku-4-5-20251001', + 'claude-sonnet-4-5-20250929', + 'claude-opus-4-1-20250805', + 'claude-sonnet-4-20250514', + 'claude-opus-4-20250514', + 'claude-3-7-sonnet-20250219', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-haiku-20241022', + 'claude-3-opus-20240229', + 'claude-3-haiku-20240307' + ] + }, + openai: { + provider: 'openai', + description: 'OpenAI GPT models', + models: [ + 'gpt-5.1-2025-11-13', + 'gpt-5.1-codex-mini', + 'gpt-5.1-codex', + 'gpt-5.1-codex-max', + 'gpt-5-2025-08-07', + 'gpt-5.3-codex', + 'gpt-5.3-codex-spark', + 'gpt-5.4', + 'gpt-5.4-pro', + 'gpt-5.6-sol', + 'gpt-5.6-terra', + 'gpt-5.6-luna' + ] + }, + gemini: { + provider: 'google', + description: 'Google Gemini models', + models: [ + 'gemini-2.5-pro', + 'gemini-3-pro-preview', + 'gemini-3.1-pro-preview', + 'gemini-2.5-flash' + ] + } + } + } + + /** + * 获取所有支持的模型(OpenAI API 格式) + */ + getAllModels() { + const models = [] + const now = Math.floor(Date.now() / 1000) + + for (const [_service, config] of Object.entries(this.supportedModels)) { + for (const modelId of config.models) { + models.push({ + id: modelId, + object: 'model', + created: now, + owned_by: config.provider + }) + } + } + + return models.sort((a, b) => { + // 先按 provider 排序,再按 model id 排序 + if (a.owned_by !== b.owned_by) { + return a.owned_by.localeCompare(b.owned_by) + } + return a.id.localeCompare(b.id) + }) + } + + /** + * 按 provider 获取模型 + * @param {string} provider - 'anthropic', 'openai', 'google' 等 + */ + getModelsByProvider(provider) { + return this.getAllModels().filter((m) => m.owned_by === provider) + } + + /** + * 检查模型是否被支持 + * @param {string} modelId - 模型 ID + */ + isModelSupported(modelId) { + if (!modelId) { + return false + } + return this.getAllModels().some((m) => m.id === modelId) + } + + /** + * 获取模型的 provider + * @param {string} modelId - 模型 ID + */ + getModelProvider(modelId) { + const model = this.getAllModels().find((m) => m.id === modelId) + return model ? model.owned_by : null + } + + /** + * 获取服务状态 + */ + getStatus() { + const totalModels = Object.values(this.supportedModels).reduce( + (sum, config) => sum + config.models.length, + 0 + ) + + return { + initialized: true, + totalModels, + providers: Object.keys(this.supportedModels) + } + } + + /** + * 清理资源(保留接口兼容性) + */ + cleanup() { + logger.debug('📋 Model service cleanup (no-op)') + } +} + +module.exports = new ModelService() diff --git a/src/services/openaiToClaude.js b/src/services/openaiToClaude.js new file mode 100644 index 0000000..1f335f0 --- /dev/null +++ b/src/services/openaiToClaude.js @@ -0,0 +1,488 @@ +/** + * OpenAI 到 Claude 格式转换服务 + * 处理 OpenAI API 格式与 Claude API 格式之间的转换 + */ + +const logger = require('../utils/logger') + +class OpenAIToClaudeConverter { + constructor() { + // 停止原因映射 + this.stopReasonMapping = { + end_turn: 'stop', + max_tokens: 'length', + stop_sequence: 'stop', + tool_use: 'tool_calls' + } + } + + /** + * 将 OpenAI 请求格式转换为 Claude 格式 + * @param {Object} openaiRequest - OpenAI 格式的请求 + * @returns {Object} Claude 格式的请求 + */ + convertRequest(openaiRequest) { + const claudeRequest = { + model: openaiRequest.model, // 直接使用提供的模型名,不进行映射 + messages: this._convertMessages(openaiRequest.messages), + max_tokens: openaiRequest.max_tokens || 4096, + temperature: openaiRequest.temperature, + top_p: openaiRequest.top_p, + stream: openaiRequest.stream || false + } + + // 定义 Claude Code 的默认系统提示词 + const claudeCodeSystemMessage = "You are Claude Code, Anthropic's official CLI for Claude." + + // 如果 OpenAI 请求中包含系统消息,提取并检查 + const systemMessage = this._extractSystemMessage(openaiRequest.messages) + if (systemMessage && systemMessage.includes('You are currently in Xcode')) { + // Xcode 系统提示词 + claudeRequest.system = systemMessage + logger.info( + `🔍 Xcode request detected, using Xcode system prompt (${systemMessage.length} chars)` + ) + logger.debug(`📋 System prompt preview: ${systemMessage.substring(0, 150)}...`) + } else { + // 使用 Claude Code 默认系统提示词 + claudeRequest.system = claudeCodeSystemMessage + logger.debug( + `📋 Using Claude Code default system prompt${systemMessage ? ' (ignored custom prompt)' : ''}` + ) + } + + // 处理停止序列 + if (openaiRequest.stop) { + claudeRequest.stop_sequences = Array.isArray(openaiRequest.stop) + ? openaiRequest.stop + : [openaiRequest.stop] + } + + // 处理工具调用 + if (openaiRequest.tools) { + claudeRequest.tools = this._convertTools(openaiRequest.tools) + if (openaiRequest.tool_choice) { + claudeRequest.tool_choice = this._convertToolChoice(openaiRequest.tool_choice) + } + } + + // OpenAI 特有的参数已在转换过程中被忽略 + // 包括: n, presence_penalty, frequency_penalty, logit_bias, user + + logger.debug('📝 Converted OpenAI request to Claude format:', { + model: claudeRequest.model, + messageCount: claudeRequest.messages.length, + hasSystem: !!claudeRequest.system, + stream: claudeRequest.stream + }) + + return claudeRequest + } + + /** + * 将 Claude 响应格式转换为 OpenAI 格式 + * @param {Object} claudeResponse - Claude 格式的响应 + * @param {String} requestModel - 原始请求的模型名 + * @returns {Object} OpenAI 格式的响应 + */ + convertResponse(claudeResponse, requestModel) { + const timestamp = Math.floor(Date.now() / 1000) + + const openaiResponse = { + id: `chatcmpl-${this._generateId()}`, + object: 'chat.completion', + created: timestamp, + model: requestModel || 'gpt-4', + choices: [ + { + index: 0, + message: this._convertClaudeMessage(claudeResponse), + finish_reason: this._mapStopReason(claudeResponse.stop_reason) + } + ], + usage: this._convertUsage(claudeResponse.usage) + } + + logger.debug('📝 Converted Claude response to OpenAI format:', { + responseId: openaiResponse.id, + finishReason: openaiResponse.choices[0].finish_reason, + usage: openaiResponse.usage + }) + + return openaiResponse + } + + /** + * 转换流式响应的单个数据块 + * @param {String} chunk - Claude SSE 数据块 + * @param {String} requestModel - 原始请求的模型名 + * @param {String} sessionId - 会话ID + * @returns {String} OpenAI 格式的 SSE 数据块 + */ + convertStreamChunk(chunk, requestModel, sessionId) { + if (!chunk || chunk.trim() === '') { + return '' + } + + // 解析 SSE 数据 + const lines = chunk.split('\n') + const convertedChunks = [] + let hasMessageStop = false + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.substring(6) + if (data === '[DONE]') { + convertedChunks.push('data: [DONE]\n\n') + continue + } + + try { + const claudeEvent = JSON.parse(data) + + // 检查是否是 message_stop 事件 + if (claudeEvent.type === 'message_stop') { + hasMessageStop = true + } + + const openaiChunk = this._convertStreamEvent(claudeEvent, requestModel, sessionId) + if (openaiChunk) { + convertedChunks.push(`data: ${JSON.stringify(openaiChunk)}\n\n`) + } + } catch (e) { + // 跳过无法解析的数据,不传递非JSON格式的行 + continue + } + } + // 忽略 event: 行和空行,OpenAI 格式不包含这些 + } + + // 如果收到 message_stop 事件,添加 [DONE] 标记 + if (hasMessageStop) { + convertedChunks.push('data: [DONE]\n\n') + } + + return convertedChunks.join('') + } + + /** + * 提取系统消息 + */ + _extractSystemMessage(messages) { + const systemMessages = messages.filter((msg) => msg.role === 'system') + if (systemMessages.length === 0) { + return null + } + + // 合并所有系统消息 + return systemMessages.map((msg) => msg.content).join('\n\n') + } + + /** + * 转换消息格式 + */ + _convertMessages(messages) { + const claudeMessages = [] + + for (const msg of messages) { + // 跳过系统消息(已经在 system 字段处理) + if (msg.role === 'system') { + continue + } + + // 转换角色名称 + const role = msg.role === 'user' ? 'user' : 'assistant' + + // 转换消息内容 + const { content: rawContent } = msg + let content + + if (typeof rawContent === 'string') { + content = rawContent + } else if (Array.isArray(rawContent)) { + // 处理多模态内容 + content = this._convertMultimodalContent(rawContent) + } else { + content = JSON.stringify(rawContent) + } + + const claudeMsg = { + role, + content + } + + // 处理工具调用 + if (msg.tool_calls) { + claudeMsg.content = this._convertToolCalls(msg.tool_calls) + } + + // 处理工具响应 + if (msg.role === 'tool') { + claudeMsg.role = 'user' + claudeMsg.content = [ + { + type: 'tool_result', + tool_use_id: msg.tool_call_id, + content: msg.content + } + ] + } + + claudeMessages.push(claudeMsg) + } + + return claudeMessages + } + + /** + * 转换多模态内容 + */ + _convertMultimodalContent(content) { + return content.map((item) => { + if (item.type === 'text') { + return { + type: 'text', + text: item.text + } + } else if (item.type === 'image_url') { + const imageUrl = item.image_url.url + + // 检查是否是 base64 格式的图片 + if (imageUrl.startsWith('data:')) { + // 解析 data URL: data:image/jpeg;base64,/9j/4AAQ... + const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/) + if (matches) { + const mediaType = matches[1] // e.g., 'image/jpeg', 'image/png' + const base64Data = matches[2] + + return { + type: 'image', + source: { + type: 'base64', + media_type: mediaType, + data: base64Data + } + } + } else { + // 如果格式不正确,尝试使用默认处理 + logger.warn('⚠️ Invalid base64 image format, using default parsing') + return { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: imageUrl.split(',')[1] || '' + } + } + } + } else { + // 如果是 URL 格式的图片,Claude 不支持直接 URL,需要报错 + logger.error( + '❌ URL images are not supported by Claude API, only base64 format is accepted' + ) + throw new Error( + 'Claude API only supports base64 encoded images, not URLs. Please convert the image to base64 format.' + ) + } + } + return item + }) + } + + /** + * 转换工具定义 + */ + _convertTools(tools) { + return tools.map((tool) => { + if (tool.type === 'function') { + return { + name: tool.function.name, + description: tool.function.description, + input_schema: tool.function.parameters + } + } + return tool + }) + } + + /** + * 转换工具选择 + */ + _convertToolChoice(toolChoice) { + if (toolChoice === 'none') { + return { type: 'none' } + } + if (toolChoice === 'auto') { + return { type: 'auto' } + } + if (toolChoice === 'required') { + return { type: 'any' } + } + if (toolChoice.type === 'function') { + return { + type: 'tool', + name: toolChoice.function.name + } + } + return { type: 'auto' } + } + + /** + * 转换工具调用 + */ + _convertToolCalls(toolCalls) { + return toolCalls.map((tc) => ({ + type: 'tool_use', + id: tc.id, + name: tc.function.name, + input: JSON.parse(tc.function.arguments) + })) + } + + /** + * 转换 Claude 消息为 OpenAI 格式 + */ + _convertClaudeMessage(claudeResponse) { + const message = { + role: 'assistant', + content: null + } + + // 处理内容 + if (claudeResponse.content) { + if (typeof claudeResponse.content === 'string') { + message.content = claudeResponse.content + } else if (Array.isArray(claudeResponse.content)) { + // 提取文本内容和工具调用 + const textParts = [] + const toolCalls = [] + + for (const item of claudeResponse.content) { + if (item.type === 'text') { + textParts.push(item.text) + } else if (item.type === 'tool_use') { + toolCalls.push({ + id: item.id, + type: 'function', + function: { + name: item.name, + arguments: JSON.stringify(item.input) + } + }) + } + } + + message.content = textParts.join('') || null + if (toolCalls.length > 0) { + message.tool_calls = toolCalls + } + } + } + + return message + } + + /** + * 转换停止原因 + */ + _mapStopReason(claudeReason) { + return this.stopReasonMapping[claudeReason] || 'stop' + } + + /** + * 转换使用统计 + */ + _convertUsage(claudeUsage) { + if (!claudeUsage) { + return undefined + } + + return { + prompt_tokens: claudeUsage.input_tokens || 0, + completion_tokens: claudeUsage.output_tokens || 0, + total_tokens: (claudeUsage.input_tokens || 0) + (claudeUsage.output_tokens || 0) + } + } + + /** + * 转换流式事件 + */ + _convertStreamEvent(event, requestModel, sessionId) { + const timestamp = Math.floor(Date.now() / 1000) + const baseChunk = { + id: sessionId, + object: 'chat.completion.chunk', + created: timestamp, + model: requestModel || 'gpt-4', + choices: [ + { + index: 0, + delta: {}, + finish_reason: null + } + ] + } + + // 根据事件类型处理 + if (event.type === 'message_start') { + // 处理消息开始事件,发送角色信息 + baseChunk.choices[0].delta.role = 'assistant' + return baseChunk + } else if (event.type === 'content_block_start' && event.content_block) { + if (event.content_block.type === 'text') { + baseChunk.choices[0].delta.content = event.content_block.text || '' + } else if (event.content_block.type === 'tool_use') { + // 开始工具调用 + baseChunk.choices[0].delta.tool_calls = [ + { + index: event.index || 0, + id: event.content_block.id, + type: 'function', + function: { + name: event.content_block.name, + arguments: '' + } + } + ] + } + } else if (event.type === 'content_block_delta' && event.delta) { + if (event.delta.type === 'text_delta') { + baseChunk.choices[0].delta.content = event.delta.text || '' + } else if (event.delta.type === 'input_json_delta') { + // 工具调用参数的增量更新 + baseChunk.choices[0].delta.tool_calls = [ + { + index: event.index || 0, + function: { + arguments: event.delta.partial_json || '' + } + } + ] + } + } else if (event.type === 'message_delta' && event.delta) { + if (event.delta.stop_reason) { + baseChunk.choices[0].finish_reason = this._mapStopReason(event.delta.stop_reason) + } + if (event.usage) { + baseChunk.usage = this._convertUsage(event.usage) + } + } else if (event.type === 'message_stop') { + // message_stop 事件不需要返回 chunk,[DONE] 标记会在 convertStreamChunk 中添加 + return null + } else { + // 忽略其他类型的事件 + return null + } + + return baseChunk + } + + /** + * 生成随机 ID + */ + _generateId() { + return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) + } +} + +module.exports = new OpenAIToClaudeConverter() diff --git a/src/services/pricingService.js b/src/services/pricingService.js new file mode 100644 index 0000000..87ef77b --- /dev/null +++ b/src/services/pricingService.js @@ -0,0 +1,900 @@ +const fs = require('fs') +const path = require('path') +const https = require('https') +const crypto = require('crypto') +const pricingSource = require('../../config/pricingSource') +const logger = require('../utils/logger') + +class PricingService { + constructor() { + this.dataDir = path.join(process.cwd(), 'data') + this.pricingFile = path.join(this.dataDir, 'model_pricing.json') + this.pricingUrl = pricingSource.pricingUrl + this.hashUrl = pricingSource.hashUrl + this.fallbackFile = path.join( + process.cwd(), + 'resources', + 'model-pricing', + 'model_prices_and_context_window.json' + ) + this.localHashFile = path.join(this.dataDir, 'model_pricing.sha256') + this.pricingData = null + this.lastUpdated = null + this.updateInterval = 24 * 60 * 60 * 1000 // 24小时 + this.hashCheckInterval = 10 * 60 * 1000 // 10分钟哈希校验 + this.fileWatcher = null // 文件监听器 + this.reloadDebounceTimer = null // 防抖定时器 + this.hashCheckTimer = null // 哈希轮询定时器 + this.updateTimer = null // 定时更新任务句柄 + this.hashSyncInProgress = false // 哈希同步状态 + + // Claude Prompt Caching 官方倍率(基于输入价格)— 仅作为 model_pricing.json 缺失字段时的兜底 + this.claudeCacheMultipliers = { + write5m: 1.25, + write1h: 2, + read: 0.1 + } + + // Claude 扩展计费特性 + this.claudeFeatureFlags = { + context1mBeta: 'context-1m-2025-08-07', + fastModeBeta: 'fast-mode-2026-02-01', + fastModeSpeed: 'fast' + } + } + + // 初始化价格服务 + async initialize() { + try { + // 确保data目录存在 + if (!fs.existsSync(this.dataDir)) { + fs.mkdirSync(this.dataDir, { recursive: true }) + logger.info('📁 Created data directory') + } + + // 检查是否需要下载或更新价格数据 + await this.checkAndUpdatePricing() + + // 初次启动时执行一次哈希校验,确保与远端保持一致 + await this.syncWithRemoteHash() + + // 设置定时更新 + if (this.updateTimer) { + clearInterval(this.updateTimer) + } + this.updateTimer = setInterval(() => { + this.checkAndUpdatePricing() + }, this.updateInterval) + + // 设置哈希轮询 + this.setupHashCheck() + + // 设置文件监听器 + this.setupFileWatcher() + + logger.success('Pricing service initialized successfully') + } catch (error) { + logger.error('❌ Failed to initialize pricing service:', error) + } + } + + // 检查并更新价格数据 + async checkAndUpdatePricing() { + try { + const needsUpdate = this.needsUpdate() + + if (needsUpdate) { + logger.info('🔄 Updating model pricing data...') + await this.downloadPricingData() + } else { + // 如果不需要更新,加载现有数据 + await this.loadPricingData() + } + } catch (error) { + logger.error('❌ Failed to check/update pricing:', error) + // 如果更新失败,尝试使用fallback + await this.useFallbackPricing() + } + } + + // 检查是否需要更新 + needsUpdate() { + if (!fs.existsSync(this.pricingFile)) { + logger.info('📋 Pricing file not found, will download') + return true + } + + const stats = fs.statSync(this.pricingFile) + const fileAge = Date.now() - stats.mtime.getTime() + + if (fileAge > this.updateInterval) { + logger.info( + `📋 Pricing file is ${Math.round(fileAge / (60 * 60 * 1000))} hours old, will update` + ) + return true + } + + return false + } + + // 下载价格数据 + async downloadPricingData() { + try { + await this._downloadFromRemote() + } catch (downloadError) { + logger.warn(`⚠️ Failed to download pricing data: ${downloadError.message}`) + logger.info('📋 Using local fallback pricing data...') + await this.useFallbackPricing() + } + } + + // 哈希轮询设置 + setupHashCheck() { + if (this.hashCheckTimer) { + clearInterval(this.hashCheckTimer) + } + + this.hashCheckTimer = setInterval(() => { + this.syncWithRemoteHash() + }, this.hashCheckInterval) + + logger.info('🕒 已启用价格文件哈希轮询(每10分钟校验一次)') + } + + // 与远端哈希对比 + async syncWithRemoteHash() { + if (this.hashSyncInProgress) { + return + } + + this.hashSyncInProgress = true + try { + const remoteHash = await this.fetchRemoteHash() + + if (!remoteHash) { + return + } + + const localHash = this.computeLocalHash() + + if (!localHash) { + logger.info('📄 本地价格文件缺失,尝试下载最新版本') + await this.downloadPricingData() + return + } + + if (remoteHash !== localHash) { + logger.info('🔁 检测到远端价格文件更新,开始下载最新数据') + await this.downloadPricingData() + } + } catch (error) { + logger.warn(`⚠️ 哈希校验失败:${error.message}`) + } finally { + this.hashSyncInProgress = false + } + } + + // 获取远端哈希值 + fetchRemoteHash() { + return new Promise((resolve, reject) => { + const request = https.get(this.hashUrl, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`哈希文件获取失败:HTTP ${response.statusCode}`)) + return + } + + let data = '' + response.on('data', (chunk) => { + data += chunk + }) + + response.on('end', () => { + const hash = data.trim().split(/\s+/)[0] + + if (!hash) { + reject(new Error('哈希文件内容为空')) + return + } + + resolve(hash) + }) + }) + + request.on('error', (error) => { + reject(new Error(`网络错误:${error.message}`)) + }) + + request.setTimeout(30000, () => { + request.destroy() + reject(new Error('获取哈希超时(30秒)')) + }) + }) + } + + // 计算本地文件哈希 + computeLocalHash() { + if (!fs.existsSync(this.pricingFile)) { + return null + } + + if (fs.existsSync(this.localHashFile)) { + const cached = fs.readFileSync(this.localHashFile, 'utf8').trim() + if (cached) { + return cached + } + } + + const fileBuffer = fs.readFileSync(this.pricingFile) + return this.persistLocalHash(fileBuffer) + } + + // 写入本地哈希文件 + persistLocalHash(content) { + const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf8') + const hash = crypto.createHash('sha256').update(buffer).digest('hex') + fs.writeFileSync(this.localHashFile, `${hash}\n`) + return hash + } + + // 实际的下载逻辑 + _downloadFromRemote() { + return new Promise((resolve, reject) => { + const request = https.get(this.pricingUrl, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`)) + return + } + + const chunks = [] + response.on('data', (chunk) => { + const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + chunks.push(bufferChunk) + }) + + response.on('end', () => { + try { + const buffer = Buffer.concat(chunks) + const rawContent = buffer.toString('utf8') + const jsonData = JSON.parse(rawContent) + + // 保存到文件并更新哈希 + fs.writeFileSync(this.pricingFile, rawContent) + this.persistLocalHash(buffer) + + // 更新内存中的数据 + this.pricingData = jsonData + this.lastUpdated = new Date() + + logger.success(`Downloaded pricing data for ${Object.keys(jsonData).length} models`) + + // 设置或重新设置文件监听器 + this.setupFileWatcher() + + resolve() + } catch (error) { + reject(new Error(`Failed to parse pricing data: ${error.message}`)) + } + }) + }) + + request.on('error', (error) => { + reject(new Error(`Network error: ${error.message}`)) + }) + + request.setTimeout(30000, () => { + request.destroy() + reject(new Error('Download timeout after 30 seconds')) + }) + }) + } + + // 加载本地价格数据 + async loadPricingData() { + try { + if (fs.existsSync(this.pricingFile)) { + const data = fs.readFileSync(this.pricingFile, 'utf8') + this.pricingData = JSON.parse(data) + + const stats = fs.statSync(this.pricingFile) + this.lastUpdated = stats.mtime + + logger.info( + `💰 Loaded pricing data for ${Object.keys(this.pricingData).length} models from cache` + ) + } else { + logger.warn('💰 No pricing data file found, will use fallback') + await this.useFallbackPricing() + } + } catch (error) { + logger.error('❌ Failed to load pricing data:', error) + await this.useFallbackPricing() + } + } + + // 使用fallback价格数据 + async useFallbackPricing() { + try { + if (fs.existsSync(this.fallbackFile)) { + logger.info('📋 Copying fallback pricing data to data directory...') + + // 读取fallback文件 + const fallbackData = fs.readFileSync(this.fallbackFile, 'utf8') + const jsonData = JSON.parse(fallbackData) + + const formattedJson = JSON.stringify(jsonData, null, 2) + + // 保存到data目录 + fs.writeFileSync(this.pricingFile, formattedJson) + this.persistLocalHash(formattedJson) + + // 更新内存中的数据 + this.pricingData = jsonData + this.lastUpdated = new Date() + + // 设置或重新设置文件监听器 + this.setupFileWatcher() + + logger.warn(`⚠️ Using fallback pricing data for ${Object.keys(jsonData).length} models`) + logger.info( + '💡 Note: This fallback data may be outdated. The system will try to update from the remote source on next check.' + ) + } else { + logger.error('❌ Fallback pricing file not found at:', this.fallbackFile) + logger.error( + '❌ Please ensure the resources/model-pricing directory exists with the pricing file' + ) + this.pricingData = {} + } + } catch (error) { + logger.error('❌ Failed to use fallback pricing data:', error) + this.pricingData = {} + } + } + + // 获取模型价格信息 + getModelPricing(modelName) { + if (!this.pricingData || !modelName) { + return null + } + + // 尝试直接匹配 + if (this.pricingData[modelName]) { + logger.debug(`💰 Found exact pricing match for ${modelName}`) + return this.pricingData[modelName] + } + + // 特殊处理:gpt-5.5 回退到 gpt-5 + if (modelName === 'gpt-5.5' && !this.pricingData['gpt-5.5']) { + const fallbackPricing = this.pricingData['gpt-5'] + if (fallbackPricing) { + logger.info(`💰 Using gpt-5 pricing as fallback for ${modelName}`) + return fallbackPricing + } + } + + // 特殊处理:gpt-5.6 系列(sol/terra/luna)在 LiteLLM 收录前回退到 gpt-5 + if (modelName.startsWith('gpt-5.6') && !this.pricingData[modelName]) { + const fallbackPricing = this.pricingData['gpt-5'] + if (fallbackPricing) { + logger.info(`💰 Using gpt-5 pricing as fallback for ${modelName}`) + return fallbackPricing + } + } + + // 对于Bedrock区域前缀模型(如 us.anthropic.claude-sonnet-4-20250514-v1:0), + // 尝试去掉区域前缀进行匹配 + if (modelName.includes('.anthropic.') || modelName.includes('.claude')) { + // 提取不带区域前缀的模型名 + const withoutRegion = modelName.replace(/^(us|eu|apac)\./, '') + if (this.pricingData[withoutRegion]) { + logger.debug( + `💰 Found pricing for ${modelName} by removing region prefix: ${withoutRegion}` + ) + return this.pricingData[withoutRegion] + } + } + + // 尝试模糊匹配(处理版本号等变化) + const normalizedModel = modelName.toLowerCase().replace(/[_-]/g, '') + + for (const [key, value] of Object.entries(this.pricingData)) { + const normalizedKey = key.toLowerCase().replace(/[_-]/g, '') + if (normalizedKey.includes(normalizedModel) || normalizedModel.includes(normalizedKey)) { + logger.debug(`💰 Found pricing for ${modelName} using fuzzy match: ${key}`) + return value + } + } + + // 对于Bedrock模型,尝试更智能的匹配 + if (modelName.includes('anthropic.claude')) { + // 提取核心模型名部分(去掉区域和前缀) + const coreModel = modelName.replace(/^(us|eu|apac)\./, '').replace('anthropic.', '') + + for (const [key, value] of Object.entries(this.pricingData)) { + if (key.includes(coreModel) || key.replace('anthropic.', '').includes(coreModel)) { + logger.debug(`💰 Found pricing for ${modelName} using Bedrock core model match: ${key}`) + return value + } + } + } + + logger.debug(`💰 No pricing found for model: ${modelName}`) + return null + } + + // 确保价格对象包含缓存价格 + ensureCachePricing(pricing) { + if (!pricing) { + return pricing + } + + // 如果缺少缓存价格,根据输入价格计算(缓存创建价格通常是输入价格的1.25倍,缓存读取是0.1倍) + if (!pricing.cache_creation_input_token_cost && pricing.input_cost_per_token) { + pricing.cache_creation_input_token_cost = pricing.input_cost_per_token * 1.25 + } + if (!pricing.cache_read_input_token_cost && pricing.input_cost_per_token) { + pricing.cache_read_input_token_cost = pricing.input_cost_per_token * 0.1 + } + return pricing + } + + // 从 usage 对象中提取 beta 特性列表(小写) + extractBetaFeatures(usage) { + const features = new Set() + if (!usage || typeof usage !== 'object') { + return features + } + + const requestHeaders = usage.request_headers || usage.requestHeaders || null + const headerBeta = + requestHeaders && typeof requestHeaders === 'object' + ? requestHeaders['anthropic-beta'] || + requestHeaders['Anthropic-Beta'] || + requestHeaders['ANTHROPIC-BETA'] + : null + + const candidates = [ + usage.anthropic_beta, + usage.anthropicBeta, + usage.request_anthropic_beta, + usage.requestAnthropicBeta, + usage.beta_header, + usage.betaHeader, + usage.beta_features, + headerBeta + ] + + const addFeature = (value) => { + if (!value || typeof value !== 'string') { + return + } + value + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean) + .forEach((item) => features.add(item)) + } + + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + candidate.forEach(addFeature) + } else { + addFeature(candidate) + } + } + + return features + } + + // 提取请求/响应中的 speed 字段(小写) + extractSpeedSignal(usage) { + if (!usage || typeof usage !== 'object') { + return { responseSpeed: '', requestSpeed: '' } + } + + const normalize = (value) => + typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '' + + return { + responseSpeed: normalize(usage.speed), + requestSpeed: normalize(usage.request_speed || usage.requestSpeed) + } + } + + // 去掉模型名中的 [1m] 后缀,便于价格查找 + stripLongContextSuffix(modelName) { + if (typeof modelName !== 'string') { + return modelName + } + return modelName.replace(/\[1m\]/gi, '').trim() + } + + // 计算使用费用 + calculateCost(usage, modelName) { + const normalizedModelName = this.stripLongContextSuffix(modelName) + + // 检查是否为 1M 上下文模型(用户通过 [1m] 后缀主动选择长上下文模式) + const isLongContextModel = typeof modelName === 'string' && modelName.includes('[1m]') + let isLongContextRequest = false + let useLongContextPricing = false + + // 计算总输入 tokens(用于判断是否超过 200K 阈值) + const inputTokens = usage.input_tokens || 0 + const cacheCreationTokens = usage.cache_creation_input_tokens || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + const totalInputTokens = inputTokens + cacheCreationTokens + cacheReadTokens + + // 识别 Claude 特性标识 + const betaFeatures = this.extractBetaFeatures(usage) + const hasContext1mBeta = betaFeatures.has(this.claudeFeatureFlags.context1mBeta) + const hasFastModeBeta = betaFeatures.has(this.claudeFeatureFlags.fastModeBeta) + const { responseSpeed, requestSpeed } = this.extractSpeedSignal(usage) + const hasFastSpeedSignal = + responseSpeed === this.claudeFeatureFlags.fastModeSpeed || + requestSpeed === this.claudeFeatureFlags.fastModeSpeed + const isFastModeRequest = hasFastModeBeta && hasFastSpeedSignal + const standardPricing = this.getModelPricing(modelName) + const pricing = standardPricing + const isLongContextModeEnabled = isLongContextModel || hasContext1mBeta + // Per official Anthropic pricing: all Claude models have flat pricing with no 200K+ premium + // https://platform.claude.com/docs/en/about-claude/pricing + const ignores200kLongContextPricing = + (typeof normalizedModelName === 'string' && + normalizedModelName.toLowerCase().includes('claude')) || + (typeof standardPricing?.litellm_provider === 'string' && + standardPricing.litellm_provider.toLowerCase().includes('anthropic')) + + // Fast Mode 倍率:优先从 provider_specific_entry.fast 读取,默认 6 倍 + const fastMultiplier = isFastModeRequest ? pricing?.provider_specific_entry?.fast || 6 : 1 + + // 当 [1m] 模型总输入超过 200K 时,进入 200K+ 计费逻辑 + // 根据 Anthropic 官方文档:当总输入超过 200K 时,整个请求所有 token 类型都使用高档价格 + if (isLongContextModeEnabled && totalInputTokens > 200000) { + if (ignores200kLongContextPricing) { + logger.info( + `💰 Skipping 200K+ pricing for ${modelName}: Claude models use flat pricing regardless of context length` + ) + } else { + isLongContextRequest = true + useLongContextPricing = true + logger.info( + `💰 Using 200K+ pricing for ${modelName}: total input tokens = ${totalInputTokens.toLocaleString()}` + ) + } + } + + if (!pricing) { + return { + inputCost: 0, + outputCost: 0, + cacheCreateCost: 0, + cacheReadCost: 0, + ephemeral5mCost: 0, + ephemeral1hCost: 0, + totalCost: 0, + hasPricing: false, + isLongContextRequest: false + } + } + + const isClaudeModel = + (modelName && modelName.toLowerCase().includes('claude')) || + (typeof pricing?.litellm_provider === 'string' && + pricing.litellm_provider.toLowerCase().includes('anthropic')) + + if (isFastModeRequest && fastMultiplier > 1) { + logger.info( + `🚀 Fast mode ${fastMultiplier}x multiplier applied for ${normalizedModelName} (from provider_specific_entry)` + ) + } else if (isFastModeRequest) { + logger.warn( + `⚠️ Fast mode request detected but no fast pricing found for ${normalizedModelName}; fallback to standard profile` + ) + } + + const baseInputPrice = pricing.input_cost_per_token || 0 + const hasInput200kPrice = + pricing.input_cost_per_token_above_200k_tokens !== null && + pricing.input_cost_per_token_above_200k_tokens !== undefined + + // 确定实际使用的输入价格(普通或 200K+ 高档价格) + // Claude 模型在 200K+ 场景下如果缺少官方字段,按 2 倍输入价兜底 + let actualInputPrice = useLongContextPricing + ? hasInput200kPrice + ? pricing.input_cost_per_token_above_200k_tokens + : isClaudeModel + ? baseInputPrice * 2 + : baseInputPrice + : baseInputPrice + + const baseOutputPrice = pricing.output_cost_per_token || 0 + const hasOutput200kPrice = + pricing.output_cost_per_token_above_200k_tokens !== null && + pricing.output_cost_per_token_above_200k_tokens !== undefined + let actualOutputPrice = useLongContextPricing + ? hasOutput200kPrice + ? pricing.output_cost_per_token_above_200k_tokens + : baseOutputPrice + : baseOutputPrice + + // 缓存价格:优先从 model_pricing.json 取,Claude 缺失时用倍率兜底 + let actualCacheCreatePrice = 0 + let actualCacheReadPrice = 0 + let actualEphemeral1hPrice = 0 + + if (useLongContextPricing) { + // 200K+:Claude 仅用 above_200k 专用字段,缺失留 0 让下方兜底从 actualInputPrice 推导 + actualCacheCreatePrice = isClaudeModel + ? pricing.cache_creation_input_token_cost_above_200k_tokens || 0 + : pricing.cache_creation_input_token_cost_above_200k_tokens || + pricing.cache_creation_input_token_cost || + 0 + actualCacheReadPrice = isClaudeModel + ? pricing.cache_read_input_token_cost_above_200k_tokens || 0 + : pricing.cache_read_input_token_cost_above_200k_tokens || + pricing.cache_read_input_token_cost || + 0 + const has1h200k = + pricing.cache_creation_input_token_cost_above_1hr_above_200k_tokens !== null && + pricing.cache_creation_input_token_cost_above_1hr_above_200k_tokens !== undefined + actualEphemeral1hPrice = has1h200k + ? pricing.cache_creation_input_token_cost_above_1hr_above_200k_tokens + : isClaudeModel + ? 0 + : pricing.cache_creation_input_token_cost_above_1hr || 0 + } else { + actualCacheCreatePrice = pricing.cache_creation_input_token_cost || 0 + actualCacheReadPrice = pricing.cache_read_input_token_cost || 0 + actualEphemeral1hPrice = pricing.cache_creation_input_token_cost_above_1hr || 0 + } + + // Claude 兜底:pricing 字段缺失时用倍率从 actualInputPrice 推导 + // 此时 actualInputPrice 尚未含 fastMultiplier,下方统一应用 + if (isClaudeModel) { + if (!actualCacheCreatePrice) { + actualCacheCreatePrice = actualInputPrice * this.claudeCacheMultipliers.write5m + } + if (!actualCacheReadPrice) { + actualCacheReadPrice = actualInputPrice * this.claudeCacheMultipliers.read + } + if (!actualEphemeral1hPrice) { + actualEphemeral1hPrice = actualInputPrice * this.claudeCacheMultipliers.write1h + } + } + + // Fast Mode 倍率:统一一次性应用于所有价格 + if (fastMultiplier > 1) { + actualInputPrice *= fastMultiplier + actualOutputPrice *= fastMultiplier + actualCacheCreatePrice *= fastMultiplier + actualCacheReadPrice *= fastMultiplier + actualEphemeral1hPrice *= fastMultiplier + } + + // 计算各项费用 + const inputCost = inputTokens * actualInputPrice + const outputCost = (usage.output_tokens || 0) * actualOutputPrice + + // 处理缓存费用 + let ephemeral5mCost = 0 + let ephemeral1hCost = 0 + let cacheCreateCost = 0 + let cacheReadCost = 0 + + if (usage.cache_creation && typeof usage.cache_creation === 'object') { + // 有详细的缓存创建数据 + const ephemeral5mTokens = usage.cache_creation.ephemeral_5m_input_tokens || 0 + const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0 + + // 5分钟缓存使用 cache_creation 价格 + ephemeral5mCost = ephemeral5mTokens * actualCacheCreatePrice + + // 1小时缓存使用 ephemeral_1h 价格 + ephemeral1hCost = ephemeral1hTokens * actualEphemeral1hPrice + + // 总的缓存创建费用 + cacheCreateCost = ephemeral5mCost + ephemeral1hCost + } else if (cacheCreationTokens) { + // 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容) + cacheCreateCost = cacheCreationTokens * actualCacheCreatePrice + ephemeral5mCost = cacheCreateCost + } + + // 缓存读取费用 + cacheReadCost = cacheReadTokens * actualCacheReadPrice + + return { + inputCost, + outputCost, + cacheCreateCost, + cacheReadCost, + ephemeral5mCost, + ephemeral1hCost, + totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost, + hasPricing: true, + isLongContextRequest, + pricing: { + input: actualInputPrice, + output: actualOutputPrice, + cacheCreate: actualCacheCreatePrice, + cacheRead: actualCacheReadPrice, + ephemeral1h: actualEphemeral1hPrice + } + } + } + + // 格式化价格显示 + formatCost(cost) { + if (cost === 0) { + return '$0.000000' + } + if (cost < 0.000001) { + return `$${cost.toExponential(2)}` + } + if (cost < 0.01) { + return `$${cost.toFixed(6)}` + } + if (cost < 1) { + return `$${cost.toFixed(4)}` + } + return `$${cost.toFixed(2)}` + } + + // 获取服务状态 + getStatus() { + return { + initialized: this.pricingData !== null, + lastUpdated: this.lastUpdated, + modelCount: this.pricingData ? Object.keys(this.pricingData).length : 0, + nextUpdate: this.lastUpdated + ? new Date(this.lastUpdated.getTime() + this.updateInterval) + : null + } + } + + // 强制更新价格数据 + async forceUpdate() { + try { + await this._downloadFromRemote() + return { success: true, message: 'Pricing data updated successfully' } + } catch (error) { + logger.error('❌ Force update failed:', error) + logger.info('📋 Force update failed, using fallback pricing data...') + await this.useFallbackPricing() + return { + success: false, + message: `Download failed: ${error.message}. Using fallback pricing data instead.` + } + } + } + + // 设置文件监听器 + setupFileWatcher() { + try { + // 如果已有监听器,先关闭 + if (this.fileWatcher) { + this.fileWatcher.close() + this.fileWatcher = null + } + + // 只有文件存在时才设置监听器 + if (!fs.existsSync(this.pricingFile)) { + logger.debug('💰 Pricing file does not exist yet, skipping file watcher setup') + return + } + + // 使用 fs.watchFile 作为更可靠的文件监听方式 + // 它使用轮询,虽然性能稍差,但更可靠 + const watchOptions = { + persistent: true, + interval: 60000 // 每60秒检查一次 + } + + // 记录初始的修改时间 + let lastMtime = fs.statSync(this.pricingFile).mtimeMs + + fs.watchFile(this.pricingFile, watchOptions, (curr, _prev) => { + // 检查文件是否真的被修改了(不仅仅是访问) + if (curr.mtimeMs !== lastMtime) { + lastMtime = curr.mtimeMs + logger.debug( + `💰 Detected change in pricing file (mtime: ${new Date(curr.mtime).toISOString()})` + ) + this.handleFileChange() + } + }) + + // 保存引用以便清理 + this.fileWatcher = { + close: () => fs.unwatchFile(this.pricingFile) + } + + logger.info('👁️ File watcher set up for model_pricing.json (polling every 60s)') + } catch (error) { + logger.error('❌ Failed to setup file watcher:', error) + } + } + + // 处理文件变化(带防抖) + handleFileChange() { + // 清除之前的定时器 + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer) + } + + // 设置新的定时器(防抖500ms) + this.reloadDebounceTimer = setTimeout(async () => { + logger.info('🔄 Reloading pricing data due to file change...') + await this.reloadPricingData() + }, 500) + } + + // 重新加载价格数据 + async reloadPricingData() { + try { + // 验证文件是否存在 + if (!fs.existsSync(this.pricingFile)) { + logger.warn('💰 Pricing file was deleted, using fallback') + await this.useFallbackPricing() + // 重新设置文件监听器(fallback会创建新文件) + this.setupFileWatcher() + return + } + + // 读取文件内容 + const data = fs.readFileSync(this.pricingFile, 'utf8') + + // 尝试解析JSON + const jsonData = JSON.parse(data) + + // 验证数据结构 + if (typeof jsonData !== 'object' || Object.keys(jsonData).length === 0) { + throw new Error('Invalid pricing data structure') + } + + // 更新内存中的数据 + this.pricingData = jsonData + this.lastUpdated = new Date() + + const modelCount = Object.keys(jsonData).length + logger.success(`Reloaded pricing data for ${modelCount} models from file`) + + // 显示一些统计信息 + const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length + const gptModels = Object.keys(jsonData).filter((k) => k.includes('gpt')).length + const geminiModels = Object.keys(jsonData).filter((k) => k.includes('gemini')).length + + logger.debug( + `💰 Model breakdown: Claude=${claudeModels}, GPT=${gptModels}, Gemini=${geminiModels}` + ) + } catch (error) { + logger.error('❌ Failed to reload pricing data:', error) + logger.warn('💰 Keeping existing pricing data in memory') + } + } + + // 清理资源 + cleanup() { + if (this.updateTimer) { + clearInterval(this.updateTimer) + this.updateTimer = null + logger.debug('💰 Pricing update timer cleared') + } + if (this.fileWatcher) { + this.fileWatcher.close() + this.fileWatcher = null + logger.debug('💰 File watcher closed') + } + if (this.reloadDebounceTimer) { + clearTimeout(this.reloadDebounceTimer) + this.reloadDebounceTimer = null + } + if (this.hashCheckTimer) { + clearInterval(this.hashCheckTimer) + this.hashCheckTimer = null + logger.debug('💰 Hash check timer cleared') + } + } +} + +module.exports = new PricingService() diff --git a/src/services/quotaCardService.js b/src/services/quotaCardService.js new file mode 100644 index 0000000..9862509 --- /dev/null +++ b/src/services/quotaCardService.js @@ -0,0 +1,698 @@ +/** + * 额度卡/时间卡服务 + * 管理员生成卡,用户核销,管理员可撤销 + */ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { v4: uuidv4 } = require('uuid') +const crypto = require('crypto') + +class QuotaCardService { + constructor() { + this.CARD_PREFIX = 'quota_card:' + this.REDEMPTION_PREFIX = 'redemption:' + this.CARD_CODE_PREFIX = 'CC' // 卡号前缀 + this.LIMITS_CONFIG_KEY = 'system:quota_card_limits' + } + + /** + * 获取额度卡上限配置 + */ + async getLimitsConfig() { + try { + const configStr = await redis.client.get(this.LIMITS_CONFIG_KEY) + if (configStr) { + return JSON.parse(configStr) + } + // 没有 Redis 配置时,使用 config.js 默认值 + const config = require('../../config/config') + return ( + config.quotaCardLimits || { + enabled: true, + maxExpiryDays: 90, + maxTotalCostLimit: 1000 + } + ) + } catch (error) { + logger.error('❌ Failed to get limits config:', error) + return { enabled: true, maxExpiryDays: 90, maxTotalCostLimit: 1000 } + } + } + + /** + * 保存额度卡上限配置 + */ + async saveLimitsConfig(config) { + try { + const parsedDays = parseInt(config.maxExpiryDays) + const parsedCost = parseFloat(config.maxTotalCostLimit) + const newConfig = { + enabled: config.enabled !== false, + maxExpiryDays: Number.isNaN(parsedDays) ? 90 : parsedDays, + maxTotalCostLimit: Number.isNaN(parsedCost) ? 1000 : parsedCost, + updatedAt: new Date().toISOString() + } + await redis.client.set(this.LIMITS_CONFIG_KEY, JSON.stringify(newConfig)) + logger.info('✅ Quota card limits config saved') + return newConfig + } catch (error) { + logger.error('❌ Failed to save limits config:', error) + throw error + } + } + + /** + * 生成卡号(16位,格式:CC_XXXX_XXXX_XXXX) + */ + _generateCardCode() { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 排除容易混淆的字符 + let code = '' + for (let i = 0; i < 12; i++) { + code += chars.charAt(crypto.randomInt(chars.length)) + } + return `${this.CARD_CODE_PREFIX}_${code.slice(0, 4)}_${code.slice(4, 8)}_${code.slice(8, 12)}` + } + + /** + * 创建额度卡/时间卡 + * @param {Object} options - 卡配置 + * @param {string} options.type - 卡类型:'quota' | 'time' | 'combo' + * @param {number} options.quotaAmount - CC 额度数量(quota/combo 类型必填) + * @param {number} options.timeAmount - 时间数量(time/combo 类型必填) + * @param {string} options.timeUnit - 时间单位:'hours' | 'days' | 'months' + * @param {string} options.expiresAt - 卡本身的有效期(可选) + * @param {string} options.note - 备注 + * @param {string} options.createdBy - 创建者 ID + * @returns {Object} 创建的卡信息 + */ + async createCard(options = {}) { + try { + const { + type = 'quota', + quotaAmount = 0, + timeAmount = 0, + timeUnit = 'days', + expiresAt = null, + note = '', + createdBy = 'admin' + } = options + + // 验证 + if (!['quota', 'time', 'combo'].includes(type)) { + throw new Error('Invalid card type') + } + + if ((type === 'quota' || type === 'combo') && (!quotaAmount || quotaAmount <= 0)) { + throw new Error('quotaAmount is required for quota/combo cards') + } + + if ((type === 'time' || type === 'combo') && (!timeAmount || timeAmount <= 0)) { + throw new Error('timeAmount is required for time/combo cards') + } + + const cardId = uuidv4() + const cardCode = this._generateCardCode() + + const cardData = { + id: cardId, + code: cardCode, + type, + quotaAmount: String(quotaAmount || 0), + timeAmount: String(timeAmount || 0), + timeUnit: timeUnit || 'days', + status: 'unused', // unused | redeemed | revoked | expired + createdBy, + createdAt: new Date().toISOString(), + expiresAt: expiresAt || '', + note: note || '', + // 核销信息 + redeemedBy: '', + redeemedByUsername: '', + redeemedApiKeyId: '', + redeemedApiKeyName: '', + redeemedAt: '', + // 撤销信息 + revokedAt: '', + revokedBy: '', + revokeReason: '' + } + + // 保存卡数据 + await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, cardData) + + // 建立卡号到 ID 的映射(用于快速查找) + await redis.client.set(`quota_card_code:${cardCode}`, cardId) + + // 添加到卡列表索引 + await redis.client.sadd('quota_cards:all', cardId) + await redis.client.sadd(`quota_cards:status:${cardData.status}`, cardId) + + logger.success(`🎫 Created ${type} card: ${cardCode} (${cardId})`) + + return { + id: cardId, + code: cardCode, + type, + quotaAmount: parseFloat(quotaAmount || 0), + timeAmount: parseInt(timeAmount || 0), + timeUnit, + status: 'unused', + createdBy, + createdAt: cardData.createdAt, + expiresAt: cardData.expiresAt, + note + } + } catch (error) { + logger.error('❌ Failed to create card:', error) + throw error + } + } + + /** + * 批量创建卡 + * @param {Object} options - 卡配置 + * @param {number} count - 创建数量 + * @returns {Array} 创建的卡列表 + */ + async createCardsBatch(options = {}, count = 1) { + const cards = [] + for (let i = 0; i < count; i++) { + const card = await this.createCard(options) + cards.push(card) + } + logger.success(`🎫 Batch created ${count} cards`) + return cards + } + + /** + * 通过卡号获取卡信息 + */ + async getCardByCode(code) { + try { + const cardId = await redis.client.get(`quota_card_code:${code}`) + if (!cardId) { + return null + } + return await this.getCardById(cardId) + } catch (error) { + logger.error('❌ Failed to get card by code:', error) + return null + } + } + + /** + * 通过 ID 获取卡信息 + */ + async getCardById(cardId) { + try { + const cardData = await redis.client.hgetall(`${this.CARD_PREFIX}${cardId}`) + if (!cardData || Object.keys(cardData).length === 0) { + return null + } + + return { + id: cardData.id, + code: cardData.code, + type: cardData.type, + quotaAmount: parseFloat(cardData.quotaAmount || 0), + timeAmount: parseInt(cardData.timeAmount || 0), + timeUnit: cardData.timeUnit, + status: cardData.status, + createdBy: cardData.createdBy, + createdAt: cardData.createdAt, + expiresAt: cardData.expiresAt, + note: cardData.note, + redeemedBy: cardData.redeemedBy, + redeemedByUsername: cardData.redeemedByUsername, + redeemedApiKeyId: cardData.redeemedApiKeyId, + redeemedApiKeyName: cardData.redeemedApiKeyName, + redeemedAt: cardData.redeemedAt, + revokedAt: cardData.revokedAt, + revokedBy: cardData.revokedBy, + revokeReason: cardData.revokeReason + } + } catch (error) { + logger.error('❌ Failed to get card:', error) + return null + } + } + + /** + * 获取所有卡列表 + * @param {Object} options - 查询选项 + * @param {string} options.status - 按状态筛选 + * @param {number} options.limit - 限制数量 + * @param {number} options.offset - 偏移量 + */ + async getAllCards(options = {}) { + try { + const { status, limit = 100, offset = 0 } = options + + let cardIds + if (status) { + cardIds = await redis.client.smembers(`quota_cards:status:${status}`) + } else { + cardIds = await redis.client.smembers('quota_cards:all') + } + + // 排序(按创建时间倒序) + const cards = [] + for (const cardId of cardIds) { + const card = await this.getCardById(cardId) + if (card) { + cards.push(card) + } + } + + cards.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + + // 分页 + const total = cards.length + const paginatedCards = cards.slice(offset, offset + limit) + + return { + cards: paginatedCards, + total, + limit, + offset + } + } catch (error) { + logger.error('❌ Failed to get all cards:', error) + return { cards: [], total: 0, limit: 100, offset: 0 } + } + } + + /** + * 核销卡 + * @param {string} code - 卡号 + * @param {string} apiKeyId - 目标 API Key ID + * @param {string} userId - 核销用户 ID + * @param {string} username - 核销用户名 + * @returns {Object} 核销结果 + */ + async redeemCard(code, apiKeyId, userId, username = '') { + try { + // 获取卡信息 + const card = await this.getCardByCode(code) + if (!card) { + throw new Error('卡号不存在') + } + + // 检查卡状态 + if (card.status !== 'unused') { + const statusMap = { used: '已使用', expired: '已过期', revoked: '已撤销' } + throw new Error(`卡片${statusMap[card.status] || card.status},无法兑换`) + } + + // 检查卡是否过期 + if (card.expiresAt && new Date(card.expiresAt) < new Date()) { + // 更新卡状态为过期 + await this._updateCardStatus(card.id, 'expired') + throw new Error('卡片已过期') + } + + // 获取 API Key 信息 + const apiKeyService = require('./apiKeyService') + const keyData = await redis.getApiKey(apiKeyId) + if (!keyData || Object.keys(keyData).length === 0) { + throw new Error('API Key 不存在') + } + + // 获取上限配置 + const limits = await this.getLimitsConfig() + + // 执行核销 + const redemptionId = uuidv4() + const now = new Date().toISOString() + + // 记录核销前状态 + const beforeLimit = parseFloat(keyData.totalCostLimit || 0) + const beforeExpiry = keyData.expiresAt || '' + + // 应用卡效果 + let afterLimit = beforeLimit + let afterExpiry = beforeExpiry + let quotaAdded = 0 + let timeAdded = 0 + let actualTimeUnit = card.timeUnit // 实际使用的时间单位(截断时会改为 days) + const warnings = [] // 截断警告信息 + + if (card.type === 'quota' || card.type === 'combo') { + let amountToAdd = card.quotaAmount + + // 上限保护:检查是否超过最大额度限制 + if (limits.enabled && limits.maxTotalCostLimit > 0) { + const maxAllowed = limits.maxTotalCostLimit - beforeLimit + if (amountToAdd > maxAllowed) { + amountToAdd = Math.max(0, maxAllowed) + warnings.push( + `额度已达上限,本次仅增加 ${amountToAdd} CC(原卡面 ${card.quotaAmount} CC)` + ) + logger.warn(`额度卡兑换超出上限,已截断:原 ${card.quotaAmount} -> 实际 ${amountToAdd}`) + } + } + + if (amountToAdd > 0) { + const result = await apiKeyService.addTotalCostLimit(apiKeyId, amountToAdd) + afterLimit = result.newTotalCostLimit + quotaAdded = amountToAdd + } + } + + if (card.type === 'time' || card.type === 'combo') { + // 计算新的过期时间 + let baseDate = beforeExpiry ? new Date(beforeExpiry) : new Date() + if (baseDate < new Date()) { + baseDate = new Date() + } + + let newExpiry = new Date(baseDate) + switch (card.timeUnit) { + case 'hours': + newExpiry.setTime(newExpiry.getTime() + card.timeAmount * 60 * 60 * 1000) + break + case 'days': + newExpiry.setDate(newExpiry.getDate() + card.timeAmount) + break + case 'months': + newExpiry.setMonth(newExpiry.getMonth() + card.timeAmount) + break + } + + // 上限保护:检查是否超过最大有效期 + if (limits.enabled && limits.maxExpiryDays > 0) { + const maxExpiry = new Date() + maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays) + if (newExpiry > maxExpiry) { + newExpiry = maxExpiry + warnings.push(`有效期已达上限(${limits.maxExpiryDays}天),时间已截断`) + logger.warn(`时间卡兑换超出上限,已截断至 ${maxExpiry.toISOString()}`) + } + } + + const result = await apiKeyService.extendExpiry(apiKeyId, card.timeAmount, card.timeUnit) + // 如果有上限保护,使用截断后的时间 + if (limits.enabled && limits.maxExpiryDays > 0) { + const maxExpiry = new Date() + maxExpiry.setDate(maxExpiry.getDate() + limits.maxExpiryDays) + if (new Date(result.newExpiresAt) > maxExpiry) { + await redis.client.hset(`apikey:${apiKeyId}`, 'expiresAt', maxExpiry.toISOString()) + afterExpiry = maxExpiry.toISOString() + // 计算实际增加的天数,截断时统一用天 + const actualDays = Math.max( + 0, + Math.ceil((maxExpiry - baseDate) / (1000 * 60 * 60 * 24)) + ) + timeAdded = actualDays + actualTimeUnit = 'days' + } else { + afterExpiry = result.newExpiresAt + timeAdded = card.timeAmount + } + } else { + afterExpiry = result.newExpiresAt + timeAdded = card.timeAmount + } + } + + // 更新卡状态 + await redis.client.hset(`${this.CARD_PREFIX}${card.id}`, { + status: 'redeemed', + redeemedBy: userId, + redeemedByUsername: username, + redeemedApiKeyId: apiKeyId, + redeemedApiKeyName: keyData.name || '', + redeemedAt: now + }) + + // 更新状态索引 + await redis.client.srem(`quota_cards:status:unused`, card.id) + await redis.client.sadd(`quota_cards:status:redeemed`, card.id) + + // 创建核销记录 + const redemptionData = { + id: redemptionId, + cardId: card.id, + cardCode: card.code, + cardType: card.type, + userId, + username, + apiKeyId, + apiKeyName: keyData.name || '', + quotaAdded: String(quotaAdded), + timeAdded: String(timeAdded), + timeUnit: actualTimeUnit, + beforeLimit: String(beforeLimit), + afterLimit: String(afterLimit), + beforeExpiry, + afterExpiry, + timestamp: now, + status: 'active' // active | revoked + } + + await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, redemptionData) + + // 添加到核销记录索引 + await redis.client.sadd('redemptions:all', redemptionId) + await redis.client.sadd(`redemptions:user:${userId}`, redemptionId) + await redis.client.sadd(`redemptions:apikey:${apiKeyId}`, redemptionId) + + logger.success(`✅ Card ${card.code} redeemed by ${username || userId} to key ${apiKeyId}`) + + return { + success: true, + warnings, + redemptionId, + cardCode: card.code, + cardType: card.type, + quotaAdded, + timeAdded, + timeUnit: actualTimeUnit, + beforeLimit, + afterLimit, + beforeExpiry, + afterExpiry + } + } catch (error) { + logger.error('❌ Failed to redeem card:', error) + throw error + } + } + + /** + * 撤销核销 + * @param {string} redemptionId - 核销记录 ID + * @param {string} revokedBy - 撤销者 ID + * @param {string} reason - 撤销原因 + * @returns {Object} 撤销结果 + */ + async revokeRedemption(redemptionId, revokedBy, reason = '') { + try { + // 获取核销记录 + const redemptionData = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${redemptionId}`) + if (!redemptionData || Object.keys(redemptionData).length === 0) { + throw new Error('Redemption record not found') + } + + if (redemptionData.status !== 'active') { + throw new Error('Redemption is already revoked') + } + + const apiKeyService = require('./apiKeyService') + const now = new Date().toISOString() + + // 撤销效果 + let actualDeducted = 0 + if (parseFloat(redemptionData.quotaAdded) > 0) { + const result = await apiKeyService.deductTotalCostLimit( + redemptionData.apiKeyId, + parseFloat(redemptionData.quotaAdded) + ) + ;({ actualDeducted } = result) + } + + // 注意:时间卡撤销比较复杂,这里简化处理,不回退时间 + // 如果需要回退时间,可以在这里添加逻辑 + + // 更新核销记录状态 + await redis.client.hset(`${this.REDEMPTION_PREFIX}${redemptionId}`, { + status: 'revoked', + revokedAt: now, + revokedBy, + revokeReason: reason, + actualDeducted: String(actualDeducted) + }) + + // 更新卡状态 + const { cardId } = redemptionData + await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, { + status: 'revoked', + revokedAt: now, + revokedBy, + revokeReason: reason + }) + + // 更新状态索引 + await redis.client.srem(`quota_cards:status:redeemed`, cardId) + await redis.client.sadd(`quota_cards:status:revoked`, cardId) + + logger.success(`🔄 Revoked redemption ${redemptionId} by ${revokedBy}`) + + return { + success: true, + redemptionId, + cardCode: redemptionData.cardCode, + actualDeducted, + reason + } + } catch (error) { + logger.error('❌ Failed to revoke redemption:', error) + throw error + } + } + + /** + * 获取核销记录 + * @param {Object} options - 查询选项 + * @param {string} options.userId - 按用户筛选 + * @param {string} options.apiKeyId - 按 API Key 筛选 + * @param {number} options.limit - 限制数量 + * @param {number} options.offset - 偏移量 + */ + async getRedemptions(options = {}) { + try { + const { userId, apiKeyId, limit = 100, offset = 0 } = options + + let redemptionIds + if (userId) { + redemptionIds = await redis.client.smembers(`redemptions:user:${userId}`) + } else if (apiKeyId) { + redemptionIds = await redis.client.smembers(`redemptions:apikey:${apiKeyId}`) + } else { + redemptionIds = await redis.client.smembers('redemptions:all') + } + + const redemptions = [] + for (const id of redemptionIds) { + const data = await redis.client.hgetall(`${this.REDEMPTION_PREFIX}${id}`) + if (data && Object.keys(data).length > 0) { + redemptions.push({ + id: data.id, + cardId: data.cardId, + cardCode: data.cardCode, + cardType: data.cardType, + userId: data.userId, + username: data.username, + apiKeyId: data.apiKeyId, + apiKeyName: data.apiKeyName, + quotaAdded: parseFloat(data.quotaAdded || 0), + timeAdded: parseInt(data.timeAdded || 0), + timeUnit: data.timeUnit, + beforeLimit: parseFloat(data.beforeLimit || 0), + afterLimit: parseFloat(data.afterLimit || 0), + beforeExpiry: data.beforeExpiry, + afterExpiry: data.afterExpiry, + timestamp: data.timestamp, + status: data.status, + revokedAt: data.revokedAt, + revokedBy: data.revokedBy, + revokeReason: data.revokeReason, + actualDeducted: parseFloat(data.actualDeducted || 0) + }) + } + } + + // 排序(按时间倒序) + redemptions.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + + // 分页 + const total = redemptions.length + const paginatedRedemptions = redemptions.slice(offset, offset + limit) + + return { + redemptions: paginatedRedemptions, + total, + limit, + offset + } + } catch (error) { + logger.error('❌ Failed to get redemptions:', error) + return { redemptions: [], total: 0, limit: 100, offset: 0 } + } + } + + /** + * 删除未使用的卡 + */ + async deleteCard(cardId) { + try { + const card = await this.getCardById(cardId) + if (!card) { + throw new Error('Card not found') + } + + if (card.status !== 'unused') { + throw new Error('Only unused cards can be deleted') + } + + // 删除卡数据 + await redis.client.del(`${this.CARD_PREFIX}${cardId}`) + await redis.client.del(`quota_card_code:${card.code}`) + + // 从索引中移除 + await redis.client.srem('quota_cards:all', cardId) + await redis.client.srem(`quota_cards:status:unused`, cardId) + + logger.success(`🗑️ Deleted card ${card.code}`) + + return { success: true, cardCode: card.code } + } catch (error) { + logger.error('❌ Failed to delete card:', error) + throw error + } + } + + /** + * 更新卡状态(内部方法) + */ + async _updateCardStatus(cardId, newStatus) { + const card = await this.getCardById(cardId) + if (!card) { + return + } + + const oldStatus = card.status + await redis.client.hset(`${this.CARD_PREFIX}${cardId}`, 'status', newStatus) + + // 更新状态索引 + await redis.client.srem(`quota_cards:status:${oldStatus}`, cardId) + await redis.client.sadd(`quota_cards:status:${newStatus}`, cardId) + } + + /** + * 获取卡统计信息 + */ + async getCardStats() { + try { + const [unused, redeemed, revoked, expired] = await Promise.all([ + redis.client.scard('quota_cards:status:unused'), + redis.client.scard('quota_cards:status:redeemed'), + redis.client.scard('quota_cards:status:revoked'), + redis.client.scard('quota_cards:status:expired') + ]) + + return { + total: unused + redeemed + revoked + expired, + unused, + redeemed, + revoked, + expired + } + } catch (error) { + logger.error('❌ Failed to get card stats:', error) + return { total: 0, unused: 0, redeemed: 0, revoked: 0, expired: 0 } + } + } +} + +module.exports = new QuotaCardService() diff --git a/src/services/rateLimitCleanupService.js b/src/services/rateLimitCleanupService.js new file mode 100644 index 0000000..1107c90 --- /dev/null +++ b/src/services/rateLimitCleanupService.js @@ -0,0 +1,560 @@ +/** + * 限流状态自动清理服务 + * 定期检查并清理所有类型账号的过期限流状态 + */ + +const logger = require('../utils/logger') +const openaiAccountService = require('./account/openaiAccountService') +const claudeAccountService = require('./account/claudeAccountService') +const claudeConsoleAccountService = require('./account/claudeConsoleAccountService') +const unifiedOpenAIScheduler = require('./scheduler/unifiedOpenAIScheduler') +const webhookService = require('./webhookService') + +class RateLimitCleanupService { + constructor() { + this.cleanupInterval = null + this.isRunning = false + // 默认每5分钟检查一次 + this.intervalMs = 5 * 60 * 1000 + // 存储已清理的账户信息,用于发送恢复通知 + this.clearedAccounts = [] + } + + /** + * 启动自动清理服务 + * @param {number} intervalMinutes - 检查间隔(分钟),默认5分钟 + */ + start(intervalMinutes = 5) { + if (this.cleanupInterval) { + logger.warn('⚠️ Rate limit cleanup service is already running') + return + } + + this.intervalMs = intervalMinutes * 60 * 1000 + + logger.info(`🧹 Starting rate limit cleanup service (interval: ${intervalMinutes} minutes)`) + + // 立即执行一次清理 + this.performCleanup() + + // 设置定期执行 + this.cleanupInterval = setInterval(() => { + this.performCleanup() + }, this.intervalMs) + } + + /** + * 停止自动清理服务 + */ + stop() { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval) + this.cleanupInterval = null + logger.info('🛑 Rate limit cleanup service stopped') + } + } + + /** + * 执行一次清理检查 + */ + async performCleanup() { + if (this.isRunning) { + logger.debug('⏭️ Cleanup already in progress, skipping this cycle') + return + } + + this.isRunning = true + const startTime = Date.now() + + try { + logger.debug('🔍 Starting rate limit cleanup check...') + + const results = { + openai: { checked: 0, cleared: 0, errors: [] }, + claude: { checked: 0, cleared: 0, errors: [] }, + claudeConsole: { checked: 0, cleared: 0, errors: [] }, + quotaExceeded: { checked: 0, cleared: 0, errors: [] }, + tokenRefresh: { checked: 0, refreshed: 0, errors: [] } + } + + // 清理 OpenAI 账号 + await this.cleanupOpenAIAccounts(results.openai) + + // 清理 Claude 账号 + await this.cleanupClaudeAccounts(results.claude) + + // 清理 Claude Console 账号 + await this.cleanupClaudeConsoleAccounts(results.claudeConsole) + + // 清理 Claude Console 配额超限状态 + await this.cleanupClaudeConsoleQuotaExceeded(results.quotaExceeded) + + // 主动刷新等待重置的 Claude 账户 Token(防止 5小时/7天 等待期间 Token 过期) + await this.proactiveRefreshClaudeTokens(results.tokenRefresh) + + const totalChecked = + results.openai.checked + + results.claude.checked + + results.claudeConsole.checked + + results.quotaExceeded.checked + const totalCleared = + results.openai.cleared + + results.claude.cleared + + results.claudeConsole.cleared + + results.quotaExceeded.cleared + const duration = Date.now() - startTime + + if (totalCleared > 0 || results.tokenRefresh.refreshed > 0) { + logger.info( + `✅ Rate limit cleanup completed: ${totalCleared}/${totalChecked} accounts cleared, ${results.tokenRefresh.refreshed} tokens refreshed (${duration}ms)` + ) + logger.info(` OpenAI: ${results.openai.cleared}/${results.openai.checked}`) + logger.info(` Claude: ${results.claude.cleared}/${results.claude.checked}`) + logger.info( + ` Claude Console: ${results.claudeConsole.cleared}/${results.claudeConsole.checked}` + ) + logger.info( + ` Quota Exceeded: ${results.quotaExceeded.cleared}/${results.quotaExceeded.checked}` + ) + if (results.tokenRefresh.checked > 0 || results.tokenRefresh.refreshed > 0) { + logger.info( + ` Token Refresh: ${results.tokenRefresh.refreshed}/${results.tokenRefresh.checked} refreshed` + ) + } + + // 发送 webhook 恢复通知 + if (this.clearedAccounts.length > 0) { + await this.sendRecoveryNotifications() + } + } else { + logger.debug( + `🔍 Rate limit cleanup check completed: no expired limits found (${duration}ms)` + ) + } + + // 记录错误 + const allErrors = [ + ...results.openai.errors, + ...results.claude.errors, + ...results.claudeConsole.errors, + ...results.quotaExceeded.errors, + ...results.tokenRefresh.errors + ] + if (allErrors.length > 0) { + logger.warn(`⚠️ Encountered ${allErrors.length} errors during cleanup:`, allErrors) + } + } catch (error) { + logger.error('❌ Rate limit cleanup failed:', error) + } finally { + // 确保无论成功或失败都重置列表,避免重复通知 + this.clearedAccounts = [] + this.isRunning = false + } + } + + /** + * 清理 OpenAI 账号的过期限流 + */ + async cleanupOpenAIAccounts(result) { + try { + // 使用服务层获取账户数据 + const accounts = await openaiAccountService.getAllAccounts() + + for (const account of accounts) { + const { rateLimitStatus } = account + const isRateLimited = + rateLimitStatus === 'limited' || + (rateLimitStatus && + typeof rateLimitStatus === 'object' && + (rateLimitStatus.status === 'limited' || rateLimitStatus.isRateLimited === true)) + + if (isRateLimited) { + result.checked++ + + try { + // 使用 unifiedOpenAIScheduler 的检查方法,它会自动清除过期的限流 + const isStillLimited = await unifiedOpenAIScheduler.isAccountRateLimited(account.id) + + if (!isStillLimited) { + result.cleared++ + logger.info( + `🧹 Auto-cleared expired rate limit for OpenAI account: ${account.name} (${account.id})` + ) + + // 记录已清理的账户信息 + this.clearedAccounts.push({ + platform: 'OpenAI', + accountId: account.id, + accountName: account.name, + previousStatus: 'rate_limited', + currentStatus: 'active' + }) + } + } catch (error) { + result.errors.push({ + accountId: account.id, + accountName: account.name, + error: error.message + }) + } + } + } + } catch (error) { + logger.error('Failed to cleanup OpenAI accounts:', error) + result.errors.push({ error: error.message }) + } + } + + /** + * 清理 Claude 账号的过期限流 + */ + async cleanupClaudeAccounts(result) { + try { + // 使用 Redis 获取账户数据 + const redis = require('../models/redis') + const accounts = await redis.getAllClaudeAccounts() + + for (const account of accounts) { + // 检查是否处于限流状态(兼容对象和字符串格式) + const isRateLimited = + account.rateLimitStatus === 'limited' || + (account.rateLimitStatus && + typeof account.rateLimitStatus === 'object' && + account.rateLimitStatus.status === 'limited') + + const autoStopped = account.rateLimitAutoStopped === 'true' + const needsAutoStopRecovery = + autoStopped && (account.rateLimitEndAt || account.schedulable === 'false') + + // 检查所有可能处于限流状态的账号,包括自动停止的账号 + if (isRateLimited || account.rateLimitedAt || needsAutoStopRecovery) { + result.checked++ + + try { + // 使用 claudeAccountService 的检查方法,它会自动清除过期的限流 + const isStillLimited = await claudeAccountService.isAccountRateLimited(account.id) + + if (!isStillLimited) { + if (!isRateLimited && autoStopped) { + await claudeAccountService.removeAccountRateLimit(account.id) + } + result.cleared++ + logger.info( + `🧹 Auto-cleared expired rate limit for Claude account: ${account.name} (${account.id})` + ) + + // 记录已清理的账户信息 + this.clearedAccounts.push({ + platform: 'Claude', + accountId: account.id, + accountName: account.name, + previousStatus: 'rate_limited', + currentStatus: 'active' + }) + } + } catch (error) { + result.errors.push({ + accountId: account.id, + accountName: account.name, + error: error.message + }) + } + } + } + + // 检查并恢复因5小时限制被自动停止的账号 + try { + const fiveHourResult = await claudeAccountService.checkAndRecoverFiveHourStoppedAccounts() + + if (fiveHourResult.recovered > 0) { + // 将5小时限制恢复的账号也加入到已清理账户列表中,用于发送通知 + for (const account of fiveHourResult.accounts) { + this.clearedAccounts.push({ + platform: 'Claude', + accountId: account.id, + accountName: account.name, + previousStatus: '5hour_limited', + currentStatus: 'active', + windowInfo: account.newWindow + }) + } + + // 更新统计数据 + result.checked += fiveHourResult.checked + result.cleared += fiveHourResult.recovered + + logger.info( + `🕐 Claude 5-hour limit recovery: ${fiveHourResult.recovered}/${fiveHourResult.checked} accounts recovered` + ) + } + } catch (error) { + logger.error('Failed to check and recover 5-hour stopped Claude accounts:', error) + result.errors.push({ + type: '5hour_recovery', + error: error.message + }) + } + } catch (error) { + logger.error('Failed to cleanup Claude accounts:', error) + result.errors.push({ error: error.message }) + } + } + + /** + * 清理 Claude Console 账号的过期限流 + */ + async cleanupClaudeConsoleAccounts(result) { + try { + // 使用服务层获取账户数据 + const accounts = await claudeConsoleAccountService.getAllAccounts() + + for (const account of accounts) { + // 检查是否处于限流状态(兼容对象和字符串格式) + const isRateLimited = + account.rateLimitStatus === 'limited' || + (account.rateLimitStatus && + typeof account.rateLimitStatus === 'object' && + account.rateLimitStatus.status === 'limited') + + const autoStopped = account.rateLimitAutoStopped === 'true' + const needsAutoStopRecovery = autoStopped && account.schedulable === 'false' + + // 检查两种状态字段:rateLimitStatus 和 status + const hasStatusRateLimited = account.status === 'rate_limited' + + if (isRateLimited || hasStatusRateLimited || needsAutoStopRecovery) { + result.checked++ + + try { + // 使用 claudeConsoleAccountService 的检查方法,它会自动清除过期的限流 + const isStillLimited = await claudeConsoleAccountService.isAccountRateLimited( + account.id + ) + + if (!isStillLimited) { + if (!isRateLimited && autoStopped) { + await claudeConsoleAccountService.removeAccountRateLimit(account.id) + } + result.cleared++ + + // 如果 status 字段是 rate_limited,需要额外清理 + if (hasStatusRateLimited && !isRateLimited) { + await claudeConsoleAccountService.updateAccount(account.id, { + status: 'active' + }) + } + + logger.info( + `🧹 Auto-cleared expired rate limit for Claude Console account: ${account.name} (${account.id})` + ) + + // 记录已清理的账户信息 + this.clearedAccounts.push({ + platform: 'Claude Console', + accountId: account.id, + accountName: account.name, + previousStatus: 'rate_limited', + currentStatus: 'active' + }) + } + } catch (error) { + result.errors.push({ + accountId: account.id, + accountName: account.name, + error: error.message + }) + } + } + } + } catch (error) { + logger.error('Failed to cleanup Claude Console accounts:', error) + result.errors.push({ error: error.message }) + } + } + + /** + * 检查并恢复 Claude Console 账号的配额超限状态 + */ + async cleanupClaudeConsoleQuotaExceeded(result) { + try { + const accounts = await claudeConsoleAccountService.getAllAccounts() + + for (const account of accounts) { + // 检查是否处于配额超限状态 + if (account.status === 'quota_exceeded' || account.quotaStoppedAt) { + result.checked++ + + try { + // 使用 isAccountQuotaExceeded 方法,它会自动触发恢复 + const isStillExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded( + account.id + ) + + if (!isStillExceeded) { + result.cleared++ + logger.info( + `🧹 Auto-recovered quota exceeded for Claude Console account: ${account.name} (${account.id})` + ) + + // 记录已恢复的账户信息 + this.clearedAccounts.push({ + platform: 'Claude Console', + accountId: account.id, + accountName: account.name, + previousStatus: 'quota_exceeded', + currentStatus: 'active' + }) + } + } catch (error) { + result.errors.push({ + accountId: account.id, + accountName: account.name, + error: error.message + }) + } + } + } + } catch (error) { + logger.error('Failed to cleanup Claude Console quota exceeded accounts:', error) + result.errors.push({ error: error.message }) + } + } + + /** + * 主动刷新 Claude 账户 Token(防止等待重置期间 Token 过期) + * 仅对因限流/配额限制而等待重置的账户执行刷新: + * - 429 限流账户(rateLimitAutoStopped=true) + * - 5小时限制自动停止账户(fiveHourAutoStopped=true) + * 不处理错误状态账户(error/temp_error) + */ + async proactiveRefreshClaudeTokens(result) { + try { + const redis = require('../models/redis') + const accounts = await redis.getAllClaudeAccounts() + const now = Date.now() + const refreshAheadMs = 30 * 60 * 1000 // 提前30分钟刷新 + const recentRefreshMs = 5 * 60 * 1000 // 5分钟内刷新过则跳过 + + for (const account of accounts) { + // 1. 必须激活 + if (account.isActive !== 'true') { + continue + } + + // 2. 必须有 refreshToken + if (!account.refreshToken) { + continue + } + + // 3. 【优化】仅处理因限流/配额限制而等待重置的账户 + // 正常调度的账户会在请求时自动刷新,无需主动刷新 + // 错误状态账户的 Token 可能已失效,刷新也会失败 + const isWaitingForReset = + account.rateLimitAutoStopped === 'true' || // 429 限流 + account.fiveHourAutoStopped === 'true' // 5小时限制自动停止 + if (!isWaitingForReset) { + continue + } + + // 4. 【优化】如果最近 5 分钟内已刷新,跳过(避免重复刷新) + const lastRefreshAt = account.lastRefreshAt ? new Date(account.lastRefreshAt).getTime() : 0 + if (now - lastRefreshAt < recentRefreshMs) { + continue + } + + // 5. 检查 Token 是否即将过期(30分钟内) + const expiresAt = parseInt(account.expiresAt) + if (expiresAt && now < expiresAt - refreshAheadMs) { + continue + } + + // 符合条件,执行刷新 + result.checked++ + try { + await claudeAccountService.refreshAccountToken(account.id) + result.refreshed++ + logger.info(`🔄 Proactively refreshed token: ${account.name} (${account.id})`) + } catch (error) { + result.errors.push({ + accountId: account.id, + accountName: account.name, + error: error.message + }) + logger.warn(`⚠️ Proactive refresh failed for ${account.name}: ${error.message}`) + } + } + } catch (error) { + logger.error('Failed to proactively refresh Claude tokens:', error) + result.errors.push({ error: error.message }) + } + } + + /** + * 手动触发一次清理(供 API 或 CLI 调用) + */ + async manualCleanup() { + logger.info('🧹 Manual rate limit cleanup triggered') + await this.performCleanup() + } + + /** + * 发送限流恢复通知 + */ + async sendRecoveryNotifications() { + try { + // 按平台分组账户 + const groupedAccounts = {} + for (const account of this.clearedAccounts) { + if (!groupedAccounts[account.platform]) { + groupedAccounts[account.platform] = [] + } + groupedAccounts[account.platform].push(account) + } + + // 构建通知消息 + const platforms = Object.keys(groupedAccounts) + const totalAccounts = this.clearedAccounts.length + + let message = `🎉 共有 ${totalAccounts} 个账户的限流状态已恢复\n\n` + + for (const platform of platforms) { + const accounts = groupedAccounts[platform] + message += `**${platform}** (${accounts.length} 个):\n` + for (const account of accounts) { + message += `• ${account.accountName} (ID: ${account.accountId})\n` + } + message += '\n' + } + + // 发送 webhook 通知 + await webhookService.sendNotification('rateLimitRecovery', { + title: '限流恢复通知', + message, + totalAccounts, + platforms: Object.keys(groupedAccounts), + accounts: this.clearedAccounts, + timestamp: new Date().toISOString() + }) + + logger.info(`📢 已发送限流恢复通知,涉及 ${totalAccounts} 个账户`) + } catch (error) { + logger.error('❌ 发送限流恢复通知失败:', error) + } + } + + /** + * 获取服务状态 + */ + getStatus() { + return { + running: !!this.cleanupInterval, + intervalMinutes: this.intervalMs / (60 * 1000), + isProcessing: this.isRunning + } + } +} + +// 创建单例实例 +const rateLimitCleanupService = new RateLimitCleanupService() + +module.exports = rateLimitCleanupService diff --git a/src/services/relay/antigravityRelayService.js b/src/services/relay/antigravityRelayService.js new file mode 100644 index 0000000..1513ed5 --- /dev/null +++ b/src/services/relay/antigravityRelayService.js @@ -0,0 +1,180 @@ +const apiKeyService = require('../apiKeyService') +const { convertMessagesToGemini, convertGeminiResponse } = require('./geminiRelayService') +const { normalizeAntigravityModelInput } = require('../../utils/antigravityModel') +const antigravityClient = require('../antigravityClient') + +function buildRequestData({ messages, model, temperature, maxTokens, sessionId }) { + const requestedModel = normalizeAntigravityModelInput(model) + const { contents, systemInstruction } = convertMessagesToGemini(messages) + + const requestData = { + model: requestedModel, + request: { + contents, + generationConfig: { + temperature, + maxOutputTokens: maxTokens, + candidateCount: 1, + topP: 0.95, + topK: 40 + }, + ...(sessionId ? { sessionId } : {}) + } + } + + if (systemInstruction) { + requestData.request.systemInstruction = { parts: [{ text: systemInstruction }] } + } + + return requestData +} + +async function* handleStreamResponse(response, model, apiKeyId, accountId, requestMeta = null) { + let buffer = '' + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + let usageRecorded = false + + try { + for await (const chunk of response.data) { + buffer += chunk.toString() + + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) { + continue + } + + let jsonData = line + if (line.startsWith('data: ')) { + jsonData = line.substring(6).trim() + } + + if (!jsonData || jsonData === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonData) + const payload = data?.response || data + + if (payload?.usageMetadata) { + totalUsage = payload.usageMetadata + } + + const openaiChunk = convertGeminiResponse(payload, model, true) + if (openaiChunk) { + yield `data: ${JSON.stringify(openaiChunk)}\n\n` + const finishReason = openaiChunk.choices?.[0]?.finish_reason + if (finishReason === 'stop') { + yield 'data: [DONE]\n\n' + + if (apiKeyId && totalUsage.totalTokenCount > 0) { + await apiKeyService.recordUsage( + apiKeyId, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, + 0, + model, + accountId, + 'gemini', + null, + requestMeta + ) + usageRecorded = true + } + return + } + } + } catch (e) { + // ignore chunk parse errors + } + } + } + } finally { + if (!usageRecorded && apiKeyId && totalUsage.totalTokenCount > 0) { + await apiKeyService.recordUsage( + apiKeyId, + totalUsage.promptTokenCount || 0, + totalUsage.candidatesTokenCount || 0, + 0, + 0, + model, + accountId, + 'gemini', + null, + requestMeta + ) + } + } +} + +async function sendAntigravityRequest({ + messages, + model, + temperature = 0.7, + maxTokens = 4096, + stream = false, + accessToken, + proxy, + apiKeyId, + signal, + projectId, + accountId = null, + requestMeta = null +}) { + const requestedModel = normalizeAntigravityModelInput(model) + + const requestData = buildRequestData({ + messages, + model: requestedModel, + temperature, + maxTokens, + sessionId: apiKeyId + }) + + const { response } = await antigravityClient.request({ + accessToken, + proxyConfig: proxy, + requestData, + projectId, + sessionId: apiKeyId, + stream, + signal, + params: { alt: 'sse' } + }) + + if (stream) { + return handleStreamResponse(response, requestedModel, apiKeyId, accountId, requestMeta) + } + + const payload = response.data?.response || response.data + const openaiResponse = convertGeminiResponse(payload, requestedModel, false) + + if (apiKeyId && openaiResponse?.usage) { + await apiKeyService.recordUsage( + apiKeyId, + openaiResponse.usage.prompt_tokens || 0, + openaiResponse.usage.completion_tokens || 0, + 0, + 0, + requestedModel, + accountId, + 'gemini', + null, + requestMeta + ) + } + + return openaiResponse +} + +module.exports = { + sendAntigravityRequest +} diff --git a/src/services/relay/azureOpenaiRelayService.js b/src/services/relay/azureOpenaiRelayService.js new file mode 100644 index 0000000..ed6e5dc --- /dev/null +++ b/src/services/relay/azureOpenaiRelayService.js @@ -0,0 +1,774 @@ +const axios = require('axios') +const ProxyHelper = require('../../utils/proxyHelper') +const logger = require('../../utils/logger') +const config = require('../../../config/config') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +// 转换模型名称(去掉 azure/ 前缀) +function normalizeModelName(model) { + if (model && model.startsWith('azure/')) { + return model.replace('azure/', '') + } + return model +} + +// 处理 Azure OpenAI 请求 +async function handleAzureOpenAIRequest({ + account, + requestBody, + headers: _headers = {}, // 前缀下划线表示未使用 + isStream = false, + endpoint = 'chat/completions' +}) { + // 声明变量在函数顶部,确保在 catch 块中也能访问 + let requestUrl = '' + let proxyAgent = null + let deploymentName = '' + + try { + // 构建 Azure OpenAI 请求 URL + const baseUrl = account.azureEndpoint + deploymentName = account.deploymentName || 'default' + // Azure Responses API requires preview versions; fall back appropriately + const apiVersion = + account.apiVersion || (endpoint === 'responses' ? '2025-04-01-preview' : '2024-02-01') + if (endpoint === 'chat/completions') { + requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/chat/completions?api-version=${apiVersion}` + } else if (endpoint === 'responses') { + requestUrl = `${baseUrl}/openai/responses?api-version=${apiVersion}` + } else { + requestUrl = `${baseUrl}/openai/deployments/${deploymentName}/${endpoint}?api-version=${apiVersion}` + } + + // 准备请求头 + const requestHeaders = { + 'Content-Type': 'application/json', + 'api-key': account.apiKey + } + + // 移除不需要的头部 + delete requestHeaders['anthropic-version'] + delete requestHeaders['x-api-key'] + delete requestHeaders['host'] + + // 处理请求体 + const processedBody = { ...requestBody } + + // 标准化模型名称 + if (endpoint === 'responses') { + processedBody.model = deploymentName + } else if (processedBody.model) { + processedBody.model = normalizeModelName(processedBody.model) + } else { + processedBody.model = 'gpt-4' + } + + // 使用统一的代理创建工具 + proxyAgent = ProxyHelper.createProxyAgent(account.proxy) + + // 配置请求选项 + const axiosConfig = { + method: 'POST', + url: requestUrl, + headers: requestHeaders, + data: processedBody, + timeout: config.requestTimeout || 600000, + validateStatus: () => true, + // 添加连接保活选项 + keepAlive: true, + maxRedirects: 5, + // 防止socket hang up + socketKeepAlive: true + } + + // 如果有代理,添加代理配置 + if (proxyAgent) { + axiosConfig.httpAgent = proxyAgent + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + // 为代理添加额外的keep-alive设置 + if (proxyAgent.options) { + proxyAgent.options.keepAlive = true + proxyAgent.options.keepAliveMsecs = 1000 + } + logger.debug( + `Using proxy for Azure OpenAI request: ${ProxyHelper.getProxyDescription(account.proxy)}` + ) + } + + // 流式请求特殊处理 + if (isStream) { + axiosConfig.responseType = 'stream' + requestHeaders.accept = 'text/event-stream' + } else { + requestHeaders.accept = 'application/json' + } + + logger.debug(`Making Azure OpenAI request`, { + requestUrl, + method: 'POST', + endpoint, + deploymentName, + apiVersion, + hasProxy: !!proxyAgent, + proxyInfo: ProxyHelper.maskProxyInfo(account.proxy), + isStream, + requestBodySize: JSON.stringify(processedBody).length + }) + + logger.debug('Azure OpenAI request headers', { + 'content-type': requestHeaders['Content-Type'], + 'user-agent': requestHeaders['user-agent'] || 'not-set', + customHeaders: Object.keys(requestHeaders).filter( + (key) => !['Content-Type', 'user-agent'].includes(key) + ) + }) + + logger.debug('Azure OpenAI request body', { + model: processedBody.model, + messages: processedBody.messages?.length || 0, + otherParams: Object.keys(processedBody).filter((key) => !['model', 'messages'].includes(key)) + }) + + const requestStartTime = Date.now() + logger.debug(`🔄 Starting Azure OpenAI HTTP request at ${new Date().toISOString()}`) + + // 发送请求 + const response = await axios(axiosConfig) + + const requestDuration = Date.now() - requestStartTime + logger.debug(`✅ Azure OpenAI HTTP request completed at ${new Date().toISOString()}`) + + logger.debug(`Azure OpenAI response received`, { + status: response.status, + statusText: response.statusText, + duration: `${requestDuration}ms`, + responseHeaders: Object.keys(response.headers || {}), + hasData: !!response.data, + contentType: response.headers?.['content-type'] || 'unknown' + }) + + return response + } catch (error) { + const errorDetails = { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + responseData: error.response?.data, + requestUrl: requestUrl || 'unknown', + endpoint, + deploymentName: deploymentName || account?.deploymentName || 'unknown', + hasProxy: !!proxyAgent, + proxyType: account?.proxy?.type || 'none', + isTimeout: error.code === 'ECONNABORTED', + isNetworkError: !error.response, + stack: error.stack + } + + // 特殊错误类型的详细日志 + if (error.code === 'ENOTFOUND') { + logger.error('DNS Resolution Failed for Azure OpenAI', { + ...errorDetails, + hostname: requestUrl && requestUrl !== 'unknown' ? new URL(requestUrl).hostname : 'unknown', + suggestion: 'Check if Azure endpoint URL is correct and accessible' + }) + } else if (error.code === 'ECONNREFUSED') { + logger.error('Connection Refused by Azure OpenAI', { + ...errorDetails, + suggestion: 'Check if proxy settings are correct or Azure service is accessible' + }) + } else if (error.code === 'ECONNRESET' || error.message.includes('socket hang up')) { + logger.error('🚨 Azure OpenAI Connection Reset / Socket Hang Up', { + ...errorDetails, + suggestion: + 'Connection was dropped by Azure OpenAI or proxy. This might be due to long request processing time, proxy timeout, or network instability. Try reducing request complexity or check proxy settings.' + }) + } else if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') { + logger.error('🚨 Azure OpenAI Request Timeout', { + ...errorDetails, + timeoutMs: 600000, + suggestion: + 'Request exceeded 10-minute timeout. Consider reducing model complexity or check if Azure service is responding slowly.' + }) + } else if ( + error.code === 'CERT_AUTHORITY_INVALID' || + error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' + ) { + logger.error('SSL Certificate Error for Azure OpenAI', { + ...errorDetails, + suggestion: 'SSL certificate validation failed - check proxy SSL settings' + }) + } else if (error.response?.status === 401) { + logger.error('Azure OpenAI Authentication Failed', { + ...errorDetails, + suggestion: 'Check if Azure OpenAI API key is valid and not expired' + }) + } else if (error.response?.status === 404) { + logger.error('Azure OpenAI Deployment Not Found', { + ...errorDetails, + suggestion: 'Check if deployment name and Azure endpoint are correct' + }) + } else { + logger.error('Azure OpenAI Request Failed', errorDetails) + } + + // 网络错误标记临时不可用 + const azureAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (account?.id && !azureAutoProtectionDisabled) { + const statusCode = error.response?.status || 503 + await upstreamErrorHelper + .markTempUnavailable(account.id, 'azure-openai', statusCode) + .catch(() => {}) + } + + throw error + } +} + +// 安全的流管理器 +class StreamManager { + constructor() { + this.activeStreams = new Set() + this.cleanupCallbacks = new Map() + } + + registerStream(streamId, cleanup) { + this.activeStreams.add(streamId) + this.cleanupCallbacks.set(streamId, cleanup) + } + + cleanup(streamId) { + if (this.activeStreams.has(streamId)) { + try { + const cleanup = this.cleanupCallbacks.get(streamId) + if (cleanup) { + cleanup() + } + } catch (error) { + logger.warn(`Stream cleanup error for ${streamId}:`, error.message) + } finally { + this.activeStreams.delete(streamId) + this.cleanupCallbacks.delete(streamId) + } + } + } + + getActiveStreamCount() { + return this.activeStreams.size + } +} + +const streamManager = new StreamManager() + +// SSE 缓冲区大小限制 +const MAX_BUFFER_SIZE = 64 * 1024 // 64KB +const MAX_EVENT_SIZE = 16 * 1024 // 16KB 单个事件最大大小 + +// 处理流式响应 +function handleStreamResponse(upstreamResponse, clientResponse, options = {}) { + const { onData, onEnd, onError } = options + const streamId = `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + + logger.info(`Starting Azure OpenAI stream handling`, { + streamId, + upstreamStatus: upstreamResponse.status, + upstreamHeaders: Object.keys(upstreamResponse.headers || {}), + clientRemoteAddress: clientResponse.req?.connection?.remoteAddress, + hasOnData: !!onData, + hasOnEnd: !!onEnd, + hasOnError: !!onError + }) + + return new Promise((resolve, reject) => { + let buffer = '' + let usageData = null + let actualModel = null + let hasEnded = false + let eventCount = 0 + const maxEvents = 10000 // 最大事件数量限制 + + // 专门用于保存最后几个chunks以提取usage数据 + let finalChunksBuffer = '' + const FINAL_CHUNKS_SIZE = 32 * 1024 // 32KB保留最终chunks + const allParsedEvents = [] // 存储所有解析的事件用于最终usage提取 + + // 设置响应头 + clientResponse.setHeader('Content-Type', 'text/event-stream') + clientResponse.setHeader('Cache-Control', 'no-cache') + clientResponse.setHeader('Connection', 'keep-alive') + clientResponse.setHeader('X-Accel-Buffering', 'no') + + // 透传某些头部 + const passThroughHeaders = [ + 'x-request-id', + 'x-ratelimit-remaining-requests', + 'x-ratelimit-remaining-tokens' + ] + passThroughHeaders.forEach((header) => { + const value = upstreamResponse.headers[header] + if (value) { + clientResponse.setHeader(header, value) + } + }) + + // 立即刷新响应头 + if (typeof clientResponse.flushHeaders === 'function') { + clientResponse.flushHeaders() + } + + // 强化的SSE事件解析,保存所有事件用于最终处理 + const parseSSEForUsage = (data, isFromFinalBuffer = false) => { + const lines = data.split('\n') + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6) // 移除 'data: ' 前缀 + if (jsonStr.trim() === '[DONE]') { + continue + } + const eventData = JSON.parse(jsonStr) + + // 保存所有成功解析的事件 + allParsedEvents.push(eventData) + + // 获取模型信息 + if (eventData.model) { + actualModel = eventData.model + } + + // 使用强化的usage提取函数 + const { usageData: extractedUsage, actualModel: extractedModel } = + extractUsageDataRobust( + eventData, + `stream-event-${isFromFinalBuffer ? 'final' : 'normal'}` + ) + + if (extractedUsage && !usageData) { + usageData = extractedUsage + if (extractedModel) { + actualModel = extractedModel + } + logger.debug(`🎯 Stream usage captured via robust extraction`, { + isFromFinalBuffer, + usageData, + actualModel + }) + } + + // 原有的简单提取作为备用 + if (!usageData) { + // 获取使用统计(Responses API: response.completed -> response.usage) + if (eventData.type === 'response.completed' && eventData.response) { + if (eventData.response.model) { + actualModel = eventData.response.model + } + if (eventData.response.usage) { + usageData = eventData.response.usage + logger.debug('🎯 Stream usage (backup method - response.usage):', usageData) + } + } + + // 兼容 Chat Completions 风格(顶层 usage) + if (!usageData && eventData.usage) { + usageData = eventData.usage + logger.debug('🎯 Stream usage (backup method - top-level):', usageData) + } + } + } catch (e) { + logger.debug('SSE parsing error (expected for incomplete chunks):', e.message) + } + } + } + } + + // 注册流清理 + const cleanup = () => { + if (!hasEnded) { + hasEnded = true + try { + upstreamResponse.data?.removeAllListeners?.() + upstreamResponse.data?.destroy?.() + + if (!clientResponse.headersSent) { + clientResponse.status(502).end() + } else if (!clientResponse.destroyed) { + clientResponse.end() + } + } catch (error) { + logger.warn('Stream cleanup error:', error.message) + } + } + } + + streamManager.registerStream(streamId, cleanup) + + upstreamResponse.data.on('data', (chunk) => { + try { + if (hasEnded || clientResponse.destroyed) { + return + } + + eventCount++ + if (eventCount > maxEvents) { + logger.warn(`Stream ${streamId} exceeded max events limit`) + cleanup() + return + } + + const chunkStr = chunk.toString() + + // 转发数据给客户端 + if (!clientResponse.destroyed) { + clientResponse.write(chunk) + } + + // 同时解析数据以捕获 usage 信息,带缓冲区大小限制 + buffer += chunkStr + + // 保留最后的chunks用于最终usage提取(不被truncate影响) + finalChunksBuffer += chunkStr + if (finalChunksBuffer.length > FINAL_CHUNKS_SIZE) { + finalChunksBuffer = finalChunksBuffer.slice(-FINAL_CHUNKS_SIZE) + } + + // 防止主缓冲区过大 - 但保持最后部分用于usage解析 + if (buffer.length > MAX_BUFFER_SIZE) { + logger.warn( + `Stream ${streamId} buffer exceeded limit, truncating main buffer but preserving final chunks` + ) + // 保留最后1/4而不是1/2,为usage数据留更多空间 + buffer = buffer.slice(-MAX_BUFFER_SIZE / 4) + } + + // 处理完整的 SSE 事件 + if (buffer.includes('\n\n')) { + const events = buffer.split('\n\n') + buffer = events.pop() || '' // 保留最后一个可能不完整的事件 + + for (const event of events) { + if (event.trim() && event.length <= MAX_EVENT_SIZE) { + parseSSEForUsage(event) + } + } + } + + if (onData) { + onData(chunk, { usageData, actualModel }) + } + } catch (error) { + logger.error('Error processing Azure OpenAI stream chunk:', error) + if (!hasEnded) { + cleanup() + reject(error) + } + } + }) + + upstreamResponse.data.on('end', () => { + if (hasEnded) { + return + } + + streamManager.cleanup(streamId) + hasEnded = true + + try { + logger.debug(`🔚 Stream ended, performing comprehensive usage extraction for ${streamId}`, { + mainBufferSize: buffer.length, + finalChunksBufferSize: finalChunksBuffer.length, + parsedEventsCount: allParsedEvents.length, + hasUsageData: !!usageData + }) + + // 多层次的最终usage提取策略 + if (!usageData) { + logger.debug('🔍 No usage found during stream, trying final extraction methods...') + + // 方法1: 解析剩余的主buffer + if (buffer.trim() && buffer.length <= MAX_EVENT_SIZE) { + parseSSEForUsage(buffer, false) + } + + // 方法2: 解析保留的final chunks buffer + if (!usageData && finalChunksBuffer.trim()) { + logger.debug('🔍 Trying final chunks buffer for usage extraction...') + parseSSEForUsage(finalChunksBuffer, true) + } + + // 方法3: 从所有解析的事件中重新搜索usage + if (!usageData && allParsedEvents.length > 0) { + logger.debug('🔍 Searching through all parsed events for usage...') + + // 倒序查找,因为usage通常在最后 + for (let i = allParsedEvents.length - 1; i >= 0; i--) { + const { usageData: foundUsage, actualModel: foundModel } = extractUsageDataRobust( + allParsedEvents[i], + `final-event-scan-${i}` + ) + if (foundUsage) { + usageData = foundUsage + if (foundModel) { + actualModel = foundModel + } + logger.debug(`🎯 Usage found in event ${i} during final scan!`) + break + } + } + } + + // 方法4: 尝试合并所有事件并搜索 + if (!usageData && allParsedEvents.length > 0) { + logger.debug('🔍 Trying combined events analysis...') + const combinedData = { + events: allParsedEvents, + lastEvent: allParsedEvents[allParsedEvents.length - 1], + eventCount: allParsedEvents.length + } + + const { usageData: combinedUsage } = extractUsageDataRobust( + combinedData, + 'combined-events' + ) + if (combinedUsage) { + usageData = combinedUsage + logger.debug('🎯 Usage found via combined events analysis!') + } + } + } + + // 最终usage状态报告 + if (usageData) { + logger.debug('✅ Final stream usage extraction SUCCESS', { + streamId, + usageData, + actualModel, + totalEvents: allParsedEvents.length, + finalBufferSize: finalChunksBuffer.length + }) + } else { + logger.warn('❌ Final stream usage extraction FAILED', { + streamId, + totalEvents: allParsedEvents.length, + finalBufferSize: finalChunksBuffer.length, + mainBufferSize: buffer.length, + lastFewEvents: allParsedEvents.slice(-3).map((e) => ({ + type: e.type, + hasUsage: !!e.usage, + hasResponse: !!e.response, + keys: Object.keys(e) + })) + }) + } + + if (onEnd) { + onEnd({ usageData, actualModel }) + } + + if (!clientResponse.destroyed) { + clientResponse.end() + } + + resolve({ usageData, actualModel }) + } catch (error) { + logger.error('Stream end handling error:', error) + reject(error) + } + }) + + upstreamResponse.data.on('error', (error) => { + if (hasEnded) { + return + } + + streamManager.cleanup(streamId) + hasEnded = true + + logger.error('Upstream stream error:', error) + + try { + if (onError) { + onError(error) + } + + if (!clientResponse.headersSent) { + clientResponse.status(502).json({ error: { message: 'Upstream stream error' } }) + } else if (!clientResponse.destroyed) { + clientResponse.end() + } + } catch (cleanupError) { + logger.warn('Error during stream error cleanup:', cleanupError.message) + } + + reject(error) + }) + + // 客户端断开时清理 + const clientCleanup = () => { + streamManager.cleanup(streamId) + } + + clientResponse.on('close', clientCleanup) + clientResponse.on('aborted', clientCleanup) + clientResponse.on('error', clientCleanup) + }) +} + +// 强化的用量数据提取函数 +function extractUsageDataRobust(responseData, context = 'unknown') { + logger.debug(`🔍 Attempting usage extraction for ${context}`, { + responseDataKeys: Object.keys(responseData || {}), + responseDataType: typeof responseData, + hasUsage: !!responseData?.usage, + hasResponse: !!responseData?.response + }) + + let usageData = null + let actualModel = null + + try { + // 策略 1: 顶层 usage (标准 Chat Completions) + if (responseData?.usage) { + usageData = responseData.usage + actualModel = responseData.model + logger.debug('✅ Usage extracted via Strategy 1 (top-level)', { usageData, actualModel }) + } + + // 策略 2: response.usage (Responses API) + else if (responseData?.response?.usage) { + usageData = responseData.response.usage + actualModel = responseData.response.model || responseData.model + logger.debug('✅ Usage extracted via Strategy 2 (response.usage)', { usageData, actualModel }) + } + + // 策略 3: 嵌套搜索 - 深度查找 usage 字段 + else { + const findUsageRecursive = (obj, path = '') => { + if (!obj || typeof obj !== 'object') { + return null + } + + for (const [key, value] of Object.entries(obj)) { + const currentPath = path ? `${path}.${key}` : key + + if (key === 'usage' && value && typeof value === 'object') { + logger.debug(`✅ Usage found at path: ${currentPath}`, value) + return { usage: value, path: currentPath } + } + + if (typeof value === 'object' && value !== null) { + const nested = findUsageRecursive(value, currentPath) + if (nested) { + return nested + } + } + } + return null + } + + const found = findUsageRecursive(responseData) + if (found) { + usageData = found.usage + // Try to find model in the same parent object + const pathParts = found.path.split('.') + pathParts.pop() // remove 'usage' + let modelParent = responseData + for (const part of pathParts) { + modelParent = modelParent?.[part] + } + actualModel = modelParent?.model || responseData?.model + logger.debug('✅ Usage extracted via Strategy 3 (recursive)', { + usageData, + actualModel, + foundPath: found.path + }) + } + } + + // 策略 4: 特殊响应格式处理 + if (!usageData) { + // 检查是否有 choices 数组,usage 可能在最后一个 choice 中 + if (responseData?.choices?.length > 0) { + const lastChoice = responseData.choices[responseData.choices.length - 1] + if (lastChoice?.usage) { + usageData = lastChoice.usage + actualModel = responseData.model || lastChoice.model + logger.debug('✅ Usage extracted via Strategy 4 (choices)', { usageData, actualModel }) + } + } + } + + // 最终验证和记录 + if (usageData) { + logger.debug('🎯 Final usage extraction result', { + context, + usageData, + actualModel, + inputTokens: usageData.prompt_tokens || usageData.input_tokens || 0, + outputTokens: usageData.completion_tokens || usageData.output_tokens || 0, + totalTokens: usageData.total_tokens || 0 + }) + } else { + logger.warn('❌ Failed to extract usage data', { + context, + responseDataStructure: `${JSON.stringify(responseData, null, 2).substring(0, 1000)}...`, + availableKeys: Object.keys(responseData || {}), + responseSize: JSON.stringify(responseData || {}).length + }) + } + } catch (extractionError) { + logger.error('🚨 Error during usage extraction', { + context, + error: extractionError.message, + stack: extractionError.stack, + responseDataType: typeof responseData + }) + } + + return { usageData, actualModel } +} + +// 处理非流式响应 +function handleNonStreamResponse(upstreamResponse, clientResponse) { + try { + // 设置状态码 + clientResponse.status(upstreamResponse.status) + + // 设置响应头 + clientResponse.setHeader('Content-Type', 'application/json') + + // 透传某些头部 + const passThroughHeaders = [ + 'x-request-id', + 'x-ratelimit-remaining-requests', + 'x-ratelimit-remaining-tokens' + ] + passThroughHeaders.forEach((header) => { + const value = upstreamResponse.headers[header] + if (value) { + clientResponse.setHeader(header, value) + } + }) + + // 返回响应数据 + const responseData = upstreamResponse.data + clientResponse.json(responseData) + + // 使用强化的用量提取 + const { usageData, actualModel } = extractUsageDataRobust(responseData, 'non-stream') + + return { usageData, actualModel, responseData } + } catch (error) { + logger.error('Error handling Azure OpenAI non-stream response:', error) + throw error + } +} + +module.exports = { + handleAzureOpenAIRequest, + handleStreamResponse, + handleNonStreamResponse, + normalizeModelName +} diff --git a/src/services/relay/bedrockRelayService.js b/src/services/relay/bedrockRelayService.js new file mode 100644 index 0000000..b36aa61 --- /dev/null +++ b/src/services/relay/bedrockRelayService.js @@ -0,0 +1,850 @@ +const { + BedrockRuntimeClient, + InvokeModelCommand, + InvokeModelWithResponseStreamCommand +} = require('@aws-sdk/client-bedrock-runtime') +const { fromEnv } = require('@aws-sdk/credential-providers') +const logger = require('../../utils/logger') +const config = require('../../../config/config') +const userMessageQueueService = require('../userMessageQueueService') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +class BedrockRelayService { + constructor() { + this.defaultRegion = process.env.AWS_REGION || config.bedrock?.defaultRegion || 'us-east-1' + this.smallFastModelRegion = + process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION || this.defaultRegion + + // 默认模型配置 + this.defaultModel = process.env.ANTHROPIC_MODEL || 'us.anthropic.claude-sonnet-4-20250514-v1:0' + this.defaultSmallModel = + process.env.ANTHROPIC_SMALL_FAST_MODEL || 'us.anthropic.claude-3-5-haiku-20241022-v1:0' + + // Token配置 — 仅作为客户端未指定 max_tokens 时的回退默认值,不用于截断 + this.maxOutputTokens = parseInt(process.env.BEDROCK_MAX_OUTPUT_TOKENS) || 128000 + + // 创建Bedrock客户端 + this.clients = new Map() // 缓存不同区域的客户端 + } + + // 获取或创建Bedrock客户端 + _getBedrockClient(region = null, bedrockAccount = null) { + const targetRegion = region || this.defaultRegion + const clientKey = `${targetRegion}-${bedrockAccount?.id || 'default'}` + + if (this.clients.has(clientKey)) { + return this.clients.get(clientKey) + } + + const clientConfig = { + region: targetRegion, + requestHandler: { + requestTimeout: config.requestTimeout || 600000, // 与其他 relay 服务保持一致 + connectionTimeout: 10000 + } + } + + // 如果账户配置了特定的AWS凭证,使用它们 + if (bedrockAccount?.awsCredentials) { + clientConfig.credentials = { + accessKeyId: bedrockAccount.awsCredentials.accessKeyId, + secretAccessKey: bedrockAccount.awsCredentials.secretAccessKey, + sessionToken: bedrockAccount.awsCredentials.sessionToken + } + } else if (bedrockAccount?.bearerToken) { + // Bedrock API Key (ABSK) 模式:需要通过 middleware 注入 Bearer Token, + // 因为 BedrockRuntimeClient 默认使用 SigV4 签名,不支持 token 配置 + // 使用占位凭证防止 "Could not load credentials" 错误 + // SigV4 签名会生成 Authorization header,但随后被 middleware 替换为 Bearer Token + clientConfig.credentials = { + accessKeyId: 'BEDROCK_API_KEY_PLACEHOLDER', + secretAccessKey: 'BEDROCK_API_KEY_PLACEHOLDER' + } + logger.debug(`🔑 使用 Bearer Token 认证 - 账户: ${bedrockAccount.name || 'unknown'}`) + } else { + // 检查是否有环境变量凭证 + if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { + clientConfig.credentials = fromEnv() + } else { + throw new Error( + 'AWS凭证未配置。请在Bedrock账户中配置AWS访问密钥或Bearer Token,或设置环境变量AWS_ACCESS_KEY_ID、AWS_SECRET_ACCESS_KEY 或 AWS_BEARER_TOKEN_BEDROCK' + ) + } + } + + const client = new BedrockRuntimeClient(clientConfig) + + // Bedrock API Key (ABSK) 模式:注入 Bearer Token 到 Authorization header + if (bedrockAccount?.bearerToken) { + const { bearerToken } = bedrockAccount + client.middlewareStack.add( + (next) => async (args) => { + // 清除 SigV4 签名产生的所有 authorization header(大小写均删除) + for (const key of Object.keys(args.request.headers)) { + if (key.toLowerCase() === 'authorization') { + delete args.request.headers[key] + } + } + args.request.headers['Authorization'] = `Bearer ${bearerToken}` + delete args.request.headers['x-amz-date'] + delete args.request.headers['x-amz-security-token'] + delete args.request.headers['x-amz-content-sha256'] + return next(args) + }, + { step: 'finalizeRequest', name: 'bedrockBearerTokenAuth', override: true, priority: 'low' } + ) + logger.debug(`🔑 Bearer Token middleware 已注入 - 账户: ${bedrockAccount.name || 'unknown'}`) + } + + this.clients.set(clientKey, client) + + logger.debug( + `🔧 Created Bedrock client for region: ${targetRegion}, account: ${bedrockAccount?.name || 'default'}` + ) + return client + } + + // 处理非流式请求 + async handleNonStreamRequest(requestBody, bedrockAccount = null) { + const accountId = bedrockAccount?.id + let queueLockAcquired = false + let queueRequestId = null + + try { + // 📬 用户消息队列处理 + if (userMessageQueueService.isUserMessageRequest(requestBody)) { + // 校验 accountId 非空,避免空值污染队列锁键 + if (!accountId || accountId === '') { + logger.error('❌ accountId missing for queue lock in Bedrock handleNonStreamRequest') + throw new Error('accountId missing for queue lock') + } + const queueResult = await userMessageQueueService.acquireQueueLock(accountId) + if (!queueResult.acquired && !queueResult.skipped) { + // 区分 Redis 后端错误和队列超时 + const isBackendError = queueResult.error === 'queue_backend_error' + const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT' + const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout' + const errorMessage = isBackendError + ? 'Queue service temporarily unavailable, please retry later' + : 'User message queue wait timeout, please retry later' + const statusCode = isBackendError ? 500 : 503 + + // 结构化性能日志,用于后续统计 + logger.performance('user_message_queue_error', { + errorType, + errorCode, + accountId, + statusCode, + backendError: isBackendError ? queueResult.errorMessage : undefined + }) + + logger.warn( + `📬 User message queue ${errorType} for Bedrock account ${accountId}`, + isBackendError ? { backendError: queueResult.errorMessage } : {} + ) + return { + statusCode, + headers: { + 'Content-Type': 'application/json', + 'x-user-message-queue-error': errorType + }, + body: JSON.stringify({ + type: 'error', + error: { + type: errorType, + code: errorCode, + message: errorMessage + } + }), + success: false + } + } + if (queueResult.acquired && !queueResult.skipped) { + queueLockAcquired = true + queueRequestId = queueResult.requestId + logger.debug( + `📬 User message queue lock acquired for Bedrock account ${accountId}, requestId: ${queueRequestId}` + ) + } + } + + const modelId = this._selectModel(requestBody, bedrockAccount) + const region = this._selectRegion(modelId, bedrockAccount) + const client = this._getBedrockClient(region, bedrockAccount) + + // 转换请求格式为Bedrock格式 + const bedrockPayload = this._convertToBedrockFormat(requestBody) + + const command = new InvokeModelCommand({ + modelId, + body: JSON.stringify(bedrockPayload), + contentType: 'application/json', + accept: 'application/json' + }) + + logger.debug(`🚀 Bedrock非流式请求 - 模型: ${modelId}, 区域: ${region}`) + + const startTime = Date.now() + const response = await client.send(command) + const duration = Date.now() - startTime + + // 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成) + // 因为限流基于请求发送时刻计算(RPM),不是请求完成时刻 + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + queueLockAcquired = false // 标记已释放,防止 finally 重复释放 + logger.debug( + `📬 User message queue lock released early for Bedrock account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock early for Bedrock account ${accountId}:`, + releaseError.message + ) + } + } + + // 解析响应 + const responseBody = JSON.parse(new TextDecoder().decode(response.body)) + const claudeResponse = this._convertFromBedrockFormat(responseBody) + + logger.info(`✅ Bedrock请求完成 - 模型: ${modelId}, 耗时: ${duration}ms`) + + return { + success: true, + data: claudeResponse, + usage: claudeResponse.usage, + model: modelId, + duration + } + } catch (error) { + logger.error('❌ Bedrock非流式请求失败:', error) + throw this._handleBedrockError(error, accountId, bedrockAccount) + } finally { + // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + logger.debug( + `📬 User message queue lock released in finally for Bedrock account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock for Bedrock account ${accountId}:`, + releaseError.message + ) + } + } + } + } + + // 处理流式请求 + async handleStreamRequest(requestBody, bedrockAccount = null, res, req = null) { + const accountId = bedrockAccount?.id + let queueLockAcquired = false + let queueRequestId = null + let abortController = null + + try { + // 📬 用户消息队列处理 + if (userMessageQueueService.isUserMessageRequest(requestBody)) { + // 校验 accountId 非空,避免空值污染队列锁键 + if (!accountId || accountId === '') { + logger.error('❌ accountId missing for queue lock in Bedrock handleStreamRequest') + throw new Error('accountId missing for queue lock') + } + const queueResult = await userMessageQueueService.acquireQueueLock(accountId) + if (!queueResult.acquired && !queueResult.skipped) { + // 区分 Redis 后端错误和队列超时 + const isBackendError = queueResult.error === 'queue_backend_error' + const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT' + const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout' + const errorMessage = isBackendError + ? 'Queue service temporarily unavailable, please retry later' + : 'User message queue wait timeout, please retry later' + const statusCode = isBackendError ? 500 : 503 + + // 结构化性能日志,用于后续统计 + logger.performance('user_message_queue_error', { + errorType, + errorCode, + accountId, + statusCode, + stream: true, + backendError: isBackendError ? queueResult.errorMessage : undefined + }) + + logger.warn( + `📬 User message queue ${errorType} for Bedrock account ${accountId} (stream)`, + isBackendError ? { backendError: queueResult.errorMessage } : {} + ) + if (!res.headersSent) { + const existingConnection = res.getHeader ? res.getHeader('Connection') : null + res.writeHead(statusCode, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive', + 'x-user-message-queue-error': errorType + }) + } + const errorEvent = `event: error\ndata: ${JSON.stringify({ + type: 'error', + error: { + type: errorType, + code: errorCode, + message: errorMessage + } + })}\n\n` + res.write(errorEvent) + res.write('data: [DONE]\n\n') + res.end() + return { success: false, error: errorType } + } + if (queueResult.acquired && !queueResult.skipped) { + queueLockAcquired = true + queueRequestId = queueResult.requestId + logger.debug( + `📬 User message queue lock acquired for Bedrock account ${accountId} (stream), requestId: ${queueRequestId}` + ) + } + } + + const modelId = this._selectModel(requestBody, bedrockAccount) + const region = this._selectRegion(modelId, bedrockAccount) + const client = this._getBedrockClient(region, bedrockAccount) + + // 转换请求格式为Bedrock格式 + const bedrockPayload = this._convertToBedrockFormat(requestBody) + + const command = new InvokeModelWithResponseStreamCommand({ + modelId, + body: JSON.stringify(bedrockPayload), + contentType: 'application/json', + accept: 'application/json' + }) + + logger.debug(`🌊 Bedrock流式请求 - 模型: ${modelId}, 区域: ${region}`) + + // 创建 AbortController 用于客户端断开时取消上游请求 + abortController = new AbortController() + if (req) { + req.on('close', () => { + if (abortController && !abortController.signal.aborted) { + logger.info(`🔌 客户端断开,取消 Bedrock 上游请求 - 账户: ${accountId}`) + abortController.abort() + } + }) + } + + const startTime = Date.now() + const response = await client.send(command, { abortSignal: abortController.signal }) + + // 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成) + // 因为限流基于请求发送时刻计算(RPM),不是请求完成时刻 + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + queueLockAcquired = false // 标记已释放,防止 finally 重复释放 + logger.debug( + `📬 User message queue lock released early for Bedrock stream account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock early for Bedrock stream account ${accountId}:`, + releaseError.message + ) + } + } + + // 设置SSE响应头 + // ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close + const existingConnection = res.getHeader ? res.getHeader('Connection') : null + if (existingConnection) { + logger.debug( + `🔌 [Bedrock Stream] Preserving existing Connection header: ${existingConnection}` + ) + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization' + }) + + let totalUsage = null + + // 处理流式响应 + // Bedrock InvokeModelWithResponseStream 返回的 JSON 事件结构与 Claude API 完全一致, + // 直接透传即可,无需重新构造。避免丢失字段或与新版本 API 不兼容。 + for await (const chunk of response.body) { + // 客户端已断开,停止处理 + if (abortController.signal.aborted) { + logger.debug(`🔌 Bedrock 流处理中止 - 客户端已断开`) + break + } + + if (chunk.chunk) { + const chunkData = JSON.parse(new TextDecoder().decode(chunk.chunk.bytes)) + + // 透传 Bedrock 事件到客户端(格式与 Claude SSE 一致) + // 修正 message_start 中的模型名:Bedrock 格式 → 标准 Claude 格式 + // 客户端依赖标准模型名判定上下文窗口,否则可能过早触发 "Context limit reached" + if (chunkData.type) { + if (chunkData.type === 'message_start' && chunkData.message?.model) { + chunkData.message.model = this._mapFromBedrockModel(chunkData.message.model) + } + res.write(`event: ${chunkData.type}\n`) + res.write(`data: ${JSON.stringify(chunkData)}\n\n`) + } + + // 提取使用统计 (usage is reported in message_delta per Claude API spec) + if (chunkData.type === 'message_delta' && chunkData.usage) { + totalUsage = chunkData.usage + } + } + } + + const duration = Date.now() - startTime + logger.info(`✅ Bedrock流式请求完成 - 模型: ${modelId}, 耗时: ${duration}ms`) + + // 发送结束事件 + res.write('event: done\n') + res.write('data: [DONE]\n\n') + res.end() + + return { + success: true, + usage: totalUsage, + model: modelId, + duration + } + } catch (error) { + // 客户端主动断开,不算错误 + if (abortController?.signal?.aborted) { + logger.info(`🔌 Bedrock 流请求因客户端断开而中止 - 账户: ${accountId}`) + if (!res.writableEnded) { + res.end() + } + return { success: false, aborted: true } + } + + logger.error('❌ Bedrock流式请求失败:', error) + + const bedrockError = this._handleBedrockError(error, accountId, bedrockAccount) + const statusCode = this._getErrorStatusCode(error) + + // 发送错误事件并关闭连接 + try { + if (!res.headersSent) { + res.writeHead(statusCode, { 'Content-Type': 'text/event-stream' }) + } + if (!res.writableEnded) { + res.write('event: error\n') + res.write(`data: ${JSON.stringify({ error: bedrockError.message })}\n\n`) + res.end() + } + } catch (writeError) { + logger.error('❌ Failed to write error response:', writeError.message) + if (!res.writableEnded) { + res.end() + } + } + + throw bedrockError + } finally { + // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + logger.debug( + `📬 User message queue lock released in finally for Bedrock stream account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock for Bedrock stream account ${accountId}:`, + releaseError.message + ) + } + } + } + } + + // 选择使用的模型 + _selectModel(requestBody, bedrockAccount) { + let selectedModel + + // 优先使用账户配置的模型 + if (bedrockAccount?.defaultModel) { + selectedModel = bedrockAccount.defaultModel + logger.info(`🎯 使用账户配置的模型: ${selectedModel}`, { + metadata: { source: 'account', accountId: bedrockAccount.id } + }) + } + // 检查请求中指定的模型 + else if (requestBody.model) { + selectedModel = requestBody.model + logger.info(`🎯 使用请求指定的模型: ${selectedModel}`, { metadata: { source: 'request' } }) + } + // 使用默认模型 + else { + selectedModel = this.defaultModel + logger.info(`🎯 使用系统默认模型: ${selectedModel}`, { metadata: { source: 'default' } }) + } + + // 如果是标准Claude模型名,需要映射为Bedrock格式 + const bedrockModel = this._mapToBedrockModel(selectedModel) + if (bedrockModel !== selectedModel) { + logger.info(`🔄 模型映射: ${selectedModel} → ${bedrockModel}`, { + metadata: { originalModel: selectedModel, bedrockModel } + }) + } + + return bedrockModel + } + + // 将Bedrock模型名反向映射为标准Claude格式 + // 客户端(如 Claude Code)依赖标准模型名来判定上下文窗口大小, + // 若收到 Bedrock 格式名称则可能使用保守默认值,导致过早触发 "Context limit reached"。 + _mapFromBedrockModel(bedrockModelId) { + if (!bedrockModelId) { + return bedrockModelId + } + + // 已经是标准格式,直接返回 + if (!bedrockModelId.includes('.anthropic.') && !bedrockModelId.startsWith('anthropic.')) { + return bedrockModelId + } + + // 从 Bedrock ID 中提取核心模型名 + // 格式: {region}.anthropic.{model-name}-v{version}:{variant} + // 或: anthropic.{model-name}-v{version}:{variant} + const match = bedrockModelId.match(/(?:.*\.)?anthropic\.(claude-.+?)(?:-v\d+)?(?::\d+)?$/) + if (match) { + return match[1] + } + + return bedrockModelId + } + + // 将标准Claude模型名映射为Bedrock格式 + _mapToBedrockModel(modelName) { + // Strip [1m] suffix (long context variant) — Bedrock uses the same model ID + // but supports 1M context natively for models that have it + const cleanModelName = modelName.replace(/\[1m\]$/, '') + + // 标准Claude模型名到Bedrock模型名的映射表 + const modelMapping = { + // Claude Opus 4.6 + 'claude-opus-4-6': 'global.anthropic.claude-opus-4-6-v1', + + // Claude Sonnet 4.6 — Bedrock 暂未上线,回退到 Sonnet 4.5 + 'claude-sonnet-4-6': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + + // Claude 4.5 Opus + 'claude-opus-4-5': 'us.anthropic.claude-opus-4-5-20251101-v1:0', + 'claude-opus-4-5-20251101': 'us.anthropic.claude-opus-4-5-20251101-v1:0', + + // Claude 4.5 Sonnet + 'claude-sonnet-4-5': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + 'claude-sonnet-4-5-20250929': 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + + // Claude 4.5 Haiku + 'claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + 'claude-haiku-4-5-20251001': 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + + // Claude Sonnet 4 + 'claude-sonnet-4': 'us.anthropic.claude-sonnet-4-20250514-v1:0', + 'claude-sonnet-4-20250514': 'us.anthropic.claude-sonnet-4-20250514-v1:0', + + // Claude Opus 4.1 + 'claude-opus-4': 'us.anthropic.claude-opus-4-1-20250805-v1:0', + 'claude-opus-4-1': 'us.anthropic.claude-opus-4-1-20250805-v1:0', + 'claude-opus-4-1-20250805': 'us.anthropic.claude-opus-4-1-20250805-v1:0', + // Claude Opus 4 + 'claude-opus-4-20250514': 'us.anthropic.claude-opus-4-20250514-v1:0', + + // Claude 3.7 Sonnet + 'claude-3-7-sonnet': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', + 'claude-3-7-sonnet-20250219': 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', + + // Claude 3.5 Sonnet v2 + 'claude-3-5-sonnet': 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', + 'claude-3-5-sonnet-20241022': 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', + + // Claude 3.5 Haiku + 'claude-3-5-haiku': 'us.anthropic.claude-3-5-haiku-20241022-v1:0', + 'claude-3-5-haiku-20241022': 'us.anthropic.claude-3-5-haiku-20241022-v1:0', + + // Claude 3 Sonnet + 'claude-3-sonnet': 'us.anthropic.claude-3-sonnet-20240229-v1:0', + 'claude-3-sonnet-20240229': 'us.anthropic.claude-3-sonnet-20240229-v1:0', + + // Claude 3 Haiku + 'claude-3-haiku': 'us.anthropic.claude-3-haiku-20240307-v1:0', + 'claude-3-haiku-20240307': 'us.anthropic.claude-3-haiku-20240307-v1:0', + + // Claude 3 Opus + 'claude-3-opus': 'us.anthropic.claude-3-opus-20240229-v1:0', + 'claude-3-opus-20240229': 'us.anthropic.claude-3-opus-20240229-v1:0' + } + + // 如果已经是Bedrock格式,直接返回 + // Bedrock模型格式:{region}.anthropic.{model-name} 或 anthropic.{model-name} + if (cleanModelName.includes('.anthropic.') || cleanModelName.startsWith('anthropic.')) { + return cleanModelName + } + + // 查找映射 + const mappedModel = modelMapping[cleanModelName] + if (mappedModel) { + return mappedModel + } + + // 如果没有找到映射,返回原始模型名(可能会导致错误,但保持向后兼容) + logger.warn(`⚠️ 未找到模型映射: ${cleanModelName},使用原始模型名`, { + metadata: { originalModel: modelName } + }) + return cleanModelName + } + + // 选择使用的区域 + _selectRegion(modelId, bedrockAccount) { + // 优先使用账户配置的区域 + if (bedrockAccount?.region) { + return bedrockAccount.region + } + + // 对于小模型,使用专门的区域配置 + if (modelId.includes('haiku')) { + return this.smallFastModelRegion + } + + return this.defaultRegion + } + + // Sanitize cache_control fields for Bedrock compatibility. + // Bedrock only supports { type: "ephemeral" } — extra fields like "scope" + // (added in Claude Code v2.1.38+) cause ValidationException. + _sanitizeCacheControl(obj) { + if (obj === null || obj === undefined || typeof obj !== 'object') { + return obj + } + + if (Array.isArray(obj)) { + obj.forEach((item) => this._sanitizeCacheControl(item)) + return obj + } + + if (obj.cache_control && typeof obj.cache_control === 'object') { + // Keep only the "type" field that Bedrock accepts + obj.cache_control = { type: obj.cache_control.type || 'ephemeral' } + } + + // Recurse into known nested structures (messages[].content, tool input_schema, etc.) + for (const key of Object.keys(obj)) { + const val = obj[key] + if (val && typeof val === 'object') { + this._sanitizeCacheControl(val) + } + } + + return obj + } + + // 转换Claude格式请求到Bedrock格式 + _convertToBedrockFormat(requestBody) { + // 透传客户端的 max_tokens,仅在未指定时使用默认值作为回退 + const maxTokens = requestBody.max_tokens || this.maxOutputTokens + + // Bedrock 通过 Command 类型区分流式/非流式,payload 中不需要 stream 字段 + const bedrockPayload = { + anthropic_version: 'bedrock-2023-05-31', + max_tokens: maxTokens, + messages: requestBody.messages || [] + } + + // 添加系统提示词 + if (requestBody.system) { + bedrockPayload.system = requestBody.system + } + + // 添加其他参数 + if (requestBody.temperature !== undefined) { + bedrockPayload.temperature = requestBody.temperature + } + + if (requestBody.top_p !== undefined) { + bedrockPayload.top_p = requestBody.top_p + } + + if (requestBody.top_k !== undefined) { + bedrockPayload.top_k = requestBody.top_k + } + + if (requestBody.stop_sequences) { + bedrockPayload.stop_sequences = requestBody.stop_sequences + } + + // 工具调用支持 + if (requestBody.tools) { + bedrockPayload.tools = requestBody.tools + } + + if (requestBody.tool_choice) { + bedrockPayload.tool_choice = requestBody.tool_choice + } + + // Extended thinking 支持 + // Bedrock 只支持 "enabled" / "disabled",不支持 "adaptive" + // adaptive 模式不要求 budget_tokens,但 Bedrock enabled 必须有 + if (requestBody.thinking) { + bedrockPayload.thinking = { ...requestBody.thinking } + if (bedrockPayload.thinking.type === 'adaptive') { + bedrockPayload.thinking.type = 'enabled' + if (!bedrockPayload.thinking.budget_tokens) { + bedrockPayload.thinking.budget_tokens = maxTokens - 1 + } + } + } + + // metadata 透传 + if (requestBody.metadata) { + bedrockPayload.metadata = requestBody.metadata + } + + // Sanitize cache_control for Bedrock compatibility (strip unsupported fields like "scope") + this._sanitizeCacheControl(bedrockPayload) + + return bedrockPayload + } + + // 转换Bedrock响应到Claude格式 + _convertFromBedrockFormat(bedrockResponse) { + return { + id: bedrockResponse.id || `msg_${Date.now()}_bedrock`, + type: 'message', + role: bedrockResponse.role || 'assistant', + content: bedrockResponse.content || [], + model: this._mapFromBedrockModel(bedrockResponse.model) || this.defaultModel, + stop_reason: bedrockResponse.stop_reason || 'end_turn', + stop_sequence: bedrockResponse.stop_sequence || null, + usage: bedrockResponse.usage || { + input_tokens: 0, + output_tokens: 0 + } + } + } + + // 从 Bedrock 错误中提取 HTTP 状态码 + _getErrorStatusCode(error) { + // AWS SDK v3 错误的 $metadata 包含 httpStatusCode + if (error.$metadata?.httpStatusCode) { + return error.$metadata.httpStatusCode + } + + // 根据错误类型映射状态码 + const errorStatusMap = { + ThrottlingException: 429, + AccessDeniedException: 403, + ValidationException: 400, + ModelNotReadyException: 503, + ServiceUnavailableException: 503, + InternalServerException: 500, + ModelTimeoutException: 408 + } + + return errorStatusMap[error.name] || 500 + } + + // 处理Bedrock错误 + _handleBedrockError(error, accountId = null, bedrockAccount = null) { + const autoProtectionDisabled = + bedrockAccount?.disableAutoProtection === true || + bedrockAccount?.disableAutoProtection === 'true' + if (accountId && !autoProtectionDisabled) { + if (error.name === 'ThrottlingException') { + upstreamErrorHelper.markTempUnavailable(accountId, 'bedrock', 429).catch(() => {}) + } else if (error.name === 'AccessDeniedException') { + upstreamErrorHelper.markTempUnavailable(accountId, 'bedrock', 403).catch(() => {}) + } else if ( + error.name === 'ServiceUnavailableException' || + error.name === 'InternalServerException' + ) { + upstreamErrorHelper.markTempUnavailable(accountId, 'bedrock', 500).catch(() => {}) + } else if (error.name === 'ModelNotReadyException') { + upstreamErrorHelper.markTempUnavailable(accountId, 'bedrock', 503).catch(() => {}) + } + } + + const errorMessage = error.message || 'Unknown Bedrock error' + + if (error.name === 'ValidationException') { + return new Error(`Bedrock参数验证失败: ${errorMessage}`) + } + + if (error.name === 'ThrottlingException') { + return new Error('Bedrock请求限流,请稍后重试') + } + + if (error.name === 'AccessDeniedException') { + return new Error('Bedrock访问被拒绝,请检查IAM权限') + } + + if (error.name === 'ModelNotReadyException') { + return new Error('Bedrock模型未就绪,请稍后重试') + } + + return new Error(`Bedrock服务错误: ${errorMessage}`) + } + + // 获取可用模型列表 + async getAvailableModels(bedrockAccount = null) { + try { + const region = bedrockAccount?.region || this.defaultRegion + + // Bedrock暂不支持列出推理配置文件的API,返回预定义的模型列表 + const models = [ + { + id: 'us.anthropic.claude-sonnet-4-20250514-v1:0', + name: 'Claude Sonnet 4', + provider: 'anthropic', + type: 'bedrock' + }, + { + id: 'us.anthropic.claude-opus-4-1-20250805-v1:0', + name: 'Claude Opus 4.1', + provider: 'anthropic', + type: 'bedrock' + }, + { + id: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', + name: 'Claude 3.7 Sonnet', + provider: 'anthropic', + type: 'bedrock' + }, + { + id: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', + name: 'Claude 3.5 Sonnet v2', + provider: 'anthropic', + type: 'bedrock' + }, + { + id: 'us.anthropic.claude-3-5-haiku-20241022-v1:0', + name: 'Claude 3.5 Haiku', + provider: 'anthropic', + type: 'bedrock' + } + ] + + logger.debug(`📋 返回Bedrock可用模型 ${models.length} 个, 区域: ${region}`) + return models + } catch (error) { + logger.error('❌ 获取Bedrock模型列表失败:', error) + return [] + } + } +} + +module.exports = new BedrockRelayService() diff --git a/src/services/relay/ccrRelayService.js b/src/services/relay/ccrRelayService.js new file mode 100644 index 0000000..c6cd76d --- /dev/null +++ b/src/services/relay/ccrRelayService.js @@ -0,0 +1,969 @@ +const axios = require('axios') +const ccrAccountService = require('../account/ccrAccountService') +const logger = require('../../utils/logger') +const config = require('../../../config/config') +const { parseVendorPrefixedModel } = require('../../utils/modelHelper') +const userMessageQueueService = require('../userMessageQueueService') +const { isStreamWritable } = require('../../utils/streamHelper') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +class CcrRelayService { + constructor() { + this.defaultUserAgent = 'claude-relay-service/1.0.0' + } + + // 🚀 转发请求到CCR API + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + accountId, + options = {} + ) { + let abortController = null + let account = null + let queueLockAcquired = false + let queueRequestId = null + + try { + // 📬 用户消息队列处理 + if (userMessageQueueService.isUserMessageRequest(requestBody)) { + // 校验 accountId 非空,避免空值污染队列锁键 + if (!accountId || accountId === '') { + logger.error('❌ accountId missing for queue lock in CCR relayRequest') + throw new Error('accountId missing for queue lock') + } + const queueResult = await userMessageQueueService.acquireQueueLock(accountId) + if (!queueResult.acquired && !queueResult.skipped) { + // 区分 Redis 后端错误和队列超时 + const isBackendError = queueResult.error === 'queue_backend_error' + const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT' + const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout' + const errorMessage = isBackendError + ? 'Queue service temporarily unavailable, please retry later' + : 'User message queue wait timeout, please retry later' + const statusCode = isBackendError ? 500 : 503 + + // 结构化性能日志,用于后续统计 + logger.performance('user_message_queue_error', { + errorType, + errorCode, + accountId, + statusCode, + backendError: isBackendError ? queueResult.errorMessage : undefined + }) + + logger.warn( + `📬 User message queue ${errorType} for CCR account ${accountId}`, + isBackendError ? { backendError: queueResult.errorMessage } : {} + ) + return { + statusCode, + headers: { + 'Content-Type': 'application/json', + 'x-user-message-queue-error': errorType + }, + body: JSON.stringify({ + type: 'error', + error: { + type: errorType, + code: errorCode, + message: errorMessage + } + }), + accountId + } + } + if (queueResult.acquired && !queueResult.skipped) { + queueLockAcquired = true + queueRequestId = queueResult.requestId + logger.debug( + `📬 User message queue lock acquired for CCR account ${accountId}, requestId: ${queueRequestId}` + ) + } + } + + // 获取账户信息 + account = await ccrAccountService.getAccount(accountId) + if (!account) { + throw new Error('CCR account not found') + } + + logger.info( + `📤 Processing CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})` + ) + logger.debug(`🌐 Account API URL: ${account.apiUrl}`) + logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`) + logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`) + logger.debug(`📝 Request model: ${requestBody.model}`) + + // 处理模型前缀解析和映射 + const { baseModel } = parseVendorPrefixedModel(requestBody.model) + logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`) + + let mappedModel = baseModel + if ( + account.supportedModels && + typeof account.supportedModels === 'object' && + !Array.isArray(account.supportedModels) + ) { + const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel) + if (newModel !== baseModel) { + logger.info(`🔄 Mapping model from ${baseModel} to ${newModel}`) + mappedModel = newModel + } + } + + // 创建修改后的请求体,使用去前缀后的模型名 + const modifiedRequestBody = { + ...requestBody, + model: mappedModel + } + + // 创建代理agent + const proxyAgent = ccrAccountService._createProxyAgent(account.proxy) + + // 创建AbortController用于取消请求 + abortController = new AbortController() + + // 设置客户端断开监听器 + const handleClientDisconnect = () => { + logger.info('🔌 Client disconnected, aborting CCR request') + if (abortController && !abortController.signal.aborted) { + abortController.abort() + } + } + + // 监听客户端断开事件 + if (clientRequest) { + clientRequest.once('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.once('close', handleClientDisconnect) + } + + // 构建完整的API URL + const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 + let apiEndpoint + + if (options.customPath) { + // 如果指定了自定义路径(如 count_tokens),使用它 + const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages + apiEndpoint = `${baseUrl}${options.customPath}` + } else { + // 默认使用 messages 端点 + apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + } + + logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`) + logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`) + logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`) + + // 过滤客户端请求头 + const filteredHeaders = this._filterClientHeaders(clientHeaders) + logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`) + + // 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值 + const userAgent = + account.userAgent || + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + this.defaultUserAgent + + // 准备请求配置 + const requestConfig = { + method: 'POST', + url: apiEndpoint, + data: modifiedRequestBody, + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'User-Agent': userAgent, + ...filteredHeaders + }, + timeout: config.requestTimeout || 600000, + signal: abortController.signal, + validateStatus: () => true // 接受所有状态码 + } + + if (proxyAgent) { + requestConfig.httpAgent = proxyAgent + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + // 根据 API Key 格式选择认证方式 + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + // Anthropic 官方 API Key 使用 x-api-key + requestConfig.headers['x-api-key'] = account.apiKey + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key') + } else { + // 其他 API Key (包括CCR API Key) 使用 Authorization Bearer + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}` + logger.debug('[DEBUG] Using Authorization Bearer authentication') + } + + logger.debug( + `[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}` + ) + + // 添加beta header如果需要 + if (options.betaHeader) { + logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`) + requestConfig.headers['anthropic-beta'] = options.betaHeader + } else { + logger.debug('[DEBUG] No beta header to add') + } + + // 发送请求 + logger.debug( + '📤 Sending request to CCR API with headers:', + JSON.stringify(requestConfig.headers, null, 2) + ) + const response = await axios(requestConfig) + + // 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成) + // 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻 + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + queueLockAcquired = false // 标记已释放,防止 finally 重复释放 + logger.debug( + `📬 User message queue lock released early for CCR account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock early for CCR account ${accountId}:`, + releaseError.message + ) + } + } + + // 移除监听器(请求成功完成) + if (clientRequest) { + clientRequest.removeListener('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.removeListener('close', handleClientDisconnect) + } + + logger.debug(`🔗 CCR API response: ${response.status}`) + logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`) + logger.debug(`[DEBUG] Response data type: ${typeof response.data}`) + logger.debug( + `[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}` + ) + logger.debug( + `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` + ) + + // 检查错误状态并相应处理 + if (response.status === 401) { + logger.warn(`🚫 Unauthorized error detected for CCR account ${accountId}`) + const autoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!autoProtectionDisabled) { + await upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 401).catch(() => {}) + } + } else if (response.status === 429) { + logger.warn(`🚫 Rate limit detected for CCR account ${accountId}`) + // 收到429先检查是否因为超过了手动配置的每日额度 + await ccrAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + + await ccrAccountService.markAccountRateLimited(accountId) + const autoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!autoProtectionDisabled) { + await upstreamErrorHelper + .markTempUnavailable( + accountId, + 'ccr', + 429, + upstreamErrorHelper.parseRetryAfter(response.headers) + ) + .catch(() => {}) + } + } else if (response.status === 529) { + logger.warn(`🚫 Overload error detected for CCR account ${accountId}`) + await ccrAccountService.markAccountOverloaded(accountId) + const autoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!autoProtectionDisabled) { + await upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 529).catch(() => {}) + } + } else if (response.status >= 500) { + logger.warn(`🔥 Server error (${response.status}) detected for CCR account ${accountId}`) + const autoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!autoProtectionDisabled) { + await upstreamErrorHelper + .markTempUnavailable(accountId, 'ccr', response.status) + .catch(() => {}) + } + } else if (response.status === 200 || response.status === 201) { + // 如果请求成功,检查并移除错误状态 + const isRateLimited = await ccrAccountService.isAccountRateLimited(accountId) + if (isRateLimited) { + await ccrAccountService.removeAccountRateLimit(accountId) + } + const isOverloaded = await ccrAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await ccrAccountService.removeAccountOverload(accountId) + } + } + + // 更新最后使用时间 + await this._updateLastUsedTime(accountId) + + const responseBody = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + logger.debug(`[DEBUG] Final response body to return: ${responseBody}`) + + return { + statusCode: response.status, + headers: response.headers, + body: responseBody, + accountId + } + } catch (error) { + // 处理特定错误 + if (error.name === 'AbortError' || error.code === 'ECONNABORTED') { + logger.info('Request aborted due to client disconnect') + throw new Error('Client disconnected') + } + + logger.error( + `❌ CCR relay request failed (Account: ${account?.name || accountId}):`, + error.message + ) + + // 网络错误标记临时不可用 + if (accountId && !error.response) { + const autoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!autoProtectionDisabled) { + await upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 503).catch(() => {}) + } + } + + throw error + } finally { + // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + logger.debug( + `📬 User message queue lock released in finally for CCR account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock for CCR account ${accountId}:`, + releaseError.message + ) + } + } + } + } + + // 🌊 处理流式响应 + async relayStreamRequestWithUsageCapture( + requestBody, + apiKeyData, + responseStream, + clientHeaders, + usageCallback, + accountId, + streamTransformer = null, + options = {} + ) { + let account = null + let queueLockAcquired = false + let queueRequestId = null + + try { + // 📬 用户消息队列处理 + if (userMessageQueueService.isUserMessageRequest(requestBody)) { + // 校验 accountId 非空,避免空值污染队列锁键 + if (!accountId || accountId === '') { + logger.error( + '❌ accountId missing for queue lock in CCR relayStreamRequestWithUsageCapture' + ) + throw new Error('accountId missing for queue lock') + } + const queueResult = await userMessageQueueService.acquireQueueLock(accountId) + if (!queueResult.acquired && !queueResult.skipped) { + // 区分 Redis 后端错误和队列超时 + const isBackendError = queueResult.error === 'queue_backend_error' + const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT' + const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout' + const errorMessage = isBackendError + ? 'Queue service temporarily unavailable, please retry later' + : 'User message queue wait timeout, please retry later' + const statusCode = isBackendError ? 500 : 503 + + // 结构化性能日志,用于后续��计 + logger.performance('user_message_queue_error', { + errorType, + errorCode, + accountId, + statusCode, + stream: true, + backendError: isBackendError ? queueResult.errorMessage : undefined + }) + + logger.warn( + `📬 User message queue ${errorType} for CCR account ${accountId} (stream)`, + isBackendError ? { backendError: queueResult.errorMessage } : {} + ) + if (!responseStream.headersSent) { + const existingConnection = responseStream.getHeader + ? responseStream.getHeader('Connection') + : null + responseStream.writeHead(statusCode, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive', + 'x-user-message-queue-error': errorType + }) + } + const errorEvent = `event: error\ndata: ${JSON.stringify({ + type: 'error', + error: { + type: errorType, + code: errorCode, + message: errorMessage + } + })}\n\n` + responseStream.write(errorEvent) + responseStream.write('data: [DONE]\n\n') + responseStream.end() + return + } + if (queueResult.acquired && !queueResult.skipped) { + queueLockAcquired = true + queueRequestId = queueResult.requestId + logger.debug( + `📬 User message queue lock acquired for CCR account ${accountId} (stream), requestId: ${queueRequestId}` + ) + } + } + + // 获取账户信息 + account = await ccrAccountService.getAccount(accountId) + if (!account) { + throw new Error('CCR account not found') + } + + logger.info( + `📡 Processing streaming CCR API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId})` + ) + logger.debug(`🌐 Account API URL: ${account.apiUrl}`) + + // 处理模型前缀解析和映射 + const { baseModel } = parseVendorPrefixedModel(requestBody.model) + logger.debug(`🔄 Parsed base model: ${baseModel} from original: ${requestBody.model}`) + + let mappedModel = baseModel + if ( + account.supportedModels && + typeof account.supportedModels === 'object' && + !Array.isArray(account.supportedModels) + ) { + const newModel = ccrAccountService.getMappedModel(account.supportedModels, baseModel) + if (newModel !== baseModel) { + logger.info(`🔄 [Stream] Mapping model from ${baseModel} to ${newModel}`) + mappedModel = newModel + } + } + + // 创建修改后的请求体,使用去前缀后的模型名 + const modifiedRequestBody = { + ...requestBody, + model: mappedModel + } + + // 创建代理agent + const proxyAgent = ccrAccountService._createProxyAgent(account.proxy) + + // 发送流式请求 + await this._makeCcrStreamRequest( + modifiedRequestBody, + account, + proxyAgent, + clientHeaders, + responseStream, + accountId, + usageCallback, + streamTransformer, + options, + // 📬 回调:在收到响应头时释放队列锁 + async () => { + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + queueLockAcquired = false // 标记已释放,防止 finally 重复释放 + logger.debug( + `📬 User message queue lock released early for CCR stream account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock early for CCR stream account ${accountId}:`, + releaseError.message + ) + } + } + } + ) + + // 更新最后使用时间 + await this._updateLastUsedTime(accountId) + } catch (error) { + // 客户端主动断开连接是正常情况,使用 INFO 级别 + if (error.message === 'Client disconnected') { + logger.info( + `🔌 CCR stream relay ended: Client disconnected (Account: ${account?.name || accountId})` + ) + } else { + logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error) + // 网络错误标记临时不可用 + if (accountId && !error.response) { + const autoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!autoProtectionDisabled) { + await upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 503).catch(() => {}) + } + } + } + throw error + } finally { + // 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放) + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + logger.debug( + `📬 User message queue lock released in finally for CCR stream account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock for CCR stream account ${accountId}:`, + releaseError.message + ) + } + } + } + } + + // 🌊 发送流式请求到CCR API + async _makeCcrStreamRequest( + body, + account, + proxyAgent, + clientHeaders, + responseStream, + accountId, + usageCallback, + streamTransformer = null, + requestOptions = {}, + onResponseHeaderReceived = null + ) { + return new Promise((resolve, reject) => { + let aborted = false + + // 构建完整的API URL + const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 + const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + + logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`) + + // 过滤客户端请求头 + const filteredHeaders = this._filterClientHeaders(clientHeaders) + logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`) + + // 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值 + const userAgent = + account.userAgent || + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + this.defaultUserAgent + + // 准备请求配置 + const requestConfig = { + method: 'POST', + url: apiEndpoint, + data: body, + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'User-Agent': userAgent, + ...filteredHeaders + }, + timeout: config.requestTimeout || 600000, + responseType: 'stream', + validateStatus: () => true // 接受所有状态码 + } + + if (proxyAgent) { + requestConfig.httpAgent = proxyAgent + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + // 根据 API Key 格式选择认证方式 + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + // Anthropic 官方 API Key 使用 x-api-key + requestConfig.headers['x-api-key'] = account.apiKey + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key') + } else { + // 其他 API Key (包括CCR API Key) 使用 Authorization Bearer + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}` + logger.debug('[DEBUG] Using Authorization Bearer authentication') + } + + // 添加beta header如果需要 + if (requestOptions.betaHeader) { + requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader + } + + // 发送请求 + const request = axios(requestConfig) + + // 注意:使用 .then(async ...) 模式处理响应 + // - 内部的 releaseQueueLock 有独立的 try-catch,不会导致未捕获异常 + // - queueLockAcquired = false 的赋值会在 finally 执行前完成(JS 单线程保证) + request + .then(async (response) => { + logger.debug(`🌊 CCR stream response status: ${response.status}`) + + // 错误响应处理 + if (response.status !== 200) { + logger.error( + `❌ CCR API returned error status: ${response.status} | Account: ${account?.name || accountId}` + ) + + const autoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + + if (response.status === 401) { + if (!autoProtectionDisabled) { + upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 401).catch(() => {}) + } + } else if (response.status === 429) { + ccrAccountService.markAccountRateLimited(accountId) + if (!autoProtectionDisabled) { + upstreamErrorHelper + .markTempUnavailable( + accountId, + 'ccr', + 429, + upstreamErrorHelper.parseRetryAfter(response.headers) + ) + .catch(() => {}) + } + // 检查是否因为超过每日额度 + ccrAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + } else if (response.status === 529) { + ccrAccountService.markAccountOverloaded(accountId) + if (!autoProtectionDisabled) { + upstreamErrorHelper.markTempUnavailable(accountId, 'ccr', 529).catch(() => {}) + } + } else if (response.status >= 500) { + if (!autoProtectionDisabled) { + upstreamErrorHelper + .markTempUnavailable(accountId, 'ccr', response.status) + .catch(() => {}) + } + } + + // 设置错误响应的状态码和响应头 + if (!responseStream.headersSent) { + const existingConnection = responseStream.getHeader + ? responseStream.getHeader('Connection') + : null + const errorHeaders = { + 'Content-Type': response.headers['content-type'] || 'application/json', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive' + } + // 避免 Transfer-Encoding 冲突,让 Express 自动处理 + delete errorHeaders['Transfer-Encoding'] + delete errorHeaders['Content-Length'] + responseStream.writeHead(response.status, errorHeaders) + } + + // 直接透传错误数据,不进行包装 + response.data.on('data', (chunk) => { + if (isStreamWritable(responseStream)) { + responseStream.write(chunk) + } + }) + + response.data.on('end', () => { + if (isStreamWritable(responseStream)) { + responseStream.end() + } + resolve() // 不抛出异常,正常完成流处理 + }) + return + } + + // 📬 收到成功响应头(HTTP 200),调用回调释放队列锁 + // 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成 + if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') { + try { + await onResponseHeaderReceived() + } catch (callbackError) { + logger.error( + `❌ Failed to execute onResponseHeaderReceived callback for CCR stream account ${accountId}:`, + callbackError.message + ) + } + } + + // 成功响应,检查并移除错误状态 + ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => { + if (isRateLimited) { + ccrAccountService.removeAccountRateLimit(accountId) + } + }) + ccrAccountService.isAccountOverloaded(accountId).then((isOverloaded) => { + if (isOverloaded) { + ccrAccountService.removeAccountOverload(accountId) + } + }) + + // 设置响应头 + // ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close + if (!responseStream.headersSent) { + const existingConnection = responseStream.getHeader + ? responseStream.getHeader('Connection') + : null + if (existingConnection) { + logger.debug( + `🔌 [CCR Stream] Preserving existing Connection header: ${existingConnection}` + ) + } + const headers = { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control' + } + responseStream.writeHead(200, headers) + } + + // 处理流数据和使用统计收集 + let rawBuffer = '' + const collectedUsage = {} + + response.data.on('data', (chunk) => { + if (aborted || responseStream.destroyed) { + return + } + + try { + const chunkStr = chunk.toString('utf8') + rawBuffer += chunkStr + + // 按行分割处理 SSE 数据 + const lines = rawBuffer.split('\n') + rawBuffer = lines.pop() // 保留最后一个可能不完整的行 + + for (const line of lines) { + if (line.trim()) { + // 解析 SSE 数据并收集使用统计 + const usageData = this._parseSSELineForUsage(line) + if (usageData) { + Object.assign(collectedUsage, usageData) + } + + // 应用流转换器(如果提供) + let outputLine = line + if (streamTransformer && typeof streamTransformer === 'function') { + outputLine = streamTransformer(line) + } + + // 写入到响应流 + if (outputLine && isStreamWritable(responseStream)) { + responseStream.write(`${outputLine}\n`) + } else if (outputLine) { + // 客户端连接已断开,记录警告 + logger.warn( + `⚠️ [CCR] Client disconnected during stream, skipping data for account: ${accountId}` + ) + } + } else { + // 空行也需要传递 + if (isStreamWritable(responseStream)) { + responseStream.write('\n') + } + } + } + } catch (err) { + logger.error('❌ Error processing SSE chunk:', err) + } + }) + + response.data.on('end', () => { + // 如果收集到使用统计数据,调用回调 + if (usageCallback && Object.keys(collectedUsage).length > 0) { + try { + logger.debug(`📊 Collected usage data: ${JSON.stringify(collectedUsage)}`) + // 在 usage 回调中包含模型信息 + usageCallback({ ...collectedUsage, accountId, model: body.model }) + } catch (err) { + logger.error('❌ Error in usage callback:', err) + } + } + + if (isStreamWritable(responseStream)) { + // 等待数据完全 flush 到客户端后再 resolve + responseStream.end(() => { + logger.debug( + `🌊 CCR stream response completed and flushed | bytesWritten: ${responseStream.bytesWritten || 'unknown'}` + ) + resolve() + }) + } else { + // 连接已断开,记录警告 + logger.warn( + `⚠️ [CCR] Client disconnected before stream end, data may not have been received | account: ${accountId}` + ) + resolve() + } + }) + + response.data.on('error', (err) => { + logger.error('❌ Stream data error:', err) + if (isStreamWritable(responseStream)) { + responseStream.end() + } + reject(err) + }) + + // 客户端断开处理 + responseStream.on('close', () => { + logger.info('🔌 Client disconnected from CCR stream') + aborted = true + if (response.data && typeof response.data.destroy === 'function') { + response.data.destroy() + } + }) + + responseStream.on('error', (err) => { + logger.error('❌ Response stream error:', err) + aborted = true + }) + }) + .catch((error) => { + if (!responseStream.headersSent) { + responseStream.writeHead(500, { 'Content-Type': 'application/json' }) + } + + const errorResponse = { + error: { + type: 'internal_error', + message: 'CCR API request failed' + } + } + + if (isStreamWritable(responseStream)) { + responseStream.write(`data: ${JSON.stringify(errorResponse)}\n\n`) + responseStream.end() + } + + reject(error) + }) + }) + } + + // 📊 解析SSE行以提取使用统计信息 + _parseSSELineForUsage(line) { + try { + if (line.startsWith('data: ')) { + const data = line.substring(6).trim() + if (data === '[DONE]') { + return null + } + + const jsonData = JSON.parse(data) + + // 检查是否包含使用统计信息 + if (jsonData.usage) { + return { + input_tokens: jsonData.usage.input_tokens || 0, + output_tokens: jsonData.usage.output_tokens || 0, + cache_creation_input_tokens: jsonData.usage.cache_creation_input_tokens || 0, + cache_read_input_tokens: jsonData.usage.cache_read_input_tokens || 0, + // 支持 ephemeral cache 字段 + cache_creation_input_tokens_ephemeral_5m: + jsonData.usage.cache_creation_input_tokens_ephemeral_5m || 0, + cache_creation_input_tokens_ephemeral_1h: + jsonData.usage.cache_creation_input_tokens_ephemeral_1h || 0 + } + } + + // 检查 message_delta 事件中的使用统计 + if (jsonData.type === 'message_delta' && jsonData.delta && jsonData.delta.usage) { + return { + input_tokens: jsonData.delta.usage.input_tokens || 0, + output_tokens: jsonData.delta.usage.output_tokens || 0, + cache_creation_input_tokens: jsonData.delta.usage.cache_creation_input_tokens || 0, + cache_read_input_tokens: jsonData.delta.usage.cache_read_input_tokens || 0, + cache_creation_input_tokens_ephemeral_5m: + jsonData.delta.usage.cache_creation_input_tokens_ephemeral_5m || 0, + cache_creation_input_tokens_ephemeral_1h: + jsonData.delta.usage.cache_creation_input_tokens_ephemeral_1h || 0 + } + } + } + } catch (err) { + // 忽略解析错误,不是所有行都包含 JSON + } + + return null + } + + // 🔍 过滤客户端请求头 + _filterClientHeaders(clientHeaders) { + if (!clientHeaders) { + return {} + } + + const filteredHeaders = {} + const allowedHeaders = [ + 'accept-language', + 'anthropic-beta', + 'anthropic-dangerous-direct-browser-access' + ] + + // 只保留允许的头部信息 + for (const [key, value] of Object.entries(clientHeaders)) { + const lowerKey = key.toLowerCase() + if (allowedHeaders.includes(lowerKey)) { + filteredHeaders[key] = value + } + } + + return filteredHeaders + } + + // ⏰ 更新账户最后使用时间 + async _updateLastUsedTime(accountId) { + try { + const redis = require('../../models/redis') + const client = redis.getClientSafe() + await client.hset(`ccr_account:${accountId}`, 'lastUsedAt', new Date().toISOString()) + } catch (error) { + logger.error(`❌ Failed to update last used time for CCR account ${accountId}:`, error) + } + } +} + +module.exports = new CcrRelayService() diff --git a/src/services/relay/claudeConsoleRelayService.js b/src/services/relay/claudeConsoleRelayService.js new file mode 100644 index 0000000..2b8be5c --- /dev/null +++ b/src/services/relay/claudeConsoleRelayService.js @@ -0,0 +1,1534 @@ +const axios = require('axios') +const { v4: uuidv4 } = require('uuid') +const claudeConsoleAccountService = require('../account/claudeConsoleAccountService') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const config = require('../../../config/config') +const { + sanitizeUpstreamError, + sanitizeErrorMessage, + isAccountDisabledError +} = require('../../utils/errorSanitizer') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') +const userMessageQueueService = require('../userMessageQueueService') +const { isStreamWritable } = require('../../utils/streamHelper') +const { filterForClaude } = require('../../utils/headerFilter') + +class ClaudeConsoleRelayService { + constructor() { + this.defaultUserAgent = 'claude-cli/2.0.52 (external, cli)' + } + + // 🚀 转发请求到Claude Console API + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + accountId, + options = {} + ) { + let abortController = null + let account = null + const requestId = uuidv4() // 用于并发追踪 + let concurrencyAcquired = false + let queueLockAcquired = false + let queueRequestId = null + + try { + // 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁 + if (userMessageQueueService.isUserMessageRequest(requestBody)) { + // 校验 accountId 非空,避免空值污染队列锁键 + if (!accountId || accountId === '') { + logger.error('❌ accountId missing for queue lock in console relayRequest') + throw new Error('accountId missing for queue lock') + } + const queueResult = await userMessageQueueService.acquireQueueLock(accountId) + if (!queueResult.acquired && !queueResult.skipped) { + // 区分 Redis 后端错误和队列超时 + const isBackendError = queueResult.error === 'queue_backend_error' + const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT' + const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout' + const errorMessage = isBackendError + ? 'Queue service temporarily unavailable, please retry later' + : 'User message queue wait timeout, please retry later' + const statusCode = isBackendError ? 500 : 503 + + // 结构化性能日志,用于后续统计 + logger.performance('user_message_queue_error', { + errorType, + errorCode, + accountId, + statusCode, + apiKeyName: apiKeyData.name, + backendError: isBackendError ? queueResult.errorMessage : undefined + }) + + logger.warn( + `📬 User message queue ${errorType} for console account ${accountId}, key: ${apiKeyData.name}`, + isBackendError ? { backendError: queueResult.errorMessage } : {} + ) + return { + statusCode, + headers: { + 'Content-Type': 'application/json', + 'x-user-message-queue-error': errorType + }, + body: JSON.stringify({ + type: 'error', + error: { + type: errorType, + code: errorCode, + message: errorMessage + } + }), + accountId + } + } + if (queueResult.acquired && !queueResult.skipped) { + queueLockAcquired = true + queueRequestId = queueResult.requestId + logger.debug( + `📬 User message queue lock acquired for console account ${accountId}, requestId: ${queueRequestId}` + ) + } + } + + // 获取账户信息 + account = await claudeConsoleAccountService.getAccount(accountId) + if (!account) { + throw new Error('Claude Console Claude account not found') + } + + const autoProtectionDisabled = account.disableAutoProtection === true + + logger.info( + `📤 Processing Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}` + ) + + // 🔒 并发控制:原子性抢占槽位 + if (account.maxConcurrentTasks > 0) { + // 先抢占,再检查 - 避免竞态条件 + const newConcurrency = Number( + await redis.incrConsoleAccountConcurrency(accountId, requestId, 600) + ) + concurrencyAcquired = true + + // 检查是否超过限制 + if (newConcurrency > account.maxConcurrentTasks) { + // 超限,立即回滚 + await redis.decrConsoleAccountConcurrency(accountId, requestId) + concurrencyAcquired = false + + logger.warn( + `⚠️ Console account ${account.name} (${accountId}) concurrency limit exceeded: ${newConcurrency}/${account.maxConcurrentTasks} (request: ${requestId}, rolled back)` + ) + + const error = new Error('Console account concurrency limit reached') + error.code = 'CONSOLE_ACCOUNT_CONCURRENCY_FULL' + error.accountId = accountId + throw error + } + + logger.debug( + `🔓 Acquired concurrency slot for account ${account.name} (${accountId}), current: ${newConcurrency}/${account.maxConcurrentTasks}, request: ${requestId}` + ) + } + logger.debug(`🌐 Account API URL: ${account.apiUrl}`) + logger.debug(`🔍 Account supportedModels: ${JSON.stringify(account.supportedModels)}`) + logger.debug(`🔑 Account has apiKey: ${!!account.apiKey}`) + logger.debug(`📝 Request model: ${requestBody.model}`) + + // 处理模型映射 + let mappedModel = requestBody.model + if ( + account.supportedModels && + typeof account.supportedModels === 'object' && + !Array.isArray(account.supportedModels) + ) { + const newModel = claudeConsoleAccountService.getMappedModel( + account.supportedModels, + requestBody.model + ) + if (newModel !== requestBody.model) { + logger.info(`🔄 Mapping model from ${requestBody.model} to ${newModel}`) + mappedModel = newModel + } + } + + // 创建修改后的请求体 + const modifiedRequestBody = { + ...requestBody, + model: mappedModel + } + + // 模型兼容性检查已经在调度器中完成,这里不需要再检查 + + // 创建代理agent + const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy) + + // 创建AbortController用于取消请求 + abortController = new AbortController() + + // 设置客户端断开监听器 + const handleClientDisconnect = () => { + logger.info('🔌 Client disconnected, aborting Claude Console Claude request') + if (abortController && !abortController.signal.aborted) { + abortController.abort() + } + } + + // 监听客户端断开事件 + if (clientRequest) { + clientRequest.once('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.once('close', handleClientDisconnect) + } + + // 构建完整的API URL + const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 + let apiEndpoint + + if (options.customPath) { + // 如果指定了自定义路径(如 count_tokens),使用它 + const baseUrl = cleanUrl.replace(/\/v1\/messages$/, '') // 移除已有的 /v1/messages + apiEndpoint = `${baseUrl}${options.customPath}` + } else { + // 默认使用 messages 端点 + apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + } + + logger.debug(`🎯 Final API endpoint: ${apiEndpoint}`) + logger.debug(`[DEBUG] Options passed to relayRequest: ${JSON.stringify(options)}`) + logger.debug(`[DEBUG] Client headers received: ${JSON.stringify(clientHeaders)}`) + + // 过滤客户端请求头 + const filteredHeaders = this._filterClientHeaders(clientHeaders) + logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`) + + // 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值 + const userAgent = + account.userAgent || + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + this.defaultUserAgent + + // 准备请求配置 + const requestConfig = { + method: 'POST', + url: apiEndpoint, + data: modifiedRequestBody, + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'User-Agent': userAgent, + ...filteredHeaders + }, + timeout: config.requestTimeout || 600000, + signal: abortController.signal, + validateStatus: () => true // 接受所有状态码 + } + + if (proxyAgent) { + requestConfig.httpAgent = proxyAgent + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + // 根据 API Key 格式选择认证方式 + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + // Anthropic 官方 API Key 使用 x-api-key + requestConfig.headers['x-api-key'] = account.apiKey + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key') + } else { + // 其他 API Key 使用 Authorization Bearer + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}` + logger.debug('[DEBUG] Using Authorization Bearer authentication') + } + + logger.debug( + `[DEBUG] Initial headers before beta: ${JSON.stringify(requestConfig.headers, null, 2)}` + ) + + // 添加beta header如果需要 + if (options.betaHeader) { + logger.debug(`[DEBUG] Adding beta header: ${options.betaHeader}`) + requestConfig.headers['anthropic-beta'] = options.betaHeader + } else { + logger.debug('[DEBUG] No beta header to add') + } + + // 发送请求 + logger.debug( + '📤 Sending request to Claude Console API with headers:', + JSON.stringify(requestConfig.headers, null, 2) + ) + const response = await axios(requestConfig) + + // 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成) + // 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻 + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + queueLockAcquired = false // 标记已释放,防止 finally 重复释放 + logger.debug( + `📬 User message queue lock released early for console account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock early for console account ${accountId}:`, + releaseError.message + ) + } + } + + // 移除监听器(请求成功完成) + if (clientRequest) { + clientRequest.removeListener('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.removeListener('close', handleClientDisconnect) + } + + logger.debug(`🔗 Claude Console API response: ${response.status}`) + logger.debug(`[DEBUG] Response headers: ${JSON.stringify(response.headers)}`) + logger.debug(`[DEBUG] Response data type: ${typeof response.data}`) + logger.debug( + `[DEBUG] Response data length: ${response.data ? (typeof response.data === 'string' ? response.data.length : JSON.stringify(response.data).length) : 0}` + ) + + // 对于错误响应,记录原始错误和清理后的预览 + if (response.status < 200 || response.status >= 300) { + // 记录原始错误响应(包含供应商信息,用于调试) + const rawData = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + logger.error( + `📝 Upstream error response from ${account?.name || accountId}: ${rawData.substring(0, 500)}` + ) + + // 记录清理后的数据到error + try { + const responseData = + typeof response.data === 'string' ? JSON.parse(response.data) : response.data + const sanitizedData = sanitizeUpstreamError(responseData) + logger.error(`🧹 [SANITIZED] Error response to client: ${JSON.stringify(sanitizedData)}`) + } catch (e) { + const rawText = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + const sanitizedText = sanitizeErrorMessage(rawText) + logger.error(`🧹 [SANITIZED] Error response to client: ${sanitizedText}`) + } + } else { + logger.debug( + `[DEBUG] Response data preview: ${typeof response.data === 'string' ? response.data.substring(0, 200) : JSON.stringify(response.data).substring(0, 200)}` + ) + } + + // 检查是否为账户禁用/不可用的 400 错误 + const accountDisabledError = isAccountDisabledError(response.status, response.data) + + // 检查错误状态并相应处理 + if (response.status === 401) { + logger.warn( + `🚫 Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` + ) + if (!autoProtectionDisabled) { + await upstreamErrorHelper + .markTempUnavailable(accountId, 'claude-console', 401) + .catch(() => {}) + } + } else if (accountDisabledError) { + logger.error( + `🚫 Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` + ) + // 传入完整的错误详情到 webhook + const errorDetails = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + if (!autoProtectionDisabled) { + await claudeConsoleAccountService.markConsoleAccountBlocked(accountId, errorDetails) + } + } else if (response.status === 429) { + logger.warn( + `🚫 Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` + ) + // 收到429先检查是否因为超过了手动配置的每日额度 + await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + + if (!autoProtectionDisabled) { + await claudeConsoleAccountService.markAccountRateLimited(accountId) + await upstreamErrorHelper + .markTempUnavailable( + accountId, + 'claude-console', + 429, + upstreamErrorHelper.parseRetryAfter(response.headers) + ) + .catch(() => {}) + } + } else if (response.status === 529) { + logger.warn( + `🚫 Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` + ) + if (!autoProtectionDisabled) { + await claudeConsoleAccountService.markAccountOverloaded(accountId) + await upstreamErrorHelper + .markTempUnavailable(accountId, 'claude-console', 529) + .catch(() => {}) + } + } else if (response.status >= 500) { + logger.warn( + `🔥 Server error (${response.status}) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` + ) + if (!autoProtectionDisabled) { + await upstreamErrorHelper + .markTempUnavailable(accountId, 'claude-console', response.status) + .catch(() => {}) + } + } else if (response.status === 200 || response.status === 201) { + // 如果请求成功,检查并移除错误状态 + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(accountId) + if (isRateLimited) { + await claudeConsoleAccountService.removeAccountRateLimit(accountId) + } + const isOverloaded = await claudeConsoleAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeConsoleAccountService.removeAccountOverload(accountId) + } + } + + // 更新最后使用时间 + await this._updateLastUsedTime(accountId) + + // 准备响应体并清理错误信息(如果是错误响应) + let responseBody + if (response.status < 200 || response.status >= 300) { + // 错误响应,清理供应商信息 + try { + const responseData = + typeof response.data === 'string' ? JSON.parse(response.data) : response.data + const sanitizedData = sanitizeUpstreamError(responseData) + responseBody = JSON.stringify(sanitizedData) + logger.debug(`🧹 Sanitized error response`) + } catch (parseError) { + // 如果无法解析为JSON,尝试清理文本 + const rawText = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + responseBody = sanitizeErrorMessage(rawText) + logger.debug(`🧹 Sanitized error text`) + } + } else { + // 成功响应,不需要清理 + responseBody = + typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + } + + logger.debug(`[DEBUG] Final response body to return: ${responseBody.substring(0, 200)}...`) + + return { + statusCode: response.status, + headers: response.headers, + body: responseBody, + accountId + } + } catch (error) { + // 处理特定错误 + if ( + error.name === 'AbortError' || + error.name === 'CanceledError' || + error.code === 'ECONNABORTED' || + error.code === 'ERR_CANCELED' + ) { + logger.info('Request aborted due to client disconnect') + throw new Error('Client disconnected') + } + + logger.error( + `❌ Claude Console relay request failed (Account: ${account?.name || accountId}):`, + error.message + ) + + // 不再因为模型不支持而block账号 + + throw error + } finally { + // 🔓 并发控制:释放并发槽位 + if (concurrencyAcquired) { + try { + await redis.decrConsoleAccountConcurrency(accountId, requestId) + logger.debug( + `🔓 Released concurrency slot for account ${account?.name || accountId}, request: ${requestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release concurrency slot for account ${accountId}, request: ${requestId}:`, + releaseError.message + ) + } + } + + // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + logger.debug( + `📬 User message queue lock released in finally for console account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock for account ${accountId}:`, + releaseError.message + ) + } + } + } + } + + // 🌊 处理流式响应 + async relayStreamRequestWithUsageCapture( + requestBody, + apiKeyData, + responseStream, + clientHeaders, + usageCallback, + accountId, + streamTransformer = null, + options = {} + ) { + let account = null + const requestId = uuidv4() // 用于并发追踪 + let concurrencyAcquired = false + let leaseRefreshInterval = null // 租约刷新定时器 + let queueLockAcquired = false + let queueRequestId = null + + try { + // 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁 + if (userMessageQueueService.isUserMessageRequest(requestBody)) { + // 校验 accountId 非空,避免空值污染队列锁键 + if (!accountId || accountId === '') { + logger.error( + '❌ accountId missing for queue lock in console relayStreamRequestWithUsageCapture' + ) + throw new Error('accountId missing for queue lock') + } + const queueResult = await userMessageQueueService.acquireQueueLock(accountId) + if (!queueResult.acquired && !queueResult.skipped) { + // 区分 Redis 后端错误和队列超时 + const isBackendError = queueResult.error === 'queue_backend_error' + const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT' + const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout' + const errorMessage = isBackendError + ? 'Queue service temporarily unavailable, please retry later' + : 'User message queue wait timeout, please retry later' + const statusCode = isBackendError ? 500 : 503 + + // 结构化性能日志,用于后续统计 + logger.performance('user_message_queue_error', { + errorType, + errorCode, + accountId, + statusCode, + stream: true, + apiKeyName: apiKeyData.name, + backendError: isBackendError ? queueResult.errorMessage : undefined + }) + + logger.warn( + `📬 User message queue ${errorType} for console account ${accountId} (stream), key: ${apiKeyData.name}`, + isBackendError ? { backendError: queueResult.errorMessage } : {} + ) + if (!responseStream.headersSent) { + const existingConnection = responseStream.getHeader + ? responseStream.getHeader('Connection') + : null + responseStream.writeHead(statusCode, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive', + 'x-user-message-queue-error': errorType + }) + } + const errorEvent = `event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: errorType, code: errorCode, message: errorMessage } })}\n\n` + responseStream.write(errorEvent) + responseStream.write('data: [DONE]\n\n') + responseStream.end() + return + } + if (queueResult.acquired && !queueResult.skipped) { + queueLockAcquired = true + queueRequestId = queueResult.requestId + logger.debug( + `📬 User message queue lock acquired for console account ${accountId} (stream), requestId: ${queueRequestId}` + ) + } + } + + // 获取账户信息 + account = await claudeConsoleAccountService.getAccount(accountId) + if (!account) { + throw new Error('Claude Console Claude account not found') + } + + logger.info( + `📡 Processing streaming Claude Console API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${account.name} (${accountId}), request: ${requestId}` + ) + + // 🔒 并发控制:原子性抢占槽位 + if (account.maxConcurrentTasks > 0) { + // 先抢占,再检查 - 避免竞态条件 + const newConcurrency = Number( + await redis.incrConsoleAccountConcurrency(accountId, requestId, 600) + ) + concurrencyAcquired = true + + // 检查是否超过限制 + if (newConcurrency > account.maxConcurrentTasks) { + // 超限,立即回滚 + await redis.decrConsoleAccountConcurrency(accountId, requestId) + concurrencyAcquired = false + + logger.warn( + `⚠️ Console account ${account.name} (${accountId}) concurrency limit exceeded: ${newConcurrency}/${account.maxConcurrentTasks} (stream request: ${requestId}, rolled back)` + ) + + const error = new Error('Console account concurrency limit reached') + error.code = 'CONSOLE_ACCOUNT_CONCURRENCY_FULL' + error.accountId = accountId + throw error + } + + logger.debug( + `🔓 Acquired concurrency slot for stream account ${account.name} (${accountId}), current: ${newConcurrency}/${account.maxConcurrentTasks}, request: ${requestId}` + ) + + // 🔄 启动租约刷新定时器(每5分钟刷新一次,防止长连接租约过期) + leaseRefreshInterval = setInterval( + async () => { + try { + await redis.refreshConsoleAccountConcurrencyLease(accountId, requestId, 600) + logger.debug( + `🔄 Refreshed concurrency lease for stream account ${account.name} (${accountId}), request: ${requestId}` + ) + } catch (refreshError) { + logger.error( + `❌ Failed to refresh concurrency lease for account ${accountId}, request: ${requestId}:`, + refreshError.message + ) + } + }, + 5 * 60 * 1000 + ) // 5分钟刷新一次 + } + + logger.debug(`🌐 Account API URL: ${account.apiUrl}`) + + // 处理模型映射 + let mappedModel = requestBody.model + if ( + account.supportedModels && + typeof account.supportedModels === 'object' && + !Array.isArray(account.supportedModels) + ) { + const newModel = claudeConsoleAccountService.getMappedModel( + account.supportedModels, + requestBody.model + ) + if (newModel !== requestBody.model) { + logger.info(`🔄 [Stream] Mapping model from ${requestBody.model} to ${newModel}`) + mappedModel = newModel + } + } + + // 创建修改后的请求体 + const modifiedRequestBody = { + ...requestBody, + model: mappedModel + } + + // 模型兼容性检查已经在调度器中完成,这里不需要再检查 + + // 创建代理agent + const proxyAgent = claudeConsoleAccountService._createProxyAgent(account.proxy) + + // 发送流式请求 + await this._makeClaudeConsoleStreamRequest( + modifiedRequestBody, + account, + proxyAgent, + clientHeaders, + responseStream, + accountId, + usageCallback, + streamTransformer, + options, + // 📬 回调:在收到响应头时释放队列锁 + async () => { + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + queueLockAcquired = false // 标记已释放,防止 finally 重复释放 + logger.debug( + `📬 User message queue lock released early for console stream account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock early for console stream account ${accountId}:`, + releaseError.message + ) + } + } + } + ) + + // 更新最后使用时间 + await this._updateLastUsedTime(accountId) + } catch (error) { + // 客户端主动断开连接是正常情况,使用 INFO 级别 + if (error.message === 'Client disconnected') { + logger.info( + `🔌 Claude Console stream relay ended: Client disconnected (Account: ${account?.name || accountId})` + ) + } else { + logger.error( + `❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`, + error + ) + } + throw error + } finally { + // 🛑 清理租约刷新定时器 + if (leaseRefreshInterval) { + clearInterval(leaseRefreshInterval) + logger.debug( + `🛑 Cleared lease refresh interval for stream account ${account?.name || accountId}, request: ${requestId}` + ) + } + + // 🔓 并发控制:释放并发槽位 + if (concurrencyAcquired) { + try { + await redis.decrConsoleAccountConcurrency(accountId, requestId) + logger.debug( + `🔓 Released concurrency slot for stream account ${account?.name || accountId}, request: ${requestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release concurrency slot for stream account ${accountId}, request: ${requestId}:`, + releaseError.message + ) + } + } + + // 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放) + if (queueLockAcquired && queueRequestId && accountId) { + try { + await userMessageQueueService.releaseQueueLock(accountId, queueRequestId) + logger.debug( + `📬 User message queue lock released in finally for console stream account ${accountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock for stream account ${accountId}:`, + releaseError.message + ) + } + } + } + } + + // 🌊 发送流式请求到Claude Console API + async _makeClaudeConsoleStreamRequest( + body, + account, + proxyAgent, + clientHeaders, + responseStream, + accountId, + usageCallback, + streamTransformer = null, + requestOptions = {}, + onResponseHeaderReceived = null + ) { + return new Promise((resolve, reject) => { + let aborted = false + + // 构建完整的API URL + const cleanUrl = account.apiUrl.replace(/\/$/, '') // 移除末尾斜杠 + const apiEndpoint = cleanUrl.endsWith('/v1/messages') ? cleanUrl : `${cleanUrl}/v1/messages` + + logger.debug(`🎯 Final API endpoint for stream: ${apiEndpoint}`) + + // 过滤客户端请求头 + const filteredHeaders = this._filterClientHeaders(clientHeaders) + logger.debug(`[DEBUG] Filtered client headers: ${JSON.stringify(filteredHeaders)}`) + + // 决定使用的 User-Agent:优先使用账户自定义的,否则透传客户端的,最后才使用默认值 + const userAgent = + account.userAgent || + clientHeaders?.['user-agent'] || + clientHeaders?.['User-Agent'] || + this.defaultUserAgent + + // 准备请求配置 + const requestConfig = { + method: 'POST', + url: apiEndpoint, + data: body, + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'User-Agent': userAgent, + ...filteredHeaders + }, + timeout: config.requestTimeout || 600000, + responseType: 'stream', + validateStatus: () => true // 接受所有状态码 + } + + if (proxyAgent) { + requestConfig.httpAgent = proxyAgent + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + // 根据 API Key 格式选择认证方式 + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + // Anthropic 官方 API Key 使用 x-api-key + requestConfig.headers['x-api-key'] = account.apiKey + logger.debug('[DEBUG] Using x-api-key authentication for sk-ant-* API key') + } else { + // 其他 API Key 使用 Authorization Bearer + requestConfig.headers['Authorization'] = `Bearer ${account.apiKey}` + logger.debug('[DEBUG] Using Authorization Bearer authentication') + } + + // 添加beta header如果需要 + if (requestOptions.betaHeader) { + requestConfig.headers['anthropic-beta'] = requestOptions.betaHeader + } + + // 发送请求 + const request = axios(requestConfig) + + // 注意:使用 .then(async ...) 模式处理响应 + // - 内部的 releaseQueueLock 有独立的 try-catch,不会导致未捕获异常 + // - queueLockAcquired = false 的赋值会在 finally 执行前完成(JS 单线程保证) + request + .then(async (response) => { + logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`) + + // 错误响应处理 + if (response.status !== 200) { + logger.error( + `❌ Claude Console API returned error status: ${response.status} | Account: ${account?.name || accountId}` + ) + + // 收集错误数据用于检测 + let errorDataForCheck = '' + const errorChunks = [] + + response.data.on('data', (chunk) => { + errorChunks.push(chunk) + errorDataForCheck += chunk.toString() + }) + + response.data.on('end', async () => { + const autoProtectionDisabled = account.disableAutoProtection === true + // 记录原始错误消息到日志(方便调试,包含供应商信息) + logger.error( + `📝 [Stream] Upstream error response from ${account?.name || accountId}: ${errorDataForCheck.substring(0, 500)}` + ) + + // 检查是否为账户禁用错误 + const accountDisabledError = isAccountDisabledError( + response.status, + errorDataForCheck + ) + + if (response.status === 401) { + logger.warn( + `🚫 [Stream] Unauthorized error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` + ) + if (!autoProtectionDisabled) { + await upstreamErrorHelper + .markTempUnavailable(accountId, 'claude-console', 401) + .catch(() => {}) + } + } else if (accountDisabledError) { + logger.error( + `🚫 [Stream] Account disabled error (400) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` + ) + // 传入完整的错误详情到 webhook + if (!autoProtectionDisabled) { + await claudeConsoleAccountService.markConsoleAccountBlocked( + accountId, + errorDataForCheck + ) + } + } else if (response.status === 429) { + logger.warn( + `🚫 [Stream] Rate limit detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` + ) + // 检查是否因为超过每日额度 + claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + if (!autoProtectionDisabled) { + await claudeConsoleAccountService.markAccountRateLimited(accountId) + await upstreamErrorHelper + .markTempUnavailable( + accountId, + 'claude-console', + 429, + upstreamErrorHelper.parseRetryAfter(response.headers) + ) + .catch(() => {}) + } + } else if (response.status === 529) { + logger.warn( + `🚫 [Stream] Overload error detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` + ) + if (!autoProtectionDisabled) { + await claudeConsoleAccountService.markAccountOverloaded(accountId) + await upstreamErrorHelper + .markTempUnavailable(accountId, 'claude-console', 529) + .catch(() => {}) + } + } else if (response.status >= 500) { + logger.warn( + `🔥 [Stream] Server error (${response.status}) detected for Claude Console account ${accountId}${autoProtectionDisabled ? ' (auto-protection disabled, skipping status change)' : ''}` + ) + if (!autoProtectionDisabled) { + await upstreamErrorHelper + .markTempUnavailable(accountId, 'claude-console', response.status) + .catch(() => {}) + } + } + + // 设置响应头 + if (!responseStream.headersSent) { + responseStream.writeHead(response.status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + }) + } + + // 清理并发送错误响应 + try { + const fullErrorData = Buffer.concat(errorChunks).toString() + const errorJson = JSON.parse(fullErrorData) + const sanitizedError = sanitizeUpstreamError(errorJson) + + // 记录清理后的错误消息(发送给客户端的,完整记录) + logger.error( + `🧹 [Stream] [SANITIZED] Error response to client: ${JSON.stringify(sanitizedError)}` + ) + + if (isStreamWritable(responseStream)) { + responseStream.write(JSON.stringify(sanitizedError)) + responseStream.end() + } + } catch (parseError) { + const sanitizedText = sanitizeErrorMessage(errorDataForCheck) + logger.error(`🧹 [Stream] [SANITIZED] Error response to client: ${sanitizedText}`) + + if (isStreamWritable(responseStream)) { + responseStream.write(sanitizedText) + responseStream.end() + } + } + resolve() // 不抛出异常,正常完成流处理 + }) + + return + } + + // 📬 收到成功响应头(HTTP 200),调用回调释放队列锁 + // 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成 + if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') { + try { + await onResponseHeaderReceived() + } catch (callbackError) { + logger.error( + `❌ Failed to execute onResponseHeaderReceived callback for console stream account ${accountId}:`, + callbackError.message + ) + } + } + + // 成功响应,检查并移除错误状态 + claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => { + if (isRateLimited) { + claudeConsoleAccountService.removeAccountRateLimit(accountId) + } + }) + claudeConsoleAccountService.isAccountOverloaded(accountId).then((isOverloaded) => { + if (isOverloaded) { + claudeConsoleAccountService.removeAccountOverload(accountId) + } + }) + + // 设置响应头 + // ⚠️ 关键修复:尊重 auth.js 提前设置的 Connection: close + // 当并发队列功能启用时,auth.js 会设置 Connection: close 来禁用 Keep-Alive + if (!responseStream.headersSent) { + const existingConnection = responseStream.getHeader + ? responseStream.getHeader('Connection') + : null + const connectionHeader = existingConnection || 'keep-alive' + if (existingConnection) { + logger.debug( + `🔌 [Console Stream] Preserving existing Connection header: ${existingConnection}` + ) + } + responseStream.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: connectionHeader, + 'X-Accel-Buffering': 'no' + }) + } + + let buffer = '' + let finalUsageReported = false + const collectedUsageData = { + model: body.model || account?.defaultModel || null + } + + // 处理流数据 + response.data.on('data', (chunk) => { + try { + if (aborted) { + return + } + + const chunkStr = chunk.toString() + buffer += chunkStr + + // 处理完整的SSE行 + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + // 转发数据并解析usage + if (lines.length > 0) { + // 检查流是否可写(客户端连接是否有效) + if (isStreamWritable(responseStream)) { + const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '') + + // 应用流转换器如果有 + let dataToWrite = linesToForward + if (streamTransformer) { + const transformed = streamTransformer(linesToForward) + if (transformed) { + dataToWrite = transformed + } else { + dataToWrite = null + } + } + + if (dataToWrite) { + responseStream.write(dataToWrite) + } + } else { + // 客户端连接已断开,记录警告(但仍继续解析usage) + logger.warn( + `⚠️ [Console] Client disconnected during stream, skipping ${lines.length} lines for account: ${account?.name || accountId}` + ) + } + + // 解析SSE数据寻找usage信息(无论连接状态如何) + for (const line of lines) { + if (line.startsWith('data:')) { + const jsonStr = line.slice(5).trimStart() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + try { + const data = JSON.parse(jsonStr) + + // 收集usage数据 + if (data.type === 'message_start' && data.message && data.message.usage) { + collectedUsageData.input_tokens = data.message.usage.input_tokens || 0 + collectedUsageData.cache_creation_input_tokens = + data.message.usage.cache_creation_input_tokens || 0 + collectedUsageData.cache_read_input_tokens = + data.message.usage.cache_read_input_tokens || 0 + collectedUsageData.model = data.message.model + + // 检查是否有详细的 cache_creation 对象 + if ( + data.message.usage.cache_creation && + typeof data.message.usage.cache_creation === 'object' + ) { + collectedUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + logger.info( + '📊 Collected detailed cache creation data:', + JSON.stringify(collectedUsageData.cache_creation) + ) + } + } + + if (data.type === 'message_delta' && data.usage) { + // 提取所有usage字段,message_delta可能包含完整的usage信息 + if (data.usage.output_tokens !== undefined) { + collectedUsageData.output_tokens = data.usage.output_tokens || 0 + } + + // 提取input_tokens(如果存在) + if (data.usage.input_tokens !== undefined) { + collectedUsageData.input_tokens = data.usage.input_tokens || 0 + } + + // 提取cache相关的tokens + if (data.usage.cache_creation_input_tokens !== undefined) { + collectedUsageData.cache_creation_input_tokens = + data.usage.cache_creation_input_tokens || 0 + } + if (data.usage.cache_read_input_tokens !== undefined) { + collectedUsageData.cache_read_input_tokens = + data.usage.cache_read_input_tokens || 0 + } + + // 检查是否有详细的 cache_creation 对象 + if ( + data.usage.cache_creation && + typeof data.usage.cache_creation === 'object' + ) { + collectedUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + } + + logger.info( + '📊 [Console] Collected usage data from message_delta:', + JSON.stringify(collectedUsageData) + ) + + // 如果已经收集到了完整数据,触发回调 + if ( + collectedUsageData.input_tokens !== undefined && + collectedUsageData.output_tokens !== undefined && + !finalUsageReported + ) { + if (!collectedUsageData.model) { + collectedUsageData.model = body.model || account?.defaultModel || null + } + logger.info( + '🎯 [Console] Complete usage data collected:', + JSON.stringify(collectedUsageData) + ) + if (usageCallback && typeof usageCallback === 'function') { + usageCallback({ ...collectedUsageData, accountId }) + } + finalUsageReported = true + } + } + + // 不再因为模型不支持而block账号 + } catch (e) { + // 忽略解析错误 + } + } + } + } + } catch (error) { + logger.error( + `❌ Error processing Claude Console stream data (Account: ${account?.name || accountId}):`, + error + ) + if (isStreamWritable(responseStream)) { + // 如果有 streamTransformer(如测试请求),使用前端期望的格式 + if (streamTransformer) { + responseStream.write( + `data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n` + ) + } else { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Stream processing error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n` + ) + } + } + } + }) + + response.data.on('end', () => { + try { + // 处理缓冲区中剩余的数据 + if (buffer.trim() && isStreamWritable(responseStream)) { + if (streamTransformer) { + const transformed = streamTransformer(buffer) + if (transformed) { + responseStream.write(transformed) + } + } else { + responseStream.write(buffer) + } + } + + // 🔧 兜底逻辑:确保所有未保存的usage数据都不会丢失 + if (!finalUsageReported) { + if ( + collectedUsageData.input_tokens !== undefined || + collectedUsageData.output_tokens !== undefined + ) { + // 补全缺失的字段 + if (collectedUsageData.input_tokens === undefined) { + collectedUsageData.input_tokens = 0 + logger.warn( + '⚠️ [Console] message_delta missing input_tokens, setting to 0. This may indicate incomplete usage data.' + ) + } + if (collectedUsageData.output_tokens === undefined) { + collectedUsageData.output_tokens = 0 + logger.warn( + '⚠️ [Console] message_delta missing output_tokens, setting to 0. This may indicate incomplete usage data.' + ) + } + // 确保有 model 字段 + if (!collectedUsageData.model) { + collectedUsageData.model = body.model || account?.defaultModel || null + } + logger.info( + `📊 [Console] Saving incomplete usage data via fallback: ${JSON.stringify(collectedUsageData)}` + ) + if (usageCallback && typeof usageCallback === 'function') { + usageCallback({ ...collectedUsageData, accountId }) + } + finalUsageReported = true + } else { + logger.warn( + '⚠️ [Console] Stream completed but no usage data was captured! This indicates a problem with SSE parsing or API response format.' + ) + } + } + + // 确保流正确结束 + if (isStreamWritable(responseStream)) { + // 📊 诊断日志:流结束前状态 + logger.info( + `📤 [STREAM] Ending response | destroyed: ${responseStream.destroyed}, ` + + `socketDestroyed: ${responseStream.socket?.destroyed}, ` + + `socketBytesWritten: ${responseStream.socket?.bytesWritten || 0}` + ) + + // 禁用 Nagle 算法确保数据立即发送 + if (responseStream.socket && !responseStream.socket.destroyed) { + responseStream.socket.setNoDelay(true) + } + + // 等待数据完全 flush 到客户端后再 resolve + responseStream.end(() => { + logger.info( + `✅ [STREAM] Response ended and flushed | socketBytesWritten: ${responseStream.socket?.bytesWritten || 'unknown'}` + ) + resolve() + }) + } else { + // 连接已断开,记录警告 + logger.warn( + `⚠️ [Console] Client disconnected before stream end, data may not have been received | account: ${account?.name || accountId}` + ) + resolve() + } + } catch (error) { + logger.error('❌ Error processing stream end:', error) + reject(error) + } + }) + + response.data.on('error', (error) => { + logger.error( + `❌ Claude Console stream error (Account: ${account?.name || accountId}):`, + error + ) + if (isStreamWritable(responseStream)) { + // 如果有 streamTransformer(如测试请求),使用前端期望的格式 + if (streamTransformer) { + responseStream.write( + `data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n` + ) + } else { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Stream error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n` + ) + } + responseStream.end() + } + reject(error) + }) + }) + .catch((error) => { + if (aborted) { + return + } + + logger.error( + `❌ Claude Console stream request error (Account: ${account?.name || accountId}):`, + error.message + ) + + // 检查错误状态 + if (error.response) { + const catchAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (error.response.status === 401) { + if (!catchAutoProtectionDisabled) { + upstreamErrorHelper + .markTempUnavailable(accountId, 'claude-console', 401) + .catch(() => {}) + } + } else if (error.response.status === 429) { + if (!catchAutoProtectionDisabled) { + claudeConsoleAccountService.markAccountRateLimited(accountId) + // 检查是否因为超过每日额度 + claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => { + logger.error('❌ Failed to check quota after 429 error:', err) + }) + upstreamErrorHelper + .markTempUnavailable( + accountId, + 'claude-console', + 429, + upstreamErrorHelper.parseRetryAfter(error.response.headers) + ) + .catch(() => {}) + } + } else if (error.response.status === 529) { + if (!catchAutoProtectionDisabled) { + claudeConsoleAccountService.markAccountOverloaded(accountId) + upstreamErrorHelper + .markTempUnavailable(accountId, 'claude-console', 529) + .catch(() => {}) + } + } + } + + // 发送错误响应 + if (!responseStream.headersSent) { + const existingConnection = responseStream.getHeader + ? responseStream.getHeader('Connection') + : null + responseStream.writeHead(error.response?.status || 500, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive' + }) + } + + if (isStreamWritable(responseStream)) { + // 如果有 streamTransformer(如测试请求),使用前端期望的格式 + if (streamTransformer) { + responseStream.write( + `data: ${JSON.stringify({ type: 'error', error: error.message })}\n\n` + ) + } else { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: error.message, + code: error.code, + timestamp: new Date().toISOString() + })}\n\n` + ) + } + responseStream.end() + } + + reject(error) + }) + + // 处理客户端断开连接 + responseStream.on('close', () => { + logger.debug('🔌 Client disconnected, cleaning up Claude Console stream') + aborted = true + }) + }) + } + + // 🔧 过滤客户端请求头 + _filterClientHeaders(clientHeaders) { + // 使用统一的 headerFilter 工具类(白名单模式) + // 与 claudeRelayService 保持一致,避免透传 CDN headers 触发上游 API 安全检查 + return filterForClaude(clientHeaders) + } + + // 🕐 更新最后使用时间 + async _updateLastUsedTime(accountId) { + try { + const client = require('../../models/redis').getClientSafe() + const accountKey = `claude_console_account:${accountId}` + const exists = await client.exists(accountKey) + + if (!exists) { + logger.debug(`🔎 跳过更新已删除的Claude Console账号最近使用时间: ${accountId}`) + return + } + + await client.hset(accountKey, 'lastUsedAt', new Date().toISOString()) + } catch (error) { + logger.warn( + `⚠️ Failed to update last used time for Claude Console account ${accountId}:`, + error.message + ) + } + } + + // 🧪 创建测试用的流转换器,将 Claude API SSE 格式转换为前端期望的格式 + _createTestStreamTransformer() { + let testStartSent = false + + return (rawData) => { + const lines = rawData.split('\n') + const outputLines = [] + + for (const line of lines) { + if (!line.startsWith('data: ')) { + // 保留空行用于 SSE 分隔 + if (line.trim() === '') { + outputLines.push('') + } + continue + } + + const jsonStr = line.substring(6).trim() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonStr) + + // 发送 test_start 事件(只在第一次 message_start 时发送) + if (data.type === 'message_start' && !testStartSent) { + testStartSent = true + outputLines.push(`data: ${JSON.stringify({ type: 'test_start' })}`) + outputLines.push('') + } + + // 转换 content_block_delta 为 content + if (data.type === 'content_block_delta' && data.delta && data.delta.text) { + outputLines.push(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}`) + outputLines.push('') + } + + // 转换 message_stop 为 test_complete + if (data.type === 'message_stop') { + outputLines.push(`data: ${JSON.stringify({ type: 'test_complete', success: true })}`) + outputLines.push('') + } + + // 处理错误事件 + if (data.type === 'error') { + const errorMsg = data.error?.message || data.message || '未知错误' + outputLines.push(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}`) + outputLines.push('') + } + } catch { + // 忽略解析错误 + } + } + + return outputLines.length > 0 ? outputLines.join('\n') : null + } + } + + // 🧪 测试账号连接(供Admin API使用) + async testAccountConnection(accountId, responseStream, model) { + const { + createClaudeTestPayload, + sendStreamTestRequest + } = require('../../utils/testPayloadHelper') + + try { + const account = await claudeConsoleAccountService.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + logger.info(`🧪 Testing Claude Console account connection: ${account.name} (${accountId})`) + + const cleanUrl = account.apiUrl.replace(/\/$/, '') + const apiUrl = cleanUrl.endsWith('/v1/messages') + ? cleanUrl + : `${cleanUrl}/v1/messages?beta=true` + const payload = createClaudeTestPayload(model, { stream: true }) + + const extraHeaders = account.userAgent ? { 'User-Agent': account.userAgent } : {} + const requestOptions = { + apiUrl, + responseStream, + payload, + proxyAgent: claudeConsoleAccountService._createProxyAgent(account.proxy), + extraHeaders + } + + if (account.apiKey && account.apiKey.startsWith('sk-ant-')) { + requestOptions.extraHeaders['x-api-key'] = account.apiKey + } else { + requestOptions.authorization = `Bearer ${account.apiKey}` + } + + await sendStreamTestRequest(requestOptions) + } catch (error) { + logger.error(`❌ Test account connection failed:`, error) + if (!responseStream.headersSent) { + responseStream.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache' + }) + } + if (isStreamWritable(responseStream)) { + responseStream.write( + `data: ${JSON.stringify({ type: 'test_complete', success: false, error: error.message })}\n\n` + ) + responseStream.end() + } + } + } + + // 🎯 健康检查 + async healthCheck() { + try { + const accounts = await claudeConsoleAccountService.getAllAccounts() + const activeAccounts = accounts.filter((acc) => acc.isActive && acc.status === 'active') + + return { + healthy: activeAccounts.length > 0, + activeAccounts: activeAccounts.length, + totalAccounts: accounts.length, + timestamp: new Date().toISOString() + } + } catch (error) { + logger.error('❌ Claude Console Claude health check failed:', error) + return { + healthy: false, + error: error.message, + timestamp: new Date().toISOString() + } + } + } +} + +module.exports = new ClaudeConsoleRelayService() diff --git a/src/services/relay/claudeRelayService.js b/src/services/relay/claudeRelayService.js new file mode 100644 index 0000000..92727cd --- /dev/null +++ b/src/services/relay/claudeRelayService.js @@ -0,0 +1,3635 @@ +const https = require('https') +const zlib = require('zlib') +const path = require('path') +const ProxyHelper = require('../../utils/proxyHelper') +const { filterForClaude } = require('../../utils/headerFilter') +const claudeAccountService = require('../account/claudeAccountService') +const unifiedClaudeScheduler = require('../scheduler/unifiedClaudeScheduler') +const sessionHelper = require('../../utils/sessionHelper') +const logger = require('../../utils/logger') +const config = require('../../../config/config') +const { getRateLimitModelFamily } = require('../../utils/modelHelper') +const claudeCodeHeadersService = require('../claudeCodeHeadersService') +const redis = require('../../models/redis') +const ClaudeCodeValidator = require('../../validators/clients/claudeCodeValidator') +const { formatDateWithTimezone } = require('../../utils/dateHelper') +const requestIdentityService = require('../requestIdentityService') +const { createClaudeTestPayload } = require('../../utils/testPayloadHelper') +const userMessageQueueService = require('../userMessageQueueService') +const { isStreamWritable } = require('../../utils/streamHelper') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') +const metadataUserIdHelper = require('../../utils/metadataUserIdHelper') +const { + getHttpsAgentForStream, + getHttpsAgentForNonStream, + getPricingData +} = require('../../utils/performanceOptimizer') + +// structuredClone polyfill for Node < 17 +const safeClone = + typeof structuredClone === 'function' ? structuredClone : (obj) => JSON.parse(JSON.stringify(obj)) + +class ClaudeRelayService { + constructor() { + this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true' + // 🧹 内存优化:用于存储请求体字符串,避免闭包捕获 + this.bodyStore = new Map() + this._bodyStoreIdCounter = 0 + this.apiVersion = config.claude.apiVersion + this.betaHeader = config.claude.betaHeader + this.systemPrompt = config.claude.systemPrompt + this.claudeCodeSystemPrompt = "You are Claude Code, Anthropic's official CLI for Claude." + this.toolNameSuffix = null + this.toolNameSuffixGeneratedAt = 0 + this.toolNameSuffixTtlMs = 60 * 60 * 1000 + } + + // 🔧 根据模型ID和客户端传递的 anthropic-beta 获取最终的 header + _getBetaHeader(modelId, clientBetaHeader) { + const OAUTH_BETA = 'oauth-2025-04-20' + const CLAUDE_CODE_BETA = 'claude-code-20250219' + const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14' + const TOOL_STREAMING_BETA = 'fine-grained-tool-streaming-2025-05-14' + + const isHaikuModel = modelId && modelId.toLowerCase().includes('haiku') + const baseBetas = isHaikuModel + ? [OAUTH_BETA, INTERLEAVED_THINKING_BETA] + : [CLAUDE_CODE_BETA, OAUTH_BETA, INTERLEAVED_THINKING_BETA, TOOL_STREAMING_BETA] + + const betaList = [] + const seen = new Set() + const addBeta = (beta) => { + if (!beta || seen.has(beta)) { + return + } + seen.add(beta) + betaList.push(beta) + } + + baseBetas.forEach(addBeta) + + if (clientBetaHeader) { + clientBetaHeader + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + .forEach(addBeta) + } + + return betaList.join(',') + } + + _buildStandardRateLimitMessage(resetTime) { + if (!resetTime) { + return '此专属账号已触发 Anthropic 限流控制。' + } + const formattedReset = formatDateWithTimezone(resetTime) + return `此专属账号已触发 Anthropic 限流控制,将于 ${formattedReset} 自动恢复。` + } + + _buildOpusLimitMessage(resetTime) { + if (!resetTime) { + return '此专属账号的Opus模型已达到周使用限制,请尝试切换其他模型后再试。' + } + const formattedReset = formatDateWithTimezone(resetTime) + return `此专属账号的Opus模型已达到周使用限制,将于 ${formattedReset} 自动恢复,请尝试切换其他模型后再试。` + } + + // 🧾 提取错误消息文本 + _extractErrorMessage(body) { + if (!body) { + return '' + } + + if (typeof body === 'string') { + const trimmed = body.trim() + if (!trimmed) { + return '' + } + try { + const parsed = JSON.parse(trimmed) + return this._extractErrorMessage(parsed) + } catch (error) { + return trimmed + } + } + + if (typeof body === 'object') { + if (typeof body.error === 'string') { + return body.error + } + if (body.error && typeof body.error === 'object') { + if (typeof body.error.message === 'string') { + return body.error.message + } + if (typeof body.error.error === 'string') { + return body.error.error + } + } + if (typeof body.message === 'string') { + return body.message + } + } + + return '' + } + + // 🚫 检查是否为组织被禁用/封禁错误 + // 支持两种场景: + // 1. HTTP 400 + "this organization has been disabled"(原有) + // 2. HTTP 403 + "OAuth authentication is currently not allowed for this organization"(封禁后新返回格式) + _isOrganizationDisabledError(statusCode, body) { + if (statusCode !== 400 && statusCode !== 403) { + return false + } + const message = this._extractErrorMessage(body) + if (!message) { + return false + } + const lowerMessage = message.toLowerCase() + return ( + lowerMessage.includes('this organization has been disabled') || + lowerMessage.includes('oauth authentication is currently not allowed') + ) + } + + // 🔍 判断是否是真实的 Claude Code 请求 + isRealClaudeCodeRequest(requestBody) { + return ClaudeCodeValidator.includesClaudeCodeSystemPrompt(requestBody, 1) + } + + _isClaudeCodeUserAgent(clientHeaders) { + const userAgent = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] + return typeof userAgent === 'string' && /^claude-cli\/[^\s]+\s+\(/i.test(userAgent) + } + + _isActualClaudeCodeRequest(requestBody, clientHeaders) { + return this.isRealClaudeCodeRequest(requestBody) && this._isClaudeCodeUserAgent(clientHeaders) + } + + _getHeaderValueCaseInsensitive(headers, key) { + if (!headers || typeof headers !== 'object') { + return undefined + } + const lowerKey = key.toLowerCase() + for (const candidate of Object.keys(headers)) { + if (candidate.toLowerCase() === lowerKey) { + return headers[candidate] + } + } + return undefined + } + + _extractTextFromContent(content) { + if (typeof content === 'string') { + return content + } + if (!Array.isArray(content)) { + return '' + } + return content + .filter((block) => block && block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join('\n') + } + + _extractSystemText(system) { + if (typeof system === 'string') { + return system + } + if (!Array.isArray(system)) { + return '' + } + return system + .filter((block) => block && block.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join('\n') + } + + _isAgentViewAuxiliaryRequest(requestBody, clientHeaders) { + const appHeader = this._getHeaderValueCaseInsensitive(clientHeaders, 'x-app') + if (appHeader !== 'cli-bg') { + return false + } + if (requestBody?.stream === true) { + return false + } + + const systemText = this._extractSystemText(requestBody?.system) + const messageText = Array.isArray(requestBody?.messages) + ? requestBody.messages + .map((message) => this._extractTextFromContent(message?.content)) + .join('\n') + : '' + + return ( + systemText.includes('A user kicked off a Claude Code agent') || + messageText.includes('2-4 word lowercase label for this job') + ) + } + + _isClaudeCodeCredentialError(body) { + const message = this._extractErrorMessage(body) + if (!message) { + return false + } + const lower = message.toLowerCase() + return ( + lower.includes('only authorized for use with claude code') || + lower.includes('cannot be used for other api requests') + ) + } + + // 💰 检查是否为 "Extra usage required" 的非限流 429 + // Anthropic 对未开启 Extra Usage 的账户请求长上下文模型时返回此错误 + // 这不是真正的限流,不应标记账户为 rate limited + _isExtraUsageRequired429(statusCode, body) { + if (statusCode !== 429) { + return false + } + const message = this._extractErrorMessage(body) + if (!message) { + return false + } + return message.toLowerCase().includes('extra usage') + } + + _toPascalCaseToolName(name) { + const parts = name.split(/[_-]/).filter(Boolean) + if (parts.length === 0) { + return name + } + const pascal = parts + .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) + .join('') + return `${pascal}_tool` + } + + _getToolNameSuffix() { + const now = Date.now() + if (!this.toolNameSuffix || now - this.toolNameSuffixGeneratedAt > this.toolNameSuffixTtlMs) { + this.toolNameSuffix = Math.random().toString(36).substring(2, 8) + this.toolNameSuffixGeneratedAt = now + } + return this.toolNameSuffix + } + + _toRandomizedToolName(name) { + const suffix = this._getToolNameSuffix() + return `${name}_${suffix}` + } + + _transformToolNamesInRequestBody(body, options = {}) { + if (!body || typeof body !== 'object') { + return null + } + + const useRandomized = options.useRandomizedToolNames === true + const forwardMap = new Map() + const reverseMap = new Map() + + const transformName = (name) => { + if (typeof name !== 'string' || name.length === 0) { + return name + } + if (forwardMap.has(name)) { + return forwardMap.get(name) + } + const transformed = useRandomized + ? this._toRandomizedToolName(name) + : this._toPascalCaseToolName(name) + if (transformed !== name) { + forwardMap.set(name, transformed) + reverseMap.set(transformed, name) + } + return transformed + } + + if (Array.isArray(body.tools)) { + body.tools.forEach((tool) => { + if (tool && typeof tool.name === 'string') { + tool.name = transformName(tool.name) + } + }) + } + + if (body.tool_choice && typeof body.tool_choice === 'object') { + if (typeof body.tool_choice.name === 'string') { + body.tool_choice.name = transformName(body.tool_choice.name) + } + } + + if (Array.isArray(body.messages)) { + body.messages.forEach((message) => { + const content = message?.content + if (Array.isArray(content)) { + content.forEach((block) => { + if (block?.type === 'tool_use' && typeof block.name === 'string') { + block.name = transformName(block.name) + } + }) + } + }) + } + + return reverseMap.size > 0 ? reverseMap : null + } + + _restoreToolName(name, toolNameMap) { + if (!toolNameMap || toolNameMap.size === 0) { + return name + } + return toolNameMap.get(name) || name + } + + _restoreToolNamesInContentBlocks(content, toolNameMap) { + if (!Array.isArray(content)) { + return + } + + content.forEach((block) => { + if (block?.type === 'tool_use' && typeof block.name === 'string') { + block.name = this._restoreToolName(block.name, toolNameMap) + } + }) + } + + _restoreToolNamesInResponseObject(responseBody, toolNameMap) { + if (!responseBody || typeof responseBody !== 'object') { + return + } + + if (Array.isArray(responseBody.content)) { + this._restoreToolNamesInContentBlocks(responseBody.content, toolNameMap) + } + + if (responseBody.message && Array.isArray(responseBody.message.content)) { + this._restoreToolNamesInContentBlocks(responseBody.message.content, toolNameMap) + } + } + + _restoreToolNamesInResponseBody(responseBody, toolNameMap) { + if (!responseBody || !toolNameMap || toolNameMap.size === 0) { + return responseBody + } + + if (typeof responseBody === 'string') { + try { + const parsed = JSON.parse(responseBody) + this._restoreToolNamesInResponseObject(parsed, toolNameMap) + return JSON.stringify(parsed) + } catch (error) { + return responseBody + } + } + + if (typeof responseBody === 'object') { + this._restoreToolNamesInResponseObject(responseBody, toolNameMap) + } + + return responseBody + } + + _restoreToolNamesInStreamEvent(event, toolNameMap) { + if (!event || typeof event !== 'object') { + return + } + + if (event.content_block && event.content_block.type === 'tool_use') { + if (typeof event.content_block.name === 'string') { + event.content_block.name = this._restoreToolName(event.content_block.name, toolNameMap) + } + } + + if (event.delta && event.delta.type === 'tool_use') { + if (typeof event.delta.name === 'string') { + event.delta.name = this._restoreToolName(event.delta.name, toolNameMap) + } + } + + if (event.message && Array.isArray(event.message.content)) { + this._restoreToolNamesInContentBlocks(event.message.content, toolNameMap) + } + + if (Array.isArray(event.content)) { + this._restoreToolNamesInContentBlocks(event.content, toolNameMap) + } + } + + _createToolNameStripperStreamTransformer(streamTransformer, toolNameMap) { + if (!toolNameMap || toolNameMap.size === 0) { + return streamTransformer + } + + return (payload) => { + const transformed = streamTransformer ? streamTransformer(payload) : payload + if (!transformed || typeof transformed !== 'string') { + return transformed + } + + const lines = transformed.split('\n') + const updated = lines.map((line) => { + if (!line.startsWith('data:')) { + return line + } + const jsonStr = line.slice(5).trimStart() + if (!jsonStr || jsonStr === '[DONE]') { + return line + } + try { + const data = JSON.parse(jsonStr) + this._restoreToolNamesInStreamEvent(data, toolNameMap) + return `data: ${JSON.stringify(data)}` + } catch (error) { + return line + } + }) + + return updated.join('\n') + } + } + + // 🚀 转发请求到Claude API + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + options = {} + ) { + let upstreamRequest = null + let queueLockAcquired = false + let queueRequestId = null + let selectedAccountId = null + let bodyStoreIdNonStream = null // 🧹 在 try 块外声明,以便 finally 清理 + + try { + // 调试日志:查看API Key数据 + logger.info('🔍 API Key data received:', { + apiKeyName: apiKeyData.name, + enableModelRestriction: apiKeyData.enableModelRestriction, + restrictedModels: apiKeyData.restrictedModels, + requestedModel: requestBody.model + }) + + // 请求模型所属的限流家族(opus/sonnet/haiku/fable),决定 429 记入哪个限流桶 + const requestModelFamily = getRateLimitModelFamily(requestBody?.model) + const isOpusModelRequest = requestModelFamily === 'opus' + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(requestBody) + + // 选择可用的Claude账户(支持专属绑定和sticky会话) + let accountSelection + try { + accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + requestBody.model + ) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = this._buildStandardRateLimitMessage(error.rateLimitEndAt) + logger.warn( + `🚫 Dedicated account ${error.accountId} is rate limited for API key ${apiKeyData.name}, returning 403` + ) + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }), + accountId: error.accountId + } + } + if (error.code === 'CLAUDE_DEDICATED_UNAVAILABLE') { + logger.warn( + `🚫 Dedicated account ${error.accountId} is unavailable (${error.reason}) for API key ${apiKeyData.name}, returning 503` + ) + return { + statusCode: 503, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'dedicated_account_unavailable', + message: `该 API Key 绑定的专属账号当前不可用(${error.reason}),请稍后重试。` + }), + accountId: error.accountId + } + } + throw error + } + const { accountId } = accountSelection + const { accountType } = accountSelection + selectedAccountId = accountId + + logger.info( + `📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}` + ) + + // 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁 + if (userMessageQueueService.isUserMessageRequest(requestBody)) { + // 校验 accountId 非空,避免空值污染队列锁键 + if (!accountId || accountId === '') { + logger.error('❌ accountId missing for queue lock in relayRequest') + throw new Error('accountId missing for queue lock') + } + // 获取账户信息以检查账户级串行队列配置 + const accountForQueue = await claudeAccountService.getAccount(accountId) + const accountConfig = accountForQueue + ? { maxConcurrency: parseInt(accountForQueue.maxConcurrency || '0', 10) } + : null + const queueResult = await userMessageQueueService.acquireQueueLock( + accountId, + null, + null, + accountConfig + ) + if (!queueResult.acquired && !queueResult.skipped) { + // 区分 Redis 后端错误和队列超时 + const isBackendError = queueResult.error === 'queue_backend_error' + const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT' + const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout' + const errorMessage = isBackendError + ? 'Queue service temporarily unavailable, please retry later' + : 'User message queue wait timeout, please retry later' + const statusCode = isBackendError ? 500 : 503 + + // 结构化性能日志,用于后续统计 + logger.performance('user_message_queue_error', { + errorType, + errorCode, + accountId, + statusCode, + apiKeyName: apiKeyData.name, + backendError: isBackendError ? queueResult.errorMessage : undefined + }) + + logger.warn( + `📬 User message queue ${errorType} for account ${accountId}, key: ${apiKeyData.name}`, + isBackendError ? { backendError: queueResult.errorMessage } : {} + ) + return { + statusCode, + headers: { + 'Content-Type': 'application/json', + 'x-user-message-queue-error': errorType + }, + body: JSON.stringify({ + type: 'error', + error: { + type: errorType, + code: errorCode, + message: errorMessage + } + }), + accountId + } + } + if (queueResult.acquired && !queueResult.skipped) { + queueLockAcquired = true + queueRequestId = queueResult.requestId + logger.debug( + `📬 User message queue lock acquired for account ${accountId}, requestId: ${queueRequestId}` + ) + } + } + + // 获取账户信息 + let account = await claudeAccountService.getAccount(accountId) + + if (isOpusModelRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(accountId) + account = await claudeAccountService.getAccount(accountId) + } + + const isDedicatedOfficialAccount = + accountType === 'claude-official' && + apiKeyData.claudeAccountId && + !apiKeyData.claudeAccountId.startsWith('group:') && + apiKeyData.claudeAccountId === accountId + + let opusRateLimitActive = false + let opusRateLimitEndAt = null + if (isOpusModelRequest) { + opusRateLimitActive = await claudeAccountService.isAccountOpusRateLimited(accountId) + opusRateLimitEndAt = account?.opusRateLimitEndAt || null + } + + if (isOpusModelRequest && isDedicatedOfficialAccount && opusRateLimitActive) { + const limitMessage = this._buildOpusLimitMessage(opusRateLimitEndAt) + logger.warn( + `🚫 Dedicated account ${account?.name || accountId} is under Opus weekly limit until ${opusRateLimitEndAt}` + ) + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'opus_weekly_limit', + message: limitMessage + }), + accountId + } + } + + // 获取有效的访问token + const accessToken = await claudeAccountService.getValidAccessToken(accountId) + + const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders) + const processedBody = this._processRequestBody(requestBody, account, isRealClaudeCodeRequest) + // 🧹 内存优化:存储到 bodyStore,避免闭包捕获 + const originalBodyString = JSON.stringify(processedBody) + bodyStoreIdNonStream = ++this._bodyStoreIdCounter + this.bodyStore.set(bodyStoreIdNonStream, originalBodyString) + + // 获取代理配置 + const proxyAgent = await this._getProxyAgent(accountId) + + // 设置客户端断开监听器 + const handleClientDisconnect = () => { + logger.info('🔌 Client disconnected, aborting upstream request') + if (upstreamRequest && !upstreamRequest.destroyed) { + upstreamRequest.destroy() + } + } + + // 监听客户端断开事件 + if (clientRequest) { + clientRequest.once('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.once('close', handleClientDisconnect) + } + + const makeRequestWithRetries = async (requestOptions) => { + const maxRetries = this._shouldRetryOn403(accountType) ? 2 : 0 + let retryCount = 0 + let response + let shouldRetry = false + + do { + // 🧹 每次重试从 bodyStore 解析新对象,避免闭包捕获 + let retryRequestBody + try { + retryRequestBody = JSON.parse(this.bodyStore.get(bodyStoreIdNonStream)) + } catch (parseError) { + logger.error(`❌ Failed to parse body for retry: ${parseError.message}`) + throw new Error(`Request body parse failed: ${parseError.message}`) + } + response = await this._makeClaudeRequest( + retryRequestBody, + accessToken, + proxyAgent, + clientHeaders, + accountId, + (req) => { + upstreamRequest = req + }, + { + ...requestOptions, + isRealClaudeCodeRequest + } + ) + + shouldRetry = response.statusCode === 403 && retryCount < maxRetries + if (shouldRetry) { + retryCount++ + logger.warn( + `🔄 403 error for account ${accountId}, retry ${retryCount}/${maxRetries} after 2s` + ) + await this._sleep(2000) + } + } while (shouldRetry) + + return { response, retryCount } + } + + let requestOptions = options + let { response, retryCount } = await makeRequestWithRetries(requestOptions) + + if ( + this._isClaudeCodeCredentialError(response.body) && + requestOptions.useRandomizedToolNames !== true + ) { + requestOptions = { ...requestOptions, useRandomizedToolNames: true } + ;({ response, retryCount } = await makeRequestWithRetries(requestOptions)) + } + + // 如果进行了重试,记录最终结果 + if (retryCount > 0) { + if (response.statusCode === 403) { + logger.error(`🚫 403 error persists for account ${accountId} after ${retryCount} retries`) + } else { + logger.info( + `✅ 403 retry successful for account ${accountId} on attempt ${retryCount}, got status ${response.statusCode}` + ) + } + } + + // 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成) + // 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻 + if (queueLockAcquired && queueRequestId && selectedAccountId) { + try { + await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId) + queueLockAcquired = false // 标记已释放,防止 finally 重复释放 + logger.debug( + `📬 User message queue lock released early for account ${selectedAccountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock early for account ${selectedAccountId}:`, + releaseError.message + ) + } + } + + response.accountId = accountId + response.accountType = accountType + + // 移除监听器(请求成功完成) + if (clientRequest) { + clientRequest.removeListener('close', handleClientDisconnect) + } + if (clientResponse) { + clientResponse.removeListener('close', handleClientDisconnect) + } + + // 检查响应是否为限流错误或认证错误 + if (response.statusCode !== 200 && response.statusCode !== 201) { + let isRateLimited = false + let rateLimitResetTimestamp = null + let dedicatedRateLimitMessage = null + const organizationDisabledError = this._isOrganizationDisabledError( + response.statusCode, + response.body + ) + + // 检查是否为401状态码(未授权) + if (response.statusCode === 401) { + logger.warn(`🔐 Unauthorized error (401) detected for account ${accountId}`) + + // 记录401错误 + await this.recordUnauthorizedError(accountId) + + // 检查是否需要标记为异常(遇到1次401就停止调度) + const errorCount = await this.getUnauthorizedErrorCount(accountId) + logger.info( + `🔐 Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes` + ) + + if (errorCount >= 1) { + logger.error( + `❌ Account ${accountId} encountered 401 error (${errorCount} errors), temporarily pausing` + ) + } + await upstreamErrorHelper.markTempUnavailable(accountId, accountType, 401).catch(() => {}) + // 清除粘性会话,让后续请求路由到其他账户 + if (sessionHash) { + await unifiedClaudeScheduler.clearSessionMapping(sessionHash).catch(() => {}) + } + } + // 检查是否为组织被禁用/封禁错误(400 或 403) + // 必须在通用 403 处理之前检测,否则会被截断 + else if (organizationDisabledError) { + logger.error( + `🚫 Organization disabled/banned error (${response.statusCode}) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler.markAccountBlocked(accountId, accountType, sessionHash) + } + // 检查是否为403状态码(禁止访问,非封禁类) + // 注意:如果进行了重试,retryCount > 0;这里的 403 是重试后最终的结果 + else if (response.statusCode === 403) { + logger.error( + `🚫 Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, temporarily pausing` + ) + await upstreamErrorHelper.markTempUnavailable(accountId, accountType, 403).catch(() => {}) + // 清除粘性会话,让后续请求路由到其他账户 + if (sessionHash) { + await unifiedClaudeScheduler.clearSessionMapping(sessionHash).catch(() => {}) + } + } + // 检查是否为529状态码(服务过载) + else if (response.statusCode === 529) { + logger.warn(`🚫 Overload error (529) detected for account ${accountId}`) + + // 检查是否启用了529错误处理 + if (config.claude.overloadHandling.enabled > 0) { + try { + await claudeAccountService.markAccountOverloaded(accountId) + logger.info( + `🚫 Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes` + ) + } catch (overloadError) { + logger.error(`❌ Failed to mark account as overloaded: ${accountId}`, overloadError) + } + } else { + logger.info(`🚫 529 error handling is disabled, skipping account overload marking`) + } + await upstreamErrorHelper.markTempUnavailable(accountId, accountType, 529).catch(() => {}) + } + // 检查是否为5xx状态码 + else if (response.statusCode >= 500 && response.statusCode < 600) { + logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`) + await this._handleServerError(accountId, response.statusCode, sessionHash) + } + // 检查是否为429状态码 + else if (response.statusCode === 429) { + // 💰 先检查是否为 "Extra usage required" 的非限流 429 + if (this._isExtraUsageRequired429(response.statusCode, response.body)) { + logger.info( + `💰 [Non-Stream] "Extra usage required" 429 for account ${accountId}, skipping rate limit marking` + ) + } else { + const resetHeader = response.headers + ? response.headers['anthropic-ratelimit-unified-reset'] + : null + const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN + + if (requestModelFamily && !Number.isNaN(parsedResetTimestamp)) { + // 模型级限额:只停用该模型家族,不改写为账号级限流 + await claudeAccountService.markAccountModelRateLimited( + accountId, + requestModelFamily, + parsedResetTimestamp + ) + logger.warn( + `🚫 Account ${accountId} hit ${requestModelFamily} limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` + ) + + if (isOpusModelRequest && isDedicatedOfficialAccount) { + const limitMessage = this._buildOpusLimitMessage(parsedResetTimestamp) + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'opus_weekly_limit', + message: limitMessage + }), + accountId + } + } + } else { + isRateLimited = true + if (!Number.isNaN(parsedResetTimestamp)) { + rateLimitResetTimestamp = parsedResetTimestamp + logger.info( + `🕐 Extracted rate limit reset timestamp: ${rateLimitResetTimestamp} (${new Date(rateLimitResetTimestamp * 1000).toISOString()})` + ) + } + // 仅在拿到权威 reset 头时构建专属限流提示;无 reset 头的 429 大概率不是真实限流, + // 直接透传上游错误,避免被改写为 403 "upstream_rate_limited" 误导客户端 + if (isDedicatedOfficialAccount && rateLimitResetTimestamp) { + dedicatedRateLimitMessage = this._buildStandardRateLimitMessage( + rateLimitResetTimestamp || account?.rateLimitEndAt + ) + } + } + } + } else { + // 检查响应体中的错误信息 + try { + const responseBody = + typeof response.body === 'string' ? JSON.parse(response.body) : response.body + if ( + responseBody && + responseBody.error && + responseBody.error.message && + responseBody.error.message.toLowerCase().includes("exceed your account's rate limit") + ) { + isRateLimited = true + } + } catch (e) { + // 如果解析失败,检查原始字符串 + if ( + response.body && + response.body.toLowerCase().includes("exceed your account's rate limit") + ) { + isRateLimited = true + } + } + } + + if (isRateLimited) { + const isAgentViewAuxiliaryRequest = this._isAgentViewAuxiliaryRequest( + requestBody, + clientHeaders + ) + if (isAgentViewAuxiliaryRequest) { + logger.warn( + `🚫 Agent View auxiliary request hit 429 for account ${accountId}; skipping account-level rate-limit marking` + ) + } else { + if (!rateLimitResetTimestamp) { + // 无权威 reset 头的 429 大概率不是真实限流,不标记账号、不进入冷却,直接透传错误 + logger.warn( + `⚠️ Rate limit without reset header for account ${accountId}, status: ${response.statusCode}, skipping rate limit marking` + ) + } else { + if (isDedicatedOfficialAccount && !dedicatedRateLimitMessage) { + dedicatedRateLimitMessage = this._buildStandardRateLimitMessage( + rateLimitResetTimestamp || account?.rateLimitEndAt + ) + } + logger.warn( + `🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}` + ) + // 标记账号为限流状态并删除粘性会话映射,传递准确的重置时间戳 + await unifiedClaudeScheduler.markAccountRateLimited( + accountId, + accountType, + sessionHash, + rateLimitResetTimestamp + ) + await upstreamErrorHelper + .markTempUnavailable( + accountId, + accountType, + 429, + upstreamErrorHelper.parseRetryAfter(response.headers) + ) + .catch(() => {}) + } + } + + if (dedicatedRateLimitMessage) { + return { + statusCode: 403, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + error: 'upstream_rate_limited', + message: dedicatedRateLimitMessage + }), + accountId + } + } + } + } else if (response.statusCode === 200 || response.statusCode === 201) { + // 提取5小时会话窗口状态 + // 使用大小写不敏感的方式获取响应头 + const get5hStatus = (headers) => { + if (!headers) { + return null + } + // HTTP头部名称不区分大小写,需要处理不同情况 + return ( + headers['anthropic-ratelimit-unified-5h-status'] || + headers['Anthropic-Ratelimit-Unified-5h-Status'] || + headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS'] + ) + } + + const sessionWindowStatus = get5hStatus(response.headers) + if (sessionWindowStatus) { + logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`) + // 保存会话窗口状态到账户数据 + await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus) + } + + // 请求成功,清除401和500错误计数 + await this.clearUnauthorizedErrors(accountId) + await claudeAccountService.clearInternalErrors(accountId) + // 如果请求成功,检查并移除限流状态 + const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( + accountId, + accountType + ) + if (isRateLimited) { + await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType) + } + + // 如果请求成功,检查并移除过载状态 + try { + const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeAccountService.removeAccountOverload(accountId) + } + } catch (overloadError) { + logger.error( + `❌ Failed to check/remove overload status for account ${accountId}:`, + overloadError + ) + } + + // 只有真实的 Claude Code 请求才更新 headers + if ( + clientHeaders && + Object.keys(clientHeaders).length > 0 && + this.isRealClaudeCodeRequest(requestBody) + ) { + await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) + } + } + + // 记录成功的API调用并打印详细的usage数据 + let responseBody = null + try { + responseBody = typeof response.body === 'string' ? JSON.parse(response.body) : response.body + } catch (e) { + logger.debug('Failed to parse response body for usage logging') + } + + if (responseBody && responseBody.usage) { + const { usage } = responseBody + // 打印原始usage数据为JSON字符串 + logger.info( + `📊 === Non-Stream Request Usage Summary === Model: ${requestBody.model}, Usage: ${JSON.stringify(usage)}` + ) + } else { + // 如果没有usage数据,使用估算值 + const inputTokens = requestBody.messages + ? requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 + : 0 + const outputTokens = response.content + ? response.content.reduce((sum, content) => sum + (content.text?.length || 0), 0) / 4 + : 0 + + logger.info( + `✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens (estimated), Output: ~${Math.round(outputTokens)} tokens (estimated)` + ) + } + + // 在响应中添加accountId,以便调用方记录账户级别统计 + response.accountId = accountId + return response + } catch (error) { + logger.error( + `❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, + error.message + ) + throw error + } finally { + // 🧹 清理 bodyStore + if (bodyStoreIdNonStream !== null) { + this.bodyStore.delete(bodyStoreIdNonStream) + } + // 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放) + if (queueLockAcquired && queueRequestId && selectedAccountId) { + try { + await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId) + logger.debug( + `📬 User message queue lock released in finally for account ${selectedAccountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock for account ${selectedAccountId}:`, + releaseError.message + ) + } + } + } + } + + // 🔧 修补孤立的 tool_use(缺少对应 tool_result) + // 客户端在长对话中可能截断历史消息,导致 tool_use 丢失对应的 tool_result, + // 上游 Claude API 严格校验每个 tool_use 必须紧跟 tool_result,否则返回 400。 + _patchOrphanedToolUse(messages) { + if (!Array.isArray(messages) || messages.length === 0) { + return messages + } + + const SYNTHETIC_TEXT = '[tool_result missing; tool execution interrupted]' + const makeSyntheticResult = (toolUseId) => ({ + type: 'tool_result', + tool_use_id: toolUseId, + is_error: true, + content: [{ type: 'text', text: SYNTHETIC_TEXT }] + }) + + const pendingToolUseIds = [] + const patched = [] + + for (const message of messages) { + if (!message || !Array.isArray(message.content)) { + patched.push(message) + continue + } + + if (message.role === 'assistant') { + if (pendingToolUseIds.length > 0) { + patched.push({ + role: 'user', + content: pendingToolUseIds.map(makeSyntheticResult) + }) + logger.warn( + `🔧 Patched ${pendingToolUseIds.length} orphaned tool_use(s): ${pendingToolUseIds.join(', ')}` + ) + pendingToolUseIds.length = 0 + } + + const toolUseIds = message.content + .filter((part) => part?.type === 'tool_use' && typeof part.id === 'string') + .map((part) => part.id) + if (toolUseIds.length > 0) { + pendingToolUseIds.push(...toolUseIds) + } + + patched.push(message) + continue + } + + if (message.role === 'user' && pendingToolUseIds.length > 0) { + const toolResultIds = new Set( + message.content + .filter((p) => p?.type === 'tool_result' && typeof p.tool_use_id === 'string') + .map((p) => p.tool_use_id) + ) + const missing = pendingToolUseIds.filter((id) => !toolResultIds.has(id)) + + if (missing.length > 0) { + const synthetic = missing.map(makeSyntheticResult) + logger.warn( + `🔧 Patched ${missing.length} missing tool_result(s) in user message: ${missing.join(', ')}` + ) + message.content = [...synthetic, ...message.content] + } + + pendingToolUseIds.length = 0 + } + + patched.push(message) + } + + if (pendingToolUseIds.length > 0) { + patched.push({ + role: 'user', + content: pendingToolUseIds.map(makeSyntheticResult) + }) + logger.warn( + `🔧 Patched ${pendingToolUseIds.length} trailing orphaned tool_use(s): ${pendingToolUseIds.join(', ')}` + ) + } + + return patched + } + + // 🔄 处理请求体 + _processRequestBody(body, account = null, isRealClaudeCodeOverride = undefined) { + if (!body) { + return body + } + + // 使用 safeClone 替代 JSON.parse(JSON.stringify()) 提升性能 + const processedBody = safeClone(body) + + processedBody.messages = this._patchOrphanedToolUse(processedBody.messages) + + // 验证并限制max_tokens参数 + this._validateAndLimitMaxTokens(processedBody) + + // 移除cache_control中的ttl字段 + this._stripTtlFromCacheControl(processedBody) + + // 判断是否是真实的 Claude Code 请求 + // 优先使用调用方传入的值(基于 UA + system prompt 综合判断), + // 解决原逻辑中仅凭 system prompt 相似度判断导致的不一致问题 + const isRealClaudeCode = + isRealClaudeCodeOverride !== undefined + ? isRealClaudeCodeOverride + : this.isRealClaudeCodeRequest(processedBody) + + // 如果不是真实的 Claude Code 请求,需要处理 system prompt + // 策略:将原始 system prompt 迁移至 messages,system 仅保留 Claude Code 标识 + // 原因:Anthropic 基于 system 参数内容检测第三方应用,仅前置追加 Claude Code 提示词 + // 无法通过检测,因为后续内容仍为非 Claude Code 格式 + if (!isRealClaudeCode) { + // 提取原始 system prompt 文本 + let originalSystemText = '' + if (typeof processedBody.system === 'string') { + originalSystemText = processedBody.system + } else if (Array.isArray(processedBody.system)) { + originalSystemText = processedBody.system + .filter((item) => item && item.type === 'text' && item.text) + .map((item) => item.text) + .join('\n\n') + } + + // 将 system 替换为 Claude Code 标准提示词 + processedBody.system = this.claudeCodeSystemPrompt + + // 将原始 system prompt 作为 user/assistant 消息对注入到 messages 开头 + // 模型仍通过 messages 接收完整指令,保留客户端功能 + if (originalSystemText && originalSystemText.trim()) { + const instructionMessage = { + role: 'user', + content: [ + { + type: 'text', + text: `[System Instructions - follow these strictly]\n${originalSystemText.trim()}` + } + ] + } + const ackMessage = { + role: 'assistant', + content: [{ type: 'text', text: 'Understood. I will follow these instructions.' }] + } + if (!Array.isArray(processedBody.messages)) { + processedBody.messages = [] + } + processedBody.messages.unshift(instructionMessage, ackMessage) + } + } + + // 如果不是真实的 Claude Code 请求且缺少 metadata.user_id,注入合法的 user_id + // 非 Claude Code 客户端通常不发送 metadata,补全后避免上游检测到缺失 + if (!isRealClaudeCode) { + if (!processedBody.metadata || typeof processedBody.metadata !== 'object') { + processedBody.metadata = {} + } + if (!processedBody.metadata.user_id || typeof processedBody.metadata.user_id !== 'string') { + const crypto = require('crypto') + const deviceId = crypto.createHash('sha256').update('relay-generated-device').digest('hex') + const sessionId = crypto.randomUUID() + processedBody.metadata.user_id = JSON.stringify({ + device_id: deviceId, + account_uuid: '', + session_id: sessionId + }) + } + } + + // 移除 x-anthropic-billing-header 系统元素,避免将客户端 billing 标识传递给上游 API + this._removeBillingHeaderFromSystem(processedBody) + + this._enforceCacheControlLimit(processedBody) + + // 处理原有的系统提示(如果配置了) + if (this.systemPrompt && this.systemPrompt.trim()) { + const systemPrompt = { + type: 'text', + text: this.systemPrompt + } + + // 经过上面的处理,system 现在应该总是数组格式 + if (processedBody.system && Array.isArray(processedBody.system)) { + // 不要重复添加相同的系统提示 + const hasSystemPrompt = processedBody.system.some( + (item) => item && item.text && item.text === this.systemPrompt + ) + if (!hasSystemPrompt) { + processedBody.system.push(systemPrompt) + } + } else { + // 理论上不应该走到这里,但为了安全起见 + processedBody.system = [systemPrompt] + } + } else { + // 如果没有配置系统提示,且system字段为空,则删除它 + if (processedBody.system && Array.isArray(processedBody.system)) { + const hasValidContent = processedBody.system.some( + (item) => item && item.text && item.text.trim() + ) + if (!hasValidContent) { + delete processedBody.system + } + } + } + + // Claude API只允许temperature或top_p其中之一,优先使用temperature + if (processedBody.top_p !== undefined && processedBody.top_p !== null) { + delete processedBody.top_p + } + + // 处理统一的客户端标识 + if (account && account.useUnifiedClientId === 'true' && account.unifiedClientId) { + this._replaceClientId(processedBody, account.unifiedClientId) + } + + return processedBody + } + + // 🔄 替换请求中的客户端标识 + _replaceClientId(body, unifiedClientId) { + if (!body?.metadata?.user_id || !unifiedClientId) { + return + } + + const parsed = metadataUserIdHelper.parse(body.metadata.user_id) + if (!parsed) { + return + } + + body.metadata.user_id = metadataUserIdHelper.build({ + ...parsed, + deviceId: unifiedClientId + }) + logger.info(`🔄 Replaced client ID with unified ID: ${body.metadata.user_id}`) + } + + // 🧹 移除 billing header 系统提示元素 + _removeBillingHeaderFromSystem(processedBody) { + if (!processedBody || !processedBody.system) { + return + } + + if (typeof processedBody.system === 'string') { + if (processedBody.system.trim().startsWith('x-anthropic-billing-header')) { + logger.debug('🧹 Removed billing header from string system prompt') + delete processedBody.system + } + return + } + + if (Array.isArray(processedBody.system)) { + const originalLength = processedBody.system.length + processedBody.system = processedBody.system.filter( + (item) => + !( + item && + item.type === 'text' && + typeof item.text === 'string' && + item.text.trim().startsWith('x-anthropic-billing-header') + ) + ) + if (processedBody.system.length < originalLength) { + logger.debug( + `🧹 Removed ${originalLength - processedBody.system.length} billing header element(s) from system array` + ) + } + } + } + + // 🔢 验证并限制max_tokens参数 + _validateAndLimitMaxTokens(body) { + if (!body || !body.max_tokens) { + return + } + + try { + // 使用缓存的定价数据 + const pricingFilePath = path.join(__dirname, '../../data/model_pricing.json') + const pricingData = getPricingData(pricingFilePath) + + if (!pricingData) { + logger.warn('⚠️ Model pricing file not found, skipping max_tokens validation') + return + } + + const model = body.model || 'claude-sonnet-4-20250514' + + // 查找对应模型的配置 + const modelConfig = pricingData[model] + + if (!modelConfig) { + // 如果找不到模型配置,直接透传客户端参数,不进行任何干预 + logger.info( + `📝 Model ${model} not found in pricing file, passing through client parameters without modification` + ) + return + } + + // 获取模型的最大token限制 + const maxLimit = modelConfig.max_tokens || modelConfig.max_output_tokens + + if (!maxLimit) { + logger.debug(`🔍 No max_tokens limit found for model ${model}, skipping validation`) + return + } + + // 检查并调整max_tokens + if (body.max_tokens > maxLimit) { + logger.warn( + `⚠️ max_tokens ${body.max_tokens} exceeds limit ${maxLimit} for model ${model}, adjusting to ${maxLimit}` + ) + body.max_tokens = maxLimit + } + } catch (error) { + logger.error('❌ Failed to validate max_tokens from pricing file:', error) + // 如果文件读取失败,不进行校验,让请求继续处理 + } + } + + // 🧹 移除TTL字段 + _stripTtlFromCacheControl(body) { + if (!body || typeof body !== 'object') { + return + } + + const processContentArray = (contentArray) => { + if (!Array.isArray(contentArray)) { + return + } + + contentArray.forEach((item) => { + if (item && typeof item === 'object' && item.cache_control) { + if (item.cache_control.ttl) { + delete item.cache_control.ttl + logger.debug('🧹 Removed ttl from cache_control') + } + } + }) + } + + if (Array.isArray(body.system)) { + processContentArray(body.system) + } + + if (Array.isArray(body.messages)) { + body.messages.forEach((message) => { + if (message && Array.isArray(message.content)) { + processContentArray(message.content) + } + }) + } + } + + // ⚖️ 限制带缓存控制的内容数量 + _enforceCacheControlLimit(body) { + const MAX_CACHE_CONTROL_BLOCKS = 4 + + if (!body || typeof body !== 'object') { + return + } + + const countCacheControlBlocks = () => { + let total = 0 + + if (Array.isArray(body.messages)) { + body.messages.forEach((message) => { + if (!message || !Array.isArray(message.content)) { + return + } + message.content.forEach((item) => { + if (item && item.cache_control) { + total += 1 + } + }) + }) + } + + if (Array.isArray(body.system)) { + body.system.forEach((item) => { + if (item && item.cache_control) { + total += 1 + } + }) + } + + return total + } + + // 只移除 cache_control 属性,保留内容本身,避免丢失用户消息 + const removeCacheControlFromMessages = () => { + if (!Array.isArray(body.messages)) { + return false + } + + for (let messageIndex = 0; messageIndex < body.messages.length; messageIndex += 1) { + const message = body.messages[messageIndex] + if (!message || !Array.isArray(message.content)) { + continue + } + + for (let contentIndex = 0; contentIndex < message.content.length; contentIndex += 1) { + const contentItem = message.content[contentIndex] + if (contentItem && contentItem.cache_control) { + // 只删除 cache_control 属性,保留内容 + delete contentItem.cache_control + return true + } + } + } + + return false + } + + // 只移除 cache_control 属性,保留 system 内容 + const removeCacheControlFromSystem = () => { + if (!Array.isArray(body.system)) { + return false + } + + for (let index = 0; index < body.system.length; index += 1) { + const systemItem = body.system[index] + if (systemItem && systemItem.cache_control) { + // 只删除 cache_control 属性,保留内容 + delete systemItem.cache_control + return true + } + } + + return false + } + + let total = countCacheControlBlocks() + + while (total > MAX_CACHE_CONTROL_BLOCKS) { + // 优先从 messages 中移除 cache_control,再从 system 中移除 + if (removeCacheControlFromMessages()) { + total -= 1 + continue + } + + if (removeCacheControlFromSystem()) { + total -= 1 + continue + } + + break + } + } + + // 🌐 获取代理Agent(使用统一的代理工具) + async _getProxyAgent(accountId, account = null) { + try { + // 优先使用传入的 account 对象,避免重复查询 + const accountData = account || (await claudeAccountService.getAccount(accountId)) + + if (!accountData || !accountData.proxy) { + logger.debug('🌐 No proxy configured for Claude account') + return null + } + + const proxyAgent = ProxyHelper.createProxyAgent(accountData.proxy) + if (proxyAgent) { + logger.info( + `🌐 Using proxy for Claude request: ${ProxyHelper.getProxyDescription(accountData.proxy)}` + ) + } + return proxyAgent + } catch (error) { + logger.warn('⚠️ Failed to create proxy agent:', error) + return null + } + } + + // 🔧 过滤客户端请求头 + _filterClientHeaders(clientHeaders) { + // 使用统一的 headerFilter 工具类 + // 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查 + return filterForClaude(clientHeaders) + } + + // 🔧 准备请求头和 payload(抽离公共逻辑) + async _prepareRequestHeadersAndPayload( + body, + clientHeaders, + accountId, + accessToken, + options = {} + ) { + const { account, accountType, sessionHash, requestOptions = {}, isStream = false } = options + + // 获取统一的 User-Agent + const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account) + + // 获取过滤后的客户端 headers + const filteredHeaders = this._filterClientHeaders(clientHeaders) + + const isRealClaudeCode = + requestOptions.isRealClaudeCodeRequest === undefined + ? this.isRealClaudeCodeRequest(body) + : requestOptions.isRealClaudeCodeRequest === true + + // 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers + let finalHeaders = { ...filteredHeaders } + let requestPayload = body + + if (!isRealClaudeCode) { + const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId) + Object.keys(claudeCodeHeaders).forEach((key) => { + finalHeaders[key] = claudeCodeHeaders[key] + }) + } + + // 应用请求身份转换 + const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, { + account, + accountId, + accountType, + sessionHash, + clientHeaders, + requestOptions, + isStream + }) + + if (extensionResult.abortResponse) { + return { abortResponse: extensionResult.abortResponse } + } + + requestPayload = extensionResult.body + finalHeaders = extensionResult.headers + + let toolNameMap = null + if (!isRealClaudeCode) { + toolNameMap = this._transformToolNamesInRequestBody(requestPayload, { + useRandomizedToolNames: requestOptions.useRandomizedToolNames === true + }) + } + + // 序列化请求体,计算 content-length + const bodyString = JSON.stringify(requestPayload) + const contentLength = Buffer.byteLength(bodyString, 'utf8') + + // 构建最终请求头(包含认证、版本、User-Agent、Beta 等) + // Force identity encoding to prevent upstream (Cloudflare) from returning + // gzip-compressed responses without a Content-Encoding header, which causes + // binary data to be silently corrupted by UTF-8 text decoding in the stream + // handler. See: https://github.com/Wei-Shaw/claude-relay-service/issues/1030 + const headers = { + host: 'api.anthropic.com', + connection: 'keep-alive', + 'content-type': 'application/json', + 'content-length': String(contentLength), + 'accept-encoding': 'identity', + authorization: `Bearer ${accessToken}`, + 'anthropic-version': this.apiVersion, + ...finalHeaders + } + + // 强制 identity 编码:finalHeaders 可能携带客户端或 Redis 缓存中的 accept-encoding(如 zstd), + // 必须在 spread 后覆盖回 identity,因为 https.request 的手动解压只支持 gzip/deflate + headers['accept-encoding'] = 'identity' + + // 使用统一 User-Agent 或客户端提供的,最后使用默认值 + const userAgent = unifiedUA || headers['user-agent'] || 'claude-cli/1.0.119 (external, cli)' + const acceptHeader = headers['accept'] || 'application/json' + delete headers['user-agent'] + delete headers['accept'] + headers['User-Agent'] = userAgent + headers['Accept'] = acceptHeader + + logger.debug(`🔗 Request User-Agent: ${headers['User-Agent']}`) + + // 根据模型和客户端传递的 anthropic-beta 动态设置 header + const modelId = requestPayload?.model || body?.model + const clientBetaHeader = this._getHeaderValueCaseInsensitive(clientHeaders, 'anthropic-beta') + headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader) + return { + requestPayload, + bodyString, + headers, + isRealClaudeCode, + toolNameMap + } + } + + _applyRequestIdentityTransform(body, headers, context = {}) { + const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {} + + try { + const payload = { + body, + headers: normalizedHeaders, + ...context + } + + const result = requestIdentityService.transform(payload) + if (!result || typeof result !== 'object') { + return { body, headers: normalizedHeaders } + } + + const nextBody = result.body && typeof result.body === 'object' ? result.body : body + const nextHeaders = + result.headers && typeof result.headers === 'object' ? result.headers : normalizedHeaders + const abortResponse = + result.abortResponse && typeof result.abortResponse === 'object' + ? result.abortResponse + : null + + return { body: nextBody, headers: nextHeaders, abortResponse } + } catch (error) { + logger.warn('⚠️ 应用请求身份转换失败:', error) + return { body, headers: normalizedHeaders } + } + } + + // 🔗 发送请求到Claude API + async _makeClaudeRequest( + body, + accessToken, + proxyAgent, + clientHeaders, + accountId, + onRequest, + requestOptions = {} + ) { + const url = new URL(this.claudeApiUrl) + + // 获取账户信息用于统一 User-Agent + const account = await claudeAccountService.getAccount(accountId) + + // 使用公共方法准备请求头和 payload + const prepared = await this._prepareRequestHeadersAndPayload( + body, + clientHeaders, + accountId, + accessToken, + { + account, + requestOptions, + isStream: false + } + ) + + if (prepared.abortResponse) { + return prepared.abortResponse + } + + let { bodyString } = prepared + const { headers, isRealClaudeCode, toolNameMap } = prepared + + return new Promise((resolve, reject) => { + // 支持自定义路径(如 count_tokens) + let requestPath = url.pathname + if (requestOptions.customPath) { + const baseUrl = new URL('https://api.anthropic.com') + const customUrl = new URL(requestOptions.customPath, baseUrl) + requestPath = customUrl.pathname + } + + const options = { + hostname: url.hostname, + port: url.port || 443, + path: requestPath + (url.search || ''), + method: 'POST', + headers, + agent: proxyAgent || getHttpsAgentForNonStream(), + timeout: config.requestTimeout || 600000 + } + + const req = https.request(options, (res) => { + // 使用数组收集 chunks,避免 O(n²) 的 Buffer.concat + const chunks = [] + + res.on('data', (chunk) => { + chunks.push(chunk) + }) + + res.on('end', () => { + try { + // 一次性合并所有 chunks + const responseData = Buffer.concat(chunks) + let responseBody = '' + + // 根据Content-Encoding处理响应数据 + const contentEncoding = res.headers['content-encoding'] + if (contentEncoding === 'gzip') { + try { + responseBody = zlib.gunzipSync(responseData).toString('utf8') + } catch (unzipError) { + logger.error('❌ Failed to decompress gzip response:', unzipError) + responseBody = responseData.toString('utf8') + } + } else if (contentEncoding === 'deflate') { + try { + responseBody = zlib.inflateSync(responseData).toString('utf8') + } catch (unzipError) { + logger.error('❌ Failed to decompress deflate response:', unzipError) + responseBody = responseData.toString('utf8') + } + } else { + responseBody = responseData.toString('utf8') + } + + if (!isRealClaudeCode) { + responseBody = this._restoreToolNamesInResponseBody(responseBody, toolNameMap) + } + + const response = { + statusCode: res.statusCode, + headers: res.headers, + body: responseBody + } + + logger.debug(`🔗 Claude API response: ${res.statusCode}`) + + resolve(response) + } catch (error) { + logger.error(`❌ Failed to parse Claude API response (Account: ${accountId}):`, error) + reject(error) + } + }) + }) + + // 如果提供了 onRequest 回调,传递请求对象 + if (onRequest && typeof onRequest === 'function') { + onRequest(req) + } + + req.on('error', async (error) => { + logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, { + code: error.code, + errno: error.errno, + syscall: error.syscall, + address: error.address, + port: error.port + }) + + // 根据错误类型提供更具体的错误信息 + let errorMessage = 'Upstream request failed' + if (error.code === 'ECONNRESET') { + errorMessage = 'Connection reset by Claude API server' + } else if (error.code === 'ENOTFOUND') { + errorMessage = 'Unable to resolve Claude API hostname' + } else if (error.code === 'ECONNREFUSED') { + errorMessage = 'Connection refused by Claude API server' + } else if (error.code === 'ETIMEDOUT') { + errorMessage = 'Connection timed out to Claude API server' + + await this._handleServerError(accountId, 504, null, 'Network') + } + + reject(new Error(errorMessage)) + }) + + req.on('timeout', async () => { + req.destroy() + logger.error(`❌ Claude API request timeout (Account: ${accountId})`) + + await this._handleServerError(accountId, 504, null, 'Request') + + reject(new Error('Request timeout')) + }) + + // 写入请求体 + req.write(bodyString) + // 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获 + bodyString = null + req.end() + }) + } + + // 🌊 处理流式响应(带usage数据捕获) + async relayStreamRequestWithUsageCapture( + requestBody, + apiKeyData, + responseStream, + clientHeaders, + usageCallback, + streamTransformer = null, + options = {} + ) { + let queueLockAcquired = false + let queueRequestId = null + let selectedAccountId = null + + try { + // 调试日志:查看API Key数据(流式请求) + logger.info('🔍 [Stream] API Key data received:', { + apiKeyName: apiKeyData.name, + enableModelRestriction: apiKeyData.enableModelRestriction, + restrictedModels: apiKeyData.restrictedModels, + requestedModel: requestBody.model + }) + + const isOpusModelRequest = + typeof requestBody?.model === 'string' && requestBody.model.toLowerCase().includes('opus') + + // 生成会话哈希用于sticky会话 + const sessionHash = sessionHelper.generateSessionHash(requestBody) + + // 选择可用的Claude账户(支持专属绑定和sticky会话) + let accountSelection + try { + accountSelection = await unifiedClaudeScheduler.selectAccountForApiKey( + apiKeyData, + sessionHash, + requestBody.model + ) + } catch (error) { + if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { + const limitMessage = this._buildStandardRateLimitMessage(error.rateLimitEndAt) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }) + ) + responseStream.end() + return + } + if (error.code === 'CLAUDE_DEDICATED_UNAVAILABLE') { + logger.warn( + `🚫 [Stream] Dedicated account ${error.accountId} is unavailable (${error.reason}) for API key ${apiKeyData.name}, returning 503` + ) + if (!responseStream.headersSent) { + responseStream.status(503) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'dedicated_account_unavailable', + message: `该 API Key 绑定的专属账号当前不可用(${error.reason}),请稍后重试。` + }) + ) + responseStream.end() + return + } + throw error + } + const { accountId } = accountSelection + const { accountType } = accountSelection + selectedAccountId = accountId + + // 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁 + if (userMessageQueueService.isUserMessageRequest(requestBody)) { + // 校验 accountId 非空,避免空值污染队列锁键 + if (!accountId || accountId === '') { + logger.error('❌ accountId missing for queue lock in relayStreamRequestWithUsageCapture') + throw new Error('accountId missing for queue lock') + } + // 获取账户信息以检查账户级串行队列配置 + const accountForQueue = await claudeAccountService.getAccount(accountId) + const accountConfig = accountForQueue + ? { maxConcurrency: parseInt(accountForQueue.maxConcurrency || '0', 10) } + : null + const queueResult = await userMessageQueueService.acquireQueueLock( + accountId, + null, + null, + accountConfig + ) + if (!queueResult.acquired && !queueResult.skipped) { + // 区分 Redis 后端错误和队列超时 + const isBackendError = queueResult.error === 'queue_backend_error' + const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT' + const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout' + const errorMessage = isBackendError + ? 'Queue service temporarily unavailable, please retry later' + : 'User message queue wait timeout, please retry later' + const statusCode = isBackendError ? 500 : 503 + + // 结构化性能日志,用于后续统计 + logger.performance('user_message_queue_error', { + errorType, + errorCode, + accountId, + statusCode, + stream: true, + apiKeyName: apiKeyData.name, + backendError: isBackendError ? queueResult.errorMessage : undefined + }) + + logger.warn( + `📬 User message queue ${errorType} for account ${accountId} (stream), key: ${apiKeyData.name}`, + isBackendError ? { backendError: queueResult.errorMessage } : {} + ) + if (!responseStream.headersSent) { + const existingConnection = responseStream.getHeader + ? responseStream.getHeader('Connection') + : null + responseStream.writeHead(statusCode, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive', + 'x-user-message-queue-error': errorType + }) + } + const errorEvent = `event: error\ndata: ${JSON.stringify({ + type: 'error', + error: { + type: errorType, + code: errorCode, + message: errorMessage + } + })}\n\n` + responseStream.write(errorEvent) + responseStream.write('data: [DONE]\n\n') + responseStream.end() + return + } + if (queueResult.acquired && !queueResult.skipped) { + queueLockAcquired = true + queueRequestId = queueResult.requestId + logger.debug( + `📬 User message queue lock acquired for account ${accountId} (stream), requestId: ${queueRequestId}` + ) + } + } + + logger.info( + `📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}` + ) + + // 获取账户信息 + let account = await claudeAccountService.getAccount(accountId) + + if (isOpusModelRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(accountId) + account = await claudeAccountService.getAccount(accountId) + } + + const isDedicatedOfficialAccount = + accountType === 'claude-official' && + apiKeyData.claudeAccountId && + !apiKeyData.claudeAccountId.startsWith('group:') && + apiKeyData.claudeAccountId === accountId + + let opusRateLimitActive = false + if (isOpusModelRequest) { + opusRateLimitActive = await claudeAccountService.isAccountOpusRateLimited(accountId) + } + + if (isOpusModelRequest && isDedicatedOfficialAccount && opusRateLimitActive) { + const limitMessage = this._buildOpusLimitMessage(account?.opusRateLimitEndAt) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'opus_weekly_limit', + message: limitMessage + }) + ) + responseStream.end() + return + } + + // 获取有效的访问token + const accessToken = await claudeAccountService.getValidAccessToken(accountId) + + const isRealClaudeCodeRequest = this._isActualClaudeCodeRequest(requestBody, clientHeaders) + const processedBody = this._processRequestBody(requestBody, account, isRealClaudeCodeRequest) + // 🧹 内存优化:存储到 bodyStore,不放入 requestOptions 避免闭包捕获 + const originalBodyString = JSON.stringify(processedBody) + const bodyStoreId = ++this._bodyStoreIdCounter + this.bodyStore.set(bodyStoreId, originalBodyString) + + // 获取代理配置 + const proxyAgent = await this._getProxyAgent(accountId) + + // 发送流式请求并捕获usage数据 + await this._makeClaudeStreamRequestWithUsageCapture( + processedBody, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + (usageData) => { + // 在usageCallback中添加accountId + if (usageCallback && typeof usageCallback === 'function') { + usageCallback({ ...usageData, accountId }) + } + }, + accountId, + accountType, + sessionHash, + streamTransformer, + { + ...options, + bodyStoreId, + isRealClaudeCodeRequest + }, + isDedicatedOfficialAccount, + // 📬 新增回调:在收到响应头时释放队列锁 + async () => { + if (queueLockAcquired && queueRequestId && selectedAccountId) { + try { + await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId) + queueLockAcquired = false // 标记已释放,防止 finally 重复释放 + logger.debug( + `📬 User message queue lock released early for stream account ${selectedAccountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock early for stream account ${selectedAccountId}:`, + releaseError.message + ) + } + } + } + ) + } catch (error) { + // 客户端主动断开连接是正常情况,使用 INFO 级别 + if (error.message === 'Client disconnected') { + logger.info(`🔌 Claude stream relay ended: Client disconnected`) + } else { + logger.error(`❌ Claude stream relay with usage capture failed:`, error) + } + throw error + } finally { + // 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放) + if (queueLockAcquired && queueRequestId && selectedAccountId) { + try { + await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId) + logger.debug( + `📬 User message queue lock released in finally for stream account ${selectedAccountId}, requestId: ${queueRequestId}` + ) + } catch (releaseError) { + logger.error( + `❌ Failed to release user message queue lock for stream account ${selectedAccountId}:`, + releaseError.message + ) + } + } + } + } + + // 🌊 发送流式请求到Claude API(带usage数据捕获) + async _makeClaudeStreamRequestWithUsageCapture( + body, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + usageCallback, + accountId, + accountType, + sessionHash, + streamTransformer = null, + requestOptions = {}, + isDedicatedOfficialAccount = false, + onResponseStart = null, // 📬 新增:收到响应头时的回调,用于提前释放队列锁 + retryCount = 0 // 🔄 403 重试计数器 + ) { + const maxRetries = 2 // 最大重试次数 + // 获取账户信息用于统一 User-Agent + const account = await claudeAccountService.getAccount(accountId) + + // 请求模型所属的限流家族(opus/sonnet/haiku/fable),决定 429 记入哪个限流桶 + const requestModelFamily = getRateLimitModelFamily(body?.model) + const isOpusModelRequest = requestModelFamily === 'opus' + + // 使用公共方法准备请求头和 payload + const prepared = await this._prepareRequestHeadersAndPayload( + body, + clientHeaders, + accountId, + accessToken, + { + account, + accountType, + sessionHash, + requestOptions, + isStream: true + } + ) + + if (prepared.abortResponse) { + return prepared.abortResponse + } + + let { bodyString } = prepared + const { headers, toolNameMap } = prepared + const toolNameStreamTransformer = this._createToolNameStripperStreamTransformer( + streamTransformer, + toolNameMap + ) + + return new Promise((resolve, reject) => { + const url = new URL(this.claudeApiUrl) + const options = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname + (url.search || ''), + method: 'POST', + headers, + agent: proxyAgent || getHttpsAgentForStream(), + timeout: config.requestTimeout || 600000 + } + + const req = https.request(options, async (res) => { + logger.debug(`🌊 Claude stream response status: ${res.statusCode}`) + + // 错误响应处理 + if (res.statusCode !== 200) { + if (res.statusCode === 429) { + // 💰 先读取完整 body 以区分 "Extra usage required" 和真正的限流 + const bodyChunks429 = [] + await new Promise((resolveBody) => { + res.on('data', (chunk) => bodyChunks429.push(chunk)) + res.on('end', resolveBody) + res.on('error', resolveBody) + }) + const errorBody429 = Buffer.concat(bodyChunks429).toString() + + // 检查是否为 "Extra usage required" 的非限流 429 + if (this._isExtraUsageRequired429(res.statusCode, errorBody429)) { + logger.info( + `💰 [Stream] "Extra usage required" 429 for account ${accountId}, skipping rate limit marking` + ) + logger.error( + `❌ Claude API returned error status: 429 | Account: ${account?.name || accountId}` + ) + logger.error( + `❌ Claude API error response (Account: ${account?.name || accountId}):`, + errorBody429 + ) + if (isStreamWritable(responseStream)) { + let errorMessage = `Claude API error: 429` + try { + const parsedError = JSON.parse(errorBody429) + if (parsedError.error?.message) { + errorMessage = parsedError.error.message + } else if (parsedError.message) { + errorMessage = parsedError.message + } + } catch { + // 使用默认错误消息 + } + if (toolNameStreamTransformer) { + responseStream.write( + `data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n` + ) + } else { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Claude API error', + status: 429, + details: errorBody429, + timestamp: new Date().toISOString() + })}\n\n` + ) + } + responseStream.end() + } + reject(new Error(`Claude API error: 429`)) + return + } + + // 真正的限流处理 + const resetHeader = res.headers + ? res.headers['anthropic-ratelimit-unified-reset'] + : null + const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN + + if (requestModelFamily) { + if (!Number.isNaN(parsedResetTimestamp)) { + // 模型级限额:只停用该模型家族,不改写为账号级限流 + await claudeAccountService.markAccountModelRateLimited( + accountId, + requestModelFamily, + parsedResetTimestamp + ) + logger.warn( + `🚫 [Stream] Account ${accountId} hit ${requestModelFamily} limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` + ) + } + + if (isOpusModelRequest && isDedicatedOfficialAccount) { + const limitMessage = this._buildOpusLimitMessage(parsedResetTimestamp) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'opus_weekly_limit', + message: limitMessage + }) + ) + responseStream.end() + resolve() + return + } + } else { + const rateLimitResetTimestamp = Number.isNaN(parsedResetTimestamp) + ? null + : parsedResetTimestamp + const isAgentViewAuxiliaryRequest = this._isAgentViewAuxiliaryRequest( + body, + clientHeaders + ) + if (isAgentViewAuxiliaryRequest) { + logger.warn( + `🚫 [Stream] Agent View auxiliary request hit 429 for account ${accountId}; skipping account-level rate-limit marking` + ) + } else if (!rateLimitResetTimestamp) { + // 无权威 reset 头的 429 大概率不是真实限流,不标记账号、不进入冷却,直接透传错误 + logger.warn( + `⚠️ [Stream] 429 without reset header for account ${accountId}, skipping rate limit marking` + ) + } else { + await unifiedClaudeScheduler.markAccountRateLimited( + accountId, + accountType, + sessionHash, + rateLimitResetTimestamp + ) + await upstreamErrorHelper + .markTempUnavailable( + accountId, + accountType, + 429, + upstreamErrorHelper.parseRetryAfter(res.headers) + ) + .catch(() => {}) + logger.warn(`🚫 [Stream] Rate limit detected for account ${accountId}, status 429`) + } + + // 仅在拿到权威 reset 头时才把 429 改写为 403 "upstream_rate_limited"; + // 无 reset 头的 429 直接透传上游错误,避免客户端误判为权限问题(如提示 /login) + if ( + isDedicatedOfficialAccount && + !isAgentViewAuxiliaryRequest && + rateLimitResetTimestamp + ) { + const limitMessage = this._buildStandardRateLimitMessage( + rateLimitResetTimestamp || account?.rateLimitEndAt + ) + if (!responseStream.headersSent) { + responseStream.status(403) + responseStream.setHeader('Content-Type', 'application/json') + } + responseStream.write( + JSON.stringify({ + error: 'upstream_rate_limited', + message: limitMessage + }) + ) + responseStream.end() + resolve() + return + } + } + + // 非专属账户的真正限流:透传错误给客户端(body 已读完,无需 fall-through) + logger.error( + `❌ Claude API returned error status: 429 | Account: ${account?.name || accountId}` + ) + logger.error( + `❌ Claude API error response (Account: ${account?.name || accountId}):`, + errorBody429 + ) + if (isStreamWritable(responseStream)) { + let errorMessage = `Claude API error: 429` + try { + const parsedError = JSON.parse(errorBody429) + if (parsedError.error?.message) { + errorMessage = parsedError.error.message + } else if (parsedError.message) { + errorMessage = parsedError.message + } + } catch { + // 使用默认错误消息 + } + if (toolNameStreamTransformer) { + responseStream.write( + `data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n` + ) + } else { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Claude API error', + status: 429, + details: errorBody429, + timestamp: new Date().toISOString() + })}\n\n` + ) + } + responseStream.end() + } + reject(new Error(`Claude API error: 429`)) + return + } + + // 🔄 403 重试机制(必须在设置 res.on('data')/res.on('end') 之前处理) + // 否则重试时旧响应的 on('end') 会与新请求产生竞态条件 + if (res.statusCode === 403) { + const canRetry = + this._shouldRetryOn403(accountType) && + retryCount < maxRetries && + !responseStream.headersSent + + if (canRetry) { + logger.warn( + `🔄 [Stream] 403 error for account ${accountId}, retry ${retryCount + 1}/${maxRetries} after 2s` + ) + // 消费当前响应并销毁请求 + res.resume() + req.destroy() + + // 等待 2 秒后递归重试 + await this._sleep(2000) + + try { + // 递归调用自身进行重试 + // 🧹 从 bodyStore 获取字符串用于重试 + if ( + !requestOptions.bodyStoreId || + !this.bodyStore.has(requestOptions.bodyStoreId) + ) { + throw new Error('529 retry requires valid bodyStoreId') + } + let retryBody + try { + retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId)) + } catch (parseError) { + logger.error(`❌ Failed to parse body for 529 retry: ${parseError.message}`) + throw new Error(`529 retry body parse failed: ${parseError.message}`) + } + const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( + retryBody, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + usageCallback, + accountId, + accountType, + sessionHash, + streamTransformer, + requestOptions, + isDedicatedOfficialAccount, + onResponseStart, + retryCount + 1 + ) + resolve(retryResult) + } catch (retryError) { + reject(retryError) + } + return // 重要:提前返回,不设置后续的错误处理器 + } + } + + // 将错误处理逻辑封装在一个异步函数中 + const handleErrorResponse = async () => { + if (res.statusCode === 401) { + logger.warn(`🔐 [Stream] Unauthorized error (401) detected for account ${accountId}`) + + await this.recordUnauthorizedError(accountId) + + const errorCount = await this.getUnauthorizedErrorCount(accountId) + logger.info( + `🔐 [Stream] Account ${accountId} has ${errorCount} consecutive 401 errors in the last 5 minutes` + ) + + if (errorCount >= 1) { + logger.error( + `❌ [Stream] Account ${accountId} encountered 401 error (${errorCount} errors), temporarily pausing` + ) + } + await upstreamErrorHelper + .markTempUnavailable(accountId, accountType, 401) + .catch(() => {}) + // 清除粘性会话,让后续请求路由到其他账户 + if (sessionHash) { + await unifiedClaudeScheduler.clearSessionMapping(sessionHash).catch(() => {}) + } + } else if (res.statusCode === 403) { + // 403 处理:先检查是否为封禁性质的 403(组织被禁用/OAuth 被禁止) + // 注意:重试逻辑已在 handleErrorResponse 外部提前处理 + if (this._isOrganizationDisabledError(res.statusCode, errorData)) { + logger.error( + `🚫 [Stream] Organization disabled/banned error (403) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler + .markAccountBlocked(accountId, accountType, sessionHash) + .catch((markError) => { + logger.error( + `❌ [Stream] Failed to mark account ${accountId} as blocked:`, + markError + ) + }) + } else { + logger.error( + `🚫 [Stream] Forbidden error (403) detected for account ${accountId}${retryCount > 0 ? ` after ${retryCount} retries` : ''}, temporarily pausing` + ) + await upstreamErrorHelper + .markTempUnavailable(accountId, accountType, 403) + .catch(() => {}) + } + // 清除粘性会话,让后续请求路由到其他账户 + if (sessionHash) { + await unifiedClaudeScheduler.clearSessionMapping(sessionHash).catch(() => {}) + } + } else if (res.statusCode === 529) { + logger.warn(`🚫 [Stream] Overload error (529) detected for account ${accountId}`) + + // 检查是否启用了529错误处理 + if (config.claude.overloadHandling.enabled > 0) { + try { + await claudeAccountService.markAccountOverloaded(accountId) + logger.info( + `🚫 [Stream] Account ${accountId} marked as overloaded for ${config.claude.overloadHandling.enabled} minutes` + ) + } catch (overloadError) { + logger.error( + `❌ [Stream] Failed to mark account as overloaded: ${accountId}`, + overloadError + ) + } + } else { + logger.info( + `🚫 [Stream] 529 error handling is disabled, skipping account overload marking` + ) + } + await upstreamErrorHelper + .markTempUnavailable(accountId, accountType, 529) + .catch(() => {}) + } else if (res.statusCode >= 500 && res.statusCode < 600) { + logger.warn( + `🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}` + ) + await this._handleServerError(accountId, res.statusCode, sessionHash, '[Stream]') + } + } + + // 调用异步错误处理函数 + handleErrorResponse().catch((err) => { + logger.error('❌ Error in stream error handler:', err) + }) + + logger.error( + `❌ Claude API returned error status: ${res.statusCode} | Account: ${account?.name || accountId}` + ) + let errorData = '' + + res.on('data', (chunk) => { + errorData += chunk.toString() + }) + + res.on('end', async () => { + logger.error( + `❌ Claude API error response (Account: ${account?.name || accountId}):`, + errorData + ) + if ( + this._isClaudeCodeCredentialError(errorData) && + requestOptions.useRandomizedToolNames !== true && + requestOptions.bodyStoreId && + this.bodyStore.has(requestOptions.bodyStoreId) + ) { + let retryBody + try { + retryBody = JSON.parse(this.bodyStore.get(requestOptions.bodyStoreId)) + } catch (parseError) { + logger.error(`❌ Failed to parse body for 403 retry: ${parseError.message}`) + reject(new Error(`403 retry body parse failed: ${parseError.message}`)) + return + } + try { + const retryResult = await this._makeClaudeStreamRequestWithUsageCapture( + retryBody, + accessToken, + proxyAgent, + clientHeaders, + responseStream, + usageCallback, + accountId, + accountType, + sessionHash, + streamTransformer, + { ...requestOptions, useRandomizedToolNames: true }, + isDedicatedOfficialAccount, + onResponseStart, + retryCount + ) + resolve(retryResult) + } catch (retryError) { + reject(retryError) + } + return + } + if (this._isOrganizationDisabledError(res.statusCode, errorData)) { + ;(async () => { + try { + logger.error( + `🚫 [Stream] Organization disabled error (400) detected for account ${accountId}, marking as blocked` + ) + await unifiedClaudeScheduler.markAccountBlocked( + accountId, + accountType, + sessionHash + ) + } catch (markError) { + logger.error( + `❌ [Stream] Failed to mark account ${accountId} as blocked after organization disabled error:`, + markError + ) + } + })() + } + if (isStreamWritable(responseStream)) { + // 解析 Claude API 返回的错误详情 + let errorMessage = `Claude API error: ${res.statusCode}` + try { + const parsedError = JSON.parse(errorData) + if (parsedError.error?.message) { + errorMessage = parsedError.error.message + } else if (parsedError.message) { + errorMessage = parsedError.message + } + } catch { + // 使用默认错误消息 + } + + // 如果有 streamTransformer(如测试请求),使用前端期望的格式 + if (toolNameStreamTransformer) { + responseStream.write( + `data: ${JSON.stringify({ type: 'error', error: errorMessage })}\n\n` + ) + } else { + // 标准错误格式 + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Claude API error', + status: res.statusCode, + details: errorData, + timestamp: new Date().toISOString() + })}\n\n` + ) + } + responseStream.end() + } + reject(new Error(`Claude API error: ${res.statusCode}`)) + }) + return + } + + // 📬 收到成功响应头(HTTP 200),立即调用回调释放队列锁 + // 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成 + if (onResponseStart && typeof onResponseStart === 'function') { + try { + await onResponseStart() + } catch (callbackError) { + logger.error('❌ Error in onResponseStart callback:', callbackError.message) + } + } + + let buffer = '' + const allUsageData = [] // 收集所有的usage事件 + let currentUsageData = {} // 当前正在收集的usage数据 + let rateLimitDetected = false // 限流检测标志 + + // 监听数据块,解析SSE并寻找usage信息 + // 🧹 内存优化:在闭包创建前提取需要的值,避免闭包捕获 body 和 requestOptions + // body 和 requestOptions 只在闭包外使用,闭包内只引用基本类型 + const requestedModel = body?.model || 'unknown' + const { isRealClaudeCodeRequest } = requestOptions + + // 🔧 处理上游 gzip/deflate 压缩:Anthropic (经 Cloudflare) 可能返回压缩响应 + const upstreamEncoding = res.headers['content-encoding'] + let dataSource = res + if (upstreamEncoding === 'gzip') { + dataSource = res.pipe(zlib.createGunzip()) + dataSource.on('error', (err) => { + logger.error('❌ Gzip decompression error in stream:', err.message) + if (isStreamWritable(responseStream)) { + responseStream.end() + } + }) + } else if (upstreamEncoding === 'deflate') { + dataSource = res.pipe(zlib.createInflate()) + dataSource.on('error', (err) => { + logger.error('❌ Deflate decompression error in stream:', err.message) + if (isStreamWritable(responseStream)) { + responseStream.end() + } + }) + } + + dataSource.on('data', (chunk) => { + try { + const chunkStr = chunk.toString() + + buffer += chunkStr + + // 处理完整的SSE行 + const lines = buffer.split('\n') + buffer = lines.pop() || '' // 保留最后的不完整行 + + // 转发已处理的完整行到客户端 + if (lines.length > 0) { + if (isStreamWritable(responseStream)) { + const linesToForward = lines.join('\n') + (lines.length > 0 ? '\n' : '') + // 如果有流转换器,应用转换 + if (toolNameStreamTransformer) { + const transformed = toolNameStreamTransformer(linesToForward) + if (transformed) { + responseStream.write(transformed) + } + } else { + responseStream.write(linesToForward) + } + } else { + // 客户端连接已断开,记录警告(但仍继续解析usage) + logger.warn( + `⚠️ [Official] Client disconnected during stream, skipping ${lines.length} lines for account: ${accountId}` + ) + } + } + + for (const line of lines) { + // 解析SSE数据寻找usage信息 + if (line.startsWith('data:')) { + const jsonStr = line.slice(5).trimStart() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + try { + const data = JSON.parse(jsonStr) + + // 收集来自不同事件的usage数据 + if (data.type === 'message_start' && data.message && data.message.usage) { + // 新的消息开始,如果之前有数据,先保存 + if ( + currentUsageData.input_tokens !== undefined && + currentUsageData.output_tokens !== undefined + ) { + allUsageData.push({ ...currentUsageData }) + currentUsageData = {} + } + + // message_start包含input tokens、cache tokens和模型信息 + currentUsageData.input_tokens = data.message.usage.input_tokens || 0 + currentUsageData.cache_creation_input_tokens = + data.message.usage.cache_creation_input_tokens || 0 + currentUsageData.cache_read_input_tokens = + data.message.usage.cache_read_input_tokens || 0 + currentUsageData.model = data.message.model + + // 检查是否有详细的 cache_creation 对象 + if ( + data.message.usage.cache_creation && + typeof data.message.usage.cache_creation === 'object' + ) { + currentUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + logger.debug( + '📊 Collected detailed cache creation data:', + JSON.stringify(currentUsageData.cache_creation) + ) + } + + logger.debug( + '📊 Collected input/cache data from message_start:', + JSON.stringify(currentUsageData) + ) + } + + // message_delta包含最终的output tokens + if ( + data.type === 'message_delta' && + data.usage && + data.usage.output_tokens !== undefined + ) { + currentUsageData.output_tokens = data.usage.output_tokens || 0 + + logger.debug( + '📊 Collected output data from message_delta:', + JSON.stringify(currentUsageData) + ) + + // 如果已经收集到了input数据和output数据,这是一个完整的usage + if (currentUsageData.input_tokens !== undefined) { + logger.debug( + '🎯 Complete usage data collected for model:', + currentUsageData.model, + '- Input:', + currentUsageData.input_tokens, + 'Output:', + currentUsageData.output_tokens + ) + // 保存到列表中,但不立即触发回调 + allUsageData.push({ ...currentUsageData }) + // 重置当前数据,准备接收下一个 + currentUsageData = {} + } + } + + // 检查是否有限流错误 + if ( + data.type === 'error' && + data.error && + data.error.message && + data.error.message.toLowerCase().includes("exceed your account's rate limit") + ) { + rateLimitDetected = true + logger.warn(`🚫 Rate limit detected in stream for account ${accountId}`) + } + } catch (parseError) { + // 忽略JSON解析错误,继续处理 + logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100)) + } + } + } + } catch (error) { + logger.error('❌ Error processing stream data:', error) + // 发送错误但不破坏流,让它自然结束 + if (isStreamWritable(responseStream)) { + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Stream processing error', + message: error.message, + timestamp: new Date().toISOString() + })}\n\n` + ) + } + } + }) + + dataSource.on('end', async () => { + try { + // 处理缓冲区中剩余的数据 + if (buffer.trim() && isStreamWritable(responseStream)) { + if (toolNameStreamTransformer) { + const transformed = toolNameStreamTransformer(buffer) + if (transformed) { + responseStream.write(transformed) + } + } else { + responseStream.write(buffer) + } + } + + // 确保流正确结束 + if (isStreamWritable(responseStream)) { + responseStream.end() + logger.debug( + `🌊 Stream end called | bytesWritten: ${responseStream.bytesWritten || 'unknown'}` + ) + } else { + // 连接已断开,记录警告 + logger.warn( + `⚠️ [Official] Client disconnected before stream end, data may not have been received | account: ${account?.name || accountId}` + ) + } + } catch (error) { + logger.error('❌ Error processing stream end:', error) + } + + // 如果还有未完成的usage数据,尝试保存 + if (currentUsageData.input_tokens !== undefined) { + if (currentUsageData.output_tokens === undefined) { + currentUsageData.output_tokens = 0 // 如果没有output,设为0 + } + allUsageData.push(currentUsageData) + } + + // 检查是否捕获到usage数据 + if (allUsageData.length === 0) { + logger.warn( + '⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.' + ) + } else { + // 打印此次请求的所有usage数据汇总 + const totalUsage = allUsageData.reduce( + (acc, usage) => ({ + input_tokens: (acc.input_tokens || 0) + (usage.input_tokens || 0), + output_tokens: (acc.output_tokens || 0) + (usage.output_tokens || 0), + cache_creation_input_tokens: + (acc.cache_creation_input_tokens || 0) + (usage.cache_creation_input_tokens || 0), + cache_read_input_tokens: + (acc.cache_read_input_tokens || 0) + (usage.cache_read_input_tokens || 0), + models: [...(acc.models || []), usage.model].filter(Boolean) + }), + {} + ) + + // 打印原始的usage数据为JSON字符串,避免嵌套问题 + logger.info( + `📊 === Stream Request Usage Summary === Model: ${requestedModel}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}` + ) + + // 一般一个请求只会使用一个模型,即使有多个usage事件也应该合并 + // 计算总的usage + const finalUsage = { + input_tokens: totalUsage.input_tokens, + output_tokens: totalUsage.output_tokens, + cache_creation_input_tokens: totalUsage.cache_creation_input_tokens, + cache_read_input_tokens: totalUsage.cache_read_input_tokens, + model: allUsageData[allUsageData.length - 1].model || requestedModel // 使用最后一个模型或请求模型 + } + + // 如果有详细的cache_creation数据,合并它们 + let totalEphemeral5m = 0 + let totalEphemeral1h = 0 + allUsageData.forEach((usage) => { + if (usage.cache_creation && typeof usage.cache_creation === 'object') { + totalEphemeral5m += usage.cache_creation.ephemeral_5m_input_tokens || 0 + totalEphemeral1h += usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + }) + + // 如果有详细的缓存数据,添加到finalUsage + if (totalEphemeral5m > 0 || totalEphemeral1h > 0) { + finalUsage.cache_creation = { + ephemeral_5m_input_tokens: totalEphemeral5m, + ephemeral_1h_input_tokens: totalEphemeral1h + } + logger.info( + '📊 Detailed cache creation breakdown:', + JSON.stringify(finalUsage.cache_creation) + ) + } + + // 调用一次usageCallback记录合并后的数据 + if (usageCallback && typeof usageCallback === 'function') { + usageCallback(finalUsage) + } + } + + // 提取5小时会话窗口状态 + // 使用大小写不敏感的方式获取响应头 + const get5hStatus = (resHeaders) => { + if (!resHeaders) { + return null + } + // HTTP头部名称不区分大小写,需要处理不同情况 + return ( + resHeaders['anthropic-ratelimit-unified-5h-status'] || + resHeaders['Anthropic-Ratelimit-Unified-5h-Status'] || + resHeaders['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS'] + ) + } + + const sessionWindowStatus = get5hStatus(res.headers) + if (sessionWindowStatus) { + logger.info(`📊 Session window status for account ${accountId}: ${sessionWindowStatus}`) + // 保存会话窗口状态到账户数据 + await claudeAccountService.updateSessionWindowStatus(accountId, sessionWindowStatus) + } + + // 处理限流状态 + if (rateLimitDetected || res.statusCode === 429) { + const resetHeader = res.headers + ? res.headers['anthropic-ratelimit-unified-reset'] + : null + const parsedResetTimestamp = resetHeader ? parseInt(resetHeader, 10) : NaN + + if (requestModelFamily && !Number.isNaN(parsedResetTimestamp)) { + // 模型级限额:只停用该模型家族,不改写为账号级限流 + await claudeAccountService.markAccountModelRateLimited( + accountId, + requestModelFamily, + parsedResetTimestamp + ) + logger.warn( + `🚫 [Stream] Account ${accountId} hit ${requestModelFamily} limit, resets at ${new Date(parsedResetTimestamp * 1000).toISOString()}` + ) + } else if (this._isAgentViewAuxiliaryRequest(body, clientHeaders)) { + logger.warn( + `🚫 [Stream] Agent View auxiliary request hit rate limit at stream end for account ${accountId}; skipping account-level rate-limit marking` + ) + } else if (Number.isNaN(parsedResetTimestamp)) { + // 无权威 reset 头的 429 大概率不是真实限流,不标记账号、不进入冷却,直接透传错误 + logger.warn( + `⚠️ [Stream] Rate limit at stream end without reset header for account ${accountId}, skipping rate limit marking` + ) + } else { + logger.info( + `🕐 Extracted rate limit reset timestamp from stream: ${parsedResetTimestamp} (${new Date(parsedResetTimestamp * 1000).toISOString()})` + ) + + await unifiedClaudeScheduler.markAccountRateLimited( + accountId, + accountType, + sessionHash, + parsedResetTimestamp + ) + await upstreamErrorHelper + .markTempUnavailable( + accountId, + accountType, + 429, + upstreamErrorHelper.parseRetryAfter(res.headers) + ) + .catch(() => {}) + } + } else if (res.statusCode === 200) { + // 请求成功,清除401和500错误计数 + await this.clearUnauthorizedErrors(accountId) + await claudeAccountService.clearInternalErrors(accountId) + // 如果请求成功,检查并移除限流状态 + const isRateLimited = await unifiedClaudeScheduler.isAccountRateLimited( + accountId, + accountType + ) + if (isRateLimited) { + await unifiedClaudeScheduler.removeAccountRateLimit(accountId, accountType) + } + + // 如果流式请求成功,检查并移除过载状态 + try { + const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId) + if (isOverloaded) { + await claudeAccountService.removeAccountOverload(accountId) + } + } catch (overloadError) { + logger.error( + `❌ [Stream] Failed to check/remove overload status for account ${accountId}:`, + overloadError + ) + } + + // 只有真实的 Claude Code 请求才更新 headers(流式请求) + if (clientHeaders && Object.keys(clientHeaders).length > 0 && isRealClaudeCodeRequest) { + await claudeCodeHeadersService.storeAccountHeaders(accountId, clientHeaders) + } + } + + // 🧹 清理 bodyStore + if (requestOptions.bodyStoreId) { + this.bodyStore.delete(requestOptions.bodyStoreId) + } + logger.debug('🌊 Claude stream response with usage capture completed') + resolve() + }) + }) + + req.on('error', async (error) => { + logger.error( + `❌ Claude stream request error (Account: ${account?.name || accountId}):`, + error.message, + { + code: error.code, + errno: error.errno, + syscall: error.syscall + } + ) + + // 根据错误类型提供更具体的错误信息 + let errorMessage = 'Upstream request failed' + let statusCode = 500 + if (error.code === 'ECONNRESET') { + errorMessage = 'Connection reset by Claude API server' + statusCode = 502 + } else if (error.code === 'ENOTFOUND') { + errorMessage = 'Unable to resolve Claude API hostname' + statusCode = 502 + } else if (error.code === 'ECONNREFUSED') { + errorMessage = 'Connection refused by Claude API server' + statusCode = 502 + } else if (error.code === 'ETIMEDOUT') { + errorMessage = 'Connection timed out to Claude API server' + statusCode = 504 + } + + if (!responseStream.headersSent) { + const existingConnection = responseStream.getHeader + ? responseStream.getHeader('Connection') + : null + responseStream.writeHead(statusCode, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive' + }) + } + + if (isStreamWritable(responseStream)) { + // 发送 SSE 错误事件 + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: errorMessage, + code: error.code, + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() + } + // 🧹 清理 bodyStore + if (requestOptions.bodyStoreId) { + this.bodyStore.delete(requestOptions.bodyStoreId) + } + reject(error) + }) + + req.on('timeout', async () => { + req.destroy() + logger.error(`❌ Claude stream request timeout | Account: ${account?.name || accountId}`) + + if (!responseStream.headersSent) { + const existingConnection = responseStream.getHeader + ? responseStream.getHeader('Connection') + : null + responseStream.writeHead(504, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive' + }) + } + if (isStreamWritable(responseStream)) { + // 发送 SSE 错误事件 + responseStream.write('event: error\n') + responseStream.write( + `data: ${JSON.stringify({ + error: 'Request timeout', + code: 'TIMEOUT', + timestamp: new Date().toISOString() + })}\n\n` + ) + responseStream.end() + } + // 🧹 清理 bodyStore + if (requestOptions.bodyStoreId) { + this.bodyStore.delete(requestOptions.bodyStoreId) + } + reject(new Error('Request timeout')) + }) + + // 处理客户端断开连接 + responseStream.on('close', () => { + logger.debug('🔌 Client disconnected, cleaning up stream') + if (!req.destroyed) { + req.destroy(new Error('Client disconnected')) + } + }) + + // 写入请求体 + req.write(bodyString) + // 🧹 内存优化:立即清空 bodyString 引用,避免闭包捕获 + bodyString = null + req.end() + }) + } + + // 🛠️ 统一的错误处理方法 + async _handleServerError( + accountId, + statusCode, + sessionHash = null, + context = '', + accountType = 'claude-official' + ) { + try { + await claudeAccountService.recordServerError(accountId, statusCode) + const errorCount = await claudeAccountService.getServerErrorCount(accountId) + + // 根据错误类型设置不同的阈值和日志前缀 + const isTimeout = statusCode === 504 + const threshold = 3 // 统一使用3次阈值 + const prefix = context ? `${context} ` : '' + + logger.warn( + `⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}` + ) + + // 标记账户为临时不可用(TTL 由 upstreamError 配置决定) + try { + await unifiedClaudeScheduler.markAccountTemporarilyUnavailable( + accountId, + accountType, + sessionHash, + null, + statusCode + ) + } catch (markError) { + logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, markError) + } + + if (errorCount > threshold) { + const errorTypeLabel = isTimeout ? 'timeout' : '5xx' + // ⚠️ 只记录5xx/504告警,不再自动停止调度,避免上游抖动导致误停 + logger.error( + `❌ ${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), please investigate upstream stability` + ) + } + } catch (handlingError) { + logger.error(`❌ Failed to handle ${context} server error:`, handlingError) + } + } + + // 🔄 重试逻辑 + async _retryRequest(requestFunc, maxRetries = 3) { + let lastError + + for (let i = 0; i < maxRetries; i++) { + try { + return await requestFunc() + } catch (error) { + lastError = error + + if (i < maxRetries - 1) { + const delay = Math.pow(2, i) * 1000 // 指数退避 + logger.warn(`⏳ Retry ${i + 1}/${maxRetries} in ${delay}ms: ${error.message}`) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + throw lastError + } + + // 🔐 记录401未授权错误 + async recordUnauthorizedError(accountId) { + try { + const key = `claude_account:${accountId}:401_errors` + + // 增加错误计数,设置5分钟过期时间 + await redis.client.incr(key) + await redis.client.expire(key, 300) // 5分钟 + + logger.info(`📝 Recorded 401 error for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to record 401 error for account ${accountId}:`, error) + } + } + + // 🔍 获取401错误计数 + async getUnauthorizedErrorCount(accountId) { + try { + const key = `claude_account:${accountId}:401_errors` + + const count = await redis.client.get(key) + return parseInt(count) || 0 + } catch (error) { + logger.error(`❌ Failed to get 401 error count for account ${accountId}:`, error) + return 0 + } + } + + // 🧹 清除401错误计数 + async clearUnauthorizedErrors(accountId) { + try { + const key = `claude_account:${accountId}:401_errors` + + await redis.client.del(key) + logger.info(`✅ Cleared 401 error count for account ${accountId}`) + } catch (error) { + logger.error(`❌ Failed to clear 401 errors for account ${accountId}:`, error) + } + } + + // 🔧 动态捕获并获取统一的 User-Agent + async captureAndGetUnifiedUserAgent(clientHeaders, account) { + if (account.useUnifiedUserAgent !== 'true') { + return null + } + + const CACHE_KEY = 'claude_code_user_agent:daily' + const TTL = 90000 // 25小时 + + // ⚠️ 重要:这里通过正则表达式判断是否为 Claude Code 客户端 + // 如果未来 Claude Code 的 User-Agent 格式发生变化,需要更新这个正则表达式 + // 当前已知格式:claude-cli/1.0.102 (external, cli) + const CLAUDE_CODE_UA_PATTERN = /^claude-cli\/[\d.]+\s+\(/i + + const clientUA = clientHeaders?.['user-agent'] || clientHeaders?.['User-Agent'] + let cachedUA = await redis.client.get(CACHE_KEY) + + if (clientUA && CLAUDE_CODE_UA_PATTERN.test(clientUA)) { + if (!cachedUA) { + // 没有缓存,直接存储 + await redis.client.setex(CACHE_KEY, TTL, clientUA) + logger.info(`📱 Captured unified Claude Code User-Agent: ${clientUA}`) + cachedUA = clientUA + } else { + // 有缓存,比较版本号,保存更新的版本 + const shouldUpdate = this.compareClaudeCodeVersions(clientUA, cachedUA) + if (shouldUpdate) { + await redis.client.setex(CACHE_KEY, TTL, clientUA) + logger.info(`🔄 Updated to newer Claude Code User-Agent: ${clientUA} (was: ${cachedUA})`) + cachedUA = clientUA + } else { + // 当前版本不比缓存版本新,仅刷新TTL + await redis.client.expire(CACHE_KEY, TTL) + } + } + } + + return cachedUA // 没有缓存返回 null + } + + // 🔄 比较Claude Code版本号,判断是否需要更新 + // 返回 true 表示 newUA 版本更新,需要更新缓存 + compareClaudeCodeVersions(newUA, cachedUA) { + try { + // 提取版本号:claude-cli/1.0.102 (external, cli) -> 1.0.102 + // 支持多段版本号格式,如 1.0.102、2.1.0.beta1 等 + const newVersionMatch = newUA.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i) + const cachedVersionMatch = cachedUA.match(/claude-cli\/([\d.]+(?:[a-zA-Z0-9-]*)?)/i) + + if (!newVersionMatch || !cachedVersionMatch) { + // 无法解析版本号,优先使用新的 + logger.warn(`⚠️ Unable to parse Claude Code versions: new=${newUA}, cached=${cachedUA}`) + return true + } + + const newVersion = newVersionMatch[1] + const cachedVersion = cachedVersionMatch[1] + + // 比较版本号 (semantic version) + const compareResult = this.compareSemanticVersions(newVersion, cachedVersion) + + logger.debug(`🔍 Version comparison: ${newVersion} vs ${cachedVersion} = ${compareResult}`) + + return compareResult > 0 // 新版本更大则返回 true + } catch (error) { + logger.warn(`⚠️ Error comparing Claude Code versions, defaulting to update: ${error.message}`) + return true // 出错时优先使用新的 + } + } + + // 🔢 比较版本号 + // 返回:1 表示 v1 > v2,-1 表示 v1 < v2,0 表示相等 + compareSemanticVersions(version1, version2) { + // 将版本号字符串按"."分割成数字数组 + const arr1 = version1.split('.') + const arr2 = version2.split('.') + + // 获取两个版本号数组中的最大长度 + const maxLength = Math.max(arr1.length, arr2.length) + + // 循环遍历,逐段比较版本号 + for (let i = 0; i < maxLength; i++) { + // 如果某个版本号的某一段不存在,则视为0 + const num1 = parseInt(arr1[i] || 0, 10) + const num2 = parseInt(arr2[i] || 0, 10) + + if (num1 > num2) { + return 1 // version1 大于 version2 + } + if (num1 < num2) { + return -1 // version1 小于 version2 + } + } + + return 0 // 两个版本号相等 + } + + // 🧪 创建测试用的流转换器,将 Claude API SSE 格式转换为前端期望的格式 + _createTestStreamTransformer() { + let testStartSent = false + + return (rawData) => { + const lines = rawData.split('\n') + const outputLines = [] + + for (const line of lines) { + if (!line.startsWith('data: ')) { + // 保留空行用于 SSE 分隔 + if (line.trim() === '') { + outputLines.push('') + } + continue + } + + const jsonStr = line.substring(6).trim() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonStr) + + // 发送 test_start 事件(只在第一次 message_start 时发送) + if (data.type === 'message_start' && !testStartSent) { + testStartSent = true + outputLines.push(`data: ${JSON.stringify({ type: 'test_start' })}`) + outputLines.push('') + } + + // 转换 content_block_delta 为 content + if (data.type === 'content_block_delta' && data.delta && data.delta.text) { + outputLines.push(`data: ${JSON.stringify({ type: 'content', text: data.delta.text })}`) + outputLines.push('') + } + + // 转换 message_stop 为 test_complete + if (data.type === 'message_stop') { + outputLines.push(`data: ${JSON.stringify({ type: 'test_complete', success: true })}`) + outputLines.push('') + } + + // 处理错误事件 + if (data.type === 'error') { + const errorMsg = data.error?.message || data.message || '未知错误' + outputLines.push(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}`) + outputLines.push('') + } + } catch { + // 忽略解析错误 + } + } + + return outputLines.length > 0 ? outputLines.join('\n') : null + } + } + + // 🔧 准备测试请求的公共逻辑(供 testAccountConnection 和 testAccountConnectionSync 共用) + async _prepareAccountForTest(accountId) { + // 获取账户信息 + const account = await claudeAccountService.getAccount(accountId) + if (!account) { + throw new Error('Account not found') + } + + // 获取有效的访问token + const accessToken = await claudeAccountService.getValidAccessToken(accountId) + if (!accessToken) { + throw new Error('Failed to get valid access token') + } + + // 获取代理配置 + const proxyAgent = await this._getProxyAgent(accountId) + + return { account, accessToken, proxyAgent } + } + + // 🧪 测试账号连接(供Admin API使用,直接复用 _makeClaudeStreamRequestWithUsageCapture) + async testAccountConnection(accountId, responseStream, model = 'claude-sonnet-4-5-20250929') { + const testRequestBody = createClaudeTestPayload(model, { stream: true }) + + try { + const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId) + + logger.info(`🧪 Testing Claude account connection: ${account.name} (${accountId})`) + + // 设置响应头 + if (!responseStream.headersSent) { + const existingConnection = responseStream.getHeader + ? responseStream.getHeader('Connection') + : null + responseStream.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: existingConnection || 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + } + + // 创建流转换器,将 Claude API 格式转换为前端测试页面期望的格式 + const streamTransformer = this._createTestStreamTransformer() + + // 直接复用现有的流式请求方法 + await this._makeClaudeStreamRequestWithUsageCapture( + testRequestBody, + accessToken, + proxyAgent, + {}, // clientHeaders - 测试不需要客户端headers + responseStream, + null, // usageCallback - 测试不需要统计 + accountId, + 'claude-official', // accountType + null, // sessionHash - 测试不需要会话 + streamTransformer, // 使用转换器将 Claude API 格式转为前端期望格式 + {}, // requestOptions + false // isDedicatedOfficialAccount + ) + + logger.info(`✅ Test request completed for account: ${account.name}`) + } catch (error) { + logger.error(`❌ Test account connection failed:`, error) + // 发送错误事件给前端 + if (isStreamWritable(responseStream)) { + try { + const errorMsg = error.message || '测试失败' + responseStream.write(`data: ${JSON.stringify({ type: 'error', error: errorMsg })}\n\n`) + } catch { + // 忽略写入错误 + } + } + throw error + } + } + + // 🧪 非流式测试账号连接(供定时任务使用) + // 复用流式请求方法,收集结果后返回 + async testAccountConnectionSync(accountId, model = 'claude-sonnet-4-5-20250929') { + const testRequestBody = createClaudeTestPayload(model, { stream: true }) + const startTime = Date.now() + + try { + // 使用公共方法准备测试所需的账户信息、token 和代理 + const { account, accessToken, proxyAgent } = await this._prepareAccountForTest(accountId) + + logger.info(`🧪 Testing Claude account connection (sync): ${account.name} (${accountId})`) + + // 创建一个收集器来捕获流式响应 + let responseText = '' + let capturedUsage = null + let capturedModel = model + let hasError = false + let errorMessage = '' + + // 创建模拟的响应流对象 + const mockResponseStream = { + headersSent: true, // 跳过设置响应头 + write: (data) => { + // 解析 SSE 数据 + if (typeof data === 'string' && data.startsWith('data: ')) { + try { + const jsonStr = data.replace('data: ', '').trim() + if (jsonStr && jsonStr !== '[DONE]') { + const parsed = JSON.parse(jsonStr) + // 提取文本内容 + if (parsed.type === 'content_block_delta' && parsed.delta?.text) { + responseText += parsed.delta.text + } + // 提取 usage 信息 + if (parsed.type === 'message_delta' && parsed.usage) { + capturedUsage = parsed.usage + } + // 提取模型信息 + if (parsed.type === 'message_start' && parsed.message?.model) { + capturedModel = parsed.message.model + } + // 检测错误 + if (parsed.type === 'error') { + hasError = true + errorMessage = parsed.error?.message || 'Unknown error' + } + } + } catch { + // 忽略解析错误 + } + } + return true + }, + end: () => {}, + on: () => {}, + once: () => {}, + emit: () => {}, + writable: true + } + + // 复用流式请求方法 + await this._makeClaudeStreamRequestWithUsageCapture( + testRequestBody, + accessToken, + proxyAgent, + {}, // clientHeaders - 测试不需要客户端headers + mockResponseStream, + null, // usageCallback - 测试不需要统计 + accountId, + 'claude-official', // accountType + null, // sessionHash - 测试不需要会话 + null, // streamTransformer - 不需要转换,直接解析原始格式 + {}, // requestOptions + false // isDedicatedOfficialAccount + ) + + const latencyMs = Date.now() - startTime + + if (hasError) { + logger.warn(`⚠️ Test completed with error for account: ${account.name} - ${errorMessage}`) + return { + success: false, + error: errorMessage, + latencyMs, + timestamp: new Date().toISOString() + } + } + + logger.info(`✅ Test completed for account: ${account.name} (${latencyMs}ms)`) + + return { + success: true, + message: responseText.substring(0, 200), // 截取前200字符 + latencyMs, + model: capturedModel, + usage: capturedUsage, + timestamp: new Date().toISOString() + } + } catch (error) { + const latencyMs = Date.now() - startTime + logger.error(`❌ Test account connection (sync) failed:`, error.message) + + // 提取错误详情 + let errorMessage = error.message + if (error.response) { + errorMessage = + error.response.data?.error?.message || error.response.statusText || error.message + } + + return { + success: false, + error: errorMessage, + statusCode: error.response?.status, + latencyMs, + timestamp: new Date().toISOString() + } + } + } + + // 🎯 健康检查 + async healthCheck() { + try { + const accounts = await claudeAccountService.getAllAccounts() + const activeAccounts = accounts.filter((acc) => acc.isActive && acc.status === 'active') + + return { + healthy: activeAccounts.length > 0, + activeAccounts: activeAccounts.length, + totalAccounts: accounts.length, + timestamp: new Date().toISOString() + } + } catch (error) { + logger.error('❌ Health check failed:', error) + return { + healthy: false, + error: error.message, + timestamp: new Date().toISOString() + } + } + } + + // 🔄 判断账户是否应该在 403 错误时进行重试 + // 仅 claude-official 类型账户(OAuth 或 Setup Token 授权)需要重试 + _shouldRetryOn403(accountType) { + return accountType === 'claude-official' + } + + // ⏱️ 等待指定毫秒数 + _sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} + +module.exports = new ClaudeRelayService() diff --git a/src/services/relay/droidRelayService.js b/src/services/relay/droidRelayService.js new file mode 100644 index 0000000..f98c977 --- /dev/null +++ b/src/services/relay/droidRelayService.js @@ -0,0 +1,1574 @@ +const https = require('https') +const axios = require('axios') +const ProxyHelper = require('../../utils/proxyHelper') +const droidScheduler = require('../scheduler/droidScheduler') +const droidAccountService = require('../account/droidAccountService') +const apiKeyService = require('../apiKeyService') +const redis = require('../../models/redis') +const { updateRateLimitCounters } = require('../../utils/rateLimitHelper') +const logger = require('../../utils/logger') +const runtimeAddon = require('../../utils/runtimeAddon') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') +const { createRequestDetailMeta } = require('../../utils/requestDetailHelper') + +const SYSTEM_PROMPT = 'You are Droid, an AI software engineering agent built by Factory.' +const RUNTIME_EVENT_FMT_PAYLOAD = 'fmtPayload' + +/** + * Droid API 转发服务 + */ + +class DroidRelayService { + constructor() { + this.factoryApiBaseUrl = 'https://api.factory.ai/api/llm' + + this.endpoints = { + anthropic: '/a/v1/messages', + openai: '/o/v1/responses', + comm: '/o/v1/chat/completions' + } + + this.userAgent = 'factory-cli/0.32.1' + this.systemPrompt = SYSTEM_PROMPT + this.API_KEY_STICKY_PREFIX = 'droid_api_key' + } + + _normalizeEndpointType(endpointType) { + if (!endpointType) { + return 'anthropic' + } + + const normalized = String(endpointType).toLowerCase() + if (normalized === 'openai') { + return 'openai' + } + + if (normalized === 'comm') { + return 'comm' + } + + if (normalized === 'anthropic') { + return 'anthropic' + } + + return 'anthropic' + } + + _normalizeRequestBody(requestBody, endpointType) { + if (!requestBody || typeof requestBody !== 'object') { + return requestBody + } + + const normalizedBody = { ...requestBody } + + if (endpointType === 'anthropic' && typeof normalizedBody.model === 'string') { + const originalModel = normalizedBody.model + const trimmedModel = originalModel.trim() + const lowerModel = trimmedModel.toLowerCase() + + if (lowerModel.includes('haiku')) { + const mappedModel = 'claude-sonnet-4-20250514' + if (originalModel !== mappedModel) { + logger.info(`🔄 将请求模型从 ${originalModel} 映射为 ${mappedModel}`) + } + normalizedBody.model = mappedModel + } + } + + if (endpointType === 'openai' && typeof normalizedBody.model === 'string') { + const originalModel = normalizedBody.model + const trimmedModel = originalModel.trim() + const lowerModel = trimmedModel.toLowerCase() + + if (lowerModel === 'gpt-5') { + const mappedModel = 'gpt-5-2025-08-07' + if (originalModel !== mappedModel) { + logger.info(`🔄 将请求模型从 ${originalModel} 映射为 ${mappedModel}`) + } + normalizedBody.model = mappedModel + } + } + + return normalizedBody + } + + async _applyRateLimitTracking( + rateLimitInfo, + usageSummary, + model, + context = '', + keyId = null, + preCalculatedCost = null + ) { + if (!rateLimitInfo) { + return + } + + try { + const { totalTokens, totalCost } = await updateRateLimitCounters( + rateLimitInfo, + usageSummary, + model, + keyId, + 'droid', + preCalculatedCost + ) + + if (totalTokens > 0) { + logger.api(`📊 Updated rate limit token count${context}: +${totalTokens}`) + } + if (typeof totalCost === 'number' && totalCost > 0) { + logger.api(`💰 Updated rate limit cost count${context}: +$${totalCost.toFixed(6)}`) + } + } catch (error) { + logger.error(`❌ Failed to update rate limit counters${context}:`, error) + } + } + + _composeApiKeyStickyKey(accountId, endpointType, sessionHash) { + if (!accountId || !sessionHash) { + return null + } + + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + return `${this.API_KEY_STICKY_PREFIX}:${accountId}:${normalizedEndpoint}:${sessionHash}` + } + + async _selectApiKey(account, endpointType, sessionHash) { + const entries = await droidAccountService.getDecryptedApiKeyEntries(account.id) + if (!entries || entries.length === 0) { + throw new Error(`Droid account ${account.id} 未配置任何 API Key`) + } + + // 过滤掉异常状态的API Key + const activeEntries = entries.filter((entry) => entry.status !== 'error') + if (!activeEntries || activeEntries.length === 0) { + throw new Error(`Droid account ${account.id} 没有可用的 API Key(所有API Key均已异常)`) + } + + const stickyKey = this._composeApiKeyStickyKey(account.id, endpointType, sessionHash) + + if (stickyKey) { + const mappedKeyId = await redis.getSessionAccountMapping(stickyKey) + if (mappedKeyId) { + const mappedEntry = activeEntries.find((entry) => entry.id === mappedKeyId) + if (mappedEntry) { + await redis.extendSessionAccountMappingTTL(stickyKey) + await droidAccountService.touchApiKeyUsage(account.id, mappedEntry.id) + logger.info(`🔐 使用已绑定的 Droid API Key ${mappedEntry.id}(Account: ${account.id})`) + return mappedEntry + } + + await redis.deleteSessionAccountMapping(stickyKey) + } + } + + const selectedEntry = activeEntries[Math.floor(Math.random() * activeEntries.length)] + if (!selectedEntry) { + throw new Error(`Droid account ${account.id} 没有可用的 API Key`) + } + + if (stickyKey) { + await redis.setSessionAccountMapping(stickyKey, selectedEntry.id) + } + + await droidAccountService.touchApiKeyUsage(account.id, selectedEntry.id) + + logger.info( + `🔐 随机选取 Droid API Key ${selectedEntry.id}(Account: ${account.id}, Active Keys: ${activeEntries.length}/${entries.length})` + ) + + return selectedEntry + } + + async relayRequest( + requestBody, + apiKeyData, + clientRequest, + clientResponse, + clientHeaders, + options = {} + ) { + const { + endpointType = 'anthropic', + sessionHash = null, + customPath = null, + skipUsageRecord = false, + disableStreaming = false + } = options + const keyInfo = apiKeyData || {} + const clientApiKeyId = keyInfo.id || null + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const normalizedRequestBody = this._normalizeRequestBody(requestBody, normalizedEndpoint) + let account = null + let selectedApiKey = null + let accessToken = null + + try { + logger.info( + `📤 Processing Droid API request for key: ${ + keyInfo.name || keyInfo.id || 'unknown' + }, endpoint: ${normalizedEndpoint}${sessionHash ? `, session: ${sessionHash}` : ''}` + ) + + // 选择一个可用的 Droid 账户(支持粘性会话和分组调度) + account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash) + + if (!account) { + throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`) + } + + // 获取认证凭据:支持 Access Token 和 API Key 两种模式 + if ( + typeof account.authenticationMethod === 'string' && + account.authenticationMethod.toLowerCase().trim() === 'api_key' + ) { + selectedApiKey = await this._selectApiKey(account, normalizedEndpoint, sessionHash) + accessToken = selectedApiKey.key + } else { + accessToken = await droidAccountService.getValidAccessToken(account.id) + } + + // 获取 Factory.ai API URL + let endpointPath = this.endpoints[normalizedEndpoint] + + if (typeof customPath === 'string' && customPath.trim()) { + endpointPath = customPath.startsWith('/') ? customPath : `/${customPath}` + } + + const apiUrl = `${this.factoryApiBaseUrl}${endpointPath}` + + logger.info(`🌐 Forwarding to Factory.ai: ${apiUrl}`) + + // 获取代理配置 + const proxyConfig = account.proxy ? JSON.parse(account.proxy) : null + const proxyAgent = proxyConfig ? ProxyHelper.createProxyAgent(proxyConfig) : null + + if (proxyAgent) { + logger.info(`🌐 Using proxy: ${ProxyHelper.getProxyDescription(proxyConfig)}`) + } + + // 构建请求头 + const headers = this._buildHeaders( + accessToken, + normalizedRequestBody, + normalizedEndpoint, + clientHeaders, + account + ) + + if (selectedApiKey) { + logger.info( + `🔑 Forwarding request with Droid API Key ${selectedApiKey.id} (Account: ${account.id})` + ) + } + + // 处理请求体(注入 system prompt 等) + const streamRequested = !disableStreaming && this._isStreamRequested(normalizedRequestBody) + + let processedBody = this._processRequestBody(normalizedRequestBody, normalizedEndpoint, { + disableStreaming, + streamRequested + }) + + const extensionPayload = { + body: processedBody, + endpoint: normalizedEndpoint, + rawRequest: normalizedRequestBody, + originalRequest: requestBody + } + + const extensionResult = runtimeAddon.emitSync(RUNTIME_EVENT_FMT_PAYLOAD, extensionPayload) + const resolvedPayload = + extensionResult && typeof extensionResult === 'object' ? extensionResult : extensionPayload + + if (resolvedPayload && typeof resolvedPayload === 'object') { + if (resolvedPayload.abortResponse && typeof resolvedPayload.abortResponse === 'object') { + return resolvedPayload.abortResponse + } + + if (resolvedPayload.body && typeof resolvedPayload.body === 'object') { + processedBody = resolvedPayload.body + } else if (resolvedPayload !== extensionPayload) { + processedBody = resolvedPayload + } + } + + // 发送请求 + const isStreaming = streamRequested + + // 根据是否流式选择不同的处理方式 + if (isStreaming) { + // 流式响应:使用原生 https 模块以更好地控制流 + return await this._handleStreamRequest( + apiUrl, + headers, + processedBody, + proxyAgent, + clientRequest, + clientResponse, + account, + keyInfo, + normalizedRequestBody, + normalizedEndpoint, + skipUsageRecord, + selectedApiKey, + sessionHash, + clientApiKeyId + ) + } else { + // 非流式响应:使用 axios + const requestOptions = { + method: 'POST', + url: apiUrl, + headers, + data: processedBody, + timeout: 600 * 1000, // 10分钟超时 + responseType: 'json', + ...(proxyAgent && { + httpAgent: proxyAgent, + httpsAgent: proxyAgent, + proxy: false + }) + } + + const response = await axios(requestOptions) + + logger.info(`✅ Factory.ai response status: ${response.status}`) + + // 处理非流式响应 + return this._handleNonStreamResponse( + response, + account, + keyInfo, + normalizedRequestBody, + clientRequest, + normalizedEndpoint, + skipUsageRecord + ) + } + } catch (error) { + // 客户端主动断开连接是正常情况,使用 INFO 级别 + if (error.message === 'Client disconnected') { + logger.info(`🔌 Droid relay ended: Client disconnected`) + } else { + logger.error(`❌ Droid relay error: ${error.message}`, error) + } + + const status = error?.response?.status + const droidAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + // 5xx 错误 + if (status >= 500 && account?.id && !droidAutoProtectionDisabled) { + await upstreamErrorHelper.markTempUnavailable(account.id, 'droid', status).catch(() => {}) + } else if ( + !status && + account?.id && + error.message !== 'Client disconnected' && + !droidAutoProtectionDisabled + ) { + // 网络错误(非客户端断开),临时不可用 + await upstreamErrorHelper.markTempUnavailable(account.id, 'droid', 503).catch(() => {}) + } + + if (status >= 400 && status < 500) { + try { + await this._handleUpstreamClientError(status, { + account, + selectedAccountApiKey: selectedApiKey, + endpointType: normalizedEndpoint, + sessionHash, + clientApiKeyId + }) + } catch (handlingError) { + logger.error('❌ 处理 Droid 4xx 异常失败:', handlingError) + } + } + + if (error.response) { + // HTTP 错误响应 + return { + statusCode: error.response.status, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( + error.response.data || { + error: 'upstream_error', + message: error.message + } + ) + } + } + + // 网络错误或其他错误(统一返回 4xx) + const mappedStatus = this._mapNetworkErrorStatus(error) + return { + statusCode: mappedStatus, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(this._buildNetworkErrorBody(error)) + } + } + } + + /** + * 处理流式请求 + */ + async _handleStreamRequest( + apiUrl, + headers, + processedBody, + proxyAgent, + clientRequest, + clientResponse, + account, + apiKeyData, + requestBody, + endpointType, + skipUsageRecord = false, + selectedAccountApiKey = null, + sessionHash = null, + clientApiKeyId = null + ) { + return new Promise((resolve, reject) => { + const url = new URL(apiUrl) + const keyId = apiKeyData?.id + const bodyString = JSON.stringify(processedBody) + const contentLength = Buffer.byteLength(bodyString) + const requestHeaders = { + ...headers, + 'content-length': contentLength.toString() + } + + let responseStarted = false + let responseCompleted = false + let settled = false + let upstreamResponse = null + let completionWindow = '' + let hasForwardedData = false + + const resolveOnce = (value) => { + if (settled) { + return + } + settled = true + resolve(value) + } + + const rejectOnce = (error) => { + if (settled) { + return + } + settled = true + reject(error) + } + + const handleStreamError = (error) => { + if (responseStarted) { + const isConnectionReset = + error && (error.code === 'ECONNRESET' || error.message === 'aborted') + const upstreamComplete = + responseCompleted || upstreamResponse?.complete || clientResponse.writableEnded + + if (isConnectionReset && (upstreamComplete || hasForwardedData)) { + logger.debug('🔁 Droid stream连接在响应阶段被重置,视为正常结束:', { + message: error?.message, + code: error?.code + }) + if (!clientResponse.destroyed && !clientResponse.writableEnded) { + clientResponse.end() + } + resolveOnce({ statusCode: 200, streaming: true }) + return + } + + logger.error('❌ Droid stream error:', error) + const mappedStatus = this._mapNetworkErrorStatus(error) + const errorBody = this._buildNetworkErrorBody(error) + + if (!clientResponse.destroyed) { + if (!clientResponse.writableEnded) { + const canUseJson = + !hasForwardedData && + typeof clientResponse.status === 'function' && + typeof clientResponse.json === 'function' + + if (canUseJson) { + clientResponse.status(mappedStatus).json(errorBody) + } else { + const errorPayload = JSON.stringify(errorBody) + + if (!hasForwardedData) { + if (typeof clientResponse.setHeader === 'function') { + clientResponse.setHeader('Content-Type', 'application/json') + } + clientResponse.write(errorPayload) + clientResponse.end() + } else { + clientResponse.write(`event: error\ndata: ${errorPayload}\n\n`) + clientResponse.end() + } + } + } + } + + resolveOnce({ statusCode: mappedStatus, streaming: true, error }) + } else { + rejectOnce(error) + } + } + + const options = { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'POST', + headers: requestHeaders, + agent: proxyAgent, + timeout: 600 * 1000 + } + + const req = https.request(options, (res) => { + upstreamResponse = res + logger.info(`✅ Factory.ai stream response status: ${res.statusCode}`) + + // 错误响应 + if (res.statusCode !== 200) { + const chunks = [] + + res.on('data', (chunk) => { + chunks.push(chunk) + logger.info(`📦 got ${chunk.length} bytes of data`) + }) + + res.on('end', () => { + logger.info('✅ res.end() reached') + const body = Buffer.concat(chunks).toString() + logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`) + if (res.statusCode >= 500) { + const streamAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!streamAutoProtectionDisabled) { + upstreamErrorHelper + .markTempUnavailable(account.id, 'droid', res.statusCode) + .catch(() => {}) + } + } + if (res.statusCode >= 400 && res.statusCode < 500) { + this._handleUpstreamClientError(res.statusCode, { + account, + selectedAccountApiKey, + endpointType, + sessionHash, + clientApiKeyId + }).catch((handlingError) => { + logger.error('❌ 处理 Droid 流式4xx 异常失败:', handlingError) + }) + } + if (!clientResponse.headersSent) { + clientResponse.status(res.statusCode).json({ + error: 'upstream_error', + details: body + }) + } + resolveOnce({ statusCode: res.statusCode, streaming: true }) + }) + + res.on('close', () => { + logger.warn('⚠️ response closed before end event') + }) + + res.on('error', handleStreamError) + + return + } + + responseStarted = true + + // 设置流式响应头 + clientResponse.setHeader('Content-Type', 'text/event-stream') + clientResponse.setHeader('Cache-Control', 'no-cache') + clientResponse.setHeader('Connection', 'keep-alive') + + // Usage 数据收集 + let buffer = '' + const currentUsageData = {} + const model = requestBody.model || 'unknown' + + // 处理 SSE 流 + res.on('data', (chunk) => { + const chunkStr = chunk.toString() + completionWindow = (completionWindow + chunkStr).slice(-1024) + hasForwardedData = true + + // 转发数据到客户端 + clientResponse.write(chunk) + hasForwardedData = true + + // 解析 usage 数据(根据端点类型) + if (endpointType === 'anthropic') { + // Anthropic Messages API 格式 + this._parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData) + } else if (endpointType === 'openai' || endpointType === 'comm') { + // OpenAI Chat Completions 格式(openai 和 comm 共用) + this._parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData) + } + + if (!responseCompleted && this._detectStreamCompletion(completionWindow, endpointType)) { + responseCompleted = true + } + + buffer += chunkStr + }) + + res.on('end', async () => { + responseCompleted = true + clientResponse.end() + + // 记录 usage 数据 + if (!skipUsageRecord) { + const { normalizedUsage, costs: streamCosts } = await this._recordUsageFromStreamData( + currentUsageData, + apiKeyData, + account, + model, + createRequestDetailMeta(clientRequest, { + requestBody, + stream: true, + statusCode: clientResponse.statusCode + }) + ) + + const usageSummary = { + inputTokens: normalizedUsage.input_tokens || 0, + outputTokens: normalizedUsage.output_tokens || 0, + cacheCreateTokens: normalizedUsage.cache_creation_input_tokens || 0, + cacheReadTokens: normalizedUsage.cache_read_input_tokens || 0 + } + + await this._applyRateLimitTracking( + clientRequest?.rateLimitInfo, + usageSummary, + model, + ' [stream]', + keyId, + streamCosts + ) + + logger.success(`Droid stream completed - Account: ${account.name}`) + } else { + logger.success( + `✅ Droid stream completed - Account: ${account.name}, usage recording skipped` + ) + } + resolveOnce({ statusCode: 200, streaming: true }) + }) + + res.on('error', handleStreamError) + + res.on('close', () => { + if (settled) { + return + } + + if (responseCompleted) { + if (!clientResponse.destroyed && !clientResponse.writableEnded) { + clientResponse.end() + } + resolveOnce({ statusCode: 200, streaming: true }) + } else { + handleStreamError(new Error('Upstream stream closed unexpectedly')) + } + }) + }) + + // 客户端断开连接时清理 + clientResponse.on('close', () => { + if (req && !req.destroyed) { + req.destroy(new Error('Client disconnected')) + } + }) + + req.on('error', handleStreamError) + + req.on('timeout', () => { + req.destroy() + logger.error('❌ Droid request timeout') + handleStreamError(new Error('Request timeout')) + }) + + // 写入请求体 + req.end(bodyString) + }) + } + + /** + * 从 SSE 流中解析 Anthropic usage 数据 + */ + _parseAnthropicUsageFromSSE(chunkStr, buffer, currentUsageData) { + try { + // 分割成行 + const lines = (buffer + chunkStr).split('\n') + + for (const line of lines) { + if (line.startsWith('data: ') && line.length > 6) { + try { + const jsonStr = line.slice(6) + const data = JSON.parse(jsonStr) + + // message_start 包含 input tokens 和 cache tokens + if (data.type === 'message_start' && data.message && data.message.usage) { + currentUsageData.input_tokens = data.message.usage.input_tokens || 0 + currentUsageData.cache_creation_input_tokens = + data.message.usage.cache_creation_input_tokens || 0 + currentUsageData.cache_read_input_tokens = + data.message.usage.cache_read_input_tokens || 0 + + // 详细的缓存类型 + if (data.message.usage.cache_creation) { + currentUsageData.cache_creation = { + ephemeral_5m_input_tokens: + data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0, + ephemeral_1h_input_tokens: + data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0 + } + } + + logger.debug('📊 Droid Anthropic input usage:', currentUsageData) + } + + // message_delta 包含 output tokens + if (data.type === 'message_delta' && data.usage) { + currentUsageData.output_tokens = data.usage.output_tokens || 0 + logger.debug('📊 Droid Anthropic output usage:', currentUsageData.output_tokens) + } + } catch (parseError) { + // 忽略解析错误 + } + } + } + } catch (error) { + logger.debug('Error parsing Anthropic usage:', error) + } + } + + /** + * 从 SSE 流中解析 OpenAI usage 数据 + */ + _parseOpenAIUsageFromSSE(chunkStr, buffer, currentUsageData) { + try { + // OpenAI Chat Completions 流式格式 + const lines = (buffer + chunkStr).split('\n') + + for (const line of lines) { + if (line.startsWith('data: ') && line.length > 6) { + try { + const jsonStr = line.slice(6) + if (jsonStr === '[DONE]') { + continue + } + + const data = JSON.parse(jsonStr) + + // 兼容传统 Chat Completions usage 字段 + if (data.usage) { + currentUsageData.input_tokens = data.usage.prompt_tokens || 0 + currentUsageData.total_tokens = data.usage.total_tokens || 0 + // completion_tokens 可能缺失(如某些模型响应),从 total_tokens - prompt_tokens 计算 + if ( + data.usage.completion_tokens !== undefined && + data.usage.completion_tokens !== null + ) { + currentUsageData.output_tokens = data.usage.completion_tokens + } else if (currentUsageData.total_tokens > 0 && currentUsageData.input_tokens >= 0) { + currentUsageData.output_tokens = Math.max( + 0, + currentUsageData.total_tokens - currentUsageData.input_tokens + ) + } else { + currentUsageData.output_tokens = 0 + } + + // Capture cache tokens from OpenAI format + currentUsageData.cache_read_input_tokens = + data.usage.input_tokens_details?.cached_tokens || 0 + currentUsageData.cache_creation_input_tokens = + data.usage.input_tokens_details?.cache_creation_input_tokens || + data.usage.cache_creation_input_tokens || + 0 + + logger.debug('📊 Droid OpenAI usage:', currentUsageData) + } + + // 新 Response API 在 response.usage 中返回统计 + if (data.response && data.response.usage) { + const { usage } = data.response + currentUsageData.input_tokens = + usage.input_tokens || usage.prompt_tokens || usage.total_tokens || 0 + currentUsageData.total_tokens = usage.total_tokens || 0 + // completion_tokens/output_tokens 可能缺失,从 total_tokens - input_tokens 计算 + if (usage.output_tokens !== undefined || usage.completion_tokens !== undefined) { + currentUsageData.output_tokens = usage.output_tokens || usage.completion_tokens || 0 + } else if (currentUsageData.total_tokens > 0 && currentUsageData.input_tokens >= 0) { + currentUsageData.output_tokens = Math.max( + 0, + currentUsageData.total_tokens - currentUsageData.input_tokens + ) + } else { + currentUsageData.output_tokens = 0 + } + + // Capture cache tokens from OpenAI Response API format + currentUsageData.cache_read_input_tokens = + usage.input_tokens_details?.cached_tokens || 0 + currentUsageData.cache_creation_input_tokens = + usage.input_tokens_details?.cache_creation_input_tokens || + usage.cache_creation_input_tokens || + 0 + + logger.debug('📊 Droid OpenAI response usage:', currentUsageData) + } + } catch (parseError) { + // 忽略解析错误 + } + } + } + } catch (error) { + logger.debug('Error parsing OpenAI usage:', error) + } + } + + /** + * 检测流式响应是否已经包含终止标记 + */ + _detectStreamCompletion(windowStr, endpointType) { + if (!windowStr) { + return false + } + + const lower = windowStr.toLowerCase() + const compact = lower.replace(/\s+/g, '') + + if (endpointType === 'anthropic') { + if (lower.includes('event: message_stop')) { + return true + } + if (compact.includes('"type":"message_stop"')) { + return true + } + return false + } + + if (endpointType === 'openai' || endpointType === 'comm') { + if (lower.includes('data: [done]')) { + return true + } + + if (compact.includes('"finish_reason"')) { + return true + } + + if (lower.includes('event: response.done') || lower.includes('event: response.completed')) { + return true + } + + if ( + compact.includes('"type":"response.done"') || + compact.includes('"type":"response.completed"') + ) { + return true + } + } + + return false + } + + /** + * 记录从流中解析的 usage 数据 + */ + async _recordUsageFromStreamData(usageData, apiKeyData, account, model, requestMeta = null) { + const normalizedUsage = this._normalizeUsageSnapshot(usageData) + const costs = await this._recordUsage(apiKeyData, account, model, normalizedUsage, requestMeta) + return { normalizedUsage, costs } + } + + /** + * 标准化 usage 数据,确保字段完整且为数字 + */ + _normalizeUsageSnapshot(usageData = {}) { + const toNumber = (value) => { + if (value === undefined || value === null || value === '') { + return 0 + } + const num = Number(value) + if (!Number.isFinite(num)) { + return 0 + } + return Math.max(0, num) + } + + const inputTokens = toNumber( + usageData.input_tokens ?? + usageData.prompt_tokens ?? + usageData.inputTokens ?? + usageData.total_input_tokens + ) + const totalTokens = toNumber(usageData.total_tokens ?? usageData.totalTokens) + + // 尝试从多个字段获取 output_tokens + let outputTokens = toNumber( + usageData.output_tokens ?? usageData.completion_tokens ?? usageData.outputTokens + ) + // 如果 output_tokens 为 0 但有 total_tokens,从差值计算 + if (outputTokens === 0 && totalTokens > 0 && inputTokens >= 0) { + outputTokens = Math.max(0, totalTokens - inputTokens) + } + const cacheReadTokens = toNumber( + usageData.cache_read_input_tokens ?? + usageData.cacheReadTokens ?? + usageData.input_tokens_details?.cached_tokens + ) + + const rawCacheCreateTokens = + usageData.cache_creation_input_tokens ?? + usageData.cacheCreateTokens ?? + usageData.cache_tokens ?? + 0 + let cacheCreateTokens = toNumber(rawCacheCreateTokens) + + const ephemeral5m = toNumber( + usageData.cache_creation?.ephemeral_5m_input_tokens ?? usageData.ephemeral_5m_input_tokens + ) + const ephemeral1h = toNumber( + usageData.cache_creation?.ephemeral_1h_input_tokens ?? usageData.ephemeral_1h_input_tokens + ) + + if (cacheCreateTokens === 0 && (ephemeral5m > 0 || ephemeral1h > 0)) { + cacheCreateTokens = ephemeral5m + ephemeral1h + } + + const normalized = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + if (ephemeral5m > 0 || ephemeral1h > 0) { + normalized.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5m, + ephemeral_1h_input_tokens: ephemeral1h + } + } + + return normalized + } + + /** + * 计算 usage 对象的总 token 数 + */ + _getTotalTokens(usageObject = {}) { + const toNumber = (value) => { + if (value === undefined || value === null || value === '') { + return 0 + } + const num = Number(value) + if (!Number.isFinite(num)) { + return 0 + } + return Math.max(0, num) + } + + return ( + toNumber(usageObject.input_tokens) + + toNumber(usageObject.output_tokens) + + toNumber(usageObject.cache_creation_input_tokens) + + toNumber(usageObject.cache_read_input_tokens) + ) + } + + /** + * 提取账户 ID + */ + _extractAccountId(account) { + if (!account || typeof account !== 'object') { + return null + } + return account.id || account.accountId || account.account_id || null + } + + /** + * 根据模型名称推断 API provider + */ + _inferProviderFromModel(model) { + if (!model || typeof model !== 'string') { + return 'baseten' + } + + const lowerModel = model.toLowerCase() + + // Google Gemini 模型 + if (lowerModel.startsWith('gemini-') || lowerModel.includes('gemini')) { + return 'google' + } + + // Anthropic Claude 模型 + if (lowerModel.startsWith('claude-') || lowerModel.includes('claude')) { + return 'anthropic' + } + + // OpenAI GPT 模型 + if (lowerModel.startsWith('gpt-') || lowerModel.includes('gpt')) { + return 'azure_openai' + } + + // GLM 模型使用 fireworks + if (lowerModel.startsWith('glm-') || lowerModel.includes('glm')) { + return 'fireworks' + } + + // 默认使用 baseten + return 'baseten' + } + + /** + * 构建请求头 + */ + _buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}, account = null) { + // 使用账户配置的 userAgent 或默认值 + const userAgent = account?.userAgent || this.userAgent + const headers = { + 'content-type': 'application/json', + authorization: `Bearer ${accessToken}`, + 'user-agent': userAgent, + 'x-factory-client': 'cli', + connection: 'keep-alive' + } + + // Anthropic 特定头 + if (endpointType === 'anthropic') { + headers['accept'] = 'application/json' + headers['anthropic-version'] = '2023-06-01' + headers['x-api-key'] = 'placeholder' + headers['x-api-provider'] = 'anthropic' + + if (this._isThinkingRequested(requestBody)) { + headers['anthropic-beta'] = 'interleaved-thinking-2025-05-14' + } + } + + // OpenAI 特定头 - 根据模型动态选择 provider + if (endpointType === 'openai') { + const model = (requestBody?.model || '').toLowerCase() + // -max 模型使用 openai provider,其他使用 azure_openai + if (model.includes('-max')) { + headers['x-api-provider'] = 'openai' + } else { + headers['x-api-provider'] = 'azure_openai' + } + } + + // Comm 端点根据模型动态设置 provider + if (endpointType === 'comm') { + const model = requestBody?.model + headers['x-api-provider'] = this._inferProviderFromModel(model) + } + + // 生成会话 ID(如果客户端没有提供) + headers['x-session-id'] = clientHeaders['x-session-id'] || this._generateUUID() + + return headers + } + + /** + * 判断请求是否要求流式响应 + */ + _isStreamRequested(requestBody) { + if (!requestBody || typeof requestBody !== 'object') { + return false + } + + const value = requestBody.stream + + if (value === true) { + return true + } + + if (typeof value === 'string') { + return value.toLowerCase() === 'true' + } + + return false + } + + /** + * 判断请求是否启用 Anthropic 推理模式 + */ + _isThinkingRequested(requestBody) { + const thinking = requestBody && typeof requestBody === 'object' ? requestBody.thinking : null + if (!thinking) { + return false + } + + if (thinking === true) { + return true + } + + if (typeof thinking === 'string') { + return thinking.trim().toLowerCase() === 'enabled' + } + + if (typeof thinking === 'object') { + if (thinking.enabled === true) { + return true + } + + if (typeof thinking.type === 'string') { + return thinking.type.trim().toLowerCase() === 'enabled' + } + } + + return false + } + + /** + * 处理请求体(注入 system prompt 等) + */ + _processRequestBody(requestBody, endpointType, options = {}) { + const { disableStreaming = false, streamRequested = false } = options + const processedBody = { ...requestBody } + + const hasStreamField = + requestBody && Object.prototype.hasOwnProperty.call(requestBody, 'stream') + + if (processedBody && Object.prototype.hasOwnProperty.call(processedBody, 'metadata')) { + delete processedBody.metadata + } + + if (disableStreaming || !streamRequested) { + if (hasStreamField) { + processedBody.stream = false + } else if ('stream' in processedBody) { + delete processedBody.stream + } + } else { + processedBody.stream = true + } + + // Anthropic 端点:仅注入系统提示 + if (endpointType === 'anthropic') { + if (this.systemPrompt) { + const promptBlock = { type: 'text', text: this.systemPrompt } + if (Array.isArray(processedBody.system)) { + const hasPrompt = processedBody.system.some( + (item) => item && item.type === 'text' && item.text === this.systemPrompt + ) + if (!hasPrompt) { + processedBody.system = [promptBlock, ...processedBody.system] + } + } else { + processedBody.system = [promptBlock] + } + } + } + + // OpenAI 端点:仅前置系统提示 + if (endpointType === 'openai') { + if (this.systemPrompt) { + if (processedBody.instructions) { + if (!processedBody.instructions.startsWith(this.systemPrompt)) { + processedBody.instructions = `${this.systemPrompt}${processedBody.instructions}` + } + } else { + processedBody.instructions = this.systemPrompt + } + } + } + + // Comm 端点:在 messages 数组前注入 system 消息 + if (endpointType === 'comm') { + if (this.systemPrompt && Array.isArray(processedBody.messages)) { + const hasSystemMessage = processedBody.messages.some((m) => m && m.role === 'system') + + if (hasSystemMessage) { + // 如果已有 system 消息,在第一个 system 消息的 content 前追加 + const firstSystemIndex = processedBody.messages.findIndex((m) => m && m.role === 'system') + if (firstSystemIndex !== -1) { + const existingContent = processedBody.messages[firstSystemIndex].content || '' + if ( + typeof existingContent === 'string' && + !existingContent.startsWith(this.systemPrompt) + ) { + processedBody.messages[firstSystemIndex] = { + ...processedBody.messages[firstSystemIndex], + content: this.systemPrompt + existingContent + } + } + } + } else { + // 如果没有 system 消息,在 messages 数组最前面插入 + processedBody.messages = [ + { role: 'system', content: this.systemPrompt }, + ...processedBody.messages + ] + } + } + } + + // 处理 temperature 和 top_p 参数 + const hasValidTemperature = + processedBody.temperature !== undefined && processedBody.temperature !== null + const hasValidTopP = processedBody.top_p !== undefined && processedBody.top_p !== null + + if (hasValidTemperature && hasValidTopP) { + // 仅允许 temperature 或 top_p 其一,同时优先保留 temperature + delete processedBody.top_p + } + + return processedBody + } + + /** + * 处理非流式响应 + */ + async _handleNonStreamResponse( + response, + account, + apiKeyData, + requestBody, + clientRequest, + endpointType, + skipUsageRecord = false + ) { + const { data } = response + const keyId = apiKeyData?.id + + // 从响应中提取 usage 数据 + const usage = data.usage || {} + + const model = requestBody.model || 'unknown' + + const normalizedUsage = this._normalizeUsageSnapshot(usage) + + if (!skipUsageRecord) { + const droidCosts = await this._recordUsage( + apiKeyData, + account, + model, + normalizedUsage, + createRequestDetailMeta(clientRequest, { + requestBody, + stream: false, + statusCode: response.status || 200 + }) + ) + + const totalTokens = this._getTotalTokens(normalizedUsage) + + const usageSummary = { + inputTokens: normalizedUsage.input_tokens || 0, + outputTokens: normalizedUsage.output_tokens || 0, + cacheCreateTokens: normalizedUsage.cache_creation_input_tokens || 0, + cacheReadTokens: normalizedUsage.cache_read_input_tokens || 0 + } + + const endpointLabel = + endpointType === 'anthropic' + ? ' [anthropic]' + : endpointType === 'comm' + ? ' [comm]' + : ' [openai]' + await this._applyRateLimitTracking( + clientRequest?.rateLimitInfo, + usageSummary, + model, + endpointLabel, + keyId, + droidCosts + ) + + logger.success( + `✅ Droid request completed - Account: ${account.name}, Tokens: ${totalTokens}` + ) + } else { + logger.success( + `✅ Droid request completed - Account: ${account.name}, usage recording skipped` + ) + } + + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + } + } + + /** + * 记录使用统计 + */ + async _recordUsage(apiKeyData, account, model, usageObject = {}, requestMeta = null) { + const totalTokens = this._getTotalTokens(usageObject) + + if (totalTokens <= 0) { + logger.debug('🪙 Droid usage 数据为空,跳过记录') + return { realCost: 0, ratedCost: 0 } + } + + try { + const keyId = apiKeyData?.id + const accountId = this._extractAccountId(account) + let costs = { realCost: 0, ratedCost: 0 } + + if (keyId) { + costs = await apiKeyService.recordUsageWithDetails( + keyId, + usageObject, + model, + accountId, + 'droid', + requestMeta + ) + } else if (accountId) { + await redis.incrementAccountUsage( + accountId, + totalTokens, + usageObject.input_tokens || 0, + usageObject.output_tokens || 0, + usageObject.cache_creation_input_tokens || 0, + usageObject.cache_read_input_tokens || 0, + 0, // ephemeral5mTokens - Droid 不含详细缓存数据 + 0, // ephemeral1hTokens - Droid 不含详细缓存数据 + model, + false + ) + } else { + logger.warn('⚠️ 无法记录 Droid usage:缺少 API Key 和账户标识') + return { realCost: 0, ratedCost: 0 } + } + + logger.debug( + `📊 Droid usage recorded - Key: ${keyId || 'unknown'}, Account: ${accountId || 'unknown'}, Model: ${model}, Input: ${usageObject.input_tokens || 0}, Output: ${usageObject.output_tokens || 0}, Cache Create: ${usageObject.cache_creation_input_tokens || 0}, Cache Read: ${usageObject.cache_read_input_tokens || 0}, Total: ${totalTokens}` + ) + + return costs + } catch (error) { + logger.error('❌ Failed to record Droid usage:', error) + return { realCost: 0, ratedCost: 0 } + } + } + + /** + * 处理上游 4xx 响应,移除问题 API Key 或停止账号调度 + */ + async _handleUpstreamClientError(statusCode, context = {}) { + if (!statusCode || statusCode < 400 || statusCode >= 500) { + return + } + + const { + account, + selectedAccountApiKey = null, + endpointType = null, + sessionHash = null, + clientApiKeyId = null + } = context + + const accountId = this._extractAccountId(account) + if (!accountId) { + logger.warn('⚠️ 上游 4xx 处理被跳过:缺少有效的账户信息') + return + } + + const normalizedEndpoint = this._normalizeEndpointType( + endpointType || account?.endpointType || 'anthropic' + ) + const authMethod = + typeof account?.authenticationMethod === 'string' + ? account.authenticationMethod.toLowerCase().trim() + : '' + + if (authMethod === 'api_key') { + if (selectedAccountApiKey?.id) { + let markResult = null + const errorMessage = `${statusCode}` + + try { + // 标记API Key为异常状态而不是删除 + markResult = await droidAccountService.markApiKeyAsError( + accountId, + selectedAccountApiKey.id, + errorMessage + ) + } catch (error) { + logger.error( + `❌ 标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId})失败:`, + error + ) + } + + await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash) + + if (markResult?.marked) { + logger.warn( + `⚠️ 上游返回 ${statusCode},已标记 Droid API Key ${selectedAccountApiKey.id} 为异常状态(Account: ${accountId})` + ) + } else { + logger.warn( + `⚠️ 上游返回 ${statusCode},但未能标记 Droid API Key ${selectedAccountApiKey.id} 异常状态(Account: ${accountId}):${markResult?.error || '未知错误'}` + ) + } + + // 检查是否还有可用的API Key + try { + const availableEntries = await droidAccountService.getDecryptedApiKeyEntries(accountId) + const activeEntries = availableEntries.filter((entry) => entry.status !== 'error') + + if (activeEntries.length === 0) { + await this._stopDroidAccountScheduling(accountId, statusCode, '所有API Key均已异常') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } else { + logger.info(`ℹ️ Droid 账号 ${accountId} 仍有 ${activeEntries.length} 个可用 API Key`) + } + } catch (error) { + logger.error(`❌ 检查可用API Key失败(Account: ${accountId}):`, error) + await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key检查失败') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } + + return + } + + logger.warn( + `⚠️ 上游返回 ${statusCode},但未获取到对应的 Droid API Key(Account: ${accountId})` + ) + await this._stopDroidAccountScheduling(accountId, statusCode, '缺少可用 API Key') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + return + } + + const clientErrorAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!clientErrorAutoProtectionDisabled) { + await upstreamErrorHelper.markTempUnavailable(accountId, 'droid', statusCode) + } + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } + + /** + * 停止指定 Droid 账号的调度 + */ + async _stopDroidAccountScheduling(accountId, statusCode, reason = '') { + if (!accountId) { + return + } + + const message = reason ? `${reason}` : '上游返回 4xx 错误' + + try { + await droidAccountService.updateAccount(accountId, { + schedulable: 'false', + status: 'error', + errorMessage: `上游返回 ${statusCode}:${message}` + }) + logger.warn(`🚫 已停止调度 Droid 账号 ${accountId}(状态码 ${statusCode},原因:${message})`) + } catch (error) { + logger.error(`❌ 停止调度 Droid 账号失败:${accountId}`, error) + } + } + + /** + * 清理账号层面的粘性调度映射 + */ + async _clearAccountStickyMapping(endpointType, sessionHash, clientApiKeyId) { + if (!sessionHash) { + return + } + + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const apiKeyPart = clientApiKeyId || 'default' + const stickyKey = `droid:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}` + + try { + await redis.deleteSessionAccountMapping(stickyKey) + logger.debug(`🧹 已清理 Droid 粘性会话映射:${stickyKey}`) + } catch (error) { + logger.warn(`⚠️ 清理 Droid 粘性会话映射失败:${stickyKey}`, error) + } + } + + /** + * 清理 API Key 级别的粘性映射 + */ + async _clearApiKeyStickyMapping(accountId, endpointType, sessionHash) { + if (!accountId || !sessionHash) { + return + } + + try { + const stickyKey = this._composeApiKeyStickyKey(accountId, endpointType, sessionHash) + if (stickyKey) { + await redis.deleteSessionAccountMapping(stickyKey) + logger.debug(`🧹 已清理 Droid API Key 粘性映射:${stickyKey}`) + } + } catch (error) { + logger.warn( + `⚠️ 清理 Droid API Key 粘性映射失败:${accountId}(endpoint: ${endpointType})`, + error + ) + } + } + + _mapNetworkErrorStatus(error) { + const code = (error && error.code ? String(error.code) : '').toUpperCase() + + if (code === 'ECONNABORTED' || code === 'ETIMEDOUT') { + return 408 + } + + if (code === 'ECONNRESET' || code === 'EPIPE') { + return 424 + } + + if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') { + return 424 + } + + if (typeof error === 'object' && error !== null) { + const message = (error.message || '').toLowerCase() + if (message.includes('timeout')) { + return 408 + } + } + + return 424 + } + + _buildNetworkErrorBody(error) { + const body = { + error: 'relay_upstream_failure', + message: error?.message || '上游请求失败' + } + + if (error?.code) { + body.code = error.code + } + + if (error?.config?.url) { + body.upstream = error.config.url + } + + return body + } + + /** + * 生成 UUID + */ + _generateUUID() { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) + } +} + +// 导出单例 +module.exports = new DroidRelayService() diff --git a/src/services/relay/geminiRelayService.js b/src/services/relay/geminiRelayService.js new file mode 100644 index 0000000..5aee7c1 --- /dev/null +++ b/src/services/relay/geminiRelayService.js @@ -0,0 +1,579 @@ +const axios = require('axios') +const ProxyHelper = require('../../utils/proxyHelper') +const logger = require('../../utils/logger') +const config = require('../../../config/config') +const apiKeyService = require('../apiKeyService') + +// Gemini API 配置 +const GEMINI_API_BASE = 'https://cloudcode.googleapis.com/v1' +const DEFAULT_MODEL = 'models/gemini-2.0-flash-exp' + +// 创建代理 agent(使用统一的代理工具) +function createProxyAgent(proxyConfig) { + return ProxyHelper.createProxyAgent(proxyConfig) +} + +// 转换 OpenAI 消息格式到 Gemini 格式 +function convertMessagesToGemini(messages) { + const contents = [] + let systemInstruction = '' + + for (const message of messages) { + if (message.role === 'system') { + systemInstruction += (systemInstruction ? '\n\n' : '') + message.content + } else if (message.role === 'user') { + contents.push({ + role: 'user', + parts: [{ text: message.content }] + }) + } else if (message.role === 'assistant') { + contents.push({ + role: 'model', + parts: [{ text: message.content }] + }) + } + } + + return { contents, systemInstruction } +} + +// 转换 Gemini 响应到 OpenAI 格式 +function convertGeminiResponse(geminiResponse, model, stream = false) { + if (stream) { + // 流式响应 + const candidate = geminiResponse.candidates?.[0] + if (!candidate) { + return null + } + + const content = candidate.content?.parts?.[0]?.text || '' + const finishReason = candidate.finishReason?.toLowerCase() + + return { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion.chunk', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + delta: { + content + }, + finish_reason: finishReason === 'stop' ? 'stop' : null + } + ] + } + } else { + // 非流式响应 + const candidate = geminiResponse.candidates?.[0] + if (!candidate) { + throw new Error('No response from Gemini') + } + + const content = candidate.content?.parts?.[0]?.text || '' + const finishReason = candidate.finishReason?.toLowerCase() || 'stop' + + // 计算 token 使用量 + const usage = geminiResponse.usageMetadata || { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + + return { + id: `chatcmpl-${Date.now()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model, + choices: [ + { + index: 0, + message: { + role: 'assistant', + content + }, + finish_reason: finishReason + } + ], + usage: { + prompt_tokens: usage.promptTokenCount, + completion_tokens: usage.candidatesTokenCount, + total_tokens: usage.totalTokenCount + } + } + } +} + +// 处理流式响应 +async function* handleStreamResponse( + response, + model, + apiKeyId, + accountId = null, + requestMeta = null +) { + let buffer = '' + let totalUsage = { + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0 + } + + try { + for await (const chunk of response.data) { + buffer += chunk.toString() + + // 处理 SSE 格式的数据 + const lines = buffer.split('\n') + buffer = lines.pop() || '' // 保留最后一个不完整的行 + + for (const line of lines) { + if (!line.trim()) { + continue + } + + // 处理 SSE 格式: "data: {...}" + let jsonData = line + if (line.startsWith('data: ')) { + jsonData = line.substring(6).trim() + } + + if (!jsonData || jsonData === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonData) + + // 更新使用量统计 + if (data.usageMetadata) { + totalUsage = data.usageMetadata + } + + // 转换并发送响应 + const openaiResponse = convertGeminiResponse(data, model, true) + if (openaiResponse) { + yield `data: ${JSON.stringify(openaiResponse)}\n\n` + } + + // 检查是否结束 + if (data.candidates?.[0]?.finishReason === 'STOP') { + // 记录使用量 + if (apiKeyId && totalUsage.totalTokenCount > 0) { + await apiKeyService + .recordUsage( + apiKeyId, + totalUsage.promptTokenCount || 0, // inputTokens + totalUsage.candidatesTokenCount || 0, // outputTokens + 0, // cacheCreateTokens (Gemini 没有这个概念) + 0, // cacheReadTokens (Gemini 没有这个概念) + model, + accountId, + 'gemini', + null, + requestMeta + ) + .catch((error) => { + logger.error('❌ Failed to record Gemini usage:', error) + }) + } + + yield 'data: [DONE]\n\n' + return + } + } catch (e) { + logger.debug('Error parsing JSON line:', e.message, 'Line:', jsonData) + } + } + } + + // 处理剩余的 buffer + if (buffer.trim()) { + try { + let jsonData = buffer.trim() + if (jsonData.startsWith('data: ')) { + jsonData = jsonData.substring(6).trim() + } + + if (jsonData && jsonData !== '[DONE]') { + const data = JSON.parse(jsonData) + const openaiResponse = convertGeminiResponse(data, model, true) + if (openaiResponse) { + yield `data: ${JSON.stringify(openaiResponse)}\n\n` + } + } + } catch (e) { + logger.debug('Error parsing final buffer:', e.message) + } + } + + yield 'data: [DONE]\n\n' + } catch (error) { + // 检查是否是请求被中止 + if (error.name === 'CanceledError' || error.code === 'ECONNABORTED') { + logger.info('Stream request was aborted by client') + } else { + logger.error('Stream processing error:', error) + yield `data: ${JSON.stringify({ + error: { + message: error.message, + type: 'stream_error' + } + })}\n\n` + } + } +} + +// 发送请求到 Gemini +async function sendGeminiRequest({ + messages, + model = DEFAULT_MODEL, + temperature = 0.7, + maxTokens = 4096, + stream = false, + accessToken, + proxy, + apiKeyId, + signal, + projectId, + location = 'us-central1', + accountId = null, + requestMeta = null +}) { + // 确保模型名称格式正确 + if (!model.startsWith('models/')) { + model = `models/${model}` + } + + // 转换消息格式 + const { contents, systemInstruction } = convertMessagesToGemini(messages) + + // 构建请求体 + const requestBody = { + contents, + generationConfig: { + temperature, + maxOutputTokens: maxTokens, + candidateCount: 1 + } + } + + if (systemInstruction) { + requestBody.systemInstruction = { parts: [{ text: systemInstruction }] } + } + + // 配置请求选项 + let apiUrl + if (projectId) { + // 使用项目特定的 URL 格式(Google Cloud/Workspace 账号) + apiUrl = `${GEMINI_API_BASE}/projects/${projectId}/locations/${location}/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}?alt=sse` + logger.debug(`Using project-specific URL with projectId: ${projectId}, location: ${location}`) + } else { + // 使用标准 URL 格式(个人 Google 账号) + apiUrl = `${GEMINI_API_BASE}/${model}:${stream ? 'streamGenerateContent' : 'generateContent'}?alt=sse` + logger.debug('Using standard URL without projectId') + } + + const axiosConfig = { + method: 'POST', + url: apiUrl, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + data: requestBody, + timeout: config.requestTimeout || 600000 + } + + // 添加代理配置 + const proxyAgent = createProxyAgent(proxy) + if (proxyAgent) { + // 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode.googleapis.com) + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info(`🌐 Using proxy for Gemini API request: ${ProxyHelper.getProxyDescription(proxy)}`) + } else { + logger.debug('🌐 No proxy configured for Gemini API request') + } + + // 添加 AbortController 信号支持 + if (signal) { + axiosConfig.signal = signal + logger.debug('AbortController signal attached to request') + } + + if (stream) { + axiosConfig.responseType = 'stream' + } + + try { + logger.debug('Sending request to Gemini API') + const response = await axios(axiosConfig) + + if (stream) { + return handleStreamResponse(response, model, apiKeyId, accountId, requestMeta) + } else { + // 非流式响应 + const openaiResponse = convertGeminiResponse(response.data, model, false) + + // 记录使用量 + if (apiKeyId && openaiResponse.usage) { + await apiKeyService + .recordUsage( + apiKeyId, + openaiResponse.usage.prompt_tokens || 0, + openaiResponse.usage.completion_tokens || 0, + 0, // cacheCreateTokens + 0, // cacheReadTokens + model, + accountId, + 'gemini', + null, + requestMeta + ) + .catch((error) => { + logger.error('❌ Failed to record Gemini usage:', error) + }) + } + + return openaiResponse + } + } catch (error) { + // 检查是否是请求被中止 + if (error.name === 'CanceledError' || error.code === 'ECONNABORTED') { + logger.info('Gemini request was aborted by client') + const err = new Error('Request canceled by client') + err.status = 499 + err.error = { + message: 'Request canceled by client', + type: 'canceled', + code: 'request_canceled' + } + throw err + } + + logger.error('Gemini API request failed:', error.response?.data || error.message) + + // 转换错误格式 + if (error.response) { + const geminiError = error.response.data?.error + const err = new Error(geminiError?.message || 'Gemini API request failed') + err.status = error.response.status + err.error = { + message: geminiError?.message || 'Gemini API request failed', + type: geminiError?.code || 'api_error', + code: geminiError?.code + } + throw err + } + + const err = new Error(error.message) + err.status = 500 + err.error = { + message: error.message, + type: 'network_error' + } + throw err + } +} + +// 获取可用模型列表 +async function getAvailableModels(accessToken, proxy, projectId, location = 'us-central1') { + let apiUrl + if (projectId) { + // 使用项目特定的 URL 格式 + apiUrl = `${GEMINI_API_BASE}/projects/${projectId}/locations/${location}/models` + logger.debug(`Fetching models with projectId: ${projectId}, location: ${location}`) + } else { + // 使用标准 URL 格式 + apiUrl = `${GEMINI_API_BASE}/models` + logger.debug('Fetching models without projectId') + } + + const axiosConfig = { + method: 'GET', + url: apiUrl, + headers: { + Authorization: `Bearer ${accessToken}` + }, + timeout: config.requestTimeout || 600000 + } + + const proxyAgent = createProxyAgent(proxy) + if (proxyAgent) { + // 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode.googleapis.com) + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info( + `🌐 Using proxy for Gemini models request: ${ProxyHelper.getProxyDescription(proxy)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini models request') + } + + try { + const response = await axios(axiosConfig) + const models = response.data.models || [] + + // 转换为 OpenAI 格式 + return models + .filter((model) => model.supportedGenerationMethods?.includes('generateContent')) + .map((model) => ({ + id: model.name.replace('models/', ''), + object: 'model', + created: Date.now() / 1000, + owned_by: 'google' + })) + } catch (error) { + logger.error('Failed to get Gemini models:', error) + // 返回默认模型列表 + return [ + { + id: 'gemini-2.0-flash-exp', + object: 'model', + created: Date.now() / 1000, + owned_by: 'google' + } + ] + } +} + +// Count Tokens API - 用于Gemini CLI兼容性 +async function countTokens({ + model, + content, + accessToken, + proxy, + projectId, + location = 'us-central1' +}) { + // 确保模型名称格式正确 + if (!model.startsWith('models/')) { + model = `models/${model}` + } + + // 转换内容格式 - 支持多种输入格式 + let requestBody + if (Array.isArray(content)) { + // 如果content是数组,直接使用 + requestBody = { contents: content } + } else if (typeof content === 'string') { + // 如果是字符串,转换为Gemini格式 + requestBody = { + contents: [ + { + parts: [{ text: content }] + } + ] + } + } else if (content.parts || content.role) { + // 如果已经是Gemini格式的单个content + requestBody = { contents: [content] } + } else { + // 其他情况,尝试直接使用 + requestBody = { contents: content } + } + + // 构建API URL - countTokens需要使用generativelanguage API + const GENERATIVE_API_BASE = 'https://generativelanguage.googleapis.com/v1beta' + let apiUrl + if (projectId) { + // 使用项目特定的 URL 格式(Google Cloud/Workspace 账号) + apiUrl = `${GENERATIVE_API_BASE}/projects/${projectId}/locations/${location}/${model}:countTokens` + logger.debug( + `Using project-specific countTokens URL with projectId: ${projectId}, location: ${location}` + ) + } else { + // 使用标准 URL 格式(个人 Google 账号) + apiUrl = `${GENERATIVE_API_BASE}/${model}:countTokens` + logger.debug('Using standard countTokens URL without projectId') + } + + const axiosConfig = { + method: 'POST', + url: apiUrl, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'X-Goog-User-Project': projectId || undefined + }, + data: requestBody, + timeout: config.requestTimeout || 600000 + } + + // 添加代理配置 + const proxyAgent = createProxyAgent(proxy) + if (proxyAgent) { + // 只设置 httpsAgent,因为目标 URL 是 HTTPS (cloudcode.googleapis.com) + axiosConfig.httpsAgent = proxyAgent + axiosConfig.proxy = false + logger.info( + `🌐 Using proxy for Gemini countTokens request: ${ProxyHelper.getProxyDescription(proxy)}` + ) + } else { + logger.debug('🌐 No proxy configured for Gemini countTokens request') + } + + try { + logger.debug(`Sending countTokens request to: ${apiUrl}`) + logger.debug(`Request body: ${JSON.stringify(requestBody, null, 2)}`) + const response = await axios(axiosConfig) + + // 返回符合Gemini API格式的响应 + return { + totalTokens: response.data.totalTokens || 0, + totalBillableCharacters: response.data.totalBillableCharacters || 0, + ...response.data + } + } catch (error) { + logger.error(`Gemini countTokens API request failed for URL: ${apiUrl}`) + logger.error( + 'Request config:', + JSON.stringify( + { + url: apiUrl, + headers: axiosConfig.headers, + data: requestBody + }, + null, + 2 + ) + ) + logger.error('Error details:', error.response?.data || error.message) + + // 转换错误格式 + if (error.response) { + const geminiError = error.response.data?.error + const errorObj = new Error( + geminiError?.message || + `Gemini countTokens API request failed (Status: ${error.response.status})` + ) + errorObj.status = error.response.status + errorObj.error = { + message: + geminiError?.message || + `Gemini countTokens API request failed (Status: ${error.response.status})`, + type: geminiError?.code || 'api_error', + code: geminiError?.code + } + throw errorObj + } + + const errorObj = new Error(error.message) + errorObj.status = 500 + errorObj.error = { + message: error.message, + type: 'network_error' + } + throw errorObj + } +} + +module.exports = { + sendGeminiRequest, + getAvailableModels, + convertMessagesToGemini, + convertGeminiResponse, + countTokens +} diff --git a/src/services/relay/openaiResponsesRelayService.js b/src/services/relay/openaiResponsesRelayService.js new file mode 100644 index 0000000..dbe65cc --- /dev/null +++ b/src/services/relay/openaiResponsesRelayService.js @@ -0,0 +1,939 @@ +const axios = require('axios') +const ProxyHelper = require('../../utils/proxyHelper') +const logger = require('../../utils/logger') +const { filterForOpenAI } = require('../../utils/headerFilter') +const openaiResponsesAccountService = require('../account/openaiResponsesAccountService') +const apiKeyService = require('../apiKeyService') +const unifiedOpenAIScheduler = require('../scheduler/unifiedOpenAIScheduler') +const config = require('../../../config/config') +const crypto = require('crypto') +const LRUCache = require('../../utils/lruCache') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') +const { + createRequestDetailMeta, + extractOpenAICacheReadTokens +} = require('../../utils/requestDetailHelper') + +// lastUsedAt 更新节流(每账户 60 秒内最多更新一次,使用 LRU 防止内存泄漏) +const lastUsedAtThrottle = new LRUCache(1000) // 最多缓存 1000 个账户 +const LAST_USED_AT_THROTTLE_MS = 60000 + +// 抽取缓存写入 token,兼容多种字段命名 +function extractCacheCreationTokens(usageData) { + if (!usageData || typeof usageData !== 'object') { + return 0 + } + + const details = usageData.input_tokens_details || usageData.prompt_tokens_details || {} + const candidates = [ + details.cache_creation_input_tokens, + details.cache_creation_tokens, + usageData.cache_creation_input_tokens, + usageData.cache_creation_tokens + ] + + for (const value of candidates) { + if (value !== undefined && value !== null && value !== '') { + const parsed = Number(value) + if (!Number.isNaN(parsed)) { + return parsed + } + } + } + + return 0 +} + +class OpenAIResponsesRelayService { + constructor() { + this.defaultTimeout = config.requestTimeout || 600000 + } + + // 节流更新 lastUsedAt + async _throttledUpdateLastUsedAt(accountId) { + const now = Date.now() + const lastUpdate = lastUsedAtThrottle.get(accountId) + + if (lastUpdate && now - lastUpdate < LAST_USED_AT_THROTTLE_MS) { + return // 跳过更新 + } + + lastUsedAtThrottle.set(accountId, now, LAST_USED_AT_THROTTLE_MS) + await openaiResponsesAccountService.updateAccount(accountId, { + lastUsedAt: new Date().toISOString() + }) + } + + // 处理请求转发 + async handleRequest(req, res, account, apiKeyData) { + let abortController = null + // 获取会话哈希(如果有的话) + const sessionId = req.headers['session_id'] || req.body?.session_id + const sessionHash = sessionId + ? crypto.createHash('sha256').update(sessionId).digest('hex') + : null + + try { + // 获取完整的账户信息(包含解密的 API Key) + const fullAccount = await openaiResponsesAccountService.getAccount(account.id) + if (!fullAccount) { + throw new Error('Account not found') + } + + // 创建 AbortController 用于取消请求 + abortController = new AbortController() + + // 设置客户端断开监听器 + const handleClientDisconnect = () => { + logger.info('🔌 Client disconnected, aborting OpenAI-Responses request') + if (abortController && !abortController.signal.aborted) { + abortController.abort() + } + } + + // 监听客户端断开事件 + req.once('close', handleClientDisconnect) + res.once('close', handleClientDisconnect) + + // 构建目标 URL(根据 providerEndpoint 配置决定端点路径) + const providerEndpoint = fullAccount.providerEndpoint || 'responses' + let targetPath = req.path + + // 根据 providerEndpoint 配置归一化路径 + // 注意:unified.js 已将 /v1/chat/completions 的请求体转换为 Responses 格式, + // 因此这里只需归一化路径即可;反向 responses→completions 需要同时转换请求体, + // 目前不支持,所以只保留 responses 和 auto 两种模式 + if ( + providerEndpoint === 'responses' && + (targetPath === '/v1/chat/completions' || targetPath === '/chat/completions') + ) { + const newPath = targetPath.startsWith('/v1') ? '/v1/responses' : '/responses' + logger.info(`📝 Normalized path (${req.path}) → ${newPath} (providerEndpoint=responses)`) + targetPath = newPath + } + // providerEndpoint === 'auto' 时保持原始路径不变 + + // 防止 baseApi 已含 /v1 时路径重复(如 baseApi=http://host/v1 + targetPath=/v1/responses → /v1/v1/responses) + const baseApi = fullAccount.baseApi || '' + if (baseApi.endsWith('/v1') && targetPath.startsWith('/v1/')) { + targetPath = targetPath.slice(3) // '/v1/responses' → '/responses' + } + const targetUrl = `${baseApi}${targetPath}` + logger.info(`🎯 Forwarding to: ${targetUrl}`) + + // 构建请求头 - 使用统一的 headerFilter 移除 CDN headers + const headers = { + ...filterForOpenAI(req.headers), + Authorization: `Bearer ${fullAccount.apiKey}`, + 'Content-Type': 'application/json' + } + + // 处理 User-Agent + if (fullAccount.userAgent) { + // 使用自定义 User-Agent + headers['User-Agent'] = fullAccount.userAgent + logger.debug(`📱 Using custom User-Agent: ${fullAccount.userAgent}`) + } else if (req.headers['user-agent']) { + // 透传原始 User-Agent + headers['User-Agent'] = req.headers['user-agent'] + logger.debug(`📱 Forwarding original User-Agent: ${req.headers['user-agent']}`) + } + + // 配置请求选项 + const requestOptions = { + method: req.method, + url: targetUrl, + headers, + data: req.body, + timeout: this.defaultTimeout, + responseType: req.body?.stream ? 'stream' : 'json', + validateStatus: () => true, // 允许处理所有状态码 + signal: abortController.signal + } + + // 配置代理(如果有) + if (fullAccount.proxy) { + const proxyAgent = ProxyHelper.createProxyAgent(fullAccount.proxy) + if (proxyAgent) { + requestOptions.httpAgent = proxyAgent + requestOptions.httpsAgent = proxyAgent + requestOptions.proxy = false + logger.info( + `🌐 Using proxy for OpenAI-Responses: ${ProxyHelper.getProxyDescription(fullAccount.proxy)}` + ) + } + } + + // 记录请求信息 + logger.info('📤 OpenAI-Responses relay request', { + accountId: account.id, + accountName: account.name, + targetUrl, + method: req.method, + stream: req.body?.stream || false, + model: req.body?.model || 'unknown', + userAgent: headers['User-Agent'] || 'not set' + }) + + // 发送请求 + const response = await axios(requestOptions) + + // 处理 429 限流错误 + if (response.status === 429) { + const { resetsInSeconds, errorData } = await this._handle429Error( + account, + response, + req.body?.stream, + sessionHash + ) + + const oaiAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!oaiAutoProtectionDisabled) { + await upstreamErrorHelper + .markTempUnavailable( + account.id, + 'openai-responses', + 429, + resetsInSeconds || upstreamErrorHelper.parseRetryAfter(response.headers) + ) + .catch(() => {}) + } + + // 返回错误响应(使用处理后的数据,避免循环引用) + const errorResponse = errorData || { + error: { + message: 'Rate limit exceeded', + type: 'rate_limit_error', + code: 'rate_limit_exceeded', + resets_in_seconds: resetsInSeconds + } + } + return res.status(429).json(errorResponse) + } + + // 处理其他错误状态码 + if (response.status >= 400) { + // 处理流式错误响应 + let errorData = response.data + if (response.data && typeof response.data.pipe === 'function') { + // 流式响应需要先读取内容 + const chunks = [] + await new Promise((resolve) => { + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', resolve) + response.data.on('error', resolve) + setTimeout(resolve, 5000) // 超时保护 + }) + const fullResponse = Buffer.concat(chunks).toString() + + // 尝试解析错误响应 + try { + if (fullResponse.includes('data: ')) { + // SSE格式 + const lines = fullResponse.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6).trim() + if (jsonStr && jsonStr !== '[DONE]') { + errorData = JSON.parse(jsonStr) + break + } + } + } + } else { + // 普通JSON + errorData = JSON.parse(fullResponse) + } + } catch (e) { + logger.error('Failed to parse error response:', e) + errorData = { error: { message: fullResponse || 'Unknown error' } } + } + } + + logger.error('OpenAI-Responses API error', { + status: response.status, + statusText: response.statusText, + errorData + }) + + if (response.status === 401) { + logger.warn(`🚫 OpenAI Responses账号认证失败(401错误)for account ${account?.id}`) + + try { + // 仅临时暂停,不永久禁用 + const oaiAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!oaiAutoProtectionDisabled) { + await upstreamErrorHelper + .markTempUnavailable(account.id, 'openai-responses', 401) + .catch(() => {}) + } + if (sessionHash) { + await unifiedOpenAIScheduler._deleteSessionMapping(sessionHash).catch(() => {}) + } + } catch (markError) { + logger.error( + '❌ Failed to mark OpenAI-Responses account temporarily unavailable after 401:', + markError + ) + } + + let unauthorizedResponse = errorData + if ( + !unauthorizedResponse || + typeof unauthorizedResponse !== 'object' || + unauthorizedResponse.pipe || + Buffer.isBuffer(unauthorizedResponse) + ) { + const fallbackMessage = + typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized' + unauthorizedResponse = { + error: { + message: fallbackMessage, + type: 'unauthorized', + code: 'unauthorized' + } + } + } + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + return res.status(401).json(unauthorizedResponse) + } + + // 处理 5xx 上游错误 + if (response.status >= 500 && account?.id) { + try { + const oaiAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!oaiAutoProtectionDisabled) { + await upstreamErrorHelper.markTempUnavailable( + account.id, + 'openai-responses', + response.status + ) + } + if (sessionHash) { + await unifiedOpenAIScheduler._deleteSessionMapping(sessionHash).catch(() => {}) + } + } catch (markError) { + logger.warn( + 'Failed to mark OpenAI-Responses account temporarily unavailable:', + markError + ) + } + } + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + return res + .status(response.status) + .json(upstreamErrorHelper.sanitizeErrorForClient(errorData)) + } + + // 更新最后使用时间(节流) + await this._throttledUpdateLastUsedAt(account.id) + + // 处理流式响应 + if (req.body?.stream && response.data && typeof response.data.pipe === 'function') { + return this._handleStreamResponse( + response, + res, + account, + apiKeyData, + req.body?.model, + handleClientDisconnect, + req + ) + } + + // 处理非流式响应 + return this._handleNormalResponse(response, res, account, apiKeyData, req.body?.model, req) + } catch (error) { + // 清理 AbortController + if (abortController && !abortController.signal.aborted) { + abortController.abort() + } + + // 安全地记录错误,避免循环引用 + const errorInfo = { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText + } + logger.error('OpenAI-Responses relay error:', errorInfo) + + // 检查是否是网络错误 + if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') { + if (account?.id) { + const oaiAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!oaiAutoProtectionDisabled) { + await upstreamErrorHelper + .markTempUnavailable(account.id, 'openai-responses', 503) + .catch(() => {}) + } + } + } + + // 如果已经发送了响应头,直接结束 + if (res.headersSent) { + return res.end() + } + + // 检查是否是axios错误并包含响应 + if (error.response) { + // 处理axios错误响应 + const status = error.response.status || 500 + let errorData = { + error: { + message: error.response.statusText || 'Request failed', + type: 'api_error', + code: error.code || 'unknown' + } + } + + // 如果响应包含数据,尝试使用它 + if (error.response.data) { + // 检查是否是流 + if (typeof error.response.data === 'object' && !error.response.data.pipe) { + errorData = error.response.data + } else if (typeof error.response.data === 'string') { + try { + errorData = JSON.parse(error.response.data) + } catch (e) { + errorData.error.message = error.response.data + } + } + } + + if (status === 401) { + logger.warn( + `🚫 OpenAI Responses账号认证失败(401错误)for account ${account?.id} (catch handler)` + ) + + try { + // 仅临时暂停,不永久禁用 + const oaiAutoProtectionDisabled = + account?.disableAutoProtection === true || account?.disableAutoProtection === 'true' + if (!oaiAutoProtectionDisabled) { + await upstreamErrorHelper + .markTempUnavailable(account.id, 'openai-responses', 401) + .catch(() => {}) + } + if (sessionHash) { + await unifiedOpenAIScheduler._deleteSessionMapping(sessionHash).catch(() => {}) + } + } catch (markError) { + logger.error( + '❌ Failed to mark OpenAI-Responses account temporarily unavailable in catch handler:', + markError + ) + } + + let unauthorizedResponse = errorData + if ( + !unauthorizedResponse || + typeof unauthorizedResponse !== 'object' || + unauthorizedResponse.pipe || + Buffer.isBuffer(unauthorizedResponse) + ) { + const fallbackMessage = + typeof errorData === 'string' && errorData.trim() ? errorData.trim() : 'Unauthorized' + unauthorizedResponse = { + error: { + message: fallbackMessage, + type: 'unauthorized', + code: 'unauthorized' + } + } + } + + return res.status(401).json(unauthorizedResponse) + } + + return res.status(status).json(upstreamErrorHelper.sanitizeErrorForClient(errorData)) + } + + // 其他错误 + return res.status(500).json({ + error: { + message: 'Internal server error', + type: 'internal_error', + details: error.message + } + }) + } + } + + // 处理流式响应 + async _handleStreamResponse( + response, + res, + account, + apiKeyData, + requestedModel, + handleClientDisconnect, + req + ) { + // 设置 SSE 响应头 + res.setHeader('Content-Type', 'text/event-stream') + res.setHeader('Cache-Control', 'no-cache') + res.setHeader('Connection', 'keep-alive') + res.setHeader('X-Accel-Buffering', 'no') + + let usageData = null + let actualModel = null + let buffer = '' + let rateLimitDetected = false + let rateLimitResetsInSeconds = null + let streamEnded = false + + // 解析 SSE 事件以捕获 usage 数据和 model + const parseSSEForUsage = (data) => { + const lines = data.split('\n') + + for (const line of lines) { + if (line.startsWith('data:')) { + try { + const jsonStr = line.slice(5).trim() + if (jsonStr === '[DONE]') { + continue + } + + const eventData = JSON.parse(jsonStr) + + // 检查是否是 response.completed 事件(OpenAI-Responses 格式) + if (eventData.type === 'response.completed' && eventData.response) { + // 从响应中获取真实的 model + if (eventData.response.model) { + actualModel = eventData.response.model + logger.debug(`📊 Captured actual model from response.completed: ${actualModel}`) + } + + // 获取 usage 数据 - OpenAI-Responses 格式在 response.usage 下 + if (eventData.response.usage) { + usageData = eventData.response.usage + logger.info('📊 Successfully captured usage data from OpenAI-Responses:', { + input_tokens: usageData.input_tokens, + output_tokens: usageData.output_tokens, + total_tokens: usageData.total_tokens + }) + } + } + + // 检查是否有限流错误 + if (eventData.error) { + // 检查多种可能的限流错误类型 + if ( + eventData.error.type === 'rate_limit_error' || + eventData.error.type === 'usage_limit_reached' || + eventData.error.type === 'rate_limit_exceeded' + ) { + rateLimitDetected = true + if (eventData.error.resets_in_seconds) { + rateLimitResetsInSeconds = eventData.error.resets_in_seconds + logger.warn( + `🚫 Rate limit detected in stream, resets in ${rateLimitResetsInSeconds} seconds (${Math.ceil(rateLimitResetsInSeconds / 60)} minutes)` + ) + } + } + } + } catch (e) { + // 忽略解析错误 + } + } + } + } + + // 监听数据流 + response.data.on('data', (chunk) => { + try { + const chunkStr = chunk.toString() + + // 转发数据给客户端 + if (!res.destroyed && !streamEnded) { + res.write(chunk) + } + + // 同时解析数据以捕获 usage 信息 + buffer += chunkStr + + // 处理完整的 SSE 事件 + if (buffer.includes('\n\n')) { + const events = buffer.split('\n\n') + buffer = events.pop() || '' + + for (const event of events) { + if (event.trim()) { + parseSSEForUsage(event) + } + } + } + } catch (error) { + logger.error('Error processing stream chunk:', error) + } + }) + + response.data.on('end', async () => { + streamEnded = true + + // 处理剩余的 buffer + if (buffer.trim()) { + parseSSEForUsage(buffer) + } + + // 记录使用统计 + if (usageData) { + try { + // OpenAI-Responses 使用 input_tokens/output_tokens,标准 OpenAI 使用 prompt_tokens/completion_tokens + const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 + + // 提取缓存相关的 tokens(如果存在) + const cacheReadTokens = extractOpenAICacheReadTokens(usageData) + const cacheCreateTokens = extractCacheCreationTokens(usageData) + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) + + const totalTokens = + usageData.total_tokens || totalInputTokens + outputTokens + cacheCreateTokens + const modelToRecord = actualModel || requestedModel || 'gpt-4' + + const serviceTier = req._serviceTier || null + await apiKeyService.recordUsage( + apiKeyData.id, + actualInputTokens, // 传递实际输入(不含缓存) + outputTokens, + cacheCreateTokens, + cacheReadTokens, + modelToRecord, + account.id, + 'openai-responses', + serviceTier, + createRequestDetailMeta(req, { + requestBody: req.body, + stream: true, + statusCode: res.statusCode + }) + ) + + logger.info( + `📊 Recorded usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), CacheCreate: ${cacheCreateTokens}, Output: ${outputTokens}, Total: ${totalTokens}, Model: ${modelToRecord}` + ) + + // 更新账户的 token 使用统计 + await openaiResponsesAccountService.updateAccountUsage(account.id, totalTokens) + + // 更新账户使用额度(如果设置了额度限制) + if (parseFloat(account.dailyQuota) > 0) { + // 使用CostCalculator正确计算费用(考虑缓存token的不同价格) + const CostCalculator = require('../../utils/costCalculator') + const costInfo = CostCalculator.calculateCost( + { + input_tokens: actualInputTokens, // 实际输入(不含缓存) + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }, + modelToRecord, + serviceTier + ) + await openaiResponsesAccountService.updateUsageQuota(account.id, costInfo.costs.total) + } + } catch (error) { + logger.error('Failed to record usage:', error) + } + } + + // 如果在流式响应中检测到限流 + if (rateLimitDetected) { + // 使用统一调度器处理限流(与非流式响应保持一致) + const sessionId = req.headers['session_id'] || req.body?.session_id + const sessionHash = sessionId + ? crypto.createHash('sha256').update(sessionId).digest('hex') + : null + + await unifiedOpenAIScheduler.markAccountRateLimited( + account.id, + 'openai-responses', + sessionHash, + rateLimitResetsInSeconds + ) + + logger.warn( + `🚫 Processing rate limit for OpenAI-Responses account ${account.id} from stream` + ) + } + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + if (!res.destroyed) { + res.end() + } + + logger.info('Stream response completed', { + accountId: account.id, + hasUsage: !!usageData, + actualModel: actualModel || 'unknown' + }) + }) + + response.data.on('error', (error) => { + streamEnded = true + logger.error('Stream error:', error) + + // 清理监听器 + req.removeListener('close', handleClientDisconnect) + res.removeListener('close', handleClientDisconnect) + + if (!res.headersSent) { + res.status(502).json({ error: { message: 'Upstream stream error' } }) + } else if (!res.destroyed) { + res.end() + } + }) + + // 处理客户端断开连接 + const cleanup = () => { + streamEnded = true + try { + response.data?.unpipe?.(res) + response.data?.destroy?.() + } catch (_) { + // 忽略清理错误 + } + } + + req.on('close', cleanup) + req.on('aborted', cleanup) + } + + // 处理非流式响应 + async _handleNormalResponse(response, res, account, apiKeyData, requestedModel, req) { + const responseData = response.data + + // 提取 usage 数据和实际 model + // 支持两种格式:直接的 usage 或嵌套在 response 中的 usage + const usageData = responseData?.usage || responseData?.response?.usage + const actualModel = + responseData?.model || responseData?.response?.model || requestedModel || 'gpt-4' + + // 记录使用统计 + if (usageData) { + try { + // OpenAI-Responses 使用 input_tokens/output_tokens,标准 OpenAI 使用 prompt_tokens/completion_tokens + const totalInputTokens = usageData.input_tokens || usageData.prompt_tokens || 0 + const outputTokens = usageData.output_tokens || usageData.completion_tokens || 0 + + // 提取缓存相关的 tokens(如果存在) + const cacheReadTokens = extractOpenAICacheReadTokens(usageData) + const cacheCreateTokens = extractCacheCreationTokens(usageData) + // 计算实际输入token(总输入减去缓存部分) + const actualInputTokens = Math.max(0, totalInputTokens - cacheReadTokens) + + const totalTokens = + usageData.total_tokens || totalInputTokens + outputTokens + cacheCreateTokens + + const serviceTier = req._serviceTier || null + await apiKeyService.recordUsage( + apiKeyData.id, + actualInputTokens, // 传递实际输入(不含缓存) + outputTokens, + cacheCreateTokens, + cacheReadTokens, + actualModel, + account.id, + 'openai-responses', + serviceTier, + createRequestDetailMeta(req, { + requestBody: req?.body, + stream: false, + statusCode: response.status + }) + ) + + logger.info( + `📊 Recorded non-stream usage - Input: ${totalInputTokens}(actual:${actualInputTokens}+cached:${cacheReadTokens}), CacheCreate: ${cacheCreateTokens}, Output: ${outputTokens}, Total: ${totalTokens}, Model: ${actualModel}` + ) + + // 更新账户的 token 使用统计 + await openaiResponsesAccountService.updateAccountUsage(account.id, totalTokens) + + // 更新账户使用额度(如果设置了额度限制) + if (parseFloat(account.dailyQuota) > 0) { + // 使用CostCalculator正确计算费用(考虑缓存token的不同价格) + const CostCalculator = require('../../utils/costCalculator') + const costInfo = CostCalculator.calculateCost( + { + input_tokens: actualInputTokens, // 实际输入(不含缓存) + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }, + actualModel, + serviceTier + ) + await openaiResponsesAccountService.updateUsageQuota(account.id, costInfo.costs.total) + } + } catch (error) { + logger.error('Failed to record usage:', error) + } + } + + // 返回响应 + res.status(response.status).json(responseData) + + logger.info('Normal response completed', { + accountId: account.id, + status: response.status, + hasUsage: !!usageData, + model: actualModel + }) + } + + // 处理 429 限流错误 + async _handle429Error(account, response, isStream = false, sessionHash = null) { + let resetsInSeconds = null + let errorData = null + + try { + // 对于429错误,响应可能是JSON或SSE格式 + if (isStream && response.data && typeof response.data.pipe === 'function') { + // 流式响应需要先收集数据 + const chunks = [] + await new Promise((resolve, reject) => { + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', resolve) + response.data.on('error', reject) + // 设置超时防止无限等待 + setTimeout(resolve, 5000) + }) + + const fullResponse = Buffer.concat(chunks).toString() + + // 尝试解析SSE格式的错误响应 + if (fullResponse.includes('data: ')) { + const lines = fullResponse.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const jsonStr = line.slice(6).trim() + if (jsonStr && jsonStr !== '[DONE]') { + errorData = JSON.parse(jsonStr) + break + } + } catch (e) { + // 继续尝试下一行 + } + } + } + } + + // 如果SSE解析失败,尝试直接解析为JSON + if (!errorData) { + try { + errorData = JSON.parse(fullResponse) + } catch (e) { + logger.error('Failed to parse 429 error response:', e) + logger.debug('Raw response:', fullResponse) + } + } + } else if (response.data && typeof response.data !== 'object') { + // 如果response.data是字符串,尝试解析为JSON + try { + errorData = JSON.parse(response.data) + } catch (e) { + logger.error('Failed to parse 429 error response as JSON:', e) + errorData = { error: { message: response.data } } + } + } else if (response.data && typeof response.data === 'object' && !response.data.pipe) { + // 非流式响应,且是对象,直接使用 + errorData = response.data + } + + // 从响应体中提取重置时间(OpenAI 标准格式) + if (errorData && errorData.error) { + if (errorData.error.resets_in_seconds) { + resetsInSeconds = errorData.error.resets_in_seconds + logger.info( + `🕐 Rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)` + ) + } else if (errorData.error.resets_in) { + // 某些 API 可能使用不同的字段名 + resetsInSeconds = parseInt(errorData.error.resets_in) + logger.info( + `🕐 Rate limit will reset in ${resetsInSeconds} seconds (${Math.ceil(resetsInSeconds / 60)} minutes / ${Math.ceil(resetsInSeconds / 3600)} hours)` + ) + } + } + + if (!resetsInSeconds) { + logger.warn('⚠️ Could not extract reset time from 429 response, using default 60 minutes') + } + } catch (e) { + logger.error('⚠️ Failed to parse rate limit error:', e) + } + + // 使用统一调度器标记账户为限流状态(与普通OpenAI账号保持一致) + await unifiedOpenAIScheduler.markAccountRateLimited( + account.id, + 'openai-responses', + sessionHash, + resetsInSeconds + ) + + logger.warn('OpenAI-Responses account rate limited', { + accountId: account.id, + accountName: account.name, + resetsInSeconds: resetsInSeconds || 'unknown', + resetInMinutes: resetsInSeconds ? Math.ceil(resetsInSeconds / 60) : 60, + resetInHours: resetsInSeconds ? Math.ceil(resetsInSeconds / 3600) : 1 + }) + + // 返回处理后的数据,避免循环引用 + return { resetsInSeconds, errorData } + } + + // 过滤请求头 - 已迁移到 headerFilter 工具类 + // 此方法保留用于向后兼容,实际使用 filterForOpenAI() + _filterRequestHeaders(headers) { + return filterForOpenAI(headers) + } + + // 估算费用(简化版本,实际应该根据不同的定价模型) + _estimateCost(model, inputTokens, outputTokens) { + // 这是一个简化的费用估算,实际应该根据不同的 API 提供商和模型定价 + const rates = { + 'gpt-4': { input: 0.03, output: 0.06 }, // per 1K tokens + 'gpt-4-turbo': { input: 0.01, output: 0.03 }, + 'gpt-3.5-turbo': { input: 0.0005, output: 0.0015 }, + 'claude-3-opus': { input: 0.015, output: 0.075 }, + 'claude-3-sonnet': { input: 0.003, output: 0.015 }, + 'claude-3-haiku': { input: 0.00025, output: 0.00125 } + } + + // 查找匹配的模型定价 + let rate = rates['gpt-3.5-turbo'] // 默认使用 GPT-3.5 的价格 + for (const [modelKey, modelRate] of Object.entries(rates)) { + if (model.toLowerCase().includes(modelKey.toLowerCase())) { + rate = modelRate + break + } + } + + const inputCost = (inputTokens / 1000) * rate.input + const outputCost = (outputTokens / 1000) * rate.output + return inputCost + outputCost + } +} + +module.exports = new OpenAIResponsesRelayService() diff --git a/src/services/requestBodyRuleService.js b/src/services/requestBodyRuleService.js new file mode 100644 index 0000000..f2632fe --- /dev/null +++ b/src/services/requestBodyRuleService.js @@ -0,0 +1,192 @@ +const VALID_VALUE_TYPES = ['string', 'number', 'boolean', 'json'] + +function isNumericSegment(segment) { + return /^\d+$/.test(segment) +} + +function validatePath(path) { + if (typeof path !== 'string' || !path.trim()) { + return 'Rule path must be a non-empty string' + } + + const segments = path.split('.') + if (segments.some((segment) => !segment.trim())) { + return 'Rule path cannot contain empty segments' + } + + return null +} + +function normalizeRule(rawRule = {}) { + if (!rawRule || typeof rawRule !== 'object' || Array.isArray(rawRule)) { + return null + } + + const path = typeof rawRule.path === 'string' ? rawRule.path.trim() : '' + if (!path) { + return null + } + + return { + path, + valueType: typeof rawRule.valueType === 'string' ? rawRule.valueType.trim().toLowerCase() : '', + value: rawRule.value === undefined || rawRule.value === null ? '' : String(rawRule.value) + } +} + +function coerceRuleValue(rule) { + if (!rule || typeof rule !== 'object') { + throw new Error('Invalid rule') + } + + if (rule.value === '') { + return '' + } + + switch (rule.valueType) { + case 'string': + return rule.value + case 'number': { + const parsed = Number(rule.value) + if (!Number.isFinite(parsed)) { + throw new Error(`Rule path "${rule.path}" expects a valid number`) + } + return parsed + } + case 'boolean': { + const normalized = rule.value.trim().toLowerCase() + if (normalized !== 'true' && normalized !== 'false') { + throw new Error(`Rule path "${rule.path}" expects "true" or "false"`) + } + return normalized === 'true' + } + case 'json': + try { + return JSON.parse(rule.value) + } catch (error) { + throw new Error(`Rule path "${rule.path}" expects valid JSON`) + } + default: + throw new Error(`Rule path "${rule.path}" has unsupported valueType "${rule.valueType}"`) + } +} + +function validateAndNormalizeRules(rules) { + if (rules === undefined || rules === null) { + return { valid: true, rules: [] } + } + + if (!Array.isArray(rules)) { + return { valid: false, error: 'Payload rules must be an array' } + } + + const normalizedRules = [] + + for (let i = 0; i < rules.length; i++) { + const rawRule = rules[i] + if (!rawRule || typeof rawRule !== 'object' || Array.isArray(rawRule)) { + return { + valid: false, + error: `Payload rule #${i + 1} must be an object` + } + } + + const normalizedRule = normalizeRule(rawRule) + if (!normalizedRule) { + continue + } + + const pathError = validatePath(normalizedRule.path) + if (pathError) { + return { + valid: false, + error: `Payload rule #${i + 1}: ${pathError}` + } + } + + if (!VALID_VALUE_TYPES.includes(normalizedRule.valueType)) { + return { + valid: false, + error: `Payload rule #${i + 1} has invalid valueType` + } + } + + try { + coerceRuleValue(normalizedRule) + } catch (error) { + return { + valid: false, + error: `Payload rule #${i + 1}: ${error.message}` + } + } + + normalizedRules.push(normalizedRule) + } + + return { + valid: true, + rules: normalizedRules + } +} + +function setValueAtPath(node, segments, value) { + if (!Array.isArray(segments) || segments.length === 0) { + return value + } + + const [segment, ...rest] = segments + const isIndex = isNumericSegment(segment) + + if (isIndex) { + const index = Number(segment) + const nextNode = Array.isArray(node) ? [...node] : [] + + if (rest.length === 0) { + nextNode[index] = value + return nextNode + } + + nextNode[index] = setValueAtPath(nextNode[index], rest, value) + return nextNode + } + + const nextNode = node && typeof node === 'object' && !Array.isArray(node) ? { ...node } : {} + + if (rest.length === 0) { + nextNode[segment] = value + return nextNode + } + + nextNode[segment] = setValueAtPath(nextNode[segment], rest, value) + return nextNode +} + +function applyRules(body, rules) { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return body + } + + const validation = validateAndNormalizeRules(rules) + if (!validation.valid) { + const error = new Error(validation.error) + error.statusCode = 500 + throw error + } + + let nextBody = + typeof structuredClone === 'function' ? structuredClone(body) : JSON.parse(JSON.stringify(body)) + + for (const rule of validation.rules) { + nextBody = setValueAtPath(nextBody, rule.path.split('.'), coerceRuleValue(rule)) + } + + return nextBody +} + +module.exports = { + VALID_VALUE_TYPES, + applyRules, + coerceRuleValue, + normalizeRule, + validateAndNormalizeRules +} diff --git a/src/services/requestDetailService.js b/src/services/requestDetailService.js new file mode 100644 index 0000000..901b2d3 --- /dev/null +++ b/src/services/requestDetailService.js @@ -0,0 +1,1628 @@ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const claudeRelayConfigService = require('./claudeRelayConfigService') +const claudeAccountService = require('./account/claudeAccountService') +const claudeConsoleAccountService = require('./account/claudeConsoleAccountService') +const ccrAccountService = require('./account/ccrAccountService') +const geminiAccountService = require('./account/geminiAccountService') +const geminiApiAccountService = require('./account/geminiApiAccountService') +const openaiAccountService = require('./account/openaiAccountService') +const openaiResponsesAccountService = require('./account/openaiResponsesAccountService') +const azureOpenaiAccountService = require('./account/azureOpenaiAccountService') +const droidAccountService = require('./account/droidAccountService') +const bedrockAccountService = require('./account/bedrockAccountService') +const CostCalculator = require('../utils/costCalculator') +const { + sanitizeRequestBodySnapshot, + getRequestDetailCacheMetrics, + extractRequestReasoningInfo, + resolveRequestDetailReasoning, + CACHE_HIT_FORMULA +} = require('../utils/requestDetailHelper') + +const REQUEST_DETAIL_ITEM_PREFIX = 'request_detail:item:' +const REQUEST_DETAIL_DAY_INDEX_PREFIX = 'request_detail:index:day:' +const REQUEST_DETAIL_QUERY_SNAPSHOT_PREFIX = 'request_detail:query_snapshot:' +const DEFAULT_RETENTION_HOURS = 6 +const MAX_RETENTION_HOURS = 30 * 24 +const REQUEST_DETAIL_QUERY_BATCH_SIZE = 200 +const REQUEST_DETAIL_SCAN_BATCH_SIZE = 200 +const REQUEST_DETAIL_QUERY_SNAPSHOT_TTL_SECONDS = 30 +const MAX_REQUEST_DETAIL_SNAPSHOT_POINTERS = 25000 +const MAX_REQUEST_DETAIL_SNAPSHOT_BYTES = 2 * 1024 * 1024 + +const accountTypeNames = { + claude: 'Claude官方', + 'claude-official': 'Claude官方', + 'claude-console': 'Claude Console', + ccr: 'Claude Console Relay', + openai: 'OpenAI', + 'openai-responses': 'OpenAI Responses', + 'azure-openai': 'Azure OpenAI', + gemini: 'Gemini', + 'gemini-api': 'Gemini API', + droid: 'Droid', + bedrock: 'AWS Bedrock', + unknown: '未知渠道' +} + +const accountServices = { + claude: claudeAccountService, + 'claude-console': claudeConsoleAccountService, + ccr: ccrAccountService, + openai: openaiAccountService, + 'openai-responses': openaiResponsesAccountService, + 'azure-openai': azureOpenaiAccountService, + gemini: geminiAccountService, + 'gemini-api': geminiApiAccountService, + droid: droidAccountService, + bedrock: bedrockAccountService +} + +function clampRetentionHours(value) { + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) { + return DEFAULT_RETENTION_HOURS + } + return Math.min(Math.max(parsed, 1), MAX_RETENTION_HOURS) +} + +function normalizeNumber(value, digits = null) { + const num = Number(value) + if (!Number.isFinite(num)) { + return 0 + } + + if (digits === null) { + return num + } + + return Number(num.toFixed(digits)) +} + +function normalizeTokenValue(value) { + return Math.max(0, Math.trunc(normalizeNumber(value))) +} + +function buildCostUsageFromRequestDetail(record = {}) { + const inputTokens = normalizeTokenValue(record.inputTokens) + const outputTokens = normalizeTokenValue(record.outputTokens) + const cacheCreateTokens = normalizeTokenValue(record.cacheCreateTokens) + const cacheReadTokens = normalizeTokenValue(record.cacheReadTokens) + const usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + const ephemeral5mTokens = normalizeTokenValue( + record.ephemeral5mTokens ?? record.cache_creation?.ephemeral_5m_input_tokens + ) + const ephemeral1hTokens = normalizeTokenValue( + record.ephemeral1hTokens ?? record.cache_creation?.ephemeral_1h_input_tokens + ) + + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + return usage +} + +function getCostResultNumber(costResult, key, fallbackKey = null) { + return normalizeNumber(costResult?.costs?.[key] ?? costResult?.[fallbackKey] ?? 0, 12) +} + +function buildCostBreakdownFromResult(costResult) { + const input = getCostResultNumber(costResult, 'input', 'inputCost') + const output = getCostResultNumber(costResult, 'output', 'outputCost') + const cacheCreate = + getCostResultNumber(costResult, 'cacheCreate', 'cacheCreateCost') || + getCostResultNumber(costResult, 'cacheWrite', 'cacheCreateCost') + const cacheRead = getCostResultNumber(costResult, 'cacheRead', 'cacheReadCost') + const ephemeral5m = getCostResultNumber(costResult, 'ephemeral5m', 'ephemeral5mCost') + const ephemeral1h = getCostResultNumber(costResult, 'ephemeral1h', 'ephemeral1hCost') + const total = getCostResultNumber(costResult, 'total', 'totalCost') + + return { + input, + output, + cacheCreate, + cacheWrite: cacheCreate, + cacheRead, + ephemeral5m, + ephemeral1h, + total + } +} + +function createCostRecomputePatch(record = {}) { + const storedCost = normalizeNumber(record.cost, 6) + const storedRealCost = normalizeNumber(record.realCost, 6) + if (storedCost > 0 || storedRealCost > 0) { + return null + } + + const usage = buildCostUsageFromRequestDetail(record) + const totalTokens = + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + if (totalTokens <= 0) { + return null + } + + try { + const costResult = CostCalculator.calculateCost(usage, record.model || 'unknown') + const totalCost = normalizeNumber(costResult?.costs?.total ?? costResult?.totalCost ?? 0, 6) + if (totalCost <= 0) { + return null + } + + const breakdown = buildCostBreakdownFromResult(costResult) + const pricingSource = + costResult?.debug?.pricingSource || + (costResult?.usingDynamicPricing ? 'dynamic' : 'unknown-fallback') + + return { + cost: totalCost, + realCost: totalCost, + costBreakdown: breakdown, + realCostBreakdown: breakdown, + costRecomputed: true, + usedFallbackPricing: costResult?.debug?.usedFallbackPricing === true, + pricingSource + } + } catch (error) { + logger.debug(`⚠️ Failed to recompute request detail cost: ${error.message}`) + return null + } +} + +function prepareRecordForDisplay(record = {}) { + const costPatch = createCostRecomputePatch(record) + if (!costPatch) { + return record + } + + return { + ...record, + ...costPatch + } +} + +function formatDayKey(date) { + return date.toISOString().slice(0, 10) +} + +function listDayKeys(startDate, endDate) { + const keys = [] + const cursor = new Date( + Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate()) + ) + const endCursor = new Date( + Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate()) + ) + + while (cursor <= endCursor) { + keys.push(`${REQUEST_DETAIL_DAY_INDEX_PREFIX}${formatDayKey(cursor)}`) + cursor.setUTCDate(cursor.getUTCDate() + 1) + } + + return keys +} + +function toIsoString(value) { + if (!value) { + return null + } + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + return null + } + + return date.toISOString() +} + +function toMillis(value) { + if (value === null || value === undefined || value === '') { + return null + } + + const date = value instanceof Date ? value : new Date(value) + if (Number.isNaN(date.getTime())) { + return null + } + + return date.getTime() +} + +function safeJsonParse(value, label = 'request detail record') { + if (!value) { + return null + } + + try { + return JSON.parse(value) + } catch (error) { + logger.warn(`⚠️ Failed to parse ${label}: ${error.message}`) + return null + } +} + +function makeRequestDetailId() { + return `rd_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` +} + +function makeRequestDetailQuerySnapshotId() { + return `rds_${Date.now()}_${Math.random().toString(36).slice(2, 10)}` +} + +function normalizeOptionalFilterValue(value) { + if (value === null || value === undefined) { + return null + } + + const normalized = String(value).trim() + return normalized ? normalized : null +} + +function createRequestDetailDateBoundarySignature(type, rawValue, effectiveValue, boundaryValue) { + if (!rawValue) { + return { + mode: 'absent', + value: null + } + } + + const rawDate = rawValue instanceof Date ? rawValue : new Date(rawValue) + const effectiveIso = toIsoString(effectiveValue) + if (type === 'start') { + const floorDate = + boundaryValue instanceof Date ? boundaryValue : new Date(boundaryValue || Date.now()) + if (rawDate.getTime() <= floorDate.getTime()) { + return { + mode: 'retention_floor', + value: effectiveIso + } + } + } + + if (type === 'end') { + const ceilingDate = + boundaryValue instanceof Date ? boundaryValue : new Date(boundaryValue || Date.now()) + if (rawDate.getTime() >= ceilingDate.getTime()) { + return { + mode: 'now_cap', + value: effectiveIso + } + } + } + + return { + mode: 'fixed', + value: rawDate.toISOString() + } +} + +function normalizeRequestDetailDateBoundarySignature(boundary = {}, legacyValue = null) { + if (!boundary || typeof boundary !== 'object' || Array.isArray(boundary)) { + return { + mode: legacyValue ? 'fixed' : 'absent', + value: toIsoString(legacyValue) + } + } + + const allowedModes = new Set(['absent', 'fixed', 'retention_floor', 'now_cap']) + const mode = allowedModes.has(boundary.mode) ? boundary.mode : legacyValue ? 'fixed' : 'absent' + return { + mode, + value: toIsoString(boundary.value) + } +} + +function createRequestDetailFilterSignature( + filters = {}, + dateBoundarySignature = {}, + retentionHours = null +) { + return { + keyword: normalizeOptionalFilterValue(filters.keyword), + apiKeyId: normalizeOptionalFilterValue(filters.apiKeyId), + accountId: normalizeOptionalFilterValue(filters.accountId), + model: normalizeOptionalFilterValue(filters.model), + endpoint: normalizeOptionalFilterValue(filters.endpoint), + sortOrder: filters.sortOrder === 'asc' ? 'asc' : 'desc', + retentionHours: + retentionHours !== null && retentionHours !== undefined ? Number(retentionHours) : null, + startBoundary: normalizeRequestDetailDateBoundarySignature(dateBoundarySignature.startBoundary), + endBoundary: normalizeRequestDetailDateBoundarySignature(dateBoundarySignature.endBoundary) + } +} + +function requestDetailDateBoundarySignaturesMatch(snapshotBoundary, currentBoundary, type) { + if (snapshotBoundary.mode === currentBoundary.mode) { + if (snapshotBoundary.mode === 'fixed') { + return snapshotBoundary.value === currentBoundary.value + } + return true + } + + if (type === 'end') { + return ( + snapshotBoundary.mode === 'now_cap' && + currentBoundary.mode === 'fixed' && + snapshotBoundary.value === currentBoundary.value + ) + } + + return false +} + +function requestDetailFilterSignaturesMatch(snapshotSignature, currentSignature) { + const normalizedSnapshot = createRequestDetailFilterSignature( + snapshotSignature, + { + startBoundary: snapshotSignature?.startBoundary || { + mode: snapshotSignature?.startDate ? 'fixed' : 'absent', + value: snapshotSignature?.startDate || null + }, + endBoundary: snapshotSignature?.endBoundary || { + mode: snapshotSignature?.endDate ? 'fixed' : 'absent', + value: snapshotSignature?.endDate || null + } + }, + snapshotSignature?.retentionHours + ) + const normalizedCurrent = createRequestDetailFilterSignature( + currentSignature, + { + startBoundary: currentSignature?.startBoundary, + endBoundary: currentSignature?.endBoundary + }, + currentSignature?.retentionHours + ) + + return ( + normalizedSnapshot.keyword === normalizedCurrent.keyword && + normalizedSnapshot.apiKeyId === normalizedCurrent.apiKeyId && + normalizedSnapshot.accountId === normalizedCurrent.accountId && + normalizedSnapshot.model === normalizedCurrent.model && + normalizedSnapshot.endpoint === normalizedCurrent.endpoint && + normalizedSnapshot.sortOrder === normalizedCurrent.sortOrder && + normalizedSnapshot.retentionHours === normalizedCurrent.retentionHours && + requestDetailDateBoundarySignaturesMatch( + normalizedSnapshot.startBoundary, + normalizedCurrent.startBoundary, + 'start' + ) && + requestDetailDateBoundarySignaturesMatch( + normalizedSnapshot.endBoundary, + normalizedCurrent.endBoundary, + 'end' + ) + ) +} + +function flattenMatchedPointers(pointers = []) { + const flattened = [] + + for (const pointer of pointers) { + const requestId = pointer?.requestId || null + const timestampMs = Number(pointer?.timestampMs) + + if (!requestId || !Number.isFinite(timestampMs)) { + continue + } + + flattened.push(requestId, timestampMs) + } + + return flattened +} + +function inflateMatchedPointers(flattened = []) { + const pointers = [] + + for (let index = 0; index < flattened.length; index += 2) { + const requestId = flattened[index] + const timestampMs = Number(flattened[index + 1]) + + if (!requestId || !Number.isFinite(timestampMs)) { + continue + } + + pointers.push({ requestId, timestampMs }) + } + + return pointers +} + +class RequestDetailValidationError extends Error { + constructor(message) { + super(message) + this.name = 'RequestDetailValidationError' + this.statusCode = 400 + } +} + +function createAvailableFilterAccumulator() { + return { + apiKeyMap: new Map(), + accountMap: new Map(), + modelSet: new Set(), + endpointSet: new Set(), + earliest: null, + latest: null + } +} + +function updateAvailableFilterAccumulator(accumulator, record) { + if (record.apiKeyId) { + accumulator.apiKeyMap.set(record.apiKeyId, { + id: record.apiKeyId, + name: record.apiKeyName || record.apiKeyId + }) + } + + if (record.accountId) { + accumulator.accountMap.set(record.accountId, { + id: record.accountId, + name: record.accountName || record.accountId, + accountType: record.accountType || 'unknown', + accountTypeName: + record.accountTypeName || accountTypeNames[record.accountType] || accountTypeNames.unknown + }) + } + + if (record.model) { + accumulator.modelSet.add(record.model) + } + + if (record.endpoint) { + accumulator.endpointSet.add(record.endpoint) + } + + const ts = toMillis(record.timestamp) + if (ts !== null) { + if (accumulator.earliest === null || ts < accumulator.earliest) { + accumulator.earliest = ts + } + if (accumulator.latest === null || ts > accumulator.latest) { + accumulator.latest = ts + } + } +} + +function updateAvailableFilterAccumulatorRaw(accumulator, record) { + if (record.apiKeyId && !accumulator.apiKeyMap.has(record.apiKeyId)) { + accumulator.apiKeyMap.set(record.apiKeyId, { + id: record.apiKeyId, + name: record.apiKeyId + }) + } + + if (record.accountId && !accumulator.accountMap.has(record.accountId)) { + accumulator.accountMap.set(record.accountId, { + id: record.accountId, + name: record.accountId, + accountType: record.accountType || 'unknown', + accountTypeName: accountTypeNames[record.accountType] || accountTypeNames.unknown + }) + } + + if (record.model) { + accumulator.modelSet.add(record.model) + } + + if (record.endpoint) { + accumulator.endpointSet.add(record.endpoint) + } + + const ts = toMillis(record.timestamp) + if (ts !== null) { + if (accumulator.earliest === null || ts < accumulator.earliest) { + accumulator.earliest = ts + } + if (accumulator.latest === null || ts > accumulator.latest) { + accumulator.latest = ts + } + } +} + +function restoreRecordTimestamp(record, fallbackTimestampMs) { + if (!record) { + return null + } + + if (toMillis(record.timestamp) !== null) { + return record + } + + const timestampMs = Number(fallbackTimestampMs) + if (Number.isFinite(timestampMs)) { + record.timestamp = new Date(timestampMs).toISOString() + } + + return record +} + +function finalizeAvailableFilters(accumulator) { + return { + apiKeys: Array.from(accumulator.apiKeyMap.values()).sort((a, b) => + a.name.localeCompare(b.name) + ), + accounts: Array.from(accumulator.accountMap.values()).sort((a, b) => + a.name.localeCompare(b.name) + ), + models: Array.from(accumulator.modelSet).sort((a, b) => a.localeCompare(b)), + endpoints: Array.from(accumulator.endpointSet).sort((a, b) => a.localeCompare(b)), + dateRange: { + earliest: accumulator.earliest !== null ? new Date(accumulator.earliest).toISOString() : null, + latest: accumulator.latest !== null ? new Date(accumulator.latest).toISOString() : null + } + } +} + +function createSummaryAccumulator() { + return { + totalRequests: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreateTokens: 0, + totalCost: 0, + totalDurationMs: 0, + cacheHitNumerator: 0, + cacheHitDenominator: 0, + openAIRelatedRequests: 0 + } +} + +function updateSummaryAccumulator(accumulator, record) { + const cacheMetrics = getRequestDetailCacheMetrics(record) + + accumulator.totalRequests += 1 + accumulator.inputTokens += normalizeNumber(record.inputTokens) + accumulator.outputTokens += normalizeNumber(record.outputTokens) + accumulator.cacheReadTokens += normalizeNumber(record.cacheReadTokens) + if (!cacheMetrics.cacheCreateNotApplicable) { + accumulator.cacheCreateTokens += normalizeNumber(record.cacheCreateTokens) + } + accumulator.totalCost += normalizeNumber(record.cost) + accumulator.totalDurationMs += normalizeNumber(record.durationMs) + accumulator.cacheHitNumerator += cacheMetrics.numerator + accumulator.cacheHitDenominator += cacheMetrics.denominator + if (cacheMetrics.isOpenAIRelated) { + accumulator.openAIRelatedRequests += 1 + } +} + +function finalizeSummary(accumulator) { + return { + totalRequests: accumulator.totalRequests, + inputTokens: accumulator.inputTokens, + outputTokens: accumulator.outputTokens, + cacheReadTokens: accumulator.cacheReadTokens, + cacheCreateTokens: accumulator.cacheCreateTokens, + totalCost: Number(accumulator.totalCost.toFixed(6)), + avgDurationMs: + accumulator.totalRequests > 0 + ? Math.round(accumulator.totalDurationMs / accumulator.totalRequests) + : 0, + cacheHitRate: + accumulator.cacheHitDenominator > 0 + ? Number( + ((accumulator.cacheHitNumerator / accumulator.cacheHitDenominator) * 100).toFixed(2) + ) + : 0, + cacheHitNumerator: accumulator.cacheHitNumerator, + cacheHitDenominator: accumulator.cacheHitDenominator, + cacheHitFormula: CACHE_HIT_FORMULA, + cacheCreateNotApplicable: + accumulator.totalRequests > 0 && + accumulator.openAIRelatedRequests === accumulator.totalRequests + } +} + +class RequestDetailService { + async getSettings() { + const config = await claudeRelayConfigService.getConfig() + return { + captureEnabled: config.requestDetailCaptureEnabled === true, + retentionHours: clampRetentionHours(config.requestDetailRetentionHours), + bodyPreviewEnabled: config.requestDetailBodyPreviewEnabled === true + } + } + + _emptyListResult(settings, filters = {}) { + return { + captureEnabled: settings.captureEnabled, + retentionHours: settings.retentionHours, + bodyPreviewEnabled: settings.bodyPreviewEnabled, + snapshotId: null, + records: [], + pagination: { + currentPage: 1, + pageSize: Number.parseInt(filters.pageSize, 10) || 50, + totalRecords: 0, + totalPages: 0, + hasNextPage: false, + hasPreviousPage: false + }, + filters: { + startDate: filters.startDate || null, + endDate: filters.endDate || null, + keyword: filters.keyword || null, + apiKeyId: filters.apiKeyId || null, + accountId: filters.accountId || null, + model: filters.model || null, + endpoint: filters.endpoint || null, + hasCustomDateRange: Boolean(filters.startDate || filters.endDate), + sortOrder: filters.sortOrder === 'asc' ? 'asc' : 'desc' + }, + availableFilters: { + apiKeys: [], + accounts: [], + models: [], + endpoints: [], + dateRange: { + earliest: null, + latest: null + } + }, + summary: { + totalRequests: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreateTokens: 0, + totalCost: 0, + avgDurationMs: 0, + cacheHitRate: 0, + cacheHitNumerator: 0, + cacheHitDenominator: 0, + cacheHitFormula: CACHE_HIT_FORMULA, + cacheCreateNotApplicable: false + } + } + } + + _normalizeRecord(detail, requestId, options = {}) { + const requestBodySource = detail.requestBodySnapshot ?? detail.requestBody + const timestamp = toIsoString(detail.timestamp) || new Date().toISOString() + const durationMs = normalizeNumber(detail.durationMs) + const inputTokens = normalizeNumber(detail.inputTokens) + const outputTokens = normalizeNumber(detail.outputTokens) + const cacheReadTokens = normalizeNumber(detail.cacheReadTokens) + const cacheCreateTokens = normalizeNumber(detail.cacheCreateTokens) + const totalTokens = + normalizeNumber(detail.totalTokens) || + inputTokens + outputTokens + cacheReadTokens + cacheCreateTokens + const statusCode = normalizeNumber(detail.statusCode) + const cost = normalizeNumber(detail.cost, 6) + const realCost = normalizeNumber(detail.realCost, 6) + const reasoningInfo = extractRequestReasoningInfo(requestBodySource) + const normalized = { + requestId, + timestamp, + requestStartedAt: toIsoString(detail.requestStartedAt), + endpoint: detail.endpoint || null, + method: detail.method || null, + statusCode, + stream: detail.stream === true, + apiKeyId: detail.apiKeyId || null, + accountId: detail.accountId || null, + accountType: detail.accountType || 'unknown', + model: detail.model || 'unknown', + inputTokens, + outputTokens, + cacheReadTokens, + cacheCreateTokens, + totalTokens, + cost, + realCost, + costBreakdown: detail.costBreakdown || null, + realCostBreakdown: detail.realCostBreakdown || null, + pricingSource: detail.pricingSource || null, + usedFallbackPricing: detail.usedFallbackPricing === true, + costRecomputed: detail.costRecomputed === true, + durationMs, + isLongContextRequest: detail.isLongContextRequest === true, + reasoningDisplay: detail.reasoningDisplay || reasoningInfo.reasoningDisplay || null, + reasoningSource: detail.reasoningSource || reasoningInfo.reasoningSource || null + } + + if (options.bodyPreviewEnabled && requestBodySource !== undefined) { + normalized.requestBodySnapshot = sanitizeRequestBodySnapshot(requestBodySource) + } + + return normalized + } + + async captureRequestDetail(detail = {}) { + try { + const settings = await this.getSettings() + if (!settings.captureEnabled) { + return { captured: false, reason: 'disabled' } + } + + const client = redis.getClient() + if (!client) { + return { captured: false, reason: 'redis_unavailable' } + } + + const requestId = detail.requestId || makeRequestDetailId() + const normalized = this._normalizeRecord(detail, requestId, { + bodyPreviewEnabled: settings.bodyPreviewEnabled + }) + const timestampMs = toMillis(normalized.timestamp) || Date.now() + const itemKey = `${REQUEST_DETAIL_ITEM_PREFIX}${requestId}` + const dayKey = `${REQUEST_DETAIL_DAY_INDEX_PREFIX}${formatDayKey(new Date(timestampMs))}` + const ttlSeconds = Math.max(3600, settings.retentionHours * 3600) + const indexTtlSeconds = ttlSeconds + 86400 + + await client + .multi() + .set(itemKey, JSON.stringify(normalized), 'EX', ttlSeconds) + .zadd(dayKey, timestampMs, requestId) + .expire(dayKey, indexTtlSeconds) + .exec() + + return { captured: true, requestId } + } catch (error) { + logger.warn(`⚠️ Failed to capture request detail: ${error.message}`) + return { captured: false, reason: 'error', message: error.message } + } + } + + async _loadRequestPointersInRange(startDate, endDate) { + const client = redis.getClient() + if (!client) { + return [] + } + + const startMs = startDate.getTime() + const endMs = endDate.getTime() + const dayKeys = listDayKeys(startDate, endDate) + const requestIds = [] + + for (const dayKey of dayKeys) { + try { + const entries = await client.zrangebyscore(dayKey, startMs, endMs, 'WITHSCORES') + if (Array.isArray(entries) && entries.length > 0) { + for (let index = 0; index < entries.length; index += 2) { + const requestId = entries[index] + const timestampMs = Number(entries[index + 1]) + if (requestId && Number.isFinite(timestampMs)) { + requestIds.push({ requestId, timestampMs }) + } + } + } + } catch (error) { + logger.warn(`⚠️ Failed to load request detail index ${dayKey}: ${error.message}`) + } + } + + const uniqueRequestIds = new Map() + for (const item of requestIds) { + uniqueRequestIds.set(item.requestId, item.timestampMs) + } + + return Array.from(uniqueRequestIds.entries()).map(([requestId, timestampMs]) => ({ + requestId, + timestampMs + })) + } + + async _scanRequestDetailItemKeys(visitor) { + const client = redis.getClient() + if (!client) { + return + } + + let cursor = '0' + do { + const [nextCursor, keys] = await client.scan( + cursor, + 'MATCH', + `${REQUEST_DETAIL_ITEM_PREFIX}*`, + 'COUNT', + REQUEST_DETAIL_SCAN_BATCH_SIZE + ) + cursor = nextCursor + if (Array.isArray(keys) && keys.length > 0) { + await visitor(keys, client) + } + } while (cursor !== '0') + } + + async getRequestBodyPreviewStats() { + const settings = await this.getSettings() + let snapshotCount = 0 + + await this._scanRequestDetailItemKeys(async (keys, client) => { + const rawItems = await client.mget(keys) + for (const rawItem of rawItems) { + const parsed = safeJsonParse(rawItem) + if ( + parsed && + Object.prototype.hasOwnProperty.call(parsed, 'requestBodySnapshot') && + parsed.requestBodySnapshot !== undefined + ) { + snapshotCount += 1 + } + } + }) + + return { + captureEnabled: settings.captureEnabled, + retentionHours: settings.retentionHours, + bodyPreviewEnabled: settings.bodyPreviewEnabled, + snapshotCount, + hasSnapshots: snapshotCount > 0 + } + } + + async purgeRequestBodySnapshots() { + let updatedRecords = 0 + + await this._scanRequestDetailItemKeys(async (keys, client) => { + const rawItems = await client.mget(keys) + const pipeline = typeof client.pipeline === 'function' ? client.pipeline() : client.multi() + let hasMutations = false + + rawItems.forEach((rawItem, index) => { + const parsed = safeJsonParse(rawItem) + if ( + !parsed || + !Object.prototype.hasOwnProperty.call(parsed, 'requestBodySnapshot') || + parsed.requestBodySnapshot === undefined + ) { + return + } + + delete parsed.requestBodySnapshot + pipeline.set(keys[index], JSON.stringify(parsed), 'KEEPTTL') + hasMutations = true + updatedRecords += 1 + }) + + if (hasMutations) { + await pipeline.exec() + } + }) + + return { + updatedRecords + } + } + + async _getApiKeyName(keyId, cache) { + if (!keyId) { + return null + } + + if (cache.has(keyId)) { + return cache.get(keyId) + } + + try { + const keyData = await redis.getApiKey(keyId) + const keyName = keyData?.name || keyData?.label || keyId + cache.set(keyId, keyName) + return keyName + } catch (error) { + logger.debug(`⚠️ Failed to resolve API key ${keyId}: ${error.message}`) + cache.set(keyId, keyId) + return keyId + } + } + + async _resolveAccountInfo(accountId, accountType, cache) { + if (!accountId) { + return null + } + + const normalizedType = accountType || 'unknown' + const cacheKey = `${normalizedType}:${accountId}` + if (cache.has(cacheKey)) { + return cache.get(cacheKey) + } + + const preferredService = accountServices[normalizedType] + const servicesToTry = preferredService + ? [ + [normalizedType, preferredService], + ...Object.entries(accountServices).filter(([type]) => type !== normalizedType) + ] + : Object.entries(accountServices) + + for (const [type, service] of servicesToTry) { + try { + let account = await service.getAccount(accountId) + if (account && typeof account === 'object' && 'success' in account) { + account = account.success ? account.data : null + } + if (account) { + const info = { + accountId, + accountName: account.name || account.email || accountId, + accountType: type, + accountTypeName: accountTypeNames[type] || accountTypeNames.unknown + } + cache.set(cacheKey, info) + return info + } + } catch (error) { + logger.debug(`⚠️ Failed to resolve account ${accountId} from ${type}: ${error.message}`) + } + } + + const fallback = { + accountId, + accountName: accountId, + accountType: normalizedType, + accountTypeName: accountTypeNames[normalizedType] || accountTypeNames.unknown + } + cache.set(cacheKey, fallback) + return fallback + } + + async _resolveFilterDisplayNames(accumulator) { + const apiKeyCache = new Map() + const accountCache = new Map() + + for (const [keyId, entry] of accumulator.apiKeyMap) { + const name = await this._getApiKeyName(keyId, apiKeyCache) + if (name) { + entry.name = name + } + } + + for (const [accountId, entry] of accumulator.accountMap) { + const accountInfo = await this._resolveAccountInfo(accountId, entry.accountType, accountCache) + if (accountInfo) { + entry.name = accountInfo.accountName + entry.accountTypeName = accountInfo.accountTypeName + } + } + } + + async _findRequestTimestampInRange(requestId, startDate, endDate, client = redis.getClient()) { + if (!requestId || !client) { + return null + } + + const dayKeys = listDayKeys(startDate, endDate) + if (dayKeys.length === 0) { + return null + } + + const startMs = startDate.getTime() + const endMs = endDate.getTime() + + if (typeof client.pipeline === 'function') { + const pipeline = client.pipeline() + dayKeys.forEach((dayKey) => { + pipeline.zscore(dayKey, requestId) + }) + + const results = await pipeline.exec() + if (Array.isArray(results)) { + for (let index = 0; index < results.length; index += 1) { + const [error, score] = results[index] || [] + if (error) { + logger.debug( + `⚠️ Failed to resolve request detail timestamp from ${dayKeys[index]}: ${error.message}` + ) + continue + } + + const timestampMs = Number(score) + if (Number.isFinite(timestampMs) && timestampMs >= startMs && timestampMs <= endMs) { + return timestampMs + } + } + } + + return null + } + + if (typeof client.zscore !== 'function') { + return null + } + + for (const dayKey of dayKeys) { + try { + const score = await client.zscore(dayKey, requestId) + const timestampMs = Number(score) + if (Number.isFinite(timestampMs) && timestampMs >= startMs && timestampMs <= endMs) { + return timestampMs + } + } catch (error) { + logger.debug( + `⚠️ Failed to resolve request detail timestamp from ${dayKey}: ${error.message}` + ) + } + } + + return null + } + + async _enrichRecords(records = [], apiKeyCache = new Map(), accountCache = new Map()) { + const enriched = [] + + for (const record of records) { + const displayRecord = prepareRecordForDisplay(record) + const cacheMetrics = getRequestDetailCacheMetrics(displayRecord) + const reasoningInfo = resolveRequestDetailReasoning(displayRecord) + const apiKeyName = await this._getApiKeyName(displayRecord.apiKeyId, apiKeyCache) + const accountInfo = await this._resolveAccountInfo( + displayRecord.accountId, + displayRecord.accountType, + accountCache + ) + + enriched.push({ + ...displayRecord, + apiKeyName: apiKeyName || displayRecord.apiKeyId || '未知 Key', + accountName: accountInfo?.accountName || displayRecord.accountId || '未知账户', + accountType: accountInfo?.accountType || displayRecord.accountType || 'unknown', + accountTypeName: + accountInfo?.accountTypeName || + accountTypeNames[displayRecord.accountType] || + accountTypeNames.unknown, + isOpenAIRelated: cacheMetrics.isOpenAIRelated, + cacheCreateNotApplicable: cacheMetrics.cacheCreateNotApplicable, + cacheHitRate: cacheMetrics.rate, + cacheHitNumerator: cacheMetrics.numerator, + cacheHitDenominator: cacheMetrics.denominator, + cacheHitFormula: cacheMetrics.cacheHitFormula, + hasRequestBodySnapshot: Boolean(displayRecord.requestBodySnapshot), + reasoningDisplay: reasoningInfo.reasoningDisplay, + reasoningSource: reasoningInfo.reasoningSource + }) + } + + return enriched + } + + _matchesKeyword(record, keyword) { + if (!keyword) { + return true + } + + const normalizedKeyword = String(keyword).trim().toLowerCase() + if (!normalizedKeyword) { + return true + } + + const haystacks = [ + record.requestId, + record.apiKeyId, + record.apiKeyName, + record.accountId, + record.accountName, + record.accountTypeName, + record.model, + record.endpoint, + record.method + ] + + return haystacks.some((value) => + String(value || '') + .toLowerCase() + .includes(normalizedKeyword) + ) + } + + _matchesStructuredFilters(record, filters = {}) { + if (filters.apiKeyId && record.apiKeyId !== filters.apiKeyId) { + return false + } + if (filters.accountId && record.accountId !== filters.accountId) { + return false + } + if (filters.model && record.model !== filters.model) { + return false + } + if (filters.endpoint && record.endpoint !== filters.endpoint) { + return false + } + + return true + } + + _buildResponseFilters(filters, effectiveStart, effectiveEnd, sortOrder) { + return { + startDate: effectiveStart.toISOString(), + endDate: effectiveEnd.toISOString(), + keyword: filters.keyword || null, + apiKeyId: filters.apiKeyId || null, + accountId: filters.accountId || null, + model: filters.model || null, + endpoint: filters.endpoint || null, + hasCustomDateRange: Boolean(filters.startDate || filters.endDate), + sortOrder + } + } + + _hydrateRawRecord(rawItem, pointer = {}) { + const parsed = restoreRecordTimestamp( + safeJsonParse(rawItem), + Number(pointer?.timestampMs) || Date.now() + ) + + if (!parsed) { + return null + } + + if (!parsed.requestId && pointer?.requestId) { + parsed.requestId = pointer.requestId + } + + return parsed + } + + async _loadPointerBatchRecords(pointerBatch = [], client = redis.getClient()) { + if (!client || !Array.isArray(pointerBatch) || pointerBatch.length === 0) { + return [] + } + + const itemKeys = pointerBatch.map( + ({ requestId }) => `${REQUEST_DETAIL_ITEM_PREFIX}${requestId}` + ) + const rawItems = await client.mget(itemKeys) + const records = [] + + rawItems.forEach((rawItem, index) => { + const pointer = pointerBatch[index] + const record = this._hydrateRawRecord(rawItem, pointer) + if (record) { + records.push({ record, pointer }) + } + }) + + return records + } + + async _loadRecordsForPointers(pointers = [], client = redis.getClient()) { + const recordItems = await this._loadPointerBatchRecords(pointers, client) + return recordItems.map(({ record }) => record) + } + + _paginateMatchedPointers(matchedPointers = [], requestedPage = 1, pageSize = 50) { + const totalRecords = matchedPointers.length + const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSize) : 0 + const currentPage = totalPages > 0 ? Math.min(requestedPage, totalPages) : 1 + const pageStart = (currentPage - 1) * pageSize + const pageEnd = pageStart + pageSize + + return { + currentPage, + totalRecords, + totalPages, + pagePointers: matchedPointers.slice(pageStart, pageEnd) + } + } + + async _buildPageRecords(pagePointers = []) { + if (!Array.isArray(pagePointers) || pagePointers.length === 0) { + return [] + } + + const rawRecords = await this._loadRecordsForPointers(pagePointers) + const enrichedRecords = await this._enrichRecords(rawRecords) + + return enrichedRecords.map((record) => ({ + ...record, + requestBodySnapshot: undefined + })) + } + + async _buildListQueryData(filters, effectiveStart, effectiveEnd, sortOrder) { + const requestPointers = await this._loadRequestPointersInRange(effectiveStart, effectiveEnd) + if (requestPointers.length === 0) { + return { + hasSourceRecords: false, + matchedPointers: [], + availableFilters: { + apiKeys: [], + accounts: [], + models: [], + endpoints: [], + dateRange: { + earliest: null, + latest: null + } + }, + summary: finalizeSummary(createSummaryAccumulator()) + } + } + + requestPointers.sort((a, b) => + sortOrder === 'asc' ? a.timestampMs - b.timestampMs : b.timestampMs - a.timestampMs + ) + + const availableFilterAccumulator = createAvailableFilterAccumulator() + const summaryAccumulator = createSummaryAccumulator() + const matchedPointers = [] + const client = redis.getClient() + const hasKeyword = Boolean(filters.keyword?.trim()) + + if (hasKeyword) { + const apiKeyCache = new Map() + const accountCache = new Map() + + for ( + let startIndex = 0; + startIndex < requestPointers.length; + startIndex += REQUEST_DETAIL_QUERY_BATCH_SIZE + ) { + const pointerBatch = requestPointers.slice( + startIndex, + startIndex + REQUEST_DETAIL_QUERY_BATCH_SIZE + ) + const recordItems = await this._loadPointerBatchRecords(pointerBatch, client) + const enrichedBatch = await this._enrichRecords( + recordItems.map(({ record }) => record), + apiKeyCache, + accountCache + ) + + enrichedBatch.forEach((record, index) => { + updateAvailableFilterAccumulator(availableFilterAccumulator, record) + + if ( + !this._matchesStructuredFilters(record, filters) || + !this._matchesKeyword(record, filters.keyword) + ) { + return + } + + updateSummaryAccumulator(summaryAccumulator, record) + + matchedPointers.push({ + requestId: record.requestId, + timestampMs: toMillis(record.timestamp) ?? recordItems[index].pointer.timestampMs + }) + }) + } + } else { + for ( + let startIndex = 0; + startIndex < requestPointers.length; + startIndex += REQUEST_DETAIL_QUERY_BATCH_SIZE + ) { + const pointerBatch = requestPointers.slice( + startIndex, + startIndex + REQUEST_DETAIL_QUERY_BATCH_SIZE + ) + const recordItems = await this._loadPointerBatchRecords(pointerBatch, client) + + for (const { record, pointer } of recordItems) { + updateAvailableFilterAccumulatorRaw(availableFilterAccumulator, record) + + if (!this._matchesStructuredFilters(record, filters)) { + continue + } + + const displayRecord = prepareRecordForDisplay(record) + updateSummaryAccumulator(summaryAccumulator, displayRecord) + + matchedPointers.push({ + requestId: displayRecord.requestId, + timestampMs: toMillis(displayRecord.timestamp) ?? pointer.timestampMs + }) + } + } + + await this._resolveFilterDisplayNames(availableFilterAccumulator) + } + + return { + hasSourceRecords: true, + matchedPointers, + availableFilters: finalizeAvailableFilters(availableFilterAccumulator), + summary: finalizeSummary(summaryAccumulator) + } + } + + async _loadQuerySnapshot(snapshotId, filterSignature, client = redis.getClient()) { + if (!snapshotId || !client || typeof client.get !== 'function') { + return null + } + + let rawSnapshot + try { + rawSnapshot = await client.get(`${REQUEST_DETAIL_QUERY_SNAPSHOT_PREFIX}${snapshotId}`) + } catch (error) { + logger.warn(`⚠️ Failed to read request detail query snapshot: ${error.message}`) + return null + } + + const parsedSnapshot = safeJsonParse(rawSnapshot, 'request detail query snapshot') + if ( + !parsedSnapshot || + !requestDetailFilterSignaturesMatch(parsedSnapshot.filterSignature, filterSignature) + ) { + return null + } + + if (typeof client.expire === 'function') { + try { + await client.expire( + `${REQUEST_DETAIL_QUERY_SNAPSHOT_PREFIX}${snapshotId}`, + REQUEST_DETAIL_QUERY_SNAPSHOT_TTL_SECONDS + ) + } catch (error) { + logger.warn(`⚠️ Failed to renew request detail query snapshot TTL: ${error.message}`) + } + } + + return { + snapshotId, + matchedPointers: inflateMatchedPointers(parsedSnapshot.matchedPointers), + availableFilters: parsedSnapshot.availableFilters || { + apiKeys: [], + accounts: [], + models: [], + endpoints: [], + dateRange: { + earliest: null, + latest: null + } + }, + summary: parsedSnapshot.summary || finalizeSummary(createSummaryAccumulator()), + filters: parsedSnapshot.filters || null + } + } + + async _storeQuerySnapshot(filterSignature, queryData, responseFilters, sortOrder) { + const client = redis.getClient() + if (!client || typeof client.set !== 'function') { + return null + } + + if (queryData.matchedPointers.length > MAX_REQUEST_DETAIL_SNAPSHOT_POINTERS) { + return null + } + + const snapshotPayload = { + filterSignature, + matchedPointers: flattenMatchedPointers(queryData.matchedPointers), + summary: queryData.summary, + availableFilters: queryData.availableFilters, + filters: responseFilters, + sortOrder, + createdAt: new Date().toISOString() + } + + const serializedSnapshot = JSON.stringify(snapshotPayload) + if (Buffer.byteLength(serializedSnapshot, 'utf8') > MAX_REQUEST_DETAIL_SNAPSHOT_BYTES) { + return null + } + + const snapshotId = makeRequestDetailQuerySnapshotId() + try { + await client.set( + `${REQUEST_DETAIL_QUERY_SNAPSHOT_PREFIX}${snapshotId}`, + serializedSnapshot, + 'EX', + REQUEST_DETAIL_QUERY_SNAPSHOT_TTL_SECONDS + ) + } catch (error) { + logger.warn(`⚠️ Failed to store request detail query snapshot: ${error.message}`) + return null + } + + return snapshotId + } + + async _buildListResponse({ + settings, + responseFilters, + matchedPointers, + availableFilters, + summary, + page, + pageSize, + snapshotId = null + }) { + const pagination = this._paginateMatchedPointers(matchedPointers, page, pageSize) + const pageRecords = await this._buildPageRecords(pagination.pagePointers) + + return { + captureEnabled: settings.captureEnabled, + retentionHours: settings.retentionHours, + bodyPreviewEnabled: settings.bodyPreviewEnabled, + snapshotId, + records: pageRecords, + pagination: { + currentPage: pagination.currentPage, + pageSize, + totalRecords: pagination.totalRecords, + totalPages: pagination.totalPages, + hasNextPage: pagination.totalPages > 0 && pagination.currentPage < pagination.totalPages, + hasPreviousPage: pagination.totalPages > 0 && pagination.currentPage > 1 + }, + filters: responseFilters, + availableFilters, + summary + } + } + + async listRequestDetails(filters = {}) { + filters = { + ...filters, + apiKeyId: normalizeOptionalFilterValue(filters.apiKeyId), + accountId: normalizeOptionalFilterValue(filters.accountId), + model: normalizeOptionalFilterValue(filters.model), + endpoint: normalizeOptionalFilterValue(filters.endpoint) + } + const settings = await this.getSettings() + const emptyResult = this._emptyListResult(settings, filters) + + const now = new Date() + const retentionStart = new Date(now.getTime() - settings.retentionHours * 3600 * 1000) + const startDate = filters.startDate ? new Date(filters.startDate) : retentionStart + const endDate = filters.endDate ? new Date(filters.endDate) : now + + const effectiveStart = startDate < retentionStart ? retentionStart : startDate + const effectiveEnd = endDate > now ? now : endDate + + if (Number.isNaN(effectiveStart.getTime()) || Number.isNaN(effectiveEnd.getTime())) { + throw new RequestDetailValidationError('Invalid date range') + } + + if (effectiveStart > effectiveEnd) { + throw new RequestDetailValidationError('Start date must be before or equal to end date') + } + + const page = Math.max(Number.parseInt(filters.page, 10) || 1, 1) + const pageSize = Math.min(Math.max(Number.parseInt(filters.pageSize, 10) || 50, 1), 200) + const sortOrder = filters.sortOrder === 'asc' ? 'asc' : 'desc' + const responseFilters = this._buildResponseFilters( + filters, + effectiveStart, + effectiveEnd, + sortOrder + ) + const filterSignature = createRequestDetailFilterSignature( + filters, + { + startBoundary: createRequestDetailDateBoundarySignature( + 'start', + filters.startDate, + effectiveStart, + retentionStart + ), + endBoundary: createRequestDetailDateBoundarySignature( + 'end', + filters.endDate, + effectiveEnd, + now + ) + }, + settings.retentionHours + ) + + const snapshot = await this._loadQuerySnapshot(filters.snapshotId, filterSignature) + if (snapshot) { + return this._buildListResponse({ + settings, + responseFilters: snapshot.filters || responseFilters, + matchedPointers: snapshot.matchedPointers, + availableFilters: snapshot.availableFilters, + summary: snapshot.summary, + page, + pageSize, + snapshotId: snapshot.snapshotId + }) + } + + const queryData = await this._buildListQueryData( + filters, + effectiveStart, + effectiveEnd, + sortOrder + ) + if (!queryData.hasSourceRecords) { + return { + ...emptyResult, + captureEnabled: settings.captureEnabled, + retentionHours: settings.retentionHours, + bodyPreviewEnabled: settings.bodyPreviewEnabled, + snapshotId: null, + filters: responseFilters + } + } + + const snapshotId = await this._storeQuerySnapshot( + filterSignature, + queryData, + responseFilters, + sortOrder + ) + + return this._buildListResponse({ + settings, + responseFilters, + matchedPointers: queryData.matchedPointers, + availableFilters: queryData.availableFilters, + summary: queryData.summary, + page, + pageSize, + snapshotId + }) + } + + async getRequestDetail(requestId) { + const settings = await this.getSettings() + const client = redis.getClient() + if (!client) { + return { + captureEnabled: settings.captureEnabled, + retentionHours: settings.retentionHours, + bodyPreviewEnabled: settings.bodyPreviewEnabled, + record: null + } + } + + const raw = await client.get(`${REQUEST_DETAIL_ITEM_PREFIX}${requestId}`) + const parsed = safeJsonParse(raw) + if (!parsed) { + return { + captureEnabled: settings.captureEnabled, + retentionHours: settings.retentionHours, + bodyPreviewEnabled: settings.bodyPreviewEnabled, + record: null + } + } + + const now = new Date() + const retentionStart = new Date(now.getTime() - settings.retentionHours * 3600 * 1000) + let recordMs = toMillis(parsed.timestamp) + if (recordMs === null) { + recordMs = await this._findRequestTimestampInRange(requestId, retentionStart, now, client) + if (recordMs === null) { + return { + captureEnabled: settings.captureEnabled, + retentionHours: settings.retentionHours, + bodyPreviewEnabled: settings.bodyPreviewEnabled, + record: null + } + } + + parsed.timestamp = new Date(recordMs).toISOString() + } + + if (recordMs < retentionStart.getTime()) { + return { + captureEnabled: settings.captureEnabled, + retentionHours: settings.retentionHours, + bodyPreviewEnabled: settings.bodyPreviewEnabled, + record: null + } + } + + const [enrichedRecord] = await this._enrichRecords([parsed]) + return { + captureEnabled: settings.captureEnabled, + retentionHours: settings.retentionHours, + bodyPreviewEnabled: settings.bodyPreviewEnabled, + record: enrichedRecord || null + } + } +} + +module.exports = new RequestDetailService() +module.exports.REQUEST_DETAIL_ITEM_PREFIX = REQUEST_DETAIL_ITEM_PREFIX +module.exports.REQUEST_DETAIL_DAY_INDEX_PREFIX = REQUEST_DETAIL_DAY_INDEX_PREFIX diff --git a/src/services/requestIdentityService.js b/src/services/requestIdentityService.js new file mode 100644 index 0000000..c10fb5e --- /dev/null +++ b/src/services/requestIdentityService.js @@ -0,0 +1,397 @@ +/** + * Request Identity Service + * + * 处理 Claude 请求的身份信息规范化: + * 1. Stainless 指纹管理 - 收集、持久化和应用 x-stainless-* 系列请求头 + * 2. User ID 规范化 - 重写 metadata.user_id,使其与真实账户保持一致 + */ + +const crypto = require('crypto') +const logger = require('../utils/logger') +const redisService = require('../models/redis') +const metadataUserIdHelper = require('../utils/metadataUserIdHelper') +const STAINLESS_HEADER_KEYS = [ + 'x-stainless-retry-count', + 'x-stainless-timeout', + 'x-stainless-lang', + 'x-stainless-package-version', + 'x-stainless-os', + 'x-stainless-arch', + 'x-stainless-runtime', + 'x-stainless-runtime-version' +] + +// 小写 key 到正确大小写格式的映射(用于返回给上游时) +const STAINLESS_HEADER_CASE_MAP = { + 'x-stainless-retry-count': 'X-Stainless-Retry-Count', + 'x-stainless-timeout': 'X-Stainless-Timeout', + 'x-stainless-lang': 'X-Stainless-Lang', + 'x-stainless-package-version': 'X-Stainless-Package-Version', + 'x-stainless-os': 'X-Stainless-OS', + 'x-stainless-arch': 'X-Stainless-Arch', + 'x-stainless-runtime': 'X-Stainless-Runtime', + 'x-stainless-runtime-version': 'X-Stainless-Runtime-Version' +} +const MIN_FINGERPRINT_FIELDS = 4 +const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:' + +function formatUuidFromSeed(seed) { + const digest = crypto.createHash('sha256').update(String(seed)).digest() + const bytes = Buffer.from(digest.subarray(0, 16)) + + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + + const hex = Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +function safeParseJson(value) { + if (typeof value !== 'string' || !value.trim()) { + return null + } + + try { + const parsed = JSON.parse(value) + return parsed && typeof parsed === 'object' ? parsed : null + } catch (error) { + return null + } +} + +function getRedisClient() { + if (!redisService || typeof redisService.getClientSafe !== 'function') { + throw new Error('requestIdentityService: Redis 服务未初始化') + } + + return redisService.getClientSafe() +} + +function hasFingerprintValues(fingerprint) { + return fingerprint && typeof fingerprint === 'object' && Object.keys(fingerprint).length > 0 +} + +function sanitizeFingerprint(source) { + if (!source || typeof source !== 'object') { + return {} + } + + const normalized = {} + const lowerCaseSource = {} + + Object.keys(source).forEach((key) => { + const value = source[key] + if (value === undefined || value === null || String(value).trim() === '') { + return + } + lowerCaseSource[key.toLowerCase()] = String(value) + }) + + STAINLESS_HEADER_KEYS.forEach((key) => { + if (lowerCaseSource[key]) { + normalized[key] = lowerCaseSource[key] + } + }) + + return normalized +} + +function collectFingerprintFromHeaders(headers) { + if (!headers || typeof headers !== 'object') { + return {} + } + + const subset = {} + + Object.keys(headers).forEach((key) => { + const lowerKey = key.toLowerCase() + if (STAINLESS_HEADER_KEYS.includes(lowerKey)) { + subset[lowerKey] = headers[key] + } + }) + + return sanitizeFingerprint(subset) +} + +function removeHeaderCaseInsensitive(target, key) { + if (!target || typeof target !== 'object') { + return + } + + const lowerKey = key.toLowerCase() + Object.keys(target).forEach((candidate) => { + if (candidate.toLowerCase() === lowerKey) { + delete target[candidate] + } + }) +} + +function applyFingerprintToHeaders(headers, fingerprint) { + if (!headers || typeof headers !== 'object') { + return headers + } + + if (!hasFingerprintValues(fingerprint)) { + return { ...headers } + } + + const nextHeaders = { ...headers } + + STAINLESS_HEADER_KEYS.forEach((key) => { + if (!Object.prototype.hasOwnProperty.call(fingerprint, key)) { + return + } + removeHeaderCaseInsensitive(nextHeaders, key) + // 使用正确的大小写格式返回给上游 + const properCaseKey = STAINLESS_HEADER_CASE_MAP[key] || key + nextHeaders[properCaseKey] = fingerprint[key] + }) + + return nextHeaders +} + +function persistFingerprint(accountId, fingerprint) { + if (!accountId || !hasFingerprintValues(fingerprint)) { + return + } + + const client = getRedisClient() + const key = `${REDIS_KEY_PREFIX}${accountId}` + const serialized = JSON.stringify(fingerprint) + + const command = client.set(key, serialized, 'NX') + + if (command && typeof command.catch === 'function') { + command.catch((error) => { + logger.error(`requestIdentityService: Redis 持久化指纹失败 (${accountId}): ${error.message}`) + }) + } +} + +function getHeaderValueCaseInsensitive(headers, key) { + if (!headers || typeof headers !== 'object') { + return undefined + } + + const lowerKey = key.toLowerCase() + for (const candidate of Object.keys(headers)) { + if (candidate.toLowerCase() === lowerKey) { + return headers[candidate] + } + } + + return undefined +} + +function headersChanged(original, updated) { + if (original === updated) { + return false + } + + for (const key of STAINLESS_HEADER_KEYS) { + if ( + getHeaderValueCaseInsensitive(original, key) !== getHeaderValueCaseInsensitive(updated, key) + ) { + return true + } + } + + return false +} + +function resolveAccountId(payload) { + if (!payload || typeof payload !== 'object') { + return null + } + + const account = payload.account && typeof payload.account === 'object' ? payload.account : null + const candidates = [ + payload.accountId, + payload.account_id, + payload.accountID, + account && (account.accountId || account.account_id || account.accountID), + account && (account.id || account.uuid), + account && (account.account_uuid || account.accountUuid), + account && (account.schedulerAccountId || account.scheduler_account_id) + ] + + for (const candidate of candidates) { + if (candidate === undefined || candidate === null) { + continue + } + + const stringified = String(candidate).trim() + if (stringified) { + return stringified + } + } + + return null +} + +function rewriteHeaders(headers, accountId) { + if (!headers || typeof headers !== 'object') { + return { nextHeaders: headers, changed: false } + } + + if (!accountId) { + return { nextHeaders: { ...headers }, changed: false } + } + + const workingHeaders = { ...headers } + const fingerprint = collectFingerprintFromHeaders(workingHeaders) + const fieldCount = Object.keys(fingerprint).length + + if (fieldCount < MIN_FINGERPRINT_FIELDS) { + logger.warn( + `requestIdentityService: 账号 ${accountId} 提供的 Stainless 指纹字段不足,已保持原样` + ) + return { nextHeaders: workingHeaders, changed: false } + } + + try { + persistFingerprint(accountId, fingerprint) + } catch (error) { + logger.error(`requestIdentityService: 持久化指纹失败 (${accountId}): ${error.message}`) + return { + abortResponse: { + statusCode: 500, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ error: 'fingerprint_persist_failed', message: '指纹信息持久化失败' }) + } + } + } + + const appliedHeaders = applyFingerprintToHeaders(workingHeaders, fingerprint) + const changed = headersChanged(workingHeaders, appliedHeaders) + + return { nextHeaders: appliedHeaders, changed } +} + +function normalizeAccountUuid(candidate) { + if (typeof candidate !== 'string') { + return null + } + + const trimmed = candidate.trim() + return trimmed || null +} + +function extractAccountUuid(account) { + if (!account || typeof account !== 'object') { + return null + } + + const extInfoRaw = account.extInfo + if (!extInfoRaw) { + return null + } + + const extInfoObject = typeof extInfoRaw === 'string' ? safeParseJson(extInfoRaw) : null + + if (!extInfoObject || typeof extInfoObject !== 'object') { + return null + } + + const extUuid = normalizeAccountUuid(extInfoObject.account_uuid) + return extUuid || null +} + +function rewriteUserId(body, accountId, accountUuid) { + if (!body || typeof body !== 'object') { + return { nextBody: body, changed: false } + } + + const { metadata } = body + if (!metadata || typeof metadata !== 'object') { + return { nextBody: body, changed: false } + } + + const userId = metadata.user_id + if (typeof userId !== 'string') { + return { nextBody: body, changed: false } + } + + const parsed = metadataUserIdHelper.parse(userId) + if (!parsed) { + return { nextBody: body, changed: false } + } + + // 哈希 session(与原逻辑一致) + const seedTail = parsed.sessionId || 'default' + const effectiveScheduler = accountId ? String(accountId) : 'unknown-scheduler' + const hashedSession = formatUuidFromSeed(`${effectiveScheduler}::${seedTail}`) + + // 注入真实 accountUuid + const effectiveUuid = normalizeAccountUuid(accountUuid) || parsed.accountUuid || '' + + // 以原格式重建 + const nextUserId = metadataUserIdHelper.build({ + deviceId: parsed.deviceId, + accountUuid: effectiveUuid, + sessionId: hashedSession, + isJsonFormat: parsed.isJsonFormat + }) + + if (nextUserId === userId) { + return { nextBody: body, changed: false } + } + + return { + nextBody: { ...body, metadata: { ...metadata, user_id: nextUserId } }, + changed: true + } +} + +/** + * 转换请求身份信息 + * @param {Object} payload - 请求载荷 + * @param {Object} payload.body - 请求体 + * @param {Object} payload.headers - 请求头 + * @param {string} payload.accountId - 账户ID + * @param {Object} payload.account - 账户对象 + * @returns {Object} 转换后的 { body, headers, abortResponse? } + */ +function transform(payload = {}) { + const currentBody = payload.body + const currentHeaders = payload.headers + + if (!payload.accountId) { + return { + body: currentBody, + headers: currentHeaders + } + } + + const accountUuid = extractAccountUuid(payload.account) + const accountIdForHeaders = resolveAccountId(payload) + + const { nextBody } = rewriteUserId(currentBody, payload.accountId, accountUuid) + const headerResult = rewriteHeaders(currentHeaders, accountIdForHeaders) + + const nextHeaders = headerResult ? headerResult.nextHeaders : currentHeaders + const abortResponse = + headerResult && headerResult.abortResponse ? headerResult.abortResponse : null + + return { + body: nextBody, + headers: nextHeaders, + abortResponse + } +} + +module.exports = { + transform, + // 导出内部函数供测试使用 + _internal: { + formatUuidFromSeed, + collectFingerprintFromHeaders, + rewriteHeaders, + rewriteUserId, + extractAccountUuid, + resolveAccountId + } +} diff --git a/src/services/scheduler/droidScheduler.js b/src/services/scheduler/droidScheduler.js new file mode 100644 index 0000000..ba6796a --- /dev/null +++ b/src/services/scheduler/droidScheduler.js @@ -0,0 +1,197 @@ +const droidAccountService = require('../account/droidAccountService') +const accountGroupService = require('../accountGroupService') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') +const { + isTruthy, + isAccountHealthy, + sortAccountsByPriority, + normalizeEndpointType +} = require('../../utils/commonHelper') + +class DroidScheduler { + constructor() { + this.STICKY_PREFIX = 'droid' + } + + _isAccountSchedulable(account) { + return isTruthy(account?.schedulable ?? true) + } + + _matchesEndpoint(account, endpointType) { + const normalizedEndpoint = normalizeEndpointType(endpointType) + const accountEndpoint = normalizeEndpointType(account?.endpointType) + if (normalizedEndpoint === accountEndpoint) { + return true + } + if (normalizedEndpoint === 'comm') { + return true + } + const sharedEndpoints = new Set(['anthropic', 'openai']) + return sharedEndpoints.has(normalizedEndpoint) && sharedEndpoints.has(accountEndpoint) + } + + _composeStickySessionKey(endpointType, sessionHash, apiKeyId) { + if (!sessionHash) { + return null + } + const normalizedEndpoint = normalizeEndpointType(endpointType) + const apiKeyPart = apiKeyId || 'default' + return `${this.STICKY_PREFIX}:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}` + } + + async _loadGroupAccounts(groupId) { + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (!memberIds || memberIds.length === 0) { + return [] + } + + const accounts = await Promise.all( + memberIds.map(async (memberId) => { + try { + return await droidAccountService.getAccount(memberId) + } catch (error) { + logger.warn(`⚠️ 获取 Droid 分组成员账号失败: ${memberId}`, error) + return null + } + }) + ) + + const result = [] + for (const account of accounts) { + if (!account || !isAccountHealthy(account) || !this._isAccountSchedulable(account)) { + continue + } + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(account.id, 'droid') + if (isTempUnavailable) { + logger.debug( + `⏭️ Skipping Droid group member ${account.name || account.id} - temporarily unavailable` + ) + continue + } + result.push(account) + } + return result + } + + async _ensureLastUsedUpdated(accountId) { + try { + await droidAccountService.touchLastUsedAt(accountId) + } catch (error) { + logger.warn(`⚠️ 更新 Droid 账号最后使用时间失败: ${accountId}`, error) + } + } + + async _cleanupStickyMapping(stickyKey) { + if (!stickyKey) { + return + } + try { + await redis.deleteSessionAccountMapping(stickyKey) + } catch (error) { + logger.warn(`⚠️ 清理 Droid 粘性会话映射失败: ${stickyKey}`, error) + } + } + + async selectAccount(apiKeyData, endpointType, sessionHash) { + const normalizedEndpoint = normalizeEndpointType(endpointType) + const stickyKey = this._composeStickySessionKey(normalizedEndpoint, sessionHash, apiKeyData?.id) + + let candidates = [] + let isDedicatedBinding = false + + if (apiKeyData?.droidAccountId) { + const binding = apiKeyData.droidAccountId + if (binding.startsWith('group:')) { + const groupId = binding.substring('group:'.length) + logger.info( + `🤖 API Key ${apiKeyData.name || apiKeyData.id} 绑定 Droid 分组 ${groupId},按分组调度` + ) + candidates = await this._loadGroupAccounts(groupId, normalizedEndpoint) + } else { + const account = await droidAccountService.getAccount(binding) + if (account) { + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(account.id, 'droid') + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound Droid account ${account.name || account.id} temporarily unavailable, falling back to pool` + ) + } else { + candidates = [account] + isDedicatedBinding = true + } + } + } + } + + if (!candidates || candidates.length === 0) { + candidates = await droidAccountService.getSchedulableAccounts(normalizedEndpoint) + } + + const syncFiltered = candidates.filter( + (account) => + account && + isAccountHealthy(account) && + this._isAccountSchedulable(account) && + this._matchesEndpoint(account, normalizedEndpoint) + ) + const filteredResults = await Promise.all( + syncFiltered.map(async (account) => { + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(account.id, 'droid') + if (isTempUnavailable) { + logger.debug( + `⏭️ Skipping Droid account ${account.name || account.id} - temporarily unavailable` + ) + return null + } + return account + }) + ) + const filtered = filteredResults.filter(Boolean) + + if (filtered.length === 0) { + throw new Error( + `No available accounts for endpoint ${normalizedEndpoint}${apiKeyData?.droidAccountId ? ' (respecting binding)' : ''}` + ) + } + + if (stickyKey && !isDedicatedBinding) { + const mappedAccountId = await redis.getSessionAccountMapping(stickyKey) + if (mappedAccountId) { + const mappedAccount = filtered.find((account) => account.id === mappedAccountId) + if (mappedAccount) { + await redis.extendSessionAccountMappingTTL(stickyKey) + logger.info( + `🤖 命中 Droid 粘性会话: ${sessionHash} -> ${mappedAccount.name || mappedAccount.id}` + ) + await this._ensureLastUsedUpdated(mappedAccount.id) + return mappedAccount + } + + await this._cleanupStickyMapping(stickyKey) + } + } + + const sorted = sortAccountsByPriority(filtered) + const selected = sorted[0] + + if (!selected) { + throw new Error(`No schedulable account available after sorting (${normalizedEndpoint})`) + } + + if (stickyKey && !isDedicatedBinding) { + await redis.setSessionAccountMapping(stickyKey, selected.id) + } + + await this._ensureLastUsedUpdated(selected.id) + + logger.info( + `🤖 选择 Droid 账号 ${selected.name || selected.id}(endpoint: ${normalizedEndpoint}, priority: ${selected.priority || 50})` + ) + + return selected + } +} + +module.exports = new DroidScheduler() diff --git a/src/services/scheduler/unifiedClaudeScheduler.js b/src/services/scheduler/unifiedClaudeScheduler.js new file mode 100644 index 0000000..220a0f3 --- /dev/null +++ b/src/services/scheduler/unifiedClaudeScheduler.js @@ -0,0 +1,1924 @@ +const claudeAccountService = require('../account/claudeAccountService') +const claudeConsoleAccountService = require('../account/claudeConsoleAccountService') +const bedrockAccountService = require('../account/bedrockAccountService') +const ccrAccountService = require('../account/ccrAccountService') +const accountGroupService = require('../accountGroupService') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const { + parseVendorPrefixedModel, + isOpus45OrNewer, + getRateLimitModelFamily +} = require('../../utils/modelHelper') +const { isSchedulable, sortAccountsByPriority } = require('../../utils/commonHelper') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') +const config = require('../../../config/config') + +/** + * Check if account is Pro (not Max) + * + * ACCOUNT TYPE LOGIC (as of 2025-12-05): + * Pro accounts can be identified by either: + * 1. API real-time data: hasClaudePro=true && hasClaudeMax=false + * 2. Local config data: accountType='claude_pro' + * + * Account type restrictions for Opus models: + * - Free account: No Opus access at all + * - Pro account: Only Opus 4.5+ (new versions) + * - Max account: All Opus versions (legacy 3.x, 4.0, 4.1 and new 4.5+) + * + * Compatible with both API real-time data (hasClaudePro) and local config (accountType) + * @param {Object} info - Subscription info object + * @returns {boolean} - true if Pro account (not Free, not Max) + */ +function isProAccount(info) { + // API real-time status takes priority + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return true + } + // Local configured account type + return info.accountType === 'claude_pro' +} + +class UnifiedClaudeScheduler { + constructor() { + this.SESSION_MAPPING_PREFIX = 'unified_claude_session_mapping:' + } + + // 🔍 检查账户是否支持请求的模型 + _isModelSupportedByAccount(account, accountType, requestedModel, context = '') { + if (!requestedModel) { + return true // 没有指定模型时,默认支持 + } + + // Claude OAuth 账户的模型检查 + if (accountType === 'claude-official') { + // 1. 首先检查是否为 Claude 官方支持的模型 + // Claude Official API 只支持 Anthropic 自己的模型,不支持第三方模型(如 deepseek-chat) + const isClaudeOfficialModel = + requestedModel.startsWith('claude-') || + requestedModel.includes('claude') || + requestedModel.includes('sonnet') || + requestedModel.includes('opus') || + requestedModel.includes('haiku') + + if (!isClaudeOfficialModel) { + logger.info( + `🚫 Claude official account ${account.name} does not support non-Claude model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + + // 2. Opus model subscription level check + // VERSION RESTRICTION LOGIC: + // - Free: No Opus models + // - Pro: Only Opus 4.5+ (isOpus45OrNewer = true) + // - Max: All Opus versions + if (requestedModel.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(requestedModel) + + if (account.subscriptionInfo) { + try { + const info = + typeof account.subscriptionInfo === 'string' + ? JSON.parse(account.subscriptionInfo) + : account.subscriptionInfo + + // Free account: does not support any Opus model + if (info.accountType === 'free') { + logger.info( + `🚫 Claude account ${account.name} (Free) does not support Opus model${context ? ` ${context}` : ''}` + ) + return false + } + + // Pro account: only supports Opus 4.5+ + // Reject legacy Opus (3.x, 4.0-4.4) but allow new Opus (4.5+) + if (isProAccount(info)) { + if (!isNewOpus) { + logger.info( + `🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}` + ) + return false + } + // Opus 4.5+ supported + return true + } + + // Max account: supports all Opus versions (no restriction) + } catch (e) { + // Parse failed, assume legacy data (Max), default support + logger.debug( + `Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max` + ) + } + } + // Account without subscription info, default to supported (legacy data compatibility) + } + } + + // Claude Console 账户的模型支持检查 + if (accountType === 'claude-console' && account.supportedModels) { + // 兼容旧格式(数组)和新格式(对象) + if (Array.isArray(account.supportedModels)) { + // 旧格式:数组 + if ( + account.supportedModels.length > 0 && + !account.supportedModels.includes(requestedModel) + ) { + logger.info( + `🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + } else if (typeof account.supportedModels === 'object') { + // 新格式:映射表 + if ( + Object.keys(account.supportedModels).length > 0 && + !claudeConsoleAccountService.isModelSupported(account.supportedModels, requestedModel) + ) { + logger.info( + `🚫 Claude Console account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + } + } + + // CCR 账户的模型支持检查 + if (accountType === 'ccr' && account.supportedModels) { + // 兼容旧格式(数组)和新格式(对象) + if (Array.isArray(account.supportedModels)) { + // 旧格式:数组 + if ( + account.supportedModels.length > 0 && + !account.supportedModels.includes(requestedModel) + ) { + logger.info( + `🚫 CCR account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + } else if (typeof account.supportedModels === 'object') { + // 新格式:映射表 + if ( + Object.keys(account.supportedModels).length > 0 && + !ccrAccountService.isModelSupported(account.supportedModels, requestedModel) + ) { + logger.info( + `🚫 CCR account ${account.name} does not support model ${requestedModel}${context ? ` ${context}` : ''}` + ) + return false + } + } + } + + return true + } + + // 🎯 统一调度Claude账号(官方和Console) + async selectAccountForApiKey( + apiKeyData, + sessionHash = null, + requestedModel = null, + forcedAccount = null + ) { + try { + // 🔒 如果有强制绑定的账户(全局会话绑定),仅 claude-official 类型受影响 + if (forcedAccount && forcedAccount.accountId && forcedAccount.accountType) { + // ⚠️ 只有 claude-official 类型账户受全局会话绑定限制 + // 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度 + if (forcedAccount.accountType !== 'claude-official') { + logger.info( + `🔗 Session binding ignored for non-official account type: ${forcedAccount.accountType}, proceeding with normal scheduling` + ) + // 不使用 forcedAccount,继续走下面的正常调度逻辑 + } else { + // claude-official 类型需要检查可用性并强制使用 + logger.info( + `🔗 Forced session binding detected: ${forcedAccount.accountId} (${forcedAccount.accountType})` + ) + + const isAvailable = await this._isAccountAvailableForSessionBinding( + forcedAccount.accountId, + forcedAccount.accountType, + requestedModel + ) + + if (isAvailable) { + logger.info( + `✅ Using forced session binding account: ${forcedAccount.accountId} (${forcedAccount.accountType})` + ) + return { + accountId: forcedAccount.accountId, + accountType: forcedAccount.accountType + } + } else { + // 绑定账户不可用,抛出特定错误(不 fallback) + logger.warn( + `❌ Forced session binding account unavailable: ${forcedAccount.accountId} (${forcedAccount.accountType})` + ) + const error = new Error('Session binding account unavailable') + error.code = 'SESSION_BINDING_ACCOUNT_UNAVAILABLE' + error.accountId = forcedAccount.accountId + error.accountType = forcedAccount.accountType + throw error + } + } + } + + // 解析供应商前缀 + const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel) + const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel + + logger.debug( + `🔍 Model parsing - Original: ${requestedModel}, Vendor: ${vendor}, Effective: ${effectiveModel}` + ) + // 请求模型所属的限流家族(opus/sonnet/haiku/fable);null 表示未知模型 + const requestedModelFamily = getRateLimitModelFamily(effectiveModel) + + // 如果是 CCR 前缀,只在 CCR 账户池中选择 + if (vendor === 'ccr') { + logger.info(`🎯 CCR vendor prefix detected, routing to CCR accounts only`) + return await this._selectCcrAccount(apiKeyData, sessionHash, effectiveModel) + } + // 如果API Key绑定了专属账户或分组,优先使用 + if (apiKeyData.claudeAccountId) { + // 检查是否是分组 + if (apiKeyData.claudeAccountId.startsWith('group:')) { + const groupId = apiKeyData.claudeAccountId.replace('group:', '') + logger.info( + `🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group` + ) + return await this.selectAccountFromGroup( + groupId, + sessionHash, + effectiveModel, + vendor === 'ccr' + ) + } + + // 普通专属账户 + // + // 专属账号一旦不可用,必须显式报错,绝不能静默回退到共享池,否则 + // 「把 API Key 限定到某个账号」的语义会被破坏(请求会用到别的账号)。 + // + // 历史 bug:markAccountRateLimited 会同时置 schedulable=false 并写入 temp_unavailable, + // 而旧代码先判断 temp_unavailable 且仅打日志后继续向下执行,导致 + // CLAUDE_DEDICATED_RATE_LIMITED 永远不会抛出,专属 Key 被静默回退到共享池。 + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) + const allowDedicatedFallback = config.claude?.dedicatedAccountFallback === true + + const dedicatedUnavailableError = (reason) => { + const error = new Error(`Dedicated Claude account is unavailable (${reason})`) + error.code = 'CLAUDE_DEDICATED_UNAVAILABLE' + error.accountId = apiKeyData.claudeAccountId + error.reason = reason + return error + } + + if (!boundAccount || boundAccount.isActive !== 'true' || boundAccount.status === 'error') { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status})` + ) + if (!allowDedicatedFallback) { + throw dedicatedUnavailableError('inactive_or_error') + } + } else { + // 1) 限流优先判断(必须早于 temp_unavailable / schedulable 判断) + const rateLimitAutoStopped = boundAccount.rateLimitAutoStopped === 'true' + const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) + if (isRateLimited || (rateLimitAutoStopped && !isSchedulable(boundAccount.schedulable))) { + const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id) + const error = new Error('Dedicated Claude account is rate limited') + error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' + error.accountId = boundAccount.id + error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null + throw error + } + + // 2) 请求模型所属家族的独立限流(opus/sonnet/haiku/fable) + if (requestedModelFamily) { + await claudeAccountService.clearExpiredModelRateLimit( + boundAccount.id, + requestedModelFamily + ) + const isModelRateLimited = await claudeAccountService.isAccountModelRateLimited( + boundAccount.id, + requestedModelFamily + ) + if (isModelRateLimited) { + const info = await claudeAccountService.getAccountModelRateLimitInfo( + boundAccount.id, + requestedModelFamily + ) + const error = new Error( + `Dedicated Claude account reached its ${requestedModelFamily} model limit` + ) + error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' + error.accountId = boundAccount.id + error.rateLimitEndAt = info?.resetAt || null + error.modelFamily = requestedModelFamily + throw error + } + } + + // 3) 临时不可用(5xx / 529 / 超时等短暂抖动) + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + boundAccount.id, + 'claude-official' + ) + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound Claude OAuth account ${boundAccount.id} is temporarily unavailable` + ) + if (!allowDedicatedFallback) { + throw dedicatedUnavailableError('temporarily_unavailable') + } + } else if (!isSchedulable(boundAccount.schedulable)) { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})` + ) + if (!allowDedicatedFallback) { + throw dedicatedUnavailableError('not_schedulable') + } + } else { + logger.info( + `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.claudeAccountId, + accountType: 'claude-official' + } + } + } + } + + // 2. 检查Claude Console账户绑定 + if (apiKeyData.claudeConsoleAccountId) { + const boundConsoleAccount = await claudeConsoleAccountService.getAccount( + apiKeyData.claudeConsoleAccountId + ) + if ( + boundConsoleAccount && + boundConsoleAccount.isActive === true && + boundConsoleAccount.status === 'active' && + isSchedulable(boundConsoleAccount.schedulable) + ) { + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + boundConsoleAccount.id, + 'claude-console' + ) + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound Claude Console account ${boundConsoleAccount.id} is temporarily unavailable, falling back to pool` + ) + } else { + logger.info( + `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.claudeConsoleAccountId, + accountType: 'claude-console' + } + } + } else { + logger.warn( + `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable}), falling back to pool` + ) + } + } + + // 3. 检查Bedrock账户绑定 + if (apiKeyData.bedrockAccountId) { + const boundBedrockAccountResult = await bedrockAccountService.getAccount( + apiKeyData.bedrockAccountId + ) + if ( + boundBedrockAccountResult.success && + boundBedrockAccountResult.data.isActive === true && + isSchedulable(boundBedrockAccountResult.data.schedulable) + ) { + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + apiKeyData.bedrockAccountId, + 'bedrock' + ) + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is temporarily unavailable, falling back to pool` + ) + } else { + logger.info( + `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.bedrockAccountId, + accountType: 'bedrock' + } + } + } else { + logger.warn( + `⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable}), falling back to pool` + ) + } + } + + // CCR 账户不支持绑定(仅通过 ccr, 前缀进行 CCR 路由) + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 当本次请求不是 CCR 前缀时,不允许使用指向 CCR 的粘性会话映射 + if (vendor !== 'ccr' && mappedAccount.accountType === 'ccr') { + logger.info( + `ℹ️ Skipping CCR sticky session mapping for non-CCR request; removing mapping for session ${sessionHash}` + ) + await this._deleteSessionMapping(sessionHash) + } else { + // 验证映射的账户是否仍然可用 + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType, + effectiveModel + ) + if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天(续期正确的 unified 映射键) + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + return mappedAccount + } else { + logger.warn( + `⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account` + ) + await this._deleteSessionMapping(sessionHash) + } + } + } + } + + // 获取所有可用账户(传递请求的模型进行过滤) + const availableAccounts = await this._getAllAvailableAccounts( + apiKeyData, + effectiveModel, + false // 仅前缀才走 CCR:默认池不包含 CCR 账户 + ) + + if (availableAccounts.length === 0) { + // 提供更详细的错误信息 + if (effectiveModel) { + throw new Error( + `No available Claude accounts support the requested model: ${effectiveModel}` + ) + } else { + throw new Error('No available Claude accounts (neither official nor console)') + } + } + + // 按优先级和最后使用时间排序 + const sortedAccounts = sortAccountsByPriority(availableAccounts) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}` + ) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error('❌ Failed to select account for API key:', error) + throw error + } + } + + // 📋 获取所有可用账户(合并官方和Console) + async _getAllAvailableAccounts(apiKeyData, requestedModel = null, includeCcr = false) { + const availableAccounts = [] + // 请求模型所属的限流家族(opus/sonnet/haiku/fable) + const requestedModelFamily = getRateLimitModelFamily(requestedModel) + + // 如果API Key绑定了专属账户,优先返回 + // 1. 检查Claude OAuth账户绑定 + if (apiKeyData.claudeAccountId) { + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) + if ( + boundAccount && + boundAccount.isActive === 'true' && + boundAccount.status !== 'error' && + boundAccount.status !== 'blocked' && + boundAccount.status !== 'temp_error' + ) { + // 检查是否临时不可用 + if (await this.isAccountTemporarilyUnavailable(boundAccount.id, 'claude-official')) { + logger.warn( + `⏱️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is temporarily unavailable in pool selection, falling back to shared pool` + ) + } else { + const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) + if (isRateLimited) { + const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id) + const error = new Error('Dedicated Claude account is rate limited') + error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' + error.accountId = boundAccount.id + error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null + throw error + } + + // 请求模型所属家族的独立限流(opus/sonnet/haiku/fable) + const boundModelRateLimited = requestedModelFamily + ? await claudeAccountService.isAccountModelRateLimited( + boundAccount.id, + requestedModelFamily + ) + : false + + if (boundModelRateLimited) { + logger.warn( + `⏱️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} hit its ${requestedModelFamily} limit in pool selection, falling back to shared pool` + ) + } else if (!isSchedulable(boundAccount.schedulable)) { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable})` + ) + } else { + logger.info( + `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId})` + ) + return [ + { + ...boundAccount, + accountId: boundAccount.id, + accountType: 'claude-official', + priority: parseInt(boundAccount.priority) || 50, + lastUsedAt: boundAccount.lastUsedAt || '0' + } + ] + } + } + } else { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not available (isActive: ${boundAccount?.isActive}, status: ${boundAccount?.status})` + ) + } + } + + // 2. 检查Claude Console账户绑定 + if (apiKeyData.claudeConsoleAccountId) { + const boundConsoleAccount = await claudeConsoleAccountService.getAccount( + apiKeyData.claudeConsoleAccountId + ) + if ( + boundConsoleAccount && + boundConsoleAccount.isActive === true && + boundConsoleAccount.status === 'active' && + isSchedulable(boundConsoleAccount.schedulable) + ) { + // 主动触发一次额度检查 + try { + await claudeConsoleAccountService.checkQuotaUsage(boundConsoleAccount.id) + } catch (e) { + logger.warn( + `Failed to check quota for bound Claude Console account ${boundConsoleAccount.name}: ${e.message}` + ) + // 继续使用该账号 + } + + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + boundConsoleAccount.id, + 'claude-console' + ) + + // 检查限流状态和额度状态 + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( + boundConsoleAccount.id + ) + const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded( + boundConsoleAccount.id + ) + + if (!isTempUnavailable && !isRateLimited && !isQuotaExceeded) { + logger.info( + `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})` + ) + return [ + { + ...boundConsoleAccount, + accountId: boundConsoleAccount.id, + accountType: 'claude-console', + priority: parseInt(boundConsoleAccount.priority) || 50, + lastUsedAt: boundConsoleAccount.lastUsedAt || '0' + } + ] + } + } else { + logger.warn( + `⚠️ Bound Claude Console account ${apiKeyData.claudeConsoleAccountId} is not available (isActive: ${boundConsoleAccount?.isActive}, status: ${boundConsoleAccount?.status}, schedulable: ${boundConsoleAccount?.schedulable})` + ) + } + } + + // 3. 检查Bedrock账户绑定 + if (apiKeyData.bedrockAccountId) { + const boundBedrockAccountResult = await bedrockAccountService.getAccount( + apiKeyData.bedrockAccountId + ) + if ( + boundBedrockAccountResult.success && + boundBedrockAccountResult.data.isActive === true && + isSchedulable(boundBedrockAccountResult.data.schedulable) + ) { + // 检查是否临时不可用 + if (await this.isAccountTemporarilyUnavailable(apiKeyData.bedrockAccountId, 'bedrock')) { + logger.warn( + `⏱️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is temporarily unavailable, falling back to shared pool` + ) + } else { + logger.info( + `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId})` + ) + return [ + { + ...boundBedrockAccountResult.data, + accountId: boundBedrockAccountResult.data.id, + accountType: 'bedrock', + priority: parseInt(boundBedrockAccountResult.data.priority) || 50, + lastUsedAt: boundBedrockAccountResult.data.lastUsedAt || '0' + } + ] + } + } else { + logger.warn( + `⚠️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is not available (isActive: ${boundBedrockAccountResult?.data?.isActive}, schedulable: ${boundBedrockAccountResult?.data?.schedulable})` + ) + } + } + + // 获取官方Claude账户(共享池) + const claudeAccounts = await redis.getAllClaudeAccounts() + for (const account of claudeAccounts) { + if ( + account.isActive === 'true' && + account.status !== 'error' && + account.status !== 'blocked' && + account.status !== 'temp_error' && + (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 + isSchedulable(account.schedulable) + ) { + // 检查是否可调度 + + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'claude-official', requestedModel)) { + continue + } + + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + account.id, + 'claude-official' + ) + if (isTempUnavailable) { + logger.debug( + `⏭️ Skipping Claude Official account ${account.name} - temporarily unavailable` + ) + continue + } + + // 检查是否被限流 + const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id) + if (isRateLimited) { + continue + } + + if (requestedModelFamily) { + const isModelRateLimited = await claudeAccountService.isAccountModelRateLimited( + account.id, + requestedModelFamily + ) + if (isModelRateLimited) { + logger.info( + `🚫 Skipping account ${account.name} (${account.id}) due to active ${requestedModelFamily} limit` + ) + continue + } + } + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'claude-official', + priority: parseInt(account.priority) || 50, // 默认优先级50 + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + + // 获取Claude Console账户 + const consoleAccounts = await claudeConsoleAccountService.getAllAccounts() + logger.info(`📋 Found ${consoleAccounts.length} total Claude Console accounts`) + + // 🔢 统计Console账户并发排除情况 + let consoleAccountsEligibleCount = 0 // 符合基本条件的账户数 + let consoleAccountsExcludedByConcurrency = 0 // 因并发满额被排除的账户数 + + // 🚀 收集需要并发检查的账户ID列表(批量查询优化) + const accountsNeedingConcurrencyCheck = [] + + for (const account of consoleAccounts) { + // 主动检查封禁状态并尝试恢复(在过滤之前执行,确保可以恢复被封禁的账户) + const wasBlocked = await claudeConsoleAccountService.isAccountBlocked(account.id) + + // 如果账户之前被封禁但现在已恢复,重新获取最新状态 + let currentAccount = account + if (wasBlocked === false && account.status === 'account_blocked') { + // 可能刚刚被恢复,重新获取账户状态 + const freshAccount = await claudeConsoleAccountService.getAccount(account.id) + if (freshAccount) { + currentAccount = freshAccount + logger.info(`🔄 Account ${account.name} was recovered from blocked status`) + } + } + + // 主动检查配额超限状态并尝试恢复(在过滤之前执行,确保可以恢复配额超限的账户) + if (currentAccount.status === 'quota_exceeded') { + // 触发配额检查,如果已到重置时间会自动恢复账户 + const isStillExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded( + currentAccount.id + ) + if (!isStillExceeded) { + // 重新获取账户最新状态 + const refreshedAccount = await claudeConsoleAccountService.getAccount(currentAccount.id) + if (refreshedAccount) { + // 更新当前循环中的账户数据 + currentAccount = refreshedAccount + logger.info(`✅ Account ${currentAccount.name} recovered from quota_exceeded status`) + } + } + } + + logger.info( + `🔍 Checking Claude Console account: ${currentAccount.name} - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}` + ) + + // 注意:getAllAccounts返回的isActive是布尔值,getAccount返回的也是布尔值 + if ( + currentAccount.isActive === true && + currentAccount.status === 'active' && + currentAccount.accountType === 'shared' && + isSchedulable(currentAccount.schedulable) + ) { + // 检查是否可调度 + + // 检查模型支持 + if (!this._isModelSupportedByAccount(currentAccount, 'claude-console', requestedModel)) { + continue + } + + // 检查订阅是否过期 + if (claudeConsoleAccountService.isSubscriptionExpired(currentAccount)) { + logger.debug( + `⏰ Claude Console account ${currentAccount.name} (${currentAccount.id}) expired at ${currentAccount.subscriptionExpiresAt}` + ) + continue + } + + // 主动触发一次额度检查,确保状态即时生效 + try { + await claudeConsoleAccountService.checkQuotaUsage(currentAccount.id) + } catch (e) { + logger.warn( + `Failed to check quota for Claude Console account ${currentAccount.name}: ${e.message}` + ) + // 继续处理该账号 + } + + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + currentAccount.id, + 'claude-console' + ) + if (isTempUnavailable) { + logger.debug( + `⏭️ Skipping Claude Console account ${currentAccount.name} - temporarily unavailable` + ) + continue + } + + // 检查是否被限流 + const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( + currentAccount.id + ) + const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded( + currentAccount.id + ) + + // 🔢 记录符合基本条件的账户(通过了前面所有检查,但可能因并发被排除) + if (!isRateLimited && !isQuotaExceeded) { + consoleAccountsEligibleCount++ + // 🚀 将符合条件且需要并发检查的账户加入批量查询列表 + if (currentAccount.maxConcurrentTasks > 0) { + accountsNeedingConcurrencyCheck.push(currentAccount) + } else { + // 未配置并发限制的账户直接加入可用池 + availableAccounts.push({ + ...currentAccount, + accountId: currentAccount.id, + accountType: 'claude-console', + priority: parseInt(currentAccount.priority) || 50, + lastUsedAt: currentAccount.lastUsedAt || '0' + }) + logger.info( + `✅ Added Claude Console account to available pool: ${currentAccount.name} (priority: ${currentAccount.priority}, no concurrency limit)` + ) + } + } else { + if (isRateLimited) { + logger.warn(`⚠️ Claude Console account ${currentAccount.name} is rate limited`) + } + if (isQuotaExceeded) { + logger.warn(`💰 Claude Console account ${currentAccount.name} quota exceeded`) + } + } + } else { + logger.info( + `❌ Claude Console account ${currentAccount.name} not eligible - isActive: ${currentAccount.isActive}, status: ${currentAccount.status}, accountType: ${currentAccount.accountType}, schedulable: ${currentAccount.schedulable}` + ) + } + } + + // 🚀 批量查询所有账户的并发数(Promise.all 并行执行) + if (accountsNeedingConcurrencyCheck.length > 0) { + logger.debug( + `🚀 Batch checking concurrency for ${accountsNeedingConcurrencyCheck.length} accounts` + ) + + const concurrencyCheckPromises = accountsNeedingConcurrencyCheck.map((account) => + redis.getConsoleAccountConcurrency(account.id).then((currentConcurrency) => ({ + account, + currentConcurrency + })) + ) + + const concurrencyResults = await Promise.all(concurrencyCheckPromises) + + // 处理批量查询结果 + for (const { account, currentConcurrency } of concurrencyResults) { + const isConcurrencyFull = currentConcurrency >= account.maxConcurrentTasks + + if (!isConcurrencyFull) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'claude-console', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + logger.info( + `✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority}, concurrency: ${currentConcurrency}/${account.maxConcurrentTasks})` + ) + } else { + // 🔢 因并发满额被排除,计数器加1 + consoleAccountsExcludedByConcurrency++ + logger.warn( + `⚠️ Claude Console account ${account.name} reached concurrency limit: ${currentConcurrency}/${account.maxConcurrentTasks}` + ) + } + } + } + + // 获取Bedrock账户(共享池) + const bedrockAccountsResult = await bedrockAccountService.getAllAccounts() + if (bedrockAccountsResult.success) { + const bedrockAccounts = bedrockAccountsResult.data + logger.info(`📋 Found ${bedrockAccounts.length} total Bedrock accounts`) + + for (const account of bedrockAccounts) { + logger.info( + `🔍 Checking Bedrock account: ${account.name} - isActive: ${account.isActive}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + + if ( + account.isActive === true && + account.accountType === 'shared' && + isSchedulable(account.schedulable) + ) { + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + account.id, + 'bedrock' + ) + if (isTempUnavailable) { + logger.debug(`⏭️ Skipping Bedrock account ${account.name} - temporarily unavailable`) + continue + } + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'bedrock', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + logger.info( + `✅ Added Bedrock account to available pool: ${account.name} (priority: ${account.priority})` + ) + } else { + logger.info( + `❌ Bedrock account ${account.name} not eligible - isActive: ${account.isActive}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + } + } + } + + // 获取CCR账户(共享池)- 仅当明确要求包含时 + if (includeCcr) { + const ccrAccounts = await ccrAccountService.getAllAccounts() + logger.info(`📋 Found ${ccrAccounts.length} total CCR accounts`) + + for (const account of ccrAccounts) { + logger.info( + `🔍 Checking CCR account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + + if ( + account.isActive === true && + account.status === 'active' && + account.accountType === 'shared' && + isSchedulable(account.schedulable) + ) { + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) { + continue + } + + // 检查订阅是否过期 + if (ccrAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏰ CCR account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` + ) + continue + } + + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable(account.id, 'ccr') + if (isTempUnavailable) { + logger.debug(`⏭️ Skipping CCR account ${account.name} - temporarily unavailable`) + continue + } + + // 检查是否被限流 + const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id) + const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id) + + if (!isRateLimited && !isQuotaExceeded) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'ccr', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + logger.info( + `✅ Added CCR account to available pool: ${account.name} (priority: ${account.priority})` + ) + } else { + if (isRateLimited) { + logger.warn(`⚠️ CCR account ${account.name} is rate limited`) + } + if (isQuotaExceeded) { + logger.warn(`💰 CCR account ${account.name} quota exceeded`) + } + } + } else { + logger.info( + `❌ CCR account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + } + } + } + + logger.info( + `📊 Total available accounts: ${availableAccounts.length} (Claude: ${availableAccounts.filter((a) => a.accountType === 'claude-official').length}, Console: ${availableAccounts.filter((a) => a.accountType === 'claude-console').length}, Bedrock: ${availableAccounts.filter((a) => a.accountType === 'bedrock').length}, CCR: ${availableAccounts.filter((a) => a.accountType === 'ccr').length})` + ) + + // 🚨 最终检查:只有在没有任何可用账户时,才根据Console并发排除情况抛出专用错误码 + if (availableAccounts.length === 0) { + // 如果所有Console账户都因并发满额被排除,抛出专用错误码(503) + if ( + consoleAccountsEligibleCount > 0 && + consoleAccountsExcludedByConcurrency === consoleAccountsEligibleCount + ) { + logger.error( + `❌ All ${consoleAccountsEligibleCount} eligible Console accounts are at concurrency limit (no other account types available)` + ) + const error = new Error( + 'All available Claude Console accounts have reached their concurrency limit' + ) + error.code = 'CONSOLE_ACCOUNT_CONCURRENCY_FULL' + throw error + } + // 否则走通用的"无可用账户"错误处理(由上层 selectAccountForApiKey 捕获) + } + + return availableAccounts + } + + // 🔍 检查账户是否可用 + async _isAccountAvailable(accountId, accountType, requestedModel = null) { + try { + if (accountType === 'claude-official') { + const account = await redis.getClaudeAccount(accountId) + if ( + !account || + account.isActive !== 'true' || + account.status === 'error' || + account.status === 'temp_error' + ) { + return false + } + // 检查是否可调度 + if (!isSchedulable(account.schedulable)) { + logger.info(`🚫 Account ${accountId} is not schedulable`) + return false + } + + // 检查模型兼容性 + if ( + !this._isModelSupportedByAccount( + account, + 'claude-official', + requestedModel, + 'in session check' + ) + ) { + return false + } + + // 检查是否临时不可用 + if (await this.isAccountTemporarilyUnavailable(accountId, 'claude-official')) { + return false + } + + // 检查是否限流或过载 + const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId) + const isOverloaded = await claudeAccountService.isAccountOverloaded(accountId) + if (isRateLimited || isOverloaded) { + return false + } + + const sessionModelFamily = getRateLimitModelFamily(requestedModel) + if (sessionModelFamily) { + const isModelRateLimited = await claudeAccountService.isAccountModelRateLimited( + accountId, + sessionModelFamily + ) + if (isModelRateLimited) { + logger.info( + `🚫 Account ${accountId} skipped due to active ${sessionModelFamily} limit (session check)` + ) + return false + } + } + + return true + } else if (accountType === 'claude-console') { + const account = await claudeConsoleAccountService.getAccount(accountId) + if (!account || !account.isActive) { + return false + } + // 检查账户状态 + if ( + account.status !== 'active' && + account.status !== 'unauthorized' && + account.status !== 'overloaded' + ) { + return false + } + // 检查是否可调度 + if (!isSchedulable(account.schedulable)) { + logger.info(`🚫 Claude Console account ${accountId} is not schedulable`) + return false + } + // 检查模型支持 + if ( + !this._isModelSupportedByAccount( + account, + 'claude-console', + requestedModel, + 'in session check' + ) + ) { + return false + } + // 检查订阅是否过期 + if (claudeConsoleAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏰ Claude Console account ${account.name} (${accountId}) expired at ${account.subscriptionExpiresAt} (session check)` + ) + return false + } + // 检查是否超额 + try { + await claudeConsoleAccountService.checkQuotaUsage(accountId) + } catch (e) { + logger.warn(`Failed to check quota for Claude Console account ${accountId}: ${e.message}`) + // 继续处理 + } + + // 检查是否临时不可用 + if (await this.isAccountTemporarilyUnavailable(accountId, 'claude-console')) { + return false + } + + // 检查是否被限流 + if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) { + return false + } + if (await claudeConsoleAccountService.isAccountQuotaExceeded(accountId)) { + return false + } + // 检查是否未授权(401错误) + if (account.status === 'unauthorized') { + return false + } + // 检查是否过载(529错误) + if (await claudeConsoleAccountService.isAccountOverloaded(accountId)) { + return false + } + + // 检查并发限制(预检查,真正的原子抢占在 relayService 中进行) + if (account.maxConcurrentTasks > 0) { + const currentConcurrency = await redis.getConsoleAccountConcurrency(accountId) + if (currentConcurrency >= account.maxConcurrentTasks) { + logger.info( + `🚫 Claude Console account ${accountId} reached concurrency limit: ${currentConcurrency}/${account.maxConcurrentTasks} (pre-check)` + ) + return false + } + } + + return true + } else if (accountType === 'bedrock') { + const accountResult = await bedrockAccountService.getAccount(accountId) + if (!accountResult.success || !accountResult.data.isActive) { + return false + } + // 检查是否可调度 + if (!isSchedulable(accountResult.data.schedulable)) { + logger.info(`🚫 Bedrock account ${accountId} is not schedulable`) + return false + } + // 检查是否临时不可用 + if (await this.isAccountTemporarilyUnavailable(accountId, 'bedrock')) { + return false + } + + // Bedrock账户暂不需要限流检查,因为AWS管理限流 + return true + } else if (accountType === 'ccr') { + const account = await ccrAccountService.getAccount(accountId) + if (!account || !account.isActive) { + return false + } + // 检查账户状态 + if ( + account.status !== 'active' && + account.status !== 'unauthorized' && + account.status !== 'overloaded' + ) { + return false + } + // 检查是否可调度 + if (!isSchedulable(account.schedulable)) { + logger.info(`🚫 CCR account ${accountId} is not schedulable`) + return false + } + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel, 'in session check')) { + return false + } + // 检查订阅是否过期 + if (ccrAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏰ CCR account ${account.name} (${accountId}) expired at ${account.subscriptionExpiresAt} (session check)` + ) + return false + } + // 检查是否超额 + try { + await ccrAccountService.checkQuotaUsage(accountId) + } catch (e) { + logger.warn(`Failed to check quota for CCR account ${accountId}: ${e.message}`) + // 继续处理 + } + + // 检查是否临时不可用 + if (await this.isAccountTemporarilyUnavailable(accountId, 'ccr')) { + return false + } + + // 检查是否被限流 + if (await ccrAccountService.isAccountRateLimited(accountId)) { + return false + } + if (await ccrAccountService.isAccountQuotaExceeded(accountId)) { + return false + } + // 检查是否未授权(401错误) + if (account.status === 'unauthorized') { + return false + } + // 检查是否过载(529错误) + if (await ccrAccountService.isAccountOverloaded(accountId)) { + return false + } + return true + } + return false + } catch (error) { + logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error) + return false + } + } + + // 🔗 获取会话映射 + async _getSessionMapping(sessionHash) { + const client = redis.getClientSafe() + const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + + if (mappingData) { + try { + return JSON.parse(mappingData) + } catch (error) { + logger.warn('⚠️ Failed to parse session mapping:', error) + return null + } + } + + return null + } + + // 💾 设置会话映射 + async _setSessionMapping(sessionHash, accountId, accountType) { + const client = redis.getClientSafe() + const mappingData = JSON.stringify({ accountId, accountType }) + // 依据配置设置TTL(小时) + const appConfig = require('../../../config/config') + const ttlHours = appConfig.session?.stickyTtlHours || 1 + const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60)) + await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData) + } + + // 🗑️ 删除会话映射 + async _deleteSessionMapping(sessionHash) { + const client = redis.getClientSafe() + await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + } + + /** + * 🧹 公共方法:清理粘性会话映射(用于并发满额时的降级处理) + * @param {string} sessionHash - 会话哈希值 + */ + async clearSessionMapping(sessionHash) { + // 防御空会话哈希 + if (!sessionHash || typeof sessionHash !== 'string') { + logger.debug('⚠️ Skipping session mapping clear - invalid sessionHash') + return + } + + try { + await this._deleteSessionMapping(sessionHash) + logger.info( + `🧹 Cleared sticky session mapping for session: ${sessionHash.substring(0, 8)}...` + ) + } catch (error) { + logger.error(`❌ Failed to clear session mapping for ${sessionHash}:`, error) + throw error + } + } + + // 🔁 续期统一调度会话映射TTL(针对 unified_claude_session_mapping:* 键),遵循会话配置 + async _extendSessionMappingTTL(sessionHash) { + try { + const client = redis.getClientSafe() + const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + const remainingTTL = await client.ttl(key) + + // -2: key 不存在;-1: 无过期时间 + if (remainingTTL === -2) { + return false + } + if (remainingTTL === -1) { + return true + } + + const appConfig = require('../../../config/config') + const ttlHours = appConfig.session?.stickyTtlHours || 1 + const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 + + // 阈值为0则不续期 + if (!renewalThresholdMinutes) { + return true + } + + const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60)) + const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60)) + + if (remainingTTL < threshold) { + await client.expire(key, fullTTL) + logger.debug( + `🔄 Renewed unified session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)` + ) + } else { + logger.debug( + `✅ Unified session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)` + ) + } + return true + } catch (error) { + logger.error('❌ Failed to extend unified session TTL:', error) + return false + } + } + + // ⏱️ 标记账户为临时不可用状态(用于5xx等临时故障,默认5分钟后自动恢复) + async markAccountTemporarilyUnavailable( + accountId, + accountType, + sessionHash = null, + ttlSeconds = null, + statusCode = 500 + ) { + try { + await upstreamErrorHelper.markTempUnavailable(accountId, accountType, statusCode, ttlSeconds) + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error) + return { success: false } + } + } + + // 🔍 检查账户是否临时不可用 + async isAccountTemporarilyUnavailable(accountId, accountType) { + return upstreamErrorHelper.isTempUnavailable(accountId, accountType) + } + + // 🚫 标记账户为限流状态 + async markAccountRateLimited( + accountId, + accountType, + sessionHash = null, + rateLimitResetTimestamp = null + ) { + try { + if (accountType === 'claude-official') { + await claudeAccountService.markAccountRateLimited( + accountId, + sessionHash, + rateLimitResetTimestamp + ) + } else if (accountType === 'claude-console') { + await claudeConsoleAccountService.markAccountRateLimited(accountId) + } else if (accountType === 'ccr') { + await ccrAccountService.markAccountRateLimited(accountId) + } + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // ✅ 移除账户的限流状态 + async removeAccountRateLimit(accountId, accountType) { + try { + if (accountType === 'claude-official') { + await claudeAccountService.removeAccountRateLimit(accountId) + } else if (accountType === 'claude-console') { + await claudeConsoleAccountService.removeAccountRateLimit(accountId) + } else if (accountType === 'ccr') { + await ccrAccountService.removeAccountRateLimit(accountId) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // 🔍 检查账户是否处于限流状态 + async isAccountRateLimited(accountId, accountType) { + try { + if (accountType === 'claude-official') { + return await claudeAccountService.isAccountRateLimited(accountId) + } else if (accountType === 'claude-console') { + return await claudeConsoleAccountService.isAccountRateLimited(accountId) + } else if (accountType === 'ccr') { + return await ccrAccountService.isAccountRateLimited(accountId) + } + return false + } catch (error) { + logger.error(`❌ Failed to check rate limit status: ${accountId} (${accountType})`, error) + return false + } + } + + // 🚫 标记账户为未授权状态(401错误) + async markAccountUnauthorized(accountId, accountType, sessionHash = null) { + try { + // 只处理claude-official类型的账户,不处理claude-console和gemini + if (accountType === 'claude-official') { + await claudeAccountService.markAccountUnauthorized(accountId, sessionHash) + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + logger.warn(`🚫 Account ${accountId} marked as unauthorized due to consecutive 401 errors`) + } else { + logger.info( + `ℹ️ Skipping unauthorized marking for non-Claude OAuth account: ${accountId} (${accountType})` + ) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as unauthorized: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // 🚫 标记账户为被封锁状态(403错误) + async markAccountBlocked(accountId, accountType, sessionHash = null) { + try { + // 只处理claude-official类型的账户,不处理claude-console和gemini + if (accountType === 'claude-official') { + await claudeAccountService.markAccountBlocked(accountId, sessionHash) + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + logger.warn(`🚫 Account ${accountId} marked as blocked due to 403 error`) + } else { + logger.info( + `ℹ️ Skipping blocked marking for non-Claude OAuth account: ${accountId} (${accountType})` + ) + } + + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account as blocked: ${accountId} (${accountType})`, error) + throw error + } + } + + // 🚫 标记Claude Console账户为封锁状态(模型不支持) + async blockConsoleAccount(accountId, reason) { + try { + await claudeConsoleAccountService.blockAccount(accountId, reason) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to block console account: ${accountId}`, error) + throw error + } + } + + // 👥 从分组中选择账户 + async selectAccountFromGroup( + groupId, + sessionHash = null, + requestedModel = null, + allowCcr = false + ) { + try { + // 获取分组信息 + const group = await accountGroupService.getGroup(groupId) + if (!group) { + throw new Error(`Group ${groupId} not found`) + } + + logger.info(`👥 Selecting account from group: ${group.name} (${group.platform})`) + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 验证映射的账户是否属于这个分组 + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (memberIds.includes(mappedAccount.accountId)) { + // 非 CCR 请求时不允许 CCR 粘性映射 + if (!allowCcr && mappedAccount.accountType === 'ccr') { + await this._deleteSessionMapping(sessionHash) + } else { + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType, + requestedModel + ) + if (isAvailable) { + // 🚀 智能会话续期:续期 unified 映射键 + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + return mappedAccount + } + } + } + // 如果映射的账户不可用或不在分组中,删除映射 + await this._deleteSessionMapping(sessionHash) + } + } + + // 获取分组内的所有账户 + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (memberIds.length === 0) { + throw new Error(`Group ${group.name} has no members`) + } + + const availableAccounts = [] + // 请求模型所属的限流家族(opus/sonnet/haiku/fable) + const requestedModelFamily = getRateLimitModelFamily(requestedModel) + + // 获取所有成员账户的详细信息 + for (const memberId of memberIds) { + let account = null + let accountType = null + + // 根据平台类型获取账户 + if (group.platform === 'claude') { + // 先尝试官方账户 + account = await redis.getClaudeAccount(memberId) + if (account?.id) { + accountType = 'claude-official' + } else { + // 尝试Console账户 + account = await claudeConsoleAccountService.getAccount(memberId) + if (account) { + accountType = 'claude-console' + } else { + // 尝试CCR账户(仅允许在 allowCcr 为 true 时) + if (allowCcr) { + account = await ccrAccountService.getAccount(memberId) + if (account) { + accountType = 'ccr' + } + } + } + } + } else if (group.platform === 'gemini') { + // Gemini暂时不支持,预留接口 + logger.warn('⚠️ Gemini group scheduling not yet implemented') + continue + } + + if (!account) { + logger.warn(`⚠️ Account ${memberId} not found in group ${group.name}`) + continue + } + + // 检查账户是否可用 + const isActive = + accountType === 'claude-official' + ? account.isActive === 'true' + : account.isActive === true + + const status = + accountType === 'claude-official' + ? account.status !== 'error' && account.status !== 'blocked' + : accountType === 'ccr' + ? account.status === 'active' + : account.status === 'active' + + if (isActive && status && isSchedulable(account.schedulable)) { + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, accountType, requestedModel, 'in group')) { + continue + } + + // 检查是否临时不可用 + if (await this.isAccountTemporarilyUnavailable(account.id, accountType)) { + continue + } + + // 检查是否被限流 + const isRateLimited = await this.isAccountRateLimited(account.id, accountType) + if (isRateLimited) { + continue + } + + if (accountType === 'claude-official' && requestedModelFamily) { + const isModelRateLimited = await claudeAccountService.isAccountModelRateLimited( + account.id, + requestedModelFamily + ) + if (isModelRateLimited) { + logger.info( + `🚫 Skipping group member ${account.name} (${account.id}) due to active ${requestedModelFamily} limit` + ) + continue + } + } + + // 🔒 检查 Claude Console 账户的并发限制 + if (accountType === 'claude-console' && account.maxConcurrentTasks > 0) { + const currentConcurrency = await redis.getConsoleAccountConcurrency(account.id) + if (currentConcurrency >= account.maxConcurrentTasks) { + logger.info( + `🚫 Skipping group member ${account.name} (${account.id}) due to concurrency limit: ${currentConcurrency}/${account.maxConcurrentTasks}` + ) + continue + } + } + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType, + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + + if (availableAccounts.length === 0) { + throw new Error(`No available accounts in group ${group.name}`) + } + + // 使用现有的优先级排序逻辑 + const sortedAccounts = sortAccountsByPriority(availableAccounts) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected account from group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}` + ) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error(`❌ Failed to select account from group ${groupId}:`, error) + throw error + } + } + + // 🎯 专门选择CCR账户(仅限CCR前缀路由使用) + async _selectCcrAccount(apiKeyData, sessionHash = null, effectiveModel = null) { + try { + // 1. 检查会话粘性 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount && mappedAccount.accountType === 'ccr') { + // 验证映射的CCR账户是否仍然可用 + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType, + effectiveModel + ) + if (isAvailable) { + // 🚀 智能会话续期:续期 unified 映射键 + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky CCR session account: ${mappedAccount.accountId} for session ${sessionHash}` + ) + return mappedAccount + } else { + logger.warn( + `⚠️ Mapped CCR account ${mappedAccount.accountId} is no longer available, selecting new account` + ) + await this._deleteSessionMapping(sessionHash) + } + } + } + + // 2. 获取所有可用的CCR账户 + const availableCcrAccounts = await this._getAvailableCcrAccounts(effectiveModel) + + if (availableCcrAccounts.length === 0) { + throw new Error( + `No available CCR accounts support the requested model: ${effectiveModel || 'unspecified'}` + ) + } + + // 3. 按优先级和最后使用时间排序 + const sortedAccounts = sortAccountsByPriority(availableCcrAccounts) + const selectedAccount = sortedAccounts[0] + + // 4. 建立会话映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky CCR session mapping: ${selectedAccount.name} (${selectedAccount.accountId}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected CCR account: ${selectedAccount.name} (${selectedAccount.accountId}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}` + ) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error('❌ Failed to select CCR account:', error) + throw error + } + } + + // 📋 获取所有可用的CCR账户 + async _getAvailableCcrAccounts(requestedModel = null) { + const availableAccounts = [] + + try { + const ccrAccounts = await ccrAccountService.getAllAccounts() + logger.info(`📋 Found ${ccrAccounts.length} total CCR accounts for CCR-only selection`) + + for (const account of ccrAccounts) { + logger.debug( + `🔍 Checking CCR account: ${account.name} - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + + if ( + account.isActive === true && + account.status === 'active' && + account.accountType === 'shared' && + isSchedulable(account.schedulable) + ) { + // 检查模型支持 + if (!this._isModelSupportedByAccount(account, 'ccr', requestedModel)) { + logger.debug(`CCR account ${account.name} does not support model ${requestedModel}`) + continue + } + + // 检查订阅是否过期 + if (ccrAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏰ CCR account ${account.name} (${account.id}) expired at ${account.subscriptionExpiresAt}` + ) + continue + } + + // 检查是否临时不可用 + if (await this.isAccountTemporarilyUnavailable(account.id, 'ccr')) { + continue + } + + // 检查是否被限流或超额 + const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id) + const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id) + const isOverloaded = await ccrAccountService.isAccountOverloaded(account.id) + + if (!isRateLimited && !isQuotaExceeded && !isOverloaded) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'ccr', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + logger.debug(`✅ Added CCR account to available pool: ${account.name}`) + } else { + logger.debug( + `❌ CCR account ${account.name} not available - rateLimited: ${isRateLimited}, quotaExceeded: ${isQuotaExceeded}, overloaded: ${isOverloaded}` + ) + } + } else { + logger.debug( + `❌ CCR account ${account.name} not eligible - isActive: ${account.isActive}, status: ${account.status}, accountType: ${account.accountType}, schedulable: ${account.schedulable}` + ) + } + } + + logger.info(`📊 Total available CCR accounts: ${availableAccounts.length}`) + return availableAccounts + } catch (error) { + logger.error('❌ Failed to get available CCR accounts:', error) + return [] + } + } + + /** + * 🔒 检查 claude-official 账户是否可用于会话绑定 + * 注意:此方法仅用于 claude-official 类型账户,其他类型不受会话绑定限制 + * @param {string} accountId - 账户ID + * @param {string} accountType - 账户类型(应为 'claude-official') + * @param {string} _requestedModel - 请求的模型(保留参数,当前未使用) + * @returns {Promise} + */ + async _isAccountAvailableForSessionBinding(accountId, accountType, _requestedModel = null) { + try { + // 此方法仅处理 claude-official 类型 + if (accountType !== 'claude-official') { + logger.warn( + `Session binding: _isAccountAvailableForSessionBinding called for non-official type: ${accountType}` + ) + return true // 非 claude-official 类型不受限制 + } + + const account = await redis.getClaudeAccount(accountId) + if (!account) { + logger.warn(`Session binding: Claude OAuth account ${accountId} not found`) + return false + } + + const isActive = account.isActive === 'true' || account.isActive === true + const { status } = account + + if (!isActive) { + logger.warn(`Session binding: Claude OAuth account ${accountId} is not active`) + return false + } + + if (status === 'error' || status === 'temp_error') { + logger.warn( + `Session binding: Claude OAuth account ${accountId} has error status: ${status}` + ) + return false + } + + // 检查是否被限流 + if (await claudeAccountService.isAccountRateLimited(accountId)) { + logger.warn(`Session binding: Claude OAuth account ${accountId} is rate limited`) + return false + } + + // 检查临时不可用 + if (await this.isAccountTemporarilyUnavailable(accountId, accountType)) { + logger.warn(`Session binding: Claude OAuth account ${accountId} is temporarily unavailable`) + return false + } + + return true + } catch (error) { + logger.error( + `❌ Error checking account availability for session binding: ${accountId} (${accountType})`, + error + ) + return false + } + } +} + +module.exports = new UnifiedClaudeScheduler() diff --git a/src/services/scheduler/unifiedGeminiScheduler.js b/src/services/scheduler/unifiedGeminiScheduler.js new file mode 100644 index 0000000..80f1bc7 --- /dev/null +++ b/src/services/scheduler/unifiedGeminiScheduler.js @@ -0,0 +1,867 @@ +const geminiAccountService = require('../account/geminiAccountService') +const geminiApiAccountService = require('../account/geminiApiAccountService') +const accountGroupService = require('../accountGroupService') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const { isSchedulable, isActive, sortAccountsByPriority } = require('../../utils/commonHelper') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +const OAUTH_PROVIDER_GEMINI_CLI = 'gemini-cli' +const OAUTH_PROVIDER_ANTIGRAVITY = 'antigravity' +const KNOWN_OAUTH_PROVIDERS = [OAUTH_PROVIDER_GEMINI_CLI, OAUTH_PROVIDER_ANTIGRAVITY] + +function normalizeOauthProvider(oauthProvider) { + if (!oauthProvider) { + return OAUTH_PROVIDER_GEMINI_CLI + } + return oauthProvider === OAUTH_PROVIDER_ANTIGRAVITY + ? OAUTH_PROVIDER_ANTIGRAVITY + : OAUTH_PROVIDER_GEMINI_CLI +} + +class UnifiedGeminiScheduler { + constructor() { + this.SESSION_MAPPING_PREFIX = 'unified_gemini_session_mapping:' + } + + _getSessionMappingKey(sessionHash, oauthProvider = null) { + if (!sessionHash) { + return null + } + if (!oauthProvider) { + return `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + } + const normalized = normalizeOauthProvider(oauthProvider) + return `${this.SESSION_MAPPING_PREFIX}${normalized}:${sessionHash}` + } + + // 🔧 辅助方法:检查账户是否可调度(兼容字符串和布尔值) + _isSchedulable(schedulable) { + // 如果是 undefined 或 null,默认为可调度 + if (schedulable === undefined || schedulable === null) { + return true + } + // 明确设置为 false(布尔值)或 'false'(字符串)时不可调度 + return schedulable !== false && schedulable !== 'false' + } + + // 🔧 辅助方法:检查账户是否激活(兼容字符串和布尔值) + _isActive(activeValue) { + // 兼容布尔值 true 和字符串 'true' + return activeValue === true || activeValue === 'true' + } + + // 🎯 统一调度Gemini账号 + async selectAccountForApiKey( + apiKeyData, + sessionHash = null, + requestedModel = null, + options = {} + ) { + const { allowApiAccounts = false, oauthProvider = null } = options + const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null + + try { + // 如果API Key绑定了专属账户或分组,优先使用 + if (apiKeyData.geminiAccountId) { + // 检查是否是 Gemini API 账户(api: 前缀) + if (apiKeyData.geminiAccountId.startsWith('api:')) { + const accountId = apiKeyData.geminiAccountId.replace('api:', '') + const boundAccount = await geminiApiAccountService.getAccount(accountId) + if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') { + logger.info( + `🎯 Using bound Gemini-API account: ${boundAccount.name} (${accountId}) for API key ${apiKeyData.name}` + ) + // 更新账户的最后使用时间 + await geminiApiAccountService.markAccountUsed(accountId) + return { + accountId, + accountType: 'gemini-api' + } + } else { + // 提供详细的不可用原因 + const reason = !boundAccount + ? 'account not found' + : boundAccount.isActive !== 'true' + ? `isActive=${boundAccount.isActive}` + : `status=${boundAccount.status}` + logger.warn( + `⚠️ Bound Gemini-API account ${accountId} is not available (${reason}), falling back to pool` + ) + } + } + // 检查是否是分组 + else if (apiKeyData.geminiAccountId.startsWith('group:')) { + const groupId = apiKeyData.geminiAccountId.replace('group:', '') + logger.info( + `🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group` + ) + return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData) + } + // 普通 Gemini OAuth 专属账户 + else { + const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId) + if ( + boundAccount && + this._isActive(boundAccount.isActive) && + boundAccount.status !== 'error' + ) { + if ( + normalizedOauthProvider && + normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider + ) { + logger.warn( + `⚠️ Bound Gemini OAuth account ${boundAccount.name} oauthProvider=${normalizeOauthProvider(boundAccount.oauthProvider)} does not match requested oauthProvider=${normalizedOauthProvider}, falling back to pool` + ) + } else { + logger.info( + `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId}) for API key ${apiKeyData.name}` + ) + // 更新账户的最后使用时间 + await geminiAccountService.markAccountUsed(apiKeyData.geminiAccountId) + return { + accountId: apiKeyData.geminiAccountId, + accountType: 'gemini' + } + } + } else { + logger.warn( + `⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available, falling back to pool` + ) + } + } + } + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash, normalizedOauthProvider) + if (mappedAccount) { + // 验证映射的账户是否仍然可用 + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) + if (isAvailable) { + // 🚀 智能会话续期(续期 unified 映射键,按配置) + await this._extendSessionMappingTTL(sessionHash, normalizedOauthProvider) + logger.info( + `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + // 更新账户的最后使用时间(根据账户类型调用正确的服务) + if (mappedAccount.accountType === 'gemini-api') { + await geminiApiAccountService.markAccountUsed(mappedAccount.accountId) + } else { + await geminiAccountService.markAccountUsed(mappedAccount.accountId) + } + return mappedAccount + } else { + logger.warn( + `⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account` + ) + await this._deleteSessionMapping(sessionHash) + } + } + } + + // 获取所有可用账户 + const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel, { + allowApiAccounts, + oauthProvider: normalizedOauthProvider + }) + + if (availableAccounts.length === 0) { + // 提供更详细的错误信息 + if (requestedModel) { + throw new Error( + `No available Gemini accounts support the requested model: ${requestedModel}` + ) + } else { + throw new Error('No available Gemini accounts') + } + } + + // 按优先级和最后使用时间排序 + const sortedAccounts = sortAccountsByPriority(availableAccounts) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType, + normalizedOauthProvider + ) + logger.info( + `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}` + ) + + // 更新账户的最后使用时间(根据账户类型调用正确的服务) + if (selectedAccount.accountType === 'gemini-api') { + await geminiApiAccountService.markAccountUsed(selectedAccount.accountId) + } else { + await geminiAccountService.markAccountUsed(selectedAccount.accountId) + } + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error('❌ Failed to select account for API key:', error) + throw error + } + } + + // 📋 获取所有可用账户 + async _getAllAvailableAccounts( + apiKeyData, + requestedModel = null, + allowApiAccountsOrOptions = false + ) { + const options = + allowApiAccountsOrOptions && typeof allowApiAccountsOrOptions === 'object' + ? allowApiAccountsOrOptions + : { allowApiAccounts: allowApiAccountsOrOptions } + const { allowApiAccounts = false, oauthProvider = null } = options + const normalizedOauthProvider = oauthProvider ? normalizeOauthProvider(oauthProvider) : null + + const availableAccounts = [] + + // 如果API Key绑定了专属账户,优先返回 + if (apiKeyData.geminiAccountId) { + // 检查是否是 Gemini API 账户(api: 前缀) + if (apiKeyData.geminiAccountId.startsWith('api:')) { + const accountId = apiKeyData.geminiAccountId.replace('api:', '') + const boundAccount = await geminiApiAccountService.getAccount(accountId) + if (boundAccount && isActive(boundAccount.isActive) && boundAccount.status !== 'error') { + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + accountId, + 'gemini-api' + ) + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound Gemini-API account ${boundAccount.name} (${accountId}) temporarily unavailable, falling back to pool` + ) + } + const isRateLimited = await this.isAccountRateLimited(accountId) + if (!isRateLimited && !isTempUnavailable) { + // 检查模型支持 + if ( + requestedModel && + boundAccount.supportedModels && + boundAccount.supportedModels.length > 0 + ) { + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = boundAccount.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) + if (!modelSupported) { + logger.warn( + `⚠️ Bound Gemini-API account ${boundAccount.name} does not support model ${requestedModel}` + ) + return availableAccounts + } + } + + logger.info(`🎯 Using bound Gemini-API account: ${boundAccount.name} (${accountId})`) + return [ + { + ...boundAccount, + accountId, + accountType: 'gemini-api', + priority: parseInt(boundAccount.priority) || 50, + lastUsedAt: boundAccount.lastUsedAt || '0' + } + ] + } + } else { + // 提供详细的不可用原因 + const reason = !boundAccount + ? 'account not found' + : boundAccount.isActive !== 'true' + ? `isActive=${boundAccount.isActive}` + : `status=${boundAccount.status}` + logger.warn( + `⚠️ Bound Gemini-API account ${accountId} is not available in _getAllAvailableAccounts (${reason})` + ) + } + } + // 普通 Gemini OAuth 账户 + else if (!apiKeyData.geminiAccountId.startsWith('group:')) { + const boundAccount = await geminiAccountService.getAccount(apiKeyData.geminiAccountId) + if ( + boundAccount && + this._isActive(boundAccount.isActive) && + boundAccount.status !== 'error' + ) { + if ( + normalizedOauthProvider && + normalizeOauthProvider(boundAccount.oauthProvider) !== normalizedOauthProvider + ) { + return availableAccounts + } + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + boundAccount.id, + 'gemini' + ) + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound Gemini account ${boundAccount.name} (${boundAccount.id}) temporarily unavailable, falling back to pool` + ) + } + const isRateLimited = await this.isAccountRateLimited(boundAccount.id) + if (!isRateLimited && !isTempUnavailable) { + // 检查模型支持 + if ( + requestedModel && + boundAccount.supportedModels && + boundAccount.supportedModels.length > 0 + ) { + // 处理可能带有 models/ 前缀的模型名 + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = boundAccount.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) + if (!modelSupported) { + logger.warn( + `⚠️ Bound Gemini account ${boundAccount.name} does not support model ${requestedModel}` + ) + return availableAccounts + } + } + + logger.info( + `🎯 Using bound dedicated Gemini account: ${boundAccount.name} (${apiKeyData.geminiAccountId})` + ) + return [ + { + ...boundAccount, + accountId: boundAccount.id, + accountType: 'gemini', + priority: parseInt(boundAccount.priority) || 50, + lastUsedAt: boundAccount.lastUsedAt || '0' + } + ] + } + } else { + logger.warn(`⚠️ Bound Gemini account ${apiKeyData.geminiAccountId} is not available`) + } + } + } + + // 获取所有Gemini OAuth账户(共享池) + const geminiAccounts = await geminiAccountService.getAllAccounts() + for (const account of geminiAccounts) { + if ( + isActive(account.isActive) && + account.status !== 'error' && + (account.accountType === 'shared' || !account.accountType) && // 兼容旧数据 + isSchedulable(account.schedulable) + ) { + if ( + normalizedOauthProvider && + normalizeOauthProvider(account.oauthProvider) !== normalizedOauthProvider + ) { + continue + } + // 检查是否可调度 + + // 检查token是否过期 + const isExpired = geminiAccountService.isTokenExpired(account) + if (isExpired && !account.refreshToken) { + logger.warn( + `⚠️ Gemini account ${account.name} token expired and no refresh token available` + ) + continue + } + + // 检查临时不可用 + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(account.id, 'gemini') + if (isTempUnavailable) { + logger.debug(`⏭️ Skipping Gemini account ${account.name} - temporarily unavailable`) + continue + } + + // 检查模型支持 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + // 处理可能带有 models/ 前缀的模型名 + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = account.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) + if (!modelSupported) { + logger.debug( + `⏭️ Skipping Gemini account ${account.name} - doesn't support model ${requestedModel}` + ) + continue + } + } + + // 检查是否被限流 + const isRateLimited = await this.isAccountRateLimited(account.id) + if (!isRateLimited) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'gemini', + priority: parseInt(account.priority) || 50, // 默认优先级50 + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + } + + // 如果允许调度 Gemini API 账户,则添加到可用列表 + if (allowApiAccounts) { + const geminiApiAccounts = await geminiApiAccountService.getAllAccounts() + for (const account of geminiApiAccounts) { + if ( + isActive(account.isActive) && + account.status !== 'error' && + (account.accountType === 'shared' || !account.accountType) && + isSchedulable(account.schedulable) + ) { + // 检查模型支持 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = account.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) + if (!modelSupported) { + logger.debug( + `⏭️ Skipping Gemini-API account ${account.name} - doesn't support model ${requestedModel}` + ) + continue + } + } + + // 检查临时不可用 + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + account.id, + 'gemini-api' + ) + if (isTempUnavailable) { + logger.debug(`⏭️ Skipping Gemini-API account ${account.name} - temporarily unavailable`) + continue + } + + // 检查是否被限流 + const isRateLimited = await this.isAccountRateLimited(account.id) + if (!isRateLimited) { + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'gemini-api', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + } + } + + logger.info( + `📊 Total available accounts: ${availableAccounts.length} (Gemini OAuth + ${allowApiAccounts ? 'Gemini API' : 'no API accounts'})` + ) + return availableAccounts + } + + // 🔍 检查账户是否可用 + async _isAccountAvailable(accountId, accountType) { + try { + if (accountType === 'gemini') { + const account = await geminiAccountService.getAccount(accountId) + if (!account || !isActive(account.isActive) || account.status === 'error') { + return false + } + // 检查是否可调度 + if (!isSchedulable(account.schedulable)) { + logger.info(`🚫 Gemini account ${accountId} is not schedulable`) + return false + } + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + accountId, + accountType + ) + if (isTempUnavailable) { + logger.info(`⏱️ Gemini account ${accountId} is temporarily unavailable`) + return false + } + return !(await this.isAccountRateLimited(accountId)) + } else if (accountType === 'gemini-api') { + const account = await geminiApiAccountService.getAccount(accountId) + if (!account || !isActive(account.isActive) || account.status === 'error') { + return false + } + // 检查是否可调度 + if (!isSchedulable(account.schedulable)) { + logger.info(`🚫 Gemini-API account ${accountId} is not schedulable`) + return false + } + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + accountId, + accountType + ) + if (isTempUnavailable) { + logger.info(`⏱️ Gemini account ${accountId} is temporarily unavailable`) + return false + } + return !(await this.isAccountRateLimited(accountId)) + } + return false + } catch (error) { + logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error) + return false + } + } + + // 🔗 获取会话映射 + async _getSessionMapping(sessionHash, oauthProvider = null) { + const client = redis.getClientSafe() + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + const mappingData = key ? await client.get(key) : null + + if (mappingData) { + try { + return JSON.parse(mappingData) + } catch (error) { + logger.warn('⚠️ Failed to parse session mapping:', error) + return null + } + } + + return null + } + + // 💾 设置会话映射 + async _setSessionMapping(sessionHash, accountId, accountType, oauthProvider = null) { + const client = redis.getClientSafe() + const mappingData = JSON.stringify({ accountId, accountType }) + // 依据配置设置TTL(小时) + const appConfig = require('../../../config/config') + const ttlHours = appConfig.session?.stickyTtlHours || 1 + const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60)) + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + if (!key) { + return + } + await client.setex(key, ttlSeconds, mappingData) + } + + // 🗑️ 删除会话映射 + async _deleteSessionMapping(sessionHash) { + const client = redis.getClientSafe() + if (!sessionHash) { + return + } + + const keys = [this._getSessionMappingKey(sessionHash)] + for (const provider of KNOWN_OAUTH_PROVIDERS) { + keys.push(this._getSessionMappingKey(sessionHash, provider)) + } + await client.del(keys.filter(Boolean)) + } + + // 🔁 续期统一调度会话映射TTL(针对 unified_gemini_session_mapping:* 键),遵循会话配置 + async _extendSessionMappingTTL(sessionHash, oauthProvider = null) { + try { + const client = redis.getClientSafe() + const key = this._getSessionMappingKey(sessionHash, oauthProvider) + if (!key) { + return false + } + const remainingTTL = await client.ttl(key) + + if (remainingTTL === -2) { + return false + } + if (remainingTTL === -1) { + return true + } + + const appConfig = require('../../../config/config') + const ttlHours = appConfig.session?.stickyTtlHours || 1 + const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 + if (!renewalThresholdMinutes) { + return true + } + + const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60)) + const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60)) + + if (remainingTTL < threshold) { + await client.expire(key, fullTTL) + logger.debug( + `🔄 Renewed unified Gemini session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)` + ) + } else { + logger.debug( + `✅ Unified Gemini session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)` + ) + } + return true + } catch (error) { + logger.error('❌ Failed to extend unified Gemini session TTL:', error) + return false + } + } + + // 🚫 标记账户为限流状态 + async markAccountRateLimited(accountId, accountType, sessionHash = null) { + try { + if (accountType === 'gemini') { + await geminiAccountService.setAccountRateLimited(accountId, true) + } else if (accountType === 'gemini-api') { + await geminiApiAccountService.setAccountRateLimited(accountId, true) + } + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // ✅ 移除账户的限流状态 + async removeAccountRateLimit(accountId, accountType) { + try { + if (accountType === 'gemini') { + await geminiAccountService.setAccountRateLimited(accountId, false) + } else if (accountType === 'gemini-api') { + await geminiApiAccountService.setAccountRateLimited(accountId, false) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // 🔍 检查账户是否处于限流状态 + async isAccountRateLimited(accountId, accountType = null) { + try { + let account = null + + // 如果指定了账户类型,直接使用对应服务 + if (accountType === 'gemini-api') { + account = await geminiApiAccountService.getAccount(accountId) + } else if (accountType === 'gemini') { + account = await geminiAccountService.getAccount(accountId) + } else { + // 未指定类型,先尝试 gemini,再尝试 gemini-api + account = await geminiAccountService.getAccount(accountId) + if (!account) { + account = await geminiApiAccountService.getAccount(accountId) + } + } + + if (!account) { + return false + } + + if (account.rateLimitStatus === 'limited' && account.rateLimitedAt) { + const limitedAt = new Date(account.rateLimitedAt).getTime() + const now = Date.now() + // 使用账户配置的限流时长,默认1小时 + const rateLimitDuration = parseInt(account.rateLimitDuration) || 60 + const limitDuration = rateLimitDuration * 60 * 1000 + + return now < limitedAt + limitDuration + } + return false + } catch (error) { + logger.error(`❌ Failed to check rate limit status: ${accountId}`, error) + return false + } + } + + // 👥 从分组中选择账户(支持 Gemini OAuth 和 Gemini API 两种账户类型) + async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) { + try { + // 获取分组信息 + const group = await accountGroupService.getGroup(groupId) + if (!group) { + throw new Error(`Group ${groupId} not found`) + } + + if (group.platform !== 'gemini') { + throw new Error(`Group ${group.name} is not a Gemini group`) + } + + logger.info(`👥 Selecting account from Gemini group: ${group.name}`) + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 验证映射的账户是否属于这个分组 + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (memberIds.includes(mappedAccount.accountId)) { + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) + if (isAvailable) { + // 🚀 智能会话续期(续期 unified 映射键,按配置) + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + // 更新账户的最后使用时间(根据账户类型调用正确的服务) + if (mappedAccount.accountType === 'gemini-api') { + await geminiApiAccountService.markAccountUsed(mappedAccount.accountId) + } else { + await geminiAccountService.markAccountUsed(mappedAccount.accountId) + } + return mappedAccount + } + } + // 如果映射的账户不可用或不在分组中,删除映射 + await this._deleteSessionMapping(sessionHash) + } + } + + // 获取分组内的所有账户 + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (memberIds.length === 0) { + throw new Error(`Group ${group.name} has no members`) + } + + const availableAccounts = [] + + // 获取所有成员账户的详细信息(支持 Gemini OAuth 和 Gemini API 两种类型) + for (const memberId of memberIds) { + // 首先尝试从 Gemini OAuth 账户服务获取 + let account = await geminiAccountService.getAccount(memberId) + let accountType = 'gemini' + + // 如果 Gemini OAuth 账户不存在,尝试从 Gemini API 账户服务获取 + if (!account) { + account = await geminiApiAccountService.getAccount(memberId) + accountType = 'gemini-api' + } + + if (!account) { + logger.warn(`⚠️ Gemini account ${memberId} not found in group ${group.name}`) + continue + } + + // 检查账户是否可用 + if ( + isActive(account.isActive) && + account.status !== 'error' && + isSchedulable(account.schedulable) + ) { + // 对于 Gemini OAuth 账户,检查 token 是否过期 + if (accountType === 'gemini') { + const isExpired = geminiAccountService.isTokenExpired(account) + if (isExpired && !account.refreshToken) { + logger.warn( + `⚠️ Gemini account ${account.name} in group token expired and no refresh token available` + ) + continue + } + } + + // 检查模型支持 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + // 处理可能带有 models/ 前缀的模型名 + const normalizedModel = requestedModel.replace('models/', '') + const modelSupported = account.supportedModels.some( + (model) => model.replace('models/', '') === normalizedModel + ) + if (!modelSupported) { + logger.debug( + `⏭️ Skipping ${accountType} account ${account.name} in group - doesn't support model ${requestedModel}` + ) + continue + } + } + + // 检查是否被限流 + const isRateLimited = await this.isAccountRateLimited(account.id, accountType) + if (!isRateLimited) { + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + account.id, + accountType + ) + if (isTempUnavailable) { + logger.debug(`⏭️ Skipping group member ${account.name} - temporarily unavailable`) + continue + } + availableAccounts.push({ + ...account, + accountId: account.id, + accountType, + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + } + + if (availableAccounts.length === 0) { + throw new Error(`No available accounts in Gemini group ${group.name}`) + } + + // 使用现有的优先级排序逻辑 + const sortedAccounts = sortAccountsByPriority(availableAccounts) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping in group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected account from Gemini group ${group.name}: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority}` + ) + + // 更新账户的最后使用时间(根据账户类型调用正确的服务) + if (selectedAccount.accountType === 'gemini-api') { + await geminiApiAccountService.markAccountUsed(selectedAccount.accountId) + } else { + await geminiAccountService.markAccountUsed(selectedAccount.accountId) + } + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error(`❌ Failed to select account from Gemini group ${groupId}:`, error) + throw error + } + } +} + +module.exports = new UnifiedGeminiScheduler() diff --git a/src/services/scheduler/unifiedOpenAIScheduler.js b/src/services/scheduler/unifiedOpenAIScheduler.js new file mode 100644 index 0000000..05fbd1b --- /dev/null +++ b/src/services/scheduler/unifiedOpenAIScheduler.js @@ -0,0 +1,1015 @@ +const openaiAccountService = require('../account/openaiAccountService') +const openaiResponsesAccountService = require('../account/openaiResponsesAccountService') +const accountGroupService = require('../accountGroupService') +const redis = require('../../models/redis') +const logger = require('../../utils/logger') +const { isSchedulable, sortAccountsByPriority } = require('../../utils/commonHelper') +const upstreamErrorHelper = require('../../utils/upstreamErrorHelper') + +class UnifiedOpenAIScheduler { + constructor() { + this.SESSION_MAPPING_PREFIX = 'unified_openai_session_mapping:' + } + + // 🔧 辅助方法:检查账户是否被限流(兼容字符串和对象格式) + _isRateLimited(rateLimitStatus) { + if (!rateLimitStatus) { + return false + } + + // 兼容字符串格式(Redis 原始数据) + if (typeof rateLimitStatus === 'string') { + return rateLimitStatus === 'limited' + } + + // 兼容对象格式(getAllAccounts 返回的数据) + if (typeof rateLimitStatus === 'object') { + if (rateLimitStatus.isRateLimited === false) { + return false + } + // 检查对象中的 status 字段 + return rateLimitStatus.status === 'limited' || rateLimitStatus.isRateLimited === true + } + + return false + } + + // 🔍 判断账号是否带有限流标记(即便已过期,用于自动恢复) + _hasRateLimitFlag(rateLimitStatus) { + if (!rateLimitStatus) { + return false + } + + if (typeof rateLimitStatus === 'string') { + return rateLimitStatus === 'limited' + } + + if (typeof rateLimitStatus === 'object') { + return rateLimitStatus.status === 'limited' || rateLimitStatus.isRateLimited === true + } + + return false + } + + // ✅ 确保账号在调度前完成限流恢复与 schedulable 校正 + async _ensureAccountReadyForScheduling(account, accountId, { sanitized = true } = {}) { + const hasRateLimitFlag = this._hasRateLimitFlag(account.rateLimitStatus) + let rateLimitChecked = false + let stillLimited = false + + const accountSchedulable = isSchedulable(account.schedulable) + + if (!accountSchedulable) { + if (!hasRateLimitFlag) { + return { canUse: false, reason: 'not_schedulable' } + } + + stillLimited = await this.isAccountRateLimited(accountId) + rateLimitChecked = true + if (stillLimited) { + return { canUse: false, reason: 'rate_limited' } + } + + // 限流已恢复,矫正本地状态 + if (sanitized) { + account.schedulable = true + } else { + account.schedulable = 'true' + } + logger.info(`✅ OpenAI账号 ${account.name || accountId} 已解除限流,恢复调度权限`) + } + + if (hasRateLimitFlag) { + if (!rateLimitChecked) { + stillLimited = await this.isAccountRateLimited(accountId) + rateLimitChecked = true + } + if (stillLimited) { + return { canUse: false, reason: 'rate_limited' } + } + + // 更新本地限流状态,避免重复判定 + if (sanitized) { + account.rateLimitStatus = { + status: 'normal', + isRateLimited: false, + rateLimitedAt: null, + rateLimitResetAt: null, + minutesRemaining: 0 + } + } else { + account.rateLimitStatus = 'normal' + account.rateLimitedAt = null + account.rateLimitResetAt = null + } + + if (account.status === 'rateLimited') { + account.status = 'active' + } + } + + if (!rateLimitChecked) { + stillLimited = await this.isAccountRateLimited(accountId) + if (stillLimited) { + return { canUse: false, reason: 'rate_limited' } + } + } + + return { canUse: true } + } + + // 🎯 统一调度OpenAI账号 + async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) { + try { + // 如果API Key绑定了专属账户或分组,优先使用 + if (apiKeyData.openaiAccountId) { + // 检查是否是分组 + if (apiKeyData.openaiAccountId.startsWith('group:')) { + const groupId = apiKeyData.openaiAccountId.replace('group:', '') + logger.info( + `🎯 API key ${apiKeyData.name} is bound to group ${groupId}, selecting from group` + ) + return await this.selectAccountFromGroup(groupId, sessionHash, requestedModel, apiKeyData) + } + + // 普通专属账户 - 根据前缀判断是 OpenAI 还是 OpenAI-Responses 类型 + let boundAccount = null + let accountType = 'openai' + + // 检查是否有 responses: 前缀(用于区分 OpenAI-Responses 账户) + if (apiKeyData.openaiAccountId.startsWith('responses:')) { + const accountId = apiKeyData.openaiAccountId.replace('responses:', '') + boundAccount = await openaiResponsesAccountService.getAccount(accountId) + accountType = 'openai-responses' + } else { + // 普通 OpenAI 账户 + boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId) + accountType = 'openai' + } + + const isActiveBoundAccount = + boundAccount && + (boundAccount.isActive === true || boundAccount.isActive === 'true') && + boundAccount.status !== 'error' && + boundAccount.status !== 'unauthorized' + + if (isActiveBoundAccount) { + // 检查是否临时不可用 + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + boundAccount.id, + accountType + ) + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound ${accountType} account ${boundAccount.name} temporarily unavailable, falling back to pool` + ) + // 不 throw,让代码继续走到共享池选择 + } else { + if (accountType === 'openai') { + const readiness = await this._ensureAccountReadyForScheduling( + boundAccount, + boundAccount.id, + { sanitized: false } + ) + + if (!readiness.canUse) { + const isRateLimited = readiness.reason === 'rate_limited' + const errorMsg = isRateLimited + ? `Dedicated account ${boundAccount.name} is currently rate limited` + : `Dedicated account ${boundAccount.name} is not schedulable` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = isRateLimited ? 429 : 403 + throw error + } + } else { + const hasRateLimitFlag = this._isRateLimited(boundAccount.rateLimitStatus) + if (hasRateLimitFlag) { + const isRateLimitCleared = + await openaiResponsesAccountService.checkAndClearRateLimit(boundAccount.id) + if (!isRateLimitCleared) { + const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = 429 // Too Many Requests - 限流 + throw error + } + // 限流已解除,刷新账户最新状态,确保后续调度信息准确 + boundAccount = await openaiResponsesAccountService.getAccount(boundAccount.id) + if (!boundAccount) { + const errorMsg = `Dedicated account ${apiKeyData.openaiAccountId} not found after rate limit reset` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = 404 + throw error + } + } + + if (!isSchedulable(boundAccount.schedulable)) { + const errorMsg = `Dedicated account ${boundAccount.name} is not schedulable` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = 403 // Forbidden - 调度被禁止 + throw error + } + + // ⏰ 检查 OpenAI-Responses 专属账户订阅是否过期 + if (openaiResponsesAccountService.isSubscriptionExpired(boundAccount)) { + const errorMsg = `Dedicated account ${boundAccount.name} subscription has expired` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = 403 // Forbidden - 订阅已过期 + throw error + } + } + + // 专属账户:可选的模型检查(只有明确配置了supportedModels且不为空才检查) + // OpenAI-Responses 账户默认支持所有模型 + if ( + accountType === 'openai' && + requestedModel && + boundAccount.supportedModels && + boundAccount.supportedModels.length > 0 + ) { + const modelSupported = boundAccount.supportedModels.includes(requestedModel) + if (!modelSupported) { + const errorMsg = `Dedicated account ${boundAccount.name} does not support model ${requestedModel}` + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = 400 // Bad Request - 请求参数错误 + throw error + } + } + + logger.info( + `🎯 Using bound dedicated ${accountType} account: ${boundAccount.name} (${boundAccount.id}) for API key ${apiKeyData.name}` + ) + // 更新账户的最后使用时间 + await this.updateAccountLastUsed(boundAccount.id, accountType) + return { + accountId: boundAccount.id, + accountType + } + } + } else { + // 专属账户不可用时直接报错,不降级到共享池 + let errorMsg + if (!boundAccount) { + errorMsg = `Dedicated account ${apiKeyData.openaiAccountId} not found` + } else if (!(boundAccount.isActive === true || boundAccount.isActive === 'true')) { + errorMsg = `Dedicated account ${boundAccount.name} is not active` + } else if (boundAccount.status === 'unauthorized') { + errorMsg = `Dedicated account ${boundAccount.name} is unauthorized` + } else if (boundAccount.status === 'error') { + errorMsg = `Dedicated account ${boundAccount.name} is not available (error status)` + } else { + errorMsg = `Dedicated account ${boundAccount.name} is not available (inactive or forbidden)` + } + logger.warn(`⚠️ ${errorMsg}`) + const error = new Error(errorMsg) + error.statusCode = boundAccount ? 403 : 404 // Forbidden 或 Not Found + throw error + } + } + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 验证映射的账户是否仍然可用 + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) + if (isAvailable) { + // 🚀 智能会话续期(续期 unified 映射键,按配置) + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` + ) + // 更新账户的最后使用时间 + await this.updateAccountLastUsed(mappedAccount.accountId, mappedAccount.accountType) + return mappedAccount + } else { + logger.warn( + `⚠️ Mapped account ${mappedAccount.accountId} is no longer available, selecting new account` + ) + await this._deleteSessionMapping(sessionHash) + } + } + } + + // 获取所有可用账户 + const availableAccounts = await this._getAllAvailableAccounts(apiKeyData, requestedModel) + + if (availableAccounts.length === 0) { + // 提供更详细的错误信息 + if (requestedModel) { + const error = new Error( + `No available OpenAI accounts support the requested model: ${requestedModel}` + ) + error.statusCode = 400 // Bad Request - 模型不支持 + throw error + } else { + const error = new Error('No available OpenAI accounts') + error.statusCode = 402 // Payment Required - 资源耗尽 + throw error + } + } + + // 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致) + const sortedAccounts = sortAccountsByPriority(availableAccounts) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for session ${sessionHash}` + ) + } + + logger.info( + `🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}, priority: ${selectedAccount.priority || 50}) for API key ${apiKeyData.name}` + ) + + // 更新账户的最后使用时间 + await this.updateAccountLastUsed(selectedAccount.accountId, selectedAccount.accountType) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error('❌ Failed to select account for API key:', error) + throw error + } + } + + // 📋 获取所有可用账户(仅共享池) + async _getAllAvailableAccounts(apiKeyData, requestedModel = null) { + const availableAccounts = [] + + // 注意:专属账户的处理已经在 selectAccountForApiKey 中完成 + // 这里只处理共享池账户 + + // 获取所有OpenAI账户(共享池) + const openaiAccounts = await openaiAccountService.getAllAccounts() + for (let account of openaiAccounts) { + if ( + account.isActive && + account.status !== 'error' && + (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 + ) { + const accountId = account.id || account.accountId + + const readiness = await this._ensureAccountReadyForScheduling(account, accountId, { + sanitized: true + }) + + if (!readiness.canUse) { + if (readiness.reason === 'rate_limited') { + logger.debug(`⏭️ 跳过 OpenAI 账号 ${account.name} - 仍处于限流状态`) + } else { + logger.debug(`⏭️ 跳过 OpenAI 账号 ${account.name} - 已被管理员禁用调度`) + } + continue + } + + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable(accountId, 'openai') + if (isTempUnavailable) { + logger.debug(`⏭️ Skipping openai account ${account.name} - temporarily unavailable`) + continue + } + + // 检查token是否过期并自动刷新 + const isExpired = openaiAccountService.isTokenExpired(account) + if (isExpired) { + if (!account.refreshToken) { + logger.warn( + `⚠️ OpenAI account ${account.name} token expired and no refresh token available` + ) + continue + } + + // 自动刷新过期的 token + try { + logger.info(`🔄 Auto-refreshing expired token for OpenAI account ${account.name}`) + await openaiAccountService.refreshAccountToken(account.id) + // 重新获取更新后的账户信息 + account = await openaiAccountService.getAccount(account.id) + logger.info(`✅ Token refreshed successfully for ${account.name}`) + } catch (refreshError) { + logger.error(`❌ Failed to refresh token for ${account.name}:`, refreshError.message) + continue // 刷新失败,跳过此账户 + } + } + + // 检查模型支持(仅在明确设置了supportedModels且不为空时才检查) + // 如果没有设置supportedModels或为空数组,则支持所有模型 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + const modelSupported = account.supportedModels.includes(requestedModel) + if (!modelSupported) { + logger.debug( + `⏭️ Skipping OpenAI account ${account.name} - doesn't support model ${requestedModel}` + ) + continue + } + } + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'openai', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + + // 获取所有 OpenAI-Responses 账户(共享池) + const openaiResponsesAccounts = await openaiResponsesAccountService.getAllAccounts() + for (const account of openaiResponsesAccounts) { + if ( + (account.isActive === true || account.isActive === 'true') && + account.status !== 'error' && + (account.accountType === 'shared' || !account.accountType) + ) { + // 检查 rateLimitStatus 或 status === 'rateLimited' + const hasRateLimitFlag = + this._hasRateLimitFlag(account.rateLimitStatus) || account.status === 'rateLimited' + const schedulable = isSchedulable(account.schedulable) + + if (!schedulable && !hasRateLimitFlag) { + logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - not schedulable`) + continue + } + + let isRateLimitCleared = false + if (hasRateLimitFlag) { + // 区分正常限流和历史遗留数据 + if (this._hasRateLimitFlag(account.rateLimitStatus)) { + // 有 rateLimitStatus,走正常清理逻辑 + isRateLimitCleared = await openaiResponsesAccountService.checkAndClearRateLimit( + account.id + ) + } else { + // 只有 status=rateLimited 但没有 rateLimitStatus,是历史遗留数据,直接清除 + await openaiResponsesAccountService.updateAccount(account.id, { + status: 'active', + schedulable: 'true' + }) + isRateLimitCleared = true + logger.info( + `✅ OpenAI-Responses账号 ${account.name} 清除历史遗留限流状态(status=rateLimited 但无 rateLimitStatus)` + ) + } + + if (!isRateLimitCleared) { + logger.debug(`⏭️ Skipping OpenAI-Responses account ${account.name} - rate limited`) + continue + } + + if (!schedulable) { + account.schedulable = 'true' + account.status = 'active' + logger.info(`✅ OpenAI-Responses账号 ${account.name} 已解除限流,恢复调度权限`) + } + } + + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + account.id, + 'openai-responses' + ) + if (isTempUnavailable) { + logger.debug( + `⏭️ Skipping openai-responses account ${account.name} - temporarily unavailable` + ) + continue + } + + // ⏰ 检查订阅是否过期 + if (openaiResponsesAccountService.isSubscriptionExpired(account)) { + logger.debug( + `⏭️ Skipping OpenAI-Responses account ${account.name} - subscription expired` + ) + continue + } + + // OpenAI-Responses 账户默认支持所有模型 + // 因为它们是第三方兼容 API,模型支持由第三方决定 + + availableAccounts.push({ + ...account, + accountId: account.id, + accountType: 'openai-responses', + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + + return availableAccounts + } + + // 🔍 检查账户是否可用 + async _isAccountAvailable(accountId, accountType) { + try { + if (accountType === 'openai') { + const account = await openaiAccountService.getAccount(accountId) + if ( + !account || + !account.isActive || + account.status === 'error' || + account.status === 'unauthorized' + ) { + return false + } + const readiness = await this._ensureAccountReadyForScheduling(account, accountId, { + sanitized: false + }) + + if (!readiness.canUse) { + if (readiness.reason === 'rate_limited') { + logger.debug( + `🚫 OpenAI account ${accountId} still rate limited when checking availability` + ) + } else { + logger.info(`🚫 OpenAI account ${accountId} is not schedulable`) + } + return false + } + + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + accountId, + accountType + ) + if (isTempUnavailable) { + logger.info(`⏱️ OpenAI account ${accountId} (${accountType}) is temporarily unavailable`) + return false + } + + return true + } else if (accountType === 'openai-responses') { + const account = await openaiResponsesAccountService.getAccount(accountId) + if ( + !account || + (account.isActive !== true && account.isActive !== 'true') || + account.status === 'error' || + account.status === 'unauthorized' + ) { + return false + } + // 检查是否可调度 + if (!isSchedulable(account.schedulable)) { + logger.info(`🚫 OpenAI-Responses account ${accountId} is not schedulable`) + return false + } + // ⏰ 检查订阅是否过期 + if (openaiResponsesAccountService.isSubscriptionExpired(account)) { + logger.info(`🚫 OpenAI-Responses account ${accountId} subscription expired`) + return false + } + // 检查并清除过期的限流状态 + const isRateLimitCleared = + await openaiResponsesAccountService.checkAndClearRateLimit(accountId) + if (this._isRateLimited(account.rateLimitStatus) && !isRateLimitCleared) { + return false + } + + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + accountId, + accountType + ) + if (isTempUnavailable) { + logger.info(`⏱️ OpenAI account ${accountId} (${accountType}) is temporarily unavailable`) + return false + } + + return true + } + return false + } catch (error) { + logger.warn(`⚠️ Failed to check account availability: ${accountId}`, error) + return false + } + } + + // 🔗 获取会话映射 + async _getSessionMapping(sessionHash) { + const client = redis.getClientSafe() + const mappingData = await client.get(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + + if (mappingData) { + try { + return JSON.parse(mappingData) + } catch (error) { + logger.warn('⚠️ Failed to parse session mapping:', error) + return null + } + } + + return null + } + + // 💾 设置会话映射 + async _setSessionMapping(sessionHash, accountId, accountType) { + const client = redis.getClientSafe() + const mappingData = JSON.stringify({ accountId, accountType }) + // 依据配置设置TTL(小时) + const appConfig = require('../../../config/config') + const ttlHours = appConfig.session?.stickyTtlHours || 1 + const ttlSeconds = Math.max(1, Math.floor(ttlHours * 60 * 60)) + await client.setex(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`, ttlSeconds, mappingData) + } + + // 🗑️ 删除会话映射 + async _deleteSessionMapping(sessionHash) { + const client = redis.getClientSafe() + await client.del(`${this.SESSION_MAPPING_PREFIX}${sessionHash}`) + } + + // 🔁 续期统一调度会话映射TTL(针对 unified_openai_session_mapping:* 键),遵循会话配置 + async _extendSessionMappingTTL(sessionHash) { + try { + const client = redis.getClientSafe() + const key = `${this.SESSION_MAPPING_PREFIX}${sessionHash}` + const remainingTTL = await client.ttl(key) + + if (remainingTTL === -2) { + return false + } + if (remainingTTL === -1) { + return true + } + + const appConfig = require('../../../config/config') + const ttlHours = appConfig.session?.stickyTtlHours || 1 + const renewalThresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 + if (!renewalThresholdMinutes) { + return true + } + + const fullTTL = Math.max(1, Math.floor(ttlHours * 60 * 60)) + const threshold = Math.max(0, Math.floor(renewalThresholdMinutes * 60)) + + if (remainingTTL < threshold) { + await client.expire(key, fullTTL) + logger.debug( + `🔄 Renewed unified OpenAI session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}m, renewed to ${ttlHours}h)` + ) + } else { + logger.debug( + `✅ Unified OpenAI session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}m)` + ) + } + return true + } catch (error) { + logger.error('❌ Failed to extend unified OpenAI session TTL:', error) + return false + } + } + + // 🚫 标记账户为限流状态 + async markAccountRateLimited(accountId, accountType, sessionHash = null, resetsInSeconds = null) { + try { + if (accountType === 'openai') { + await openaiAccountService.setAccountRateLimited(accountId, true, resetsInSeconds) + } else if (accountType === 'openai-responses') { + // 对于 OpenAI-Responses 账户,使用与普通 OpenAI 账户类似的处理方式 + const account = await openaiResponsesAccountService.getAccount(accountId) + const duration = resetsInSeconds ? Math.ceil(resetsInSeconds / 60) : null + await openaiResponsesAccountService.markAccountRateLimited(accountId, duration) + + // 同时更新调度状态,避免继续被调度 + if (account?.disableAutoProtection !== true && account?.disableAutoProtection !== 'true') { + await openaiResponsesAccountService.updateAccount(accountId, { + schedulable: 'false', + rateLimitResetAt: resetsInSeconds + ? new Date(Date.now() + resetsInSeconds * 1000).toISOString() + : new Date(Date.now() + 3600000).toISOString() // 默认1小时 + }) + } + } + + // 删除会话映射 + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as rate limited: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // 🚫 标记账户为未授权状态 + async markAccountUnauthorized( + accountId, + accountType, + sessionHash = null, + reason = 'OpenAI账号认证失败(401错误)' + ) { + try { + if (accountType === 'openai') { + await openaiAccountService.markAccountUnauthorized(accountId, reason) + } else if (accountType === 'openai-responses') { + await openaiResponsesAccountService.markAccountUnauthorized(accountId, reason) + } else { + logger.warn( + `⚠️ Unsupported account type ${accountType} when marking unauthorized for account ${accountId}` + ) + return { success: false } + } + + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to mark account as unauthorized: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // ✅ 移除账户的限流状态 + async removeAccountRateLimit(accountId, accountType) { + try { + if (accountType === 'openai') { + await openaiAccountService.setAccountRateLimited(accountId, false) + } else if (accountType === 'openai-responses') { + // 清除 OpenAI-Responses 账户的限流状态 + await openaiResponsesAccountService.updateAccount(accountId, { + rateLimitedAt: '', + rateLimitStatus: '', + rateLimitResetAt: '', + status: 'active', + errorMessage: '', + schedulable: 'true' + }) + logger.info(`✅ Rate limit cleared for OpenAI-Responses account ${accountId}`) + } + + return { success: true } + } catch (error) { + logger.error( + `❌ Failed to remove rate limit for account: ${accountId} (${accountType})`, + error + ) + throw error + } + } + + // 🔍 检查账户是否处于限流状态 + async isAccountRateLimited(accountId) { + try { + const account = await openaiAccountService.getAccount(accountId) + if (!account) { + return false + } + + if (this._isRateLimited(account.rateLimitStatus)) { + // 如果有具体的重置时间,使用它 + if (account.rateLimitResetAt) { + const resetTime = new Date(account.rateLimitResetAt).getTime() + const now = Date.now() + const isStillLimited = now < resetTime + + // 如果已经过了重置时间,自动清除限流状态 + if (!isStillLimited) { + logger.info(`✅ Auto-clearing rate limit for account ${accountId} (reset time reached)`) + await openaiAccountService.setAccountRateLimited(accountId, false) + return false + } + + return isStillLimited + } + + // 如果没有具体的重置时间,使用默认的1小时 + if (account.rateLimitedAt) { + const limitedAt = new Date(account.rateLimitedAt).getTime() + const now = Date.now() + const limitDuration = 60 * 60 * 1000 // 1小时 + return now < limitedAt + limitDuration + } + } + return false + } catch (error) { + logger.error(`❌ Failed to check rate limit status: ${accountId}`, error) + return false + } + } + + // 👥 从分组中选择账户 + async selectAccountFromGroup(groupId, sessionHash = null, requestedModel = null) { + try { + // 获取分组信息 + const group = await accountGroupService.getGroup(groupId) + if (!group) { + const error = new Error(`Group ${groupId} not found`) + error.statusCode = 404 // Not Found - 资源不存在 + throw error + } + + if (group.platform !== 'openai') { + const error = new Error(`Group ${group.name} is not an OpenAI group`) + error.statusCode = 400 // Bad Request - 请求参数错误 + throw error + } + + logger.info(`👥 Selecting account from OpenAI group: ${group.name}`) + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccount = await this._getSessionMapping(sessionHash) + if (mappedAccount) { + // 验证映射的账户是否仍然可用并且在分组中 + const isInGroup = await this._isAccountInGroup(mappedAccount.accountId, groupId) + if (isInGroup) { + const isAvailable = await this._isAccountAvailable( + mappedAccount.accountId, + mappedAccount.accountType + ) + if (isAvailable) { + // 🚀 智能会话续期(续期 unified 映射键,按配置) + await this._extendSessionMappingTTL(sessionHash) + logger.info( + `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})` + ) + // 更新账户的最后使用时间 + await this.updateAccountLastUsed(mappedAccount.accountId, mappedAccount.accountType) + return mappedAccount + } + } + // 如果账户不可用或不在分组中,删除映射 + await this._deleteSessionMapping(sessionHash) + } + } + + // 获取分组成员 + const memberIds = await accountGroupService.getGroupMembers(groupId) + if (memberIds.length === 0) { + const error = new Error(`Group ${group.name} has no members`) + error.statusCode = 402 // Payment Required - 资源耗尽 + throw error + } + + // 获取可用的分组成员账户(支持 OpenAI 和 OpenAI-Responses 两种类型) + const availableAccounts = [] + for (const memberId of memberIds) { + // 首先尝试从 OpenAI 账户服务获取 + let account = await openaiAccountService.getAccount(memberId) + let accountType = 'openai' + + // 如果 OpenAI 账户不存在,尝试从 OpenAI-Responses 账户服务获取 + if (!account) { + account = await openaiResponsesAccountService.getAccount(memberId) + accountType = 'openai-responses' + } + + if ( + account && + (account.isActive === true || account.isActive === 'true') && + account.status !== 'error' + ) { + const readiness = await this._ensureAccountReadyForScheduling(account, account.id, { + sanitized: false + }) + + if (!readiness.canUse) { + if (readiness.reason === 'rate_limited') { + logger.debug( + `⏭️ Skipping group member ${accountType} account ${account.name} - still rate limited` + ) + } else { + logger.debug( + `⏭️ Skipping group member ${accountType} account ${account.name} - not schedulable` + ) + } + continue + } + + const isTempUnavailable = await upstreamErrorHelper.isTempUnavailable( + account.id, + accountType + ) + if (isTempUnavailable) { + logger.debug( + `⏭️ Skipping group member ${accountType} account ${account.name} - temporarily unavailable` + ) + continue + } + + // 检查token是否过期(仅对 OpenAI OAuth 账户检查) + if (accountType === 'openai') { + const isExpired = openaiAccountService.isTokenExpired(account) + if (isExpired && !account.refreshToken) { + logger.warn( + `⚠️ Group member OpenAI account ${account.name} token expired and no refresh token available` + ) + continue + } + } + + // 检查模型支持(仅在明确设置了supportedModels且不为空时才检查) + // 如果没有设置supportedModels或为空数组,则支持所有模型 + if (requestedModel && account.supportedModels && account.supportedModels.length > 0) { + const modelSupported = account.supportedModels.includes(requestedModel) + if (!modelSupported) { + logger.debug( + `⏭️ Skipping group member ${accountType} account ${account.name} - doesn't support model ${requestedModel}` + ) + continue + } + } + + // 添加到可用账户列表 + availableAccounts.push({ + ...account, + accountId: account.id, + accountType, + priority: parseInt(account.priority) || 50, + lastUsedAt: account.lastUsedAt || '0' + }) + } + } + + if (availableAccounts.length === 0) { + const error = new Error(`No available accounts in group ${group.name}`) + error.statusCode = 402 // Payment Required - 资源耗尽 + throw error + } + + // 按优先级和最后使用时间排序(与 Claude/Gemini 调度保持一致) + const sortedAccounts = sortAccountsByPriority(availableAccounts) + + // 选择第一个账户 + const selectedAccount = sortedAccounts[0] + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await this._setSessionMapping( + sessionHash, + selectedAccount.accountId, + selectedAccount.accountType + ) + logger.info( + `🎯 Created new sticky session mapping from group: ${selectedAccount.name} (${selectedAccount.accountId})` + ) + } + + logger.info( + `🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}, priority: ${selectedAccount.priority || 50})` + ) + + // 更新账户的最后使用时间 + await this.updateAccountLastUsed(selectedAccount.accountId, selectedAccount.accountType) + + return { + accountId: selectedAccount.accountId, + accountType: selectedAccount.accountType + } + } catch (error) { + logger.error(`❌ Failed to select account from group ${groupId}:`, error) + throw error + } + } + + // 🔍 检查账户是否在分组中 + async _isAccountInGroup(accountId, groupId) { + const members = await accountGroupService.getGroupMembers(groupId) + return members.includes(accountId) + } + + // 📊 更新账户最后使用时间 + async updateAccountLastUsed(accountId, accountType) { + try { + if (accountType === 'openai') { + await openaiAccountService.recordUsage(accountId, 0) + return + } + + if (accountType === 'openai-responses') { + await openaiResponsesAccountService.recordUsage(accountId, 0) + } + } catch (error) { + logger.warn(`⚠️ Failed to update last used time for account ${accountId}:`, error) + } + } +} + +module.exports = new UnifiedOpenAIScheduler() diff --git a/src/services/serviceRatesService.js b/src/services/serviceRatesService.js new file mode 100644 index 0000000..7496a0e --- /dev/null +++ b/src/services/serviceRatesService.js @@ -0,0 +1,259 @@ +/** + * 服务倍率配置服务 + * 管理不同服务的消费倍率,以 Claude 为基准(倍率 1.0) + * 用于聚合 Key 的虚拟额度计算 + */ +const redis = require('../models/redis') +const logger = require('../utils/logger') + +class ServiceRatesService { + constructor() { + this.CONFIG_KEY = 'system:service_rates' + this.cachedRates = null + this.cacheExpiry = 0 + this.CACHE_TTL = 60 * 1000 // 1分钟缓存 + } + + /** + * 获取默认倍率配置 + */ + getDefaultRates() { + return { + baseService: 'claude', + rates: { + claude: 1.0, // 基准:1 USD = 1 CC额度 + codex: 1.0, + gemini: 1.0, + droid: 1.0, + bedrock: 1.0, + azure: 1.0, + ccr: 1.0 + }, + updatedAt: null, + updatedBy: null + } + } + + /** + * 获取倍率配置(带缓存) + */ + async getRates() { + try { + // 检查缓存 + if (this.cachedRates && Date.now() < this.cacheExpiry) { + return this.cachedRates + } + + const configStr = await redis.client.get(this.CONFIG_KEY) + if (!configStr) { + const defaultRates = this.getDefaultRates() + this.cachedRates = defaultRates + this.cacheExpiry = Date.now() + this.CACHE_TTL + return defaultRates + } + + const storedConfig = JSON.parse(configStr) + // 合并默认值,确保新增服务有默认倍率 + const defaultRates = this.getDefaultRates() + storedConfig.rates = { + ...defaultRates.rates, + ...storedConfig.rates + } + + this.cachedRates = storedConfig + this.cacheExpiry = Date.now() + this.CACHE_TTL + return storedConfig + } catch (error) { + logger.error('获取服务倍率配置失败:', error) + return this.getDefaultRates() + } + } + + /** + * 保存倍率配置 + */ + async saveRates(config, updatedBy = 'admin') { + try { + const defaultRates = this.getDefaultRates() + + // 验证配置 + this.validateRates(config) + + const newConfig = { + baseService: config.baseService || defaultRates.baseService, + rates: { + ...defaultRates.rates, + ...config.rates + }, + updatedAt: new Date().toISOString(), + updatedBy + } + + await redis.client.set(this.CONFIG_KEY, JSON.stringify(newConfig)) + + // 清除缓存 + this.cachedRates = null + this.cacheExpiry = 0 + + logger.info(`✅ 服务倍率配置已更新 by ${updatedBy}`) + return newConfig + } catch (error) { + logger.error('保存服务倍率配置失败:', error) + throw error + } + } + + /** + * 验证倍率配置 + */ + validateRates(config) { + if (!config || typeof config !== 'object') { + throw new Error('无效的配置格式') + } + + if (config.rates) { + for (const [service, rate] of Object.entries(config.rates)) { + if (typeof rate !== 'number' || rate <= 0) { + throw new Error(`服务 ${service} 的倍率必须是正数`) + } + } + } + } + + /** + * 获取单个服务的倍率 + */ + async getServiceRate(service) { + const config = await this.getRates() + return config.rates[service] || 1.0 + } + + /** + * 计算消费的 CC 额度 + * @param {number} costUSD - 真实成本(USD) + * @param {string} service - 服务类型 + * @returns {number} CC 额度消耗 + */ + async calculateQuotaConsumption(costUSD, service) { + const rate = await this.getServiceRate(service) + return costUSD * rate + } + + /** + * 根据模型名称获取服务类型 + */ + getServiceFromModel(model) { + if (!model) { + return 'claude' + } + + const modelLower = model.toLowerCase() + + // Claude 系列 + if ( + modelLower.includes('claude') || + modelLower.includes('anthropic') || + modelLower.includes('opus') || + modelLower.includes('sonnet') || + modelLower.includes('haiku') + ) { + return 'claude' + } + + // OpenAI / Codex 系列 + if ( + modelLower.includes('gpt') || + modelLower.includes('o1') || + modelLower.includes('o3') || + modelLower.includes('o4') || + modelLower.includes('codex') || + modelLower.includes('davinci') || + modelLower.includes('curie') || + modelLower.includes('babbage') || + modelLower.includes('ada') + ) { + return 'codex' + } + + // Gemini 系列 + if ( + modelLower.includes('gemini') || + modelLower.includes('palm') || + modelLower.includes('bard') + ) { + return 'gemini' + } + + // Droid 系列 + if (modelLower.includes('droid') || modelLower.includes('factory')) { + return 'droid' + } + + // Bedrock 系列(通常带有 aws 或特定前缀) + if ( + modelLower.includes('bedrock') || + modelLower.includes('amazon') || + modelLower.includes('titan') + ) { + return 'bedrock' + } + + // Azure 系列 + if (modelLower.includes('azure')) { + return 'azure' + } + + // 默认返回 claude + return 'claude' + } + + /** + * 根据账户类型获取服务类型(优先级高于模型推断) + */ + getServiceFromAccountType(accountType) { + if (!accountType) { + return null + } + + const mapping = { + claude: 'claude', + 'claude-official': 'claude', + 'claude-console': 'claude', + ccr: 'ccr', + bedrock: 'bedrock', + gemini: 'gemini', + 'openai-responses': 'codex', + openai: 'codex', + azure: 'azure', + 'azure-openai': 'azure', + droid: 'droid' + } + + return mapping[accountType] || null + } + + /** + * 获取服务类型(优先 accountType,后备 model) + */ + getService(accountType, model) { + return this.getServiceFromAccountType(accountType) || this.getServiceFromModel(model) + } + + /** + * 获取所有支持的服务列表 + */ + async getAvailableServices() { + const config = await this.getRates() + return Object.keys(config.rates) + } + + /** + * 清除缓存(用于测试或强制刷新) + */ + clearCache() { + this.cachedRates = null + this.cacheExpiry = 0 + } +} + +module.exports = new ServiceRatesService() diff --git a/src/services/tokenRefreshService.js b/src/services/tokenRefreshService.js new file mode 100644 index 0000000..48e1cc7 --- /dev/null +++ b/src/services/tokenRefreshService.js @@ -0,0 +1,143 @@ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { v4: uuidv4 } = require('uuid') + +/** + * Token 刷新锁服务 + * 提供分布式锁机制,避免并发刷新问题 + */ +class TokenRefreshService { + constructor() { + this.lockTTL = 60 // 锁的TTL: 60秒(token刷新通常在30秒内完成) + this.lockValue = new Map() // 存储每个锁的唯一值 + } + + /** + * 获取分布式锁 + * 使用唯一标识符作为值,避免误释放其他进程的锁 + */ + async acquireLock(lockKey) { + try { + const client = redis.getClientSafe() + const lockId = uuidv4() + const result = await client.set(lockKey, lockId, 'NX', 'EX', this.lockTTL) + + if (result === 'OK') { + this.lockValue.set(lockKey, lockId) + logger.debug(`🔒 Acquired lock ${lockKey} with ID ${lockId}, TTL: ${this.lockTTL}s`) + return true + } + return false + } catch (error) { + logger.error(`Failed to acquire lock ${lockKey}:`, error) + return false + } + } + + /** + * 释放分布式锁 + * 使用 Lua 脚本确保只释放自己持有的锁 + */ + async releaseLock(lockKey) { + try { + const client = redis.getClientSafe() + const lockId = this.lockValue.get(lockKey) + + if (!lockId) { + logger.warn(`⚠️ No lock ID found for ${lockKey}, skipping release`) + return + } + + // Lua 脚本:只有当值匹配时才删除 + const luaScript = ` + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + ` + + const result = await client.eval(luaScript, 1, lockKey, lockId) + + if (result === 1) { + this.lockValue.delete(lockKey) + logger.debug(`🔓 Released lock ${lockKey} with ID ${lockId}`) + } else { + logger.warn(`⚠️ Lock ${lockKey} was not released - value mismatch or already expired`) + } + } catch (error) { + logger.error(`Failed to release lock ${lockKey}:`, error) + } + } + + /** + * 获取刷新锁 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 (claude/gemini) + * @returns {Promise} 是否成功获取锁 + */ + async acquireRefreshLock(accountId, platform = 'claude') { + const lockKey = `token_refresh_lock:${platform}:${accountId}` + return await this.acquireLock(lockKey) + } + + /** + * 释放刷新锁 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 (claude/gemini) + */ + async releaseRefreshLock(accountId, platform = 'claude') { + const lockKey = `token_refresh_lock:${platform}:${accountId}` + await this.releaseLock(lockKey) + } + + /** + * 检查刷新锁状态 + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 (claude/gemini) + * @returns {Promise} 锁是否存在 + */ + async isRefreshLocked(accountId, platform = 'claude') { + const lockKey = `token_refresh_lock:${platform}:${accountId}` + try { + const client = redis.getClientSafe() + const exists = await client.exists(lockKey) + return exists === 1 + } catch (error) { + logger.error(`Failed to check lock status ${lockKey}:`, error) + return false + } + } + + /** + * 获取锁的剩余TTL + * @param {string} accountId - 账户ID + * @param {string} platform - 平台类型 (claude/gemini) + * @returns {Promise} 剩余秒数,-1表示锁不存在 + */ + async getLockTTL(accountId, platform = 'claude') { + const lockKey = `token_refresh_lock:${platform}:${accountId}` + try { + const client = redis.getClientSafe() + const ttl = await client.ttl(lockKey) + return ttl + } catch (error) { + logger.error(`Failed to get lock TTL ${lockKey}:`, error) + return -1 + } + } + + /** + * 清理本地锁记录 + * 在进程退出时调用,避免内存泄漏 + */ + cleanup() { + this.lockValue.clear() + logger.info('🧹 Cleaned up local lock records') + } +} + +// 创建单例实例 +const tokenRefreshService = new TokenRefreshService() + +module.exports = tokenRefreshService diff --git a/src/services/userMessageQueueService.js b/src/services/userMessageQueueService.js new file mode 100644 index 0000000..6bf46c7 --- /dev/null +++ b/src/services/userMessageQueueService.js @@ -0,0 +1,373 @@ +/** + * 用户消息队列服务 + * 为 Claude 账户实现基于消息类型的串行排队机制 + * + * 当请求的最后一条消息是用户输入(role: user)时, + * 同一账户的此类请求需要串行等待,并在请求之间添加延迟 + */ + +const { v4: uuidv4 } = require('uuid') +const redis = require('../models/redis') +const config = require('../../config/config') +const logger = require('../utils/logger') +const { getCachedConfig, setCachedConfig } = require('../utils/performanceOptimizer') + +// 清理任务间隔 +const CLEANUP_INTERVAL_MS = 60000 // 1分钟 + +// 轮询等待配置 +const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔 +const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔 +const POLL_BACKOFF_FACTOR = 1.5 // 退避因子 + +// 配置缓存 key +const CONFIG_CACHE_KEY = 'user_message_queue_config' + +class UserMessageQueueService { + constructor() { + this.cleanupTimer = null + } + + /** + * 检测请求是否为真正的用户消息请求 + * 区分真正的用户输入和 tool_result 消息 + * + * Claude API 消息格式: + * - 用户文本消息: { role: 'user', content: 'text' } 或 { role: 'user', content: [{ type: 'text', text: '...' }] } + * - 工具结果消息: { role: 'user', content: [{ type: 'tool_result', tool_use_id: '...', content: '...' }] } + * + * @param {Object} requestBody - 请求体 + * @returns {boolean} - 是否为真正的用户消息(排除 tool_result) + */ + isUserMessageRequest(requestBody) { + const messages = requestBody?.messages + if (!Array.isArray(messages) || messages.length === 0) { + return false + } + const lastMessage = messages[messages.length - 1] + + // 检查 role 是否为 user + if (lastMessage?.role !== 'user') { + return false + } + + // 检查 content 是否包含 tool_result 类型 + const { content } = lastMessage + if (Array.isArray(content)) { + // 如果 content 数组中任何元素是 tool_result,则不是真正的用户消息 + const hasToolResult = content.some( + (block) => block?.type === 'tool_result' || block?.type === 'tool_use_result' + ) + if (hasToolResult) { + return false + } + } + + // role 是 user 且不包含 tool_result,是真正的用户消息 + return true + } + + /** + * 获取当前配置(支持 Web 界面配置优先,带短 TTL 缓存) + * @returns {Promise} 配置对象 + */ + async getConfig() { + // 检查缓存 + const cached = getCachedConfig(CONFIG_CACHE_KEY) + if (cached) { + return cached + } + + // 默认配置(防止 config.userMessageQueue 未定义) + const queueConfig = config.userMessageQueue || {} + const defaults = { + enabled: queueConfig.enabled ?? false, + delayMs: queueConfig.delayMs ?? 200, + timeoutMs: queueConfig.timeoutMs ?? 60000, + lockTtlMs: queueConfig.lockTtlMs ?? 120000 + } + + // 尝试从 claudeRelayConfigService 获取 Web 界面配置 + try { + const claudeRelayConfigService = require('./claudeRelayConfigService') + const webConfig = await claudeRelayConfigService.getConfig() + + const result = { + enabled: + webConfig.userMessageQueueEnabled !== undefined + ? webConfig.userMessageQueueEnabled + : defaults.enabled, + delayMs: + webConfig.userMessageQueueDelayMs !== undefined + ? webConfig.userMessageQueueDelayMs + : defaults.delayMs, + timeoutMs: + webConfig.userMessageQueueTimeoutMs !== undefined + ? webConfig.userMessageQueueTimeoutMs + : defaults.timeoutMs, + lockTtlMs: + webConfig.userMessageQueueLockTtlMs !== undefined + ? webConfig.userMessageQueueLockTtlMs + : defaults.lockTtlMs + } + + // 缓存配置 30 秒 + setCachedConfig(CONFIG_CACHE_KEY, result, 30000) + return result + } catch { + // 回退到环境变量配置,也缓存 + setCachedConfig(CONFIG_CACHE_KEY, defaults, 30000) + return defaults + } + } + + /** + * 检查功能是否启用 + * @returns {Promise} + */ + async isEnabled() { + const cfg = await this.getConfig() + return cfg.enabled === true + } + + /** + * 获取账户队列锁(阻塞等待) + * @param {string} accountId - 账户ID + * @param {string} requestId - 请求ID(可选,会自动生成) + * @param {number} timeoutMs - 超时时间(可选,使用配置默认值) + * @param {Object} accountConfig - 账户级配置(可选),优先级高于全局配置 + * @param {number} accountConfig.maxConcurrency - 账户级串行队列开关:>0启用,=0使用全局配置 + * @returns {Promise<{acquired: boolean, requestId: string, error?: string}>} + */ + async acquireQueueLock(accountId, requestId = null, timeoutMs = null, accountConfig = null) { + const cfg = await this.getConfig() + + // 账户级配置优先:maxConcurrency > 0 时强制启用,忽略全局开关 + let queueEnabled = cfg.enabled + if (accountConfig && accountConfig.maxConcurrency > 0) { + queueEnabled = true + logger.debug( + `📬 User message queue: account-level queue enabled for account ${accountId} (maxConcurrency=${accountConfig.maxConcurrency})` + ) + } + + if (!queueEnabled) { + return { acquired: true, requestId: requestId || uuidv4(), skipped: true } + } + + const reqId = requestId || uuidv4() + const timeout = timeoutMs || cfg.timeoutMs + const startTime = Date.now() + let retryCount = 0 + + logger.debug(`📬 User message queue: attempting to acquire lock for account ${accountId}`, { + requestId: reqId, + timeoutMs: timeout + }) + + while (Date.now() - startTime < timeout) { + const result = await redis.acquireUserMessageLock( + accountId, + reqId, + cfg.lockTtlMs, + cfg.delayMs + ) + + // 检测 Redis 错误,立即返回系统错误而非继续轮询 + if (result.redisError) { + logger.error(`📬 User message queue: Redis error while acquiring lock`, { + accountId, + requestId: reqId, + errorMessage: result.errorMessage + }) + return { + acquired: false, + requestId: reqId, + error: 'queue_backend_error', + errorMessage: result.errorMessage + } + } + + if (result.acquired) { + logger.debug(`📬 User message queue: lock acquired for account ${accountId}`, { + requestId: reqId, + waitedMs: Date.now() - startTime, + retries: retryCount + }) + return { acquired: true, requestId: reqId } + } + + // 需要等待 + if (result.waitMs > 0) { + // 需要延迟(上一个请求刚完成) + await this._sleep(Math.min(result.waitMs, timeout - (Date.now() - startTime))) + } else { + // 锁被占用,使用指数退避轮询等待 + const basePollInterval = Math.min( + POLL_INTERVAL_BASE_MS * Math.pow(POLL_BACKOFF_FACTOR, retryCount), + POLL_INTERVAL_MAX_MS + ) + // 添加 ±15% 随机抖动,避免高并发下的周期性碰撞 + const jitter = basePollInterval * (0.85 + Math.random() * 0.3) + const pollInterval = Math.min(jitter, POLL_INTERVAL_MAX_MS) + await this._sleep(pollInterval) + retryCount++ + } + } + + // 超时 + logger.warn(`📬 User message queue: timeout waiting for lock`, { + accountId, + requestId: reqId, + timeoutMs: timeout + }) + + return { + acquired: false, + requestId: reqId, + error: 'queue_timeout' + } + } + + /** + * 释放账户队列锁 + * @param {string} accountId - 账户ID + * @param {string} requestId - 请求ID + * @returns {Promise} + */ + async releaseQueueLock(accountId, requestId) { + if (!accountId || !requestId) { + return false + } + + const released = await redis.releaseUserMessageLock(accountId, requestId) + + if (released) { + logger.debug(`📬 User message queue: lock released for account ${accountId}`, { + requestId + }) + } else { + logger.warn(`📬 User message queue: failed to release lock (not owner?)`, { + accountId, + requestId + }) + } + + return released + } + + /** + * 获取队列统计信息 + * @param {string} accountId - 账户ID + * @returns {Promise} + */ + async getQueueStats(accountId) { + return await redis.getUserMessageQueueStats(accountId) + } + + /** + * 服务启动时清理所有残留的队列锁 + * 防止服务重启后旧锁阻塞新请求 + * @returns {Promise} 清理的锁数量 + */ + async cleanupStaleLocks() { + try { + const accountIds = await redis.scanUserMessageQueueLocks() + let cleanedCount = 0 + + for (const accountId of accountIds) { + try { + await redis.forceReleaseUserMessageLock(accountId) + cleanedCount++ + logger.debug(`📬 User message queue: cleaned stale lock for account ${accountId}`) + } catch (error) { + logger.error( + `📬 User message queue: failed to clean lock for account ${accountId}:`, + error + ) + } + } + + if (cleanedCount > 0) { + logger.info(`📬 User message queue: cleaned ${cleanedCount} stale lock(s) on startup`) + } + + return cleanedCount + } catch (error) { + logger.error('📬 User message queue: failed to cleanup stale locks on startup:', error) + return 0 + } + } + + /** + * 启动定时清理任务 + * 始终启动,每次执行时检查配置以支持运行时动态启用/禁用 + */ + startCleanupTask() { + if (this.cleanupTimer) { + return + } + + this.cleanupTimer = setInterval(async () => { + // 每次运行时检查配置,以便在运行时动态启用/禁用 + const currentConfig = await this.getConfig() + if (!currentConfig.enabled) { + logger.debug('📬 User message queue: cleanup skipped (feature disabled)') + return + } + await this._cleanupOrphanLocks() + }, CLEANUP_INTERVAL_MS) + + logger.info('📬 User message queue: cleanup task started') + } + + /** + * 停止定时清理任务 + */ + stopCleanupTask() { + if (this.cleanupTimer) { + clearInterval(this.cleanupTimer) + this.cleanupTimer = null + logger.info('📬 User message queue: cleanup task stopped') + } + } + + /** + * 清理孤儿锁 + * 检测异常情况:锁存在但没有设置过期时间(lockTtlRaw === -1) + * 正常情况下所有锁都应该有 TTL,Redis 会自动过期 + * @private + */ + async _cleanupOrphanLocks() { + try { + const accountIds = await redis.scanUserMessageQueueLocks() + + for (const accountId of accountIds) { + const stats = await redis.getUserMessageQueueStats(accountId) + + // 检测异常情况:锁存在(isLocked=true)但没有过期时间(lockTtlRaw=-1) + // 正常创建的锁都带有 PX 过期时间,如果没有说明是异常状态 + if (stats.isLocked && stats.lockTtlRaw === -1) { + logger.warn( + `📬 User message queue: cleaning up orphan lock without TTL for account ${accountId}`, + { lockHolder: stats.lockHolder } + ) + await redis.forceReleaseUserMessageLock(accountId) + } + } + } catch (error) { + logger.error('📬 User message queue: cleanup task error:', error) + } + } + + /** + * 睡眠辅助函数 + * @param {number} ms - 毫秒 + * @private + */ + _sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) + } +} + +module.exports = new UserMessageQueueService() diff --git a/src/services/userService.js b/src/services/userService.js new file mode 100644 index 0000000..c8b4b0f --- /dev/null +++ b/src/services/userService.js @@ -0,0 +1,603 @@ +const redis = require('../models/redis') +const crypto = require('crypto') +const logger = require('../utils/logger') +const config = require('../../config/config') + +class UserService { + constructor() { + this.userPrefix = 'user:' + this.usernamePrefix = 'username:' + this.userSessionPrefix = 'user_session:' + } + + // 🔑 生成用户ID + generateUserId() { + return crypto.randomBytes(16).toString('hex') + } + + // 🔑 生成会话Token + generateSessionToken() { + return crypto.randomBytes(32).toString('hex') + } + + // 👤 创建或更新用户 + async createOrUpdateUser(userData) { + try { + const { + username, + email, + displayName, + firstName, + lastName, + role = config.userManagement.defaultUserRole, + isActive = true + } = userData + + // 检查用户是否已存在 + let user = await this.getUserByUsername(username) + const isNewUser = !user + + if (isNewUser) { + const userId = this.generateUserId() + user = { + id: userId, + username, + email, + displayName, + firstName, + lastName, + role, + isActive, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + lastLoginAt: null, + apiKeyCount: 0, + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + } + } else { + // 更新现有用户信息 + user = { + ...user, + email, + displayName, + firstName, + lastName, + updatedAt: new Date().toISOString() + } + } + + // 保存用户信息 + await redis.set(`${this.userPrefix}${user.id}`, JSON.stringify(user)) + await redis.set(`${this.usernamePrefix}${username}`, user.id) + await redis.addToIndex('user:index', user.id) + + // 如果是新用户,尝试转移匹配的API Keys + if (isNewUser) { + await this.transferMatchingApiKeys(user) + } + + logger.info(`📝 ${isNewUser ? 'Created' : 'Updated'} user: ${username} (${user.id})`) + return user + } catch (error) { + logger.error('❌ Error creating/updating user:', error) + throw error + } + } + + // 👤 通过用户名获取用户 + async getUserByUsername(username) { + try { + const userId = await redis.get(`${this.usernamePrefix}${username}`) + if (!userId) { + return null + } + + const userData = await redis.get(`${this.userPrefix}${userId}`) + return userData ? JSON.parse(userData) : null + } catch (error) { + logger.error('❌ Error getting user by username:', error) + throw error + } + } + + // 👤 通过ID获取用户 + async getUserById(userId, calculateUsage = true) { + try { + const userData = await redis.get(`${this.userPrefix}${userId}`) + if (!userData) { + return null + } + + const user = JSON.parse(userData) + + // Calculate totalUsage by aggregating user's API keys usage (if requested) + if (calculateUsage) { + try { + const usageStats = await this.calculateUserUsageStats(userId) + user.totalUsage = usageStats.totalUsage + user.apiKeyCount = usageStats.apiKeyCount + } catch (error) { + logger.error('❌ Error calculating user usage stats:', error) + // Fallback to stored values if calculation fails + user.totalUsage = user.totalUsage || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + user.apiKeyCount = user.apiKeyCount || 0 + } + } + + return user + } catch (error) { + logger.error('❌ Error getting user by ID:', error) + throw error + } + } + + // 📊 计算用户使用统计(通过聚合API Keys) + async calculateUserUsageStats(userId) { + try { + // Use the existing apiKeyService method which already includes usage stats + const apiKeyService = require('./apiKeyService') + const userApiKeys = await apiKeyService.getUserApiKeys(userId, true) // Include deleted keys for stats + + const totalUsage = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + + for (const apiKey of userApiKeys) { + if (apiKey.usage && apiKey.usage.total) { + totalUsage.requests += apiKey.usage.total.requests || 0 + totalUsage.inputTokens += apiKey.usage.total.inputTokens || 0 + totalUsage.outputTokens += apiKey.usage.total.outputTokens || 0 + totalUsage.totalCost += apiKey.totalCost || 0 + } + } + + logger.debug( + `📊 Calculated user ${userId} usage: ${totalUsage.requests} requests, ${totalUsage.inputTokens} input tokens, $${totalUsage.totalCost.toFixed(4)} total cost from ${userApiKeys.length} API keys` + ) + + // Count only non-deleted API keys for the user's active count(布尔值比较) + const activeApiKeyCount = userApiKeys.filter((key) => !key.isDeleted).length + + return { + totalUsage, + apiKeyCount: activeApiKeyCount + } + } catch (error) { + logger.error('❌ Error calculating user usage stats:', error) + return { + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + }, + apiKeyCount: 0 + } + } + } + + // 📋 获取所有用户列表(管理员功能) + async getAllUsers(options = {}) { + try { + const { page = 1, limit = 20, role, isActive } = options + const userIds = await redis.getAllIdsByIndex( + 'user:index', + `${this.userPrefix}*`, + /^user:(.+)$/ + ) + const keys = userIds.map((id) => `${this.userPrefix}${id}`) + const dataList = await redis.batchGetChunked(keys) + + const users = [] + for (let i = 0; i < keys.length; i++) { + const userData = dataList[i] + if (userData) { + const user = JSON.parse(userData) + + // 应用过滤条件 + if (role && user.role !== role) { + continue + } + if (typeof isActive === 'boolean' && user.isActive !== isActive) { + continue + } + + // Calculate dynamic usage stats for each user + try { + const usageStats = await this.calculateUserUsageStats(user.id) + user.totalUsage = usageStats.totalUsage + user.apiKeyCount = usageStats.apiKeyCount + } catch (error) { + logger.error(`❌ Error calculating usage for user ${user.id}:`, error) + // Fallback to stored values + user.totalUsage = user.totalUsage || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + user.apiKeyCount = user.apiKeyCount || 0 + } + + users.push(user) + } + } + + // 排序和分页 + users.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) + const startIndex = (page - 1) * limit + const endIndex = startIndex + limit + const paginatedUsers = users.slice(startIndex, endIndex) + + return { + users: paginatedUsers, + total: users.length, + page, + limit, + totalPages: Math.ceil(users.length / limit) + } + } catch (error) { + logger.error('❌ Error getting all users:', error) + throw error + } + } + + // 🔄 更新用户状态 + async updateUserStatus(userId, isActive) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + throw new Error('User not found') + } + + user.isActive = isActive + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + logger.info(`🔄 Updated user status: ${user.username} -> ${isActive ? 'active' : 'disabled'}`) + + // 如果禁用用户,删除所有会话并禁用其所有API Keys + if (!isActive) { + await this.invalidateUserSessions(userId) + + // Disable all user's API keys when user is disabled + try { + const apiKeyService = require('./apiKeyService') + const result = await apiKeyService.disableUserApiKeys(userId) + logger.info(`🔑 Disabled ${result.count} API keys for disabled user: ${user.username}`) + } catch (error) { + logger.error('❌ Error disabling user API keys during user disable:', error) + } + } + + return user + } catch (error) { + logger.error('❌ Error updating user status:', error) + throw error + } + } + + // 🔄 更新用户角色 + async updateUserRole(userId, role) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + throw new Error('User not found') + } + + user.role = role + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + logger.info(`🔄 Updated user role: ${user.username} -> ${role}`) + + return user + } catch (error) { + logger.error('❌ Error updating user role:', error) + throw error + } + } + + // 📊 更新用户API Key数量 (已废弃,现在通过聚合计算) + async updateUserApiKeyCount(userId, _count) { + // This method is deprecated since apiKeyCount is now calculated dynamically + // in getUserById by aggregating the user's API keys + logger.debug( + `📊 updateUserApiKeyCount called for ${userId} but is now deprecated (count auto-calculated)` + ) + } + + // 📝 记录用户登录 + async recordUserLogin(userId) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + return + } + + user.lastLoginAt = new Date().toISOString() + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + } catch (error) { + logger.error('❌ Error recording user login:', error) + } + } + + // 🎫 创建用户会话 + async createUserSession(userId, sessionData = {}) { + try { + const sessionToken = this.generateSessionToken() + const session = { + token: sessionToken, + userId, + createdAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + config.userManagement.userSessionTimeout).toISOString(), + ...sessionData + } + + const ttl = Math.floor(config.userManagement.userSessionTimeout / 1000) + await redis.setex(`${this.userSessionPrefix}${sessionToken}`, ttl, JSON.stringify(session)) + + logger.info(`🎫 Created session for user: ${userId}`) + return sessionToken + } catch (error) { + logger.error('❌ Error creating user session:', error) + throw error + } + } + + // 🎫 验证用户会话 + async validateUserSession(sessionToken) { + try { + const sessionData = await redis.get(`${this.userSessionPrefix}${sessionToken}`) + if (!sessionData) { + return null + } + + const session = JSON.parse(sessionData) + + // 检查会话是否过期 + if (new Date() > new Date(session.expiresAt)) { + await this.invalidateUserSession(sessionToken) + return null + } + + // 获取用户信息 + const user = await this.getUserById(session.userId, false) // Skip usage calculation for validation + if (!user || !user.isActive) { + await this.invalidateUserSession(sessionToken) + return null + } + + return { session, user } + } catch (error) { + logger.error('❌ Error validating user session:', error) + return null + } + } + + // 🚫 使用户会话失效 + async invalidateUserSession(sessionToken) { + try { + await redis.del(`${this.userSessionPrefix}${sessionToken}`) + logger.info(`🚫 Invalidated session: ${sessionToken}`) + } catch (error) { + logger.error('❌ Error invalidating user session:', error) + } + } + + // 🚫 使用户所有会话失效 + async invalidateUserSessions(userId) { + try { + const client = redis.getClientSafe() + const pattern = `${this.userSessionPrefix}*` + const keys = await redis.scanKeys(pattern) + const dataList = await redis.batchGetChunked(keys) + + for (let i = 0; i < keys.length; i++) { + const sessionData = dataList[i] + if (sessionData) { + const session = JSON.parse(sessionData) + if (session.userId === userId) { + await client.del(keys[i]) + } + } + } + + logger.info(`🚫 Invalidated all sessions for user: ${userId}`) + } catch (error) { + logger.error('❌ Error invalidating user sessions:', error) + } + } + + // 🗑️ 删除用户(软删除,标记为不活跃) + async deleteUser(userId) { + try { + const user = await this.getUserById(userId, false) // Skip usage calculation + if (!user) { + throw new Error('User not found') + } + + // 软删除:标记为不活跃并添加删除时间戳 + user.isActive = false + user.deletedAt = new Date().toISOString() + user.updatedAt = new Date().toISOString() + + await redis.set(`${this.userPrefix}${userId}`, JSON.stringify(user)) + + // 删除所有会话 + await this.invalidateUserSessions(userId) + + // Disable all user's API keys when user is deleted + try { + const apiKeyService = require('./apiKeyService') + const result = await apiKeyService.disableUserApiKeys(userId) + logger.info(`🔑 Disabled ${result.count} API keys for deleted user: ${user.username}`) + } catch (error) { + logger.error('❌ Error disabling user API keys during user deletion:', error) + } + + logger.info(`🗑️ Soft deleted user: ${user.username} (${userId})`) + return user + } catch (error) { + logger.error('❌ Error deleting user:', error) + throw error + } + } + + // 📊 获取用户统计信息 + async getUserStats() { + try { + const userIds = await redis.getAllIdsByIndex( + 'user:index', + `${this.userPrefix}*`, + /^user:(.+)$/ + ) + const keys = userIds.map((id) => `${this.userPrefix}${id}`) + const dataList = await redis.batchGetChunked(keys) + + const stats = { + totalUsers: 0, + activeUsers: 0, + adminUsers: 0, + regularUsers: 0, + totalApiKeys: 0, + totalUsage: { + requests: 0, + inputTokens: 0, + outputTokens: 0, + totalCost: 0 + } + } + + for (let i = 0; i < keys.length; i++) { + const userData = dataList[i] + if (userData) { + const user = JSON.parse(userData) + stats.totalUsers++ + + if (user.isActive) { + stats.activeUsers++ + } + + if (user.role === 'admin') { + stats.adminUsers++ + } else { + stats.regularUsers++ + } + + // Calculate dynamic usage stats for each user + try { + const usageStats = await this.calculateUserUsageStats(user.id) + stats.totalApiKeys += usageStats.apiKeyCount + stats.totalUsage.requests += usageStats.totalUsage.requests + stats.totalUsage.inputTokens += usageStats.totalUsage.inputTokens + stats.totalUsage.outputTokens += usageStats.totalUsage.outputTokens + stats.totalUsage.totalCost += usageStats.totalUsage.totalCost + } catch (error) { + logger.error(`❌ Error calculating usage for user ${user.id} in stats:`, error) + // Fallback to stored values if calculation fails + stats.totalApiKeys += user.apiKeyCount || 0 + stats.totalUsage.requests += user.totalUsage?.requests || 0 + stats.totalUsage.inputTokens += user.totalUsage?.inputTokens || 0 + stats.totalUsage.outputTokens += user.totalUsage?.outputTokens || 0 + stats.totalUsage.totalCost += user.totalUsage?.totalCost || 0 + } + } + } + + return stats + } catch (error) { + logger.error('❌ Error getting user stats:', error) + throw error + } + } + + // 🔄 转移匹配的API Keys给新用户 + async transferMatchingApiKeys(user) { + try { + const apiKeyService = require('./apiKeyService') + const { displayName, username, email } = user + + // 获取所有API Keys + const allApiKeys = await apiKeyService.getAllApiKeysFast() + + // 找到没有用户ID的API Keys(即由Admin创建的) + const unownedApiKeys = allApiKeys.filter((key) => !key.userId || key.userId === '') + + if (unownedApiKeys.length === 0) { + logger.debug(`📝 No unowned API keys found for potential transfer to user: ${username}`) + return + } + + // 构建匹配字符串数组(只考虑displayName、username、email,去除空值和重复值) + const matchStrings = new Set() + if (displayName) { + matchStrings.add(displayName.toLowerCase().trim()) + } + if (username) { + matchStrings.add(username.toLowerCase().trim()) + } + if (email) { + matchStrings.add(email.toLowerCase().trim()) + } + + const matchingKeys = [] + + // 查找名称匹配的API Keys(只进行完全匹配) + for (const apiKey of unownedApiKeys) { + const keyName = apiKey.name ? apiKey.name.toLowerCase().trim() : '' + + // 检查API Key名称是否与用户信息完全匹配 + for (const matchString of matchStrings) { + if (keyName === matchString) { + matchingKeys.push(apiKey) + break // 找到匹配后跳出内层循环 + } + } + } + + // 转移匹配的API Keys + let transferredCount = 0 + for (const apiKey of matchingKeys) { + try { + await apiKeyService.updateApiKey(apiKey.id, { + userId: user.id, + userUsername: user.username, + createdBy: user.username + }) + + transferredCount++ + logger.info(`🔄 Transferred API key "${apiKey.name}" (${apiKey.id}) to user: ${username}`) + } catch (error) { + logger.error(`❌ Failed to transfer API key ${apiKey.id} to user ${username}:`, error) + } + } + + if (transferredCount > 0) { + logger.success( + `🎉 Successfully transferred ${transferredCount} API key(s) to new user: ${username} (${displayName})` + ) + } else if (matchingKeys.length === 0) { + logger.debug(`📝 No matching API keys found for user: ${username} (${displayName})`) + } + } catch (error) { + logger.error('❌ Error transferring matching API keys:', error) + // Don't throw error to prevent blocking user creation + } + } +} + +module.exports = new UserService() diff --git a/src/services/webhookConfigService.js b/src/services/webhookConfigService.js new file mode 100644 index 0000000..ea68985 --- /dev/null +++ b/src/services/webhookConfigService.js @@ -0,0 +1,467 @@ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const { v4: uuidv4 } = require('uuid') + +class WebhookConfigService { + constructor() { + this.KEY_PREFIX = 'webhook_config' + this.DEFAULT_CONFIG_KEY = `${this.KEY_PREFIX}:default` + } + + /** + * 获取webhook配置 + */ + async getConfig() { + try { + const configStr = await redis.client.get(this.DEFAULT_CONFIG_KEY) + if (!configStr) { + // 返回默认配置 + return this.getDefaultConfig() + } + + const storedConfig = JSON.parse(configStr) + const defaultConfig = this.getDefaultConfig() + + // 合并默认通知类型,确保新增类型有默认值 + storedConfig.notificationTypes = { + ...defaultConfig.notificationTypes, + ...(storedConfig.notificationTypes || {}) + } + + return storedConfig + } catch (error) { + logger.error('获取webhook配置失败:', error) + return this.getDefaultConfig() + } + } + + /** + * 保存webhook配置 + */ + async saveConfig(config) { + try { + const defaultConfig = this.getDefaultConfig() + + config.notificationTypes = { + ...defaultConfig.notificationTypes, + ...(config.notificationTypes || {}) + } + + // 验证配置 + this.validateConfig(config) + + // 添加更新时间 + config.updatedAt = new Date().toISOString() + + await redis.client.set(this.DEFAULT_CONFIG_KEY, JSON.stringify(config)) + logger.info('✅ Webhook配置已保存') + + return config + } catch (error) { + logger.error('保存webhook配置失败:', error) + throw error + } + } + + /** + * 验证配置 + */ + validateConfig(config) { + if (!config || typeof config !== 'object') { + throw new Error('无效的配置格式') + } + + // 验证平台配置 + if (config.platforms) { + const validPlatforms = [ + 'wechat_work', + 'dingtalk', + 'feishu', + 'slack', + 'discord', + 'telegram', + 'custom', + 'bark', + 'smtp' + ] + + for (const platform of config.platforms) { + if (!validPlatforms.includes(platform.type)) { + throw new Error(`不支持的平台类型: ${platform.type}`) + } + + // Bark和SMTP平台不使用标准URL + if (!['bark', 'smtp', 'telegram'].includes(platform.type)) { + if (!platform.url || !this.isValidUrl(platform.url)) { + throw new Error(`无效的webhook URL: ${platform.url}`) + } + } + + // 验证平台特定的配置 + this.validatePlatformConfig(platform) + } + } + } + + /** + * 验证平台特定配置 + */ + validatePlatformConfig(platform) { + switch (platform.type) { + case 'wechat_work': + // 企业微信不需要额外配置 + break + case 'dingtalk': + // 钉钉可能需要secret用于签名 + if (platform.enableSign && !platform.secret) { + throw new Error('钉钉启用签名时必须提供secret') + } + break + case 'feishu': + // 飞书可能需要签名 + if (platform.enableSign && !platform.secret) { + throw new Error('飞书启用签名时必须提供secret') + } + break + case 'slack': + // Slack webhook URL通常包含token + if (!platform.url.includes('hooks.slack.com')) { + logger.warn('⚠️ Slack webhook URL格式可能不正确') + } + break + case 'discord': + // Discord webhook URL格式检查 + if (!platform.url.includes('discord.com/api/webhooks')) { + logger.warn('⚠️ Discord webhook URL格式可能不正确') + } + break + case 'telegram': + if (!platform.botToken) { + throw new Error('Telegram 平台必须提供机器人 Token') + } + if (!platform.chatId) { + throw new Error('Telegram 平台必须提供 Chat ID') + } + + if (!platform.botToken.includes(':')) { + logger.warn('⚠️ Telegram 机器人 Token 格式可能不正确') + } + + if (!/^[-\d]+$/.test(String(platform.chatId))) { + logger.warn('⚠️ Telegram Chat ID 应该是数字,如为频道请确认已获取正确ID') + } + + if (platform.apiBaseUrl) { + if (!this.isValidUrl(platform.apiBaseUrl)) { + throw new Error('Telegram API 基础地址格式无效') + } + const { protocol } = new URL(platform.apiBaseUrl) + if (!['http:', 'https:'].includes(protocol)) { + throw new Error('Telegram API 基础地址仅支持 http 或 https 协议') + } + } + + if (platform.proxyUrl) { + if (!this.isValidUrl(platform.proxyUrl)) { + throw new Error('Telegram 代理地址格式无效') + } + const proxyProtocol = new URL(platform.proxyUrl).protocol + const supportedProtocols = ['http:', 'https:', 'socks4:', 'socks4a:', 'socks5:'] + if (!supportedProtocols.includes(proxyProtocol)) { + throw new Error('Telegram 代理仅支持 http/https/socks 协议') + } + } + break + case 'custom': + // 自定义webhook,用户自行负责格式 + break + case 'bark': + // 验证设备密钥 + if (!platform.deviceKey) { + throw new Error('Bark平台必须提供设备密钥') + } + + // 验证设备密钥格式(通常是22-24位字符) + if (platform.deviceKey.length < 20 || platform.deviceKey.length > 30) { + logger.warn('⚠️ Bark设备密钥长度可能不正确,请检查是否完整复制') + } + + // 验证服务器URL(如果提供) + if (platform.serverUrl) { + if (!this.isValidUrl(platform.serverUrl)) { + throw new Error('Bark服务器URL格式无效') + } + if (!platform.serverUrl.includes('/push')) { + logger.warn('⚠️ Bark服务器URL应该以/push结尾') + } + } + + // 验证声音参数(如果提供) + if (platform.sound) { + const validSounds = [ + 'default', + 'alarm', + 'anticipate', + 'bell', + 'birdsong', + 'bloom', + 'calypso', + 'chime', + 'choo', + 'descent', + 'electronic', + 'fanfare', + 'glass', + 'gotosleep', + 'healthnotification', + 'horn', + 'ladder', + 'mailsent', + 'minuet', + 'multiwayinvitation', + 'newmail', + 'newsflash', + 'noir', + 'paymentsuccess', + 'shake', + 'sherwoodforest', + 'silence', + 'spell', + 'suspense', + 'telegraph', + 'tiptoes', + 'typewriters', + 'update', + 'alert' + ] + if (!validSounds.includes(platform.sound)) { + logger.warn(`⚠️ 未知的Bark声音: ${platform.sound}`) + } + } + + // 验证级别参数 + if (platform.level) { + const validLevels = ['active', 'timeSensitive', 'passive', 'critical'] + if (!validLevels.includes(platform.level)) { + throw new Error(`无效的Bark通知级别: ${platform.level}`) + } + } + + // 验证图标URL(如果提供) + if (platform.icon && !this.isValidUrl(platform.icon)) { + logger.warn('⚠️ Bark图标URL格式可能不正确') + } + + // 验证点击跳转URL(如果提供) + if (platform.clickUrl && !this.isValidUrl(platform.clickUrl)) { + logger.warn('⚠️ Bark点击跳转URL格式可能不正确') + } + break + case 'smtp': { + // 验证SMTP必需配置 + if (!platform.host) { + throw new Error('SMTP平台必须提供主机地址') + } + if (!platform.user) { + throw new Error('SMTP平台必须提供用户名') + } + if (!platform.pass) { + throw new Error('SMTP平台必须提供密码') + } + if (!platform.to) { + throw new Error('SMTP平台必须提供接收邮箱') + } + + // 验证端口 + if (platform.port && (platform.port < 1 || platform.port > 65535)) { + throw new Error('SMTP端口必须在1-65535之间') + } + + // 验证邮箱格式 + // 支持两种格式:1. 纯邮箱 user@domain.com 2. 带名称 Name + const simpleEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + + // 验证接收邮箱 + const toEmails = Array.isArray(platform.to) ? platform.to : [platform.to] + for (const email of toEmails) { + // 提取实际邮箱地址(如果是 Name 格式) + const actualEmail = email.includes('<') ? email.match(/<([^>]+)>/)?.[1] : email + if (!actualEmail || !simpleEmailRegex.test(actualEmail)) { + throw new Error(`无效的接收邮箱格式: ${email}`) + } + } + + // 验证发送邮箱(支持 Name 格式) + if (platform.from) { + const actualFromEmail = platform.from.includes('<') + ? platform.from.match(/<([^>]+)>/)?.[1] + : platform.from + if (!actualFromEmail || !simpleEmailRegex.test(actualFromEmail)) { + throw new Error(`无效的发送邮箱格式: ${platform.from}`) + } + } + break + } + } + } + + /** + * 验证URL格式 + */ + isValidUrl(url) { + try { + new URL(url) + return true + } catch { + return false + } + } + + /** + * 获取默认配置 + */ + getDefaultConfig() { + return { + enabled: false, + platforms: [], + notificationTypes: { + accountAnomaly: true, // 账号异常 + quotaWarning: true, // 配额警告 + systemError: true, // 系统错误 + securityAlert: true, // 安全警报 + rateLimitRecovery: true, // 限流恢复 + test: true // 测试通知 + }, + retrySettings: { + maxRetries: 3, + retryDelay: 1000, // 毫秒 + timeout: 10000 // 毫秒 + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + } + } + + /** + * 添加webhook平台 + */ + async addPlatform(platform) { + try { + const config = await this.getConfig() + + // 生成唯一ID + platform.id = platform.id || uuidv4() + platform.enabled = platform.enabled !== false + platform.createdAt = new Date().toISOString() + + // 验证平台配置 + this.validatePlatformConfig(platform) + + // 添加到配置 + config.platforms = config.platforms || [] + config.platforms.push(platform) + + await this.saveConfig(config) + + return platform + } catch (error) { + logger.error('添加webhook平台失败:', error) + throw error + } + } + + /** + * 更新webhook平台 + */ + async updatePlatform(platformId, updates) { + try { + const config = await this.getConfig() + + const index = config.platforms.findIndex((p) => p.id === platformId) + if (index === -1) { + throw new Error('找不到指定的webhook平台') + } + + // 合并更新 + config.platforms[index] = { + ...config.platforms[index], + ...updates, + updatedAt: new Date().toISOString() + } + + // 验证更新后的配置 + this.validatePlatformConfig(config.platforms[index]) + + await this.saveConfig(config) + + return config.platforms[index] + } catch (error) { + logger.error('更新webhook平台失败:', error) + throw error + } + } + + /** + * 删除webhook平台 + */ + async deletePlatform(platformId) { + try { + const config = await this.getConfig() + + config.platforms = config.platforms.filter((p) => p.id !== platformId) + + await this.saveConfig(config) + + logger.info(`✅ 已删除webhook平台: ${platformId}`) + return true + } catch (error) { + logger.error('删除webhook平台失败:', error) + throw error + } + } + + /** + * 切换webhook平台启用状态 + */ + async togglePlatform(platformId) { + try { + const config = await this.getConfig() + + const platform = config.platforms.find((p) => p.id === platformId) + if (!platform) { + throw new Error('找不到指定的webhook平台') + } + + platform.enabled = !platform.enabled + platform.updatedAt = new Date().toISOString() + + await this.saveConfig(config) + + logger.info(`✅ Webhook平台 ${platformId} 已${platform.enabled ? '启用' : '禁用'}`) + return platform + } catch (error) { + logger.error('切换webhook平台状态失败:', error) + throw error + } + } + + /** + * 获取启用的平台列表 + */ + async getEnabledPlatforms() { + try { + const config = await this.getConfig() + + if (!config.enabled || !config.platforms) { + return [] + } + + return config.platforms.filter((p) => p.enabled) + } catch (error) { + logger.error('获取启用的webhook平台失败:', error) + return [] + } + } +} + +module.exports = new WebhookConfigService() diff --git a/src/services/webhookService.js b/src/services/webhookService.js new file mode 100755 index 0000000..d380b32 --- /dev/null +++ b/src/services/webhookService.js @@ -0,0 +1,892 @@ +const axios = require('axios') +const crypto = require('crypto') +const nodemailer = require('nodemailer') +const { HttpsProxyAgent } = require('https-proxy-agent') +const { SocksProxyAgent } = require('socks-proxy-agent') +const logger = require('../utils/logger') +const webhookConfigService = require('./webhookConfigService') +const { getISOStringWithTimezone } = require('../utils/dateHelper') +const appConfig = require('../../config/config') + +class WebhookService { + constructor() { + this.platformHandlers = { + wechat_work: this.sendToWechatWork.bind(this), + dingtalk: this.sendToDingTalk.bind(this), + feishu: this.sendToFeishu.bind(this), + slack: this.sendToSlack.bind(this), + discord: this.sendToDiscord.bind(this), + telegram: this.sendToTelegram.bind(this), + custom: this.sendToCustom.bind(this), + bark: this.sendToBark.bind(this), + smtp: this.sendToSMTP.bind(this) + } + this.timezone = appConfig.system.timezone || 'Asia/Shanghai' + } + + /** + * 发送通知到所有启用的平台 + */ + async sendNotification(type, data) { + try { + const config = await webhookConfigService.getConfig() + + // 检查是否启用webhook + if (!config.enabled) { + logger.debug('Webhook通知已禁用') + return + } + + // 检查通知类型是否启用(test类型始终允许发送) + if (type !== 'test' && config.notificationTypes && !config.notificationTypes[type]) { + logger.debug(`通知类型 ${type} 已禁用`) + return + } + + // 获取启用的平台 + const enabledPlatforms = await webhookConfigService.getEnabledPlatforms() + if (enabledPlatforms.length === 0) { + logger.debug('没有启用的webhook平台') + return + } + + logger.info(`📢 发送 ${type} 通知到 ${enabledPlatforms.length} 个平台`) + + // 并发发送到所有平台 + const promises = enabledPlatforms.map((platform) => + this.sendToPlatform(platform, type, data, config.retrySettings) + ) + + const results = await Promise.allSettled(promises) + + // 记录结果 + const succeeded = results.filter((r) => r.status === 'fulfilled').length + const failed = results.filter((r) => r.status === 'rejected').length + + if (failed > 0) { + logger.warn(`⚠️ Webhook通知: ${succeeded}成功, ${failed}失败`) + } else { + logger.info(`✅ 所有webhook通知发送成功`) + } + + return { succeeded, failed } + } catch (error) { + logger.error('发送webhook通知失败:', error) + throw error + } + } + + /** + * 发送到特定平台 + */ + async sendToPlatform(platform, type, data, retrySettings) { + try { + const handler = this.platformHandlers[platform.type] + if (!handler) { + throw new Error(`不支持的平台类型: ${platform.type}`) + } + + // 使用平台特定的处理器 + await this.retryWithBackoff( + () => handler(platform, type, data), + retrySettings?.maxRetries || 3, + retrySettings?.retryDelay || 1000 + ) + + logger.info(`✅ 成功发送到 ${platform.name || platform.type}`) + } catch (error) { + logger.error(`❌ 发送到 ${platform.name || platform.type} 失败:`, error.message) + throw error + } + } + + /** + * 企业微信webhook + */ + async sendToWechatWork(platform, type, data) { + const content = this.formatMessageForWechatWork(type, data) + + const payload = { + msgtype: 'markdown', + markdown: { + content + } + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * 钉钉webhook + */ + async sendToDingTalk(platform, type, data) { + const content = this.formatMessageForDingTalk(type, data) + + let { url } = platform + const payload = { + msgtype: 'markdown', + markdown: { + title: this.getNotificationTitle(type), + text: content + } + } + + // 如果启用签名 + if (platform.enableSign && platform.secret) { + const timestamp = Date.now() + const sign = this.generateDingTalkSign(platform.secret, timestamp) + url = `${url}×tamp=${timestamp}&sign=${encodeURIComponent(sign)}` + } + + await this.sendHttpRequest(url, payload, platform.timeout || 10000) + } + + /** + * 飞书webhook + */ + async sendToFeishu(platform, type, data) { + const content = this.formatMessageForFeishu(type, data) + + const payload = { + msg_type: 'interactive', + card: { + elements: [ + { + tag: 'markdown', + content + } + ], + header: { + title: { + tag: 'plain_text', + content: this.getNotificationTitle(type) + }, + template: this.getFeishuCardColor(type) + } + } + } + + // 如果启用签名 + if (platform.enableSign && platform.secret) { + const timestamp = Math.floor(Date.now() / 1000) + const sign = this.generateFeishuSign(platform.secret, timestamp) + payload.timestamp = timestamp.toString() + payload.sign = sign + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * Slack webhook + */ + async sendToSlack(platform, type, data) { + const text = this.formatMessageForSlack(type, data) + + const payload = { + text, + username: 'Claude Relay Service', + icon_emoji: this.getSlackEmoji(type) + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * Discord webhook + */ + async sendToDiscord(platform, type, data) { + const embed = this.formatMessageForDiscord(type, data) + + const payload = { + username: 'Claude Relay Service', + embeds: [embed] + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * 自定义webhook + */ + async sendToCustom(platform, type, data) { + // 使用通用格式 + const payload = { + type, + service: 'claude-relay-service', + timestamp: getISOStringWithTimezone(new Date()), + data + } + + await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) + } + + /** + * Telegram Bot 通知 + */ + async sendToTelegram(platform, type, data) { + if (!platform.botToken) { + throw new Error('缺少 Telegram 机器人 Token') + } + if (!platform.chatId) { + throw new Error('缺少 Telegram Chat ID') + } + + const baseUrl = this.normalizeTelegramApiBase(platform.apiBaseUrl) + const apiUrl = `${baseUrl}/bot${platform.botToken}/sendMessage` + const payload = { + chat_id: platform.chatId, + text: this.formatMessageForTelegram(type, data), + disable_web_page_preview: true + } + + const axiosOptions = this.buildTelegramAxiosOptions(platform) + + const response = await this.sendHttpRequest( + apiUrl, + payload, + platform.timeout || 10000, + axiosOptions + ) + if (!response || response.ok !== true) { + throw new Error(`Telegram API 错误: ${response?.description || '未知错误'}`) + } + } + + /** + * Bark webhook + */ + async sendToBark(platform, type, data) { + const payload = { + device_key: platform.deviceKey, + title: this.getNotificationTitle(type), + body: this.formatMessageForBark(type, data), + level: platform.level || this.getBarkLevel(type), + sound: platform.sound || this.getBarkSound(type), + group: platform.group || 'claude-relay', + badge: 1 + } + + // 添加可选参数 + if (platform.icon) { + payload.icon = platform.icon + } + + if (platform.clickUrl) { + payload.url = platform.clickUrl + } + + const url = platform.serverUrl || 'https://api.day.app/push' + await this.sendHttpRequest(url, payload, platform.timeout || 10000) + } + + /** + * SMTP邮件通知 + */ + async sendToSMTP(platform, type, data) { + try { + // 创建SMTP传输器 + const transporter = nodemailer.createTransport({ + host: platform.host, + port: platform.port || 587, + secure: platform.secure || false, // true for 465, false for other ports + auth: { + user: platform.user, + pass: platform.pass + }, + // 可选的TLS配置 + tls: platform.ignoreTLS ? { rejectUnauthorized: false } : undefined, + // 连接超时 + connectionTimeout: platform.timeout || 10000 + }) + + // 构造邮件内容 + const subject = this.getNotificationTitle(type) + const htmlContent = this.formatMessageForEmail(type, data) + const textContent = this.formatMessageForEmailText(type, data) + + // 邮件选项 + const mailOptions = { + from: platform.from || platform.user, // 发送者 + to: platform.to, // 接收者(必填) + subject: `[Claude Relay Service] ${subject}`, + text: textContent, + html: htmlContent + } + + // 发送邮件 + const info = await transporter.sendMail(mailOptions) + logger.info(`✅ 邮件发送成功: ${info.messageId}`) + + return info + } catch (error) { + logger.error('SMTP邮件发送失败:', error) + throw error + } + } + + /** + * 发送HTTP请求 + */ + async sendHttpRequest(url, payload, timeout, axiosOptions = {}) { + const headers = { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-relay-service/2.0', + ...(axiosOptions.headers || {}) + } + + const response = await axios.post(url, payload, { + timeout, + ...axiosOptions, + headers + }) + + if (response.status < 200 || response.status >= 300) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response.data + } + + /** + * 重试机制 + */ + async retryWithBackoff(fn, maxRetries, baseDelay) { + let lastError + + for (let i = 0; i < maxRetries; i++) { + try { + return await fn() + } catch (error) { + lastError = error + + if (i < maxRetries - 1) { + const delay = baseDelay * Math.pow(2, i) // 指数退避 + logger.debug(`🔄 重试 ${i + 1}/${maxRetries},等待 ${delay}ms`) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + throw lastError + } + + /** + * 生成钉钉签名 + */ + generateDingTalkSign(secret, timestamp) { + const stringToSign = `${timestamp}\n${secret}` + const hmac = crypto.createHmac('sha256', secret) + hmac.update(stringToSign) + return hmac.digest('base64') + } + + /** + * 生成飞书签名 + */ + generateFeishuSign(secret, timestamp) { + const stringToSign = `${timestamp}\n${secret}` + const hmac = crypto.createHmac('sha256', stringToSign) + hmac.update('') + return hmac.digest('base64') + } + + /** + * 格式化企业微信消息 + */ + formatMessageForWechatWork(type, data) { + const title = this.getNotificationTitle(type) + const details = this.formatNotificationDetails(data) + return ( + `## ${title}\n\n` + + `> **服务**: Claude Relay Service\n` + + `> **时间**: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}` + ) + } + + /** + * 格式化钉钉消息 + */ + formatMessageForDingTalk(type, data) { + const details = this.formatNotificationDetails(data) + + return ( + `#### 服务: Claude Relay Service\n` + + `#### 时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}` + ) + } + + /** + * 格式化飞书消息 + */ + formatMessageForFeishu(type, data) { + return this.formatNotificationDetails(data) + } + + /** + * 格式化Slack消息 + */ + formatMessageForSlack(type, data) { + const title = this.getNotificationTitle(type) + const details = this.formatNotificationDetails(data) + + return `*${title}*\n${details}` + } + + /** + * 规范化Telegram基础地址 + */ + normalizeTelegramApiBase(baseUrl) { + const defaultBase = 'https://api.telegram.org' + if (!baseUrl) { + return defaultBase + } + + try { + const parsed = new URL(baseUrl) + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('Telegram API 基础地址必须使用 http 或 https 协议') + } + + // 移除结尾的 / + return parsed.href.replace(/\/$/, '') + } catch (error) { + logger.warn(`⚠️ Telegram API 基础地址无效,将使用默认值: ${error.message}`) + return defaultBase + } + } + + /** + * 构建 Telegram 请求的 axios 选项(代理等) + */ + buildTelegramAxiosOptions(platform) { + const options = {} + + if (platform.proxyUrl) { + try { + const proxyUrl = new URL(platform.proxyUrl) + const { protocol } = proxyUrl + + if (protocol.startsWith('socks')) { + const agent = new SocksProxyAgent(proxyUrl.toString()) + options.httpAgent = agent + options.httpsAgent = agent + options.proxy = false + } else if (protocol === 'http:' || protocol === 'https:') { + const agent = new HttpsProxyAgent(proxyUrl.toString()) + options.httpAgent = agent + options.httpsAgent = agent + options.proxy = false + } else { + logger.warn(`⚠️ 不支持的Telegram代理协议: ${protocol}`) + } + } catch (error) { + logger.warn(`⚠️ Telegram代理配置无效,将忽略: ${error.message}`) + } + } + + return options + } + + /** + * 格式化 Telegram 消息 + */ + formatMessageForTelegram(type, data) { + const title = this.getNotificationTitle(type) + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) + const details = this.buildNotificationDetails(data) + + const lines = [`${title}`, '服务: Claude Relay Service'] + + if (details.length > 0) { + lines.push('') + for (const detail of details) { + lines.push(`${detail.label}: ${detail.value}`) + } + } + + lines.push('', `时间: ${timestamp}`) + + return lines.join('\n') + } + + /** + * 格式化Discord消息 + */ + formatMessageForDiscord(type, data) { + const title = this.getNotificationTitle(type) + const color = this.getDiscordColor(type) + const fields = this.formatNotificationFields(data) + + return { + title, + color, + fields, + timestamp: getISOStringWithTimezone(new Date()), + footer: { + text: 'Claude Relay Service' + } + } + } + + /** + * 获取通知标题 + */ + getNotificationTitle(type) { + const titles = { + accountAnomaly: '⚠️ 账号异常通知', + quotaWarning: '📊 配额警告', + systemError: '❌ 系统错误', + securityAlert: '🔒 安全警报', + rateLimitRecovery: '🎉 限流恢复通知', + test: '🧪 测试通知' + } + + return titles[type] || '📢 系统通知' + } + + /** + * 获取Bark通知级别 + */ + getBarkLevel(type) { + const levels = { + accountAnomaly: 'timeSensitive', + quotaWarning: 'active', + systemError: 'critical', + securityAlert: 'critical', + rateLimitRecovery: 'active', + test: 'passive' + } + + return levels[type] || 'active' + } + + /** + * 获取Bark声音 + */ + getBarkSound(type) { + const sounds = { + accountAnomaly: 'alarm', + quotaWarning: 'bell', + systemError: 'alert', + securityAlert: 'alarm', + rateLimitRecovery: 'success', + test: 'default' + } + + return sounds[type] || 'default' + } + + /** + * 格式化Bark消息 + */ + formatMessageForBark(type, data) { + const lines = [] + + if (data.accountName) { + lines.push(`账号: ${data.accountName}`) + } + + if (data.platform) { + lines.push(`平台: ${data.platform}`) + } + + if (data.status) { + lines.push(`状态: ${data.status}`) + } + + if (data.errorCode) { + lines.push(`错误: ${data.errorCode}`) + } + + if (data.reason) { + lines.push(`原因: ${data.reason}`) + } + + if (data.message) { + lines.push(`消息: ${data.message}`) + } + + if (data.quota) { + lines.push(`剩余配额: ${data.quota.remaining}/${data.quota.total}`) + } + + if (data.usage) { + lines.push(`使用率: ${data.usage}%`) + } + + // 添加服务标识和时间戳 + lines.push(`\n服务: Claude Relay Service`) + lines.push(`时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}`) + + return lines.join('\n') + } + + /** + * 构建通知详情数据 + */ + buildNotificationDetails(data) { + const details = [] + + if (data.accountName) { + details.push({ label: '账号', value: data.accountName }) + } + if (data.platform) { + details.push({ label: '平台', value: data.platform }) + } + if (data.status) { + details.push({ label: '状态', value: data.status, color: this.getStatusColor(data.status) }) + } + if (data.errorCode) { + details.push({ label: '错误代码', value: data.errorCode, isCode: true }) + } + if (data.reason) { + details.push({ label: '原因', value: data.reason }) + } + if (data.message) { + details.push({ label: '消息', value: data.message }) + } + if (data.quota) { + details.push({ label: '配额', value: `${data.quota.remaining}/${data.quota.total}` }) + } + if (data.usage) { + details.push({ label: '使用率', value: `${data.usage}%` }) + } + + return details + } + + /** + * 格式化邮件HTML内容 + */ + formatMessageForEmail(type, data) { + const title = this.getNotificationTitle(type) + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) + const details = this.buildNotificationDetails(data) + + let content = ` +
+
+

${title}

+

Claude Relay Service

+
+
+
+ ` + + // 使用统一的详情数据渲染 + details.forEach((detail) => { + if (detail.isCode) { + content += `

${detail.label}: ${detail.value}

` + } else if (detail.color) { + content += `

${detail.label}: ${detail.value}

` + } else { + content += `

${detail.label}: ${detail.value}

` + } + }) + + content += ` +
+
+

发送时间: ${timestamp}

+

此邮件由 Claude Relay Service 自动发送

+
+
+
+ ` + + return content + } + + /** + * 格式化邮件纯文本内容 + */ + formatMessageForEmailText(type, data) { + const title = this.getNotificationTitle(type) + const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) + const details = this.buildNotificationDetails(data) + + let content = `${title}\n` + content += `=====================================\n\n` + + // 使用统一的详情数据渲染 + details.forEach((detail) => { + content += `${detail.label}: ${detail.value}\n` + }) + + content += `\n发送时间: ${timestamp}\n` + content += `服务: Claude Relay Service\n` + content += `=====================================\n` + content += `此邮件由系统自动发送,请勿回复。` + + return content + } + + /** + * 获取状态颜色 + */ + getStatusColor(status) { + const colors = { + error: '#dc3545', + unauthorized: '#fd7e14', + blocked: '#6f42c1', + disabled: '#6c757d', + active: '#28a745', + warning: '#ffc107' + } + return colors[status] || '#007bff' + } + + /** + * 格式化通知详情 + */ + formatNotificationDetails(data) { + const lines = [] + + if (data.accountName) { + lines.push(`**账号**: ${data.accountName}`) + } + + if (data.platform) { + lines.push(`**平台**: ${data.platform}`) + } + + if (data.platforms) { + lines.push(`**涉及平台**: ${data.platforms.join(', ')}`) + } + + if (data.totalAccounts) { + lines.push(`**恢复账户数**: ${data.totalAccounts}`) + } + + if (data.status) { + lines.push(`**状态**: ${data.status}`) + } + + if (data.errorCode) { + lines.push(`**错误代码**: ${data.errorCode}`) + } + + if (data.reason) { + lines.push(`**原因**: ${data.reason}`) + } + + if (data.message) { + lines.push(`**消息**: ${data.message}`) + } + + if (data.quota) { + lines.push(`**剩余配额**: ${data.quota.remaining}/${data.quota.total}`) + } + + if (data.usage) { + lines.push(`**使用率**: ${data.usage}%`) + } + + return lines.join('\n') + } + + /** + * 格式化Discord字段 + */ + formatNotificationFields(data) { + const fields = [] + + if (data.accountName) { + fields.push({ name: '账号', value: data.accountName, inline: true }) + } + + if (data.platform) { + fields.push({ name: '平台', value: data.platform, inline: true }) + } + + if (data.status) { + fields.push({ name: '状态', value: data.status, inline: true }) + } + + if (data.errorCode) { + fields.push({ name: '错误代码', value: data.errorCode, inline: false }) + } + + if (data.reason) { + fields.push({ name: '原因', value: data.reason, inline: false }) + } + + if (data.message) { + fields.push({ name: '消息', value: data.message, inline: false }) + } + + return fields + } + + /** + * 获取飞书卡片颜色 + */ + getFeishuCardColor(type) { + const colors = { + accountAnomaly: 'orange', + quotaWarning: 'yellow', + systemError: 'red', + securityAlert: 'red', + rateLimitRecovery: 'green', + test: 'blue' + } + + return colors[type] || 'blue' + } + + /** + * 获取Slack emoji + */ + getSlackEmoji(type) { + const emojis = { + accountAnomaly: ':warning:', + quotaWarning: ':chart_with_downwards_trend:', + systemError: ':x:', + securityAlert: ':lock:', + rateLimitRecovery: ':tada:', + test: ':test_tube:' + } + + return emojis[type] || ':bell:' + } + + /** + * 获取Discord颜色 + */ + getDiscordColor(type) { + const colors = { + accountAnomaly: 0xff9800, // 橙色 + quotaWarning: 0xffeb3b, // 黄色 + systemError: 0xf44336, // 红色 + securityAlert: 0xf44336, // 红色 + rateLimitRecovery: 0x4caf50, // 绿色 + test: 0x2196f3 // 蓝色 + } + + return colors[type] || 0x9e9e9e // 灰色 + } + + /** + * 测试webhook连接 + */ + async testWebhook(platform) { + try { + const testData = { + message: 'Claude Relay Service webhook测试', + timestamp: getISOStringWithTimezone(new Date()) + } + + await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 }) + + return { success: true } + } catch (error) { + return { + success: false, + error: error.message + } + } + } +} + +module.exports = new WebhookService() diff --git a/src/services/weeklyClaudeCostInitService.js b/src/services/weeklyClaudeCostInitService.js new file mode 100644 index 0000000..5031bdd --- /dev/null +++ b/src/services/weeklyClaudeCostInitService.js @@ -0,0 +1,456 @@ +const redis = require('../models/redis') +const logger = require('../utils/logger') +const pricingService = require('./pricingService') +const serviceRatesService = require('./serviceRatesService') +const { isClaudeFamilyModel } = require('../utils/modelHelper') + +function pad2(n) { + return String(n).padStart(2, '0') +} + +// 生成配置时区下的 YYYY-MM-DD 字符串。 +// 注意:入参 date 必须是 redis.getDateInTimezone() 生成的"时区偏移后"的 Date。 +function formatTzDateYmd(tzDate) { + return `${tzDate.getUTCFullYear()}-${pad2(tzDate.getUTCMonth() + 1)}-${pad2(tzDate.getUTCDate())}` +} + +// 推断账户类型的辅助函数(与运行时 recordOpusCost 一致,只统计 claude-official/claude-console/ccr) +const OPUS_ACCOUNT_TYPES = ['claude-official', 'claude-console', 'ccr'] + +function inferAccountType(keyData) { + if (keyData?.ccrAccountId) { + return 'ccr' + } + if (keyData?.claudeConsoleAccountId) { + return 'claude-console' + } + if (keyData?.claudeAccountId) { + return 'claude-official' + } + // bedrock/azure/gemini 等不计入周费用 + return null +} + +function toInt(v) { + const n = parseInt(v || '0', 10) + return Number.isFinite(n) ? n : 0 +} + +class WeeklyClaudeCostInitService { + // 获取最近 7 天的日期字符串数组(覆盖任意重置配置的完整周期) + _getLast7DaysInTimezone() { + const tzNow = redis.getDateInTimezone(new Date()) + const tzToday = new Date(tzNow) + tzToday.setUTCHours(0, 0, 0, 0) + + const dates = [] + for (let i = 7; i >= 0; i--) { + const d = new Date(tzToday) + d.setUTCDate(tzToday.getUTCDate() - i) + dates.push(formatTzDateYmd(d)) + } + return dates + } + + _buildWeeklyOpusKey(keyId, periodString) { + return `usage:opus:weekly:${keyId}:${periodString}` + } + + /** + * 启动回填:从"按日/按模型"统计中反算 Claude 模型费用, + * 根据每个 API Key 的 weeklyResetDay/weeklyResetHour 计算周期, + * 写入 `usage:opus:weekly:*`,保证周限额在重启后不归零。 + * + * 说明: + * - 回填最近 8 天数据(覆盖任意重置配置的完整 7 天周期) + * - 会加分布式锁,避免多实例重复跑 + * - 会写 done 标记:同一天内重启默认不重复回填 + */ + async backfillCurrentWeekClaudeCosts() { + const client = redis.getClientSafe() + if (!client) { + logger.warn('⚠️ Claude 周费用回填跳过:Redis client 不可用') + return { success: false, reason: 'redis_unavailable' } + } + + if (!pricingService || !pricingService.pricingData) { + logger.warn('⚠️ Claude 周费用回填跳过:pricing service 未初始化') + return { success: false, reason: 'pricing_uninitialized' } + } + + const todayStr = redis.getDateStringInTimezone() + const doneKey = `init:weekly_opus_cost:${todayStr}:done` + + try { + const alreadyDone = await client.get(doneKey) + if (alreadyDone) { + logger.info(`ℹ️ Claude 周费用回填已完成(${todayStr}),跳过`) + return { success: true, skipped: true } + } + } catch (e) { + // 尽力而为:读取失败不阻断启动回填流程。 + } + + const lockKey = `lock:init:weekly_opus_cost:${todayStr}` + const lockValue = `${process.pid}:${Date.now()}` + const lockTtlMs = 15 * 60 * 1000 + + const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTtlMs) + if (!lockAcquired) { + logger.info(`ℹ️ Claude 周费用回填已在运行(${todayStr}),跳过`) + return { success: true, skipped: true, reason: 'locked' } + } + + const startedAt = Date.now() + try { + logger.info(`💰 开始回填 Claude 周费用(${todayStr})...`) + + const keyIds = await redis.scanApiKeyIds() + const dates = this._getLast7DaysInTimezone() + + // 预加载所有 API Key 数据和全局倍率 + const keyDataCache = new Map() + const globalRateCache = new Map() + const batchSize = 500 + for (let i = 0; i < keyIds.length; i += batchSize) { + const batch = keyIds.slice(i, i + batchSize) + const pipeline = client.pipeline() + for (const keyId of batch) { + pipeline.hgetall(`apikey:${keyId}`) + } + const results = await pipeline.exec() + for (let j = 0; j < batch.length; j++) { + const [, data] = results[j] || [] + if (data && Object.keys(data).length > 0) { + keyDataCache.set(batch[j], data) + } + } + } + logger.info(`💰 预加载 ${keyDataCache.size} 个 API Key 数据`) + + // 收集每个 key 每天的费用: Map> + const costByKeyDate = new Map() + let scannedKeys = 0 + let matchedClaudeKeys = 0 + + for (const dateStr of dates) { + let cursor = '0' + const pattern = `usage:*:model:daily:*:${dateStr}` + + do { + const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000) + cursor = nextCursor + scannedKeys += keys.length + + const entries = [] + for (const usageKey of keys) { + const match = usageKey.match(/^usage:([^:]+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) + if (!match) { + continue + } + const keyId = match[1] + const model = match[2] + if (!isClaudeFamilyModel(model)) { + continue + } + matchedClaudeKeys++ + entries.push({ usageKey, keyId, model, dateStr }) + } + + if (entries.length === 0) { + continue + } + + const pipeline = client.pipeline() + for (const entry of entries) { + pipeline.hgetall(entry.usageKey) + } + const results = await pipeline.exec() + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i] + const [, data] = results[i] || [] + if (!data || Object.keys(data).length === 0) { + continue + } + + const inputTokens = toInt(data.totalInputTokens || data.inputTokens) + const outputTokens = toInt(data.totalOutputTokens || data.outputTokens) + const cacheReadTokens = toInt(data.totalCacheReadTokens || data.cacheReadTokens) + const cacheCreateTokens = toInt(data.totalCacheCreateTokens || data.cacheCreateTokens) + const ephemeral5mTokens = toInt(data.ephemeral5mTokens) + const ephemeral1hTokens = toInt(data.ephemeral1hTokens) + + const cacheCreationTotal = + ephemeral5mTokens > 0 || ephemeral1hTokens > 0 + ? ephemeral5mTokens + ephemeral1hTokens + : cacheCreateTokens + + const usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreationTotal, + cache_read_input_tokens: cacheReadTokens + } + + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + const costInfo = pricingService.calculateCost(usage, entry.model) + const realCost = costInfo && costInfo.totalCost ? costInfo.totalCost : 0 + if (realCost <= 0) { + continue + } + + const keyData = keyDataCache.get(entry.keyId) + const accountType = inferAccountType(keyData) + + if (!accountType || !OPUS_ACCOUNT_TYPES.includes(accountType)) { + continue + } + + const service = serviceRatesService.getService(accountType, entry.model) + + let globalRate = globalRateCache.get(service) + if (globalRate === undefined) { + globalRate = await serviceRatesService.getServiceRate(service) + globalRateCache.set(service, globalRate) + } + + let keyRates = {} + try { + keyRates = JSON.parse(keyData?.serviceRates || '{}') + } catch (e) { + keyRates = {} + } + const keyRate = keyRates[service] ?? 1.0 + const ratedCost = realCost * globalRate * keyRate + + // 按 keyId+dateStr 累加 + if (!costByKeyDate.has(entry.keyId)) { + costByKeyDate.set(entry.keyId, new Map()) + } + const dateMap = costByKeyDate.get(entry.keyId) + dateMap.set(entry.dateStr, (dateMap.get(entry.dateStr) || 0) + ratedCost) + } + } while (cursor !== '0') + } + + // 为每个 API Key 按其重置配置计算当前周期费用 + const ttlSeconds = 14 * 24 * 3600 + let filledCount = 0 + for (let i = 0; i < keyIds.length; i += batchSize) { + const batch = keyIds.slice(i, i + batchSize) + const pipeline = client.pipeline() + for (const keyId of batch) { + const keyData = keyDataCache.get(keyId) + const resetDay = parseInt(keyData?.weeklyResetDay || 1) + const resetHour = parseInt(keyData?.weeklyResetHour || 0) + + // 获取当前周期的起始日期 + const periodStart = redis.getPeriodStartDate(resetDay, resetHour) + const periodStartDateStr = formatTzDateYmd(periodStart) + const periodString = redis.getPeriodString(resetDay, resetHour) + + // 汇总该 key 在当前周期内的费用 + const dateMap = costByKeyDate.get(keyId) + let periodCost = 0 + if (dateMap) { + for (const [dateStr, cost] of dateMap) { + if (dateStr >= periodStartDateStr) { + periodCost += cost + } + } + } + + if (periodCost > 0) { + filledCount++ + } + + const weeklyKey = this._buildWeeklyOpusKey(keyId, periodString) + pipeline.set(weeklyKey, String(periodCost)) + pipeline.expire(weeklyKey, ttlSeconds) + } + await pipeline.exec() + } + + // 写入 done 标记(保留 2 天,每天重新回填一次) + await client.set(doneKey, new Date().toISOString(), 'EX', 2 * 24 * 3600) + + const durationMs = Date.now() - startedAt + logger.info( + `✅ Claude 周费用回填完成(${todayStr}):keys=${keyIds.length}, scanned=${scannedKeys}, matchedClaude=${matchedClaudeKeys}, filled=${filledCount}(${durationMs}ms)` + ) + + return { + success: true, + todayStr, + keyCount: keyIds.length, + scannedKeys, + matchedClaudeKeys, + filledKeys: filledCount, + durationMs + } + } catch (error) { + logger.error(`❌ Claude 周费用回填失败(${todayStr}):`, error) + return { success: false, error: error.message } + } finally { + await redis.releaseAccountLock(lockKey, lockValue) + } + } + + /** + * 为单个 API Key 回填当前周期费用(重置配置变更后触发) + */ + async backfillSingleKey(keyId) { + const client = redis.getClientSafe() + if (!client) { + logger.warn(`⚠️ 单 Key 回填跳过 (${keyId}):Redis client 不可用`) + return { success: false, reason: 'redis_unavailable' } + } + + if (!pricingService || !pricingService.pricingData) { + try { + await pricingService.initialize() + } catch (e) { + logger.warn(`⚠️ 单 Key 回填跳过 (${keyId}):pricing service 未初始化`) + return { success: false, reason: 'pricing_uninitialized' } + } + } + + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + return { success: false, reason: 'key_not_found' } + } + + const resetDay = parseInt(keyData.weeklyResetDay || 1) + const resetHour = parseInt(keyData.weeklyResetHour || 0) + + const accountType = inferAccountType(keyData) + if (!accountType || !OPUS_ACCOUNT_TYPES.includes(accountType)) { + // 非 Claude 账户,写入 0 即可 + const periodString = redis.getPeriodString(resetDay, resetHour) + await redis.setWeeklyOpusCost(keyId, 0, periodString) + return { success: true, cost: 0, reason: 'non_claude_account' } + } + + const periodStart = redis.getPeriodStartDate(resetDay, resetHour) + const periodStartDateStr = formatTzDateYmd(periodStart) + const periodString = redis.getPeriodString(resetDay, resetHour) + + // 扫描最近 8 天的每日使用数据 + const dates = this._getLast7DaysInTimezone() + const globalRateCache = new Map() + let totalCost = 0 + + for (const dateStr of dates) { + if (dateStr < periodStartDateStr) { + continue + } + + let cursor = '0' + const pattern = `usage:${keyId}:model:daily:*:${dateStr}` + + do { + const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000) + cursor = nextCursor + + if (keys.length === 0) { + continue + } + + const pipeline = client.pipeline() + const models = [] + for (const usageKey of keys) { + const match = usageKey.match(/^usage:[^:]+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) + if (!match || !isClaudeFamilyModel(match[1])) { + continue + } + models.push(match[1]) + pipeline.hgetall(usageKey) + } + + if (models.length === 0) { + continue + } + + const results = await pipeline.exec() + + for (let i = 0; i < models.length; i++) { + const model = models[i] + const [, data] = results[i] || [] + if (!data || Object.keys(data).length === 0) { + continue + } + + const inputTokens = toInt(data.totalInputTokens || data.inputTokens) + const outputTokens = toInt(data.totalOutputTokens || data.outputTokens) + const cacheReadTokens = toInt(data.totalCacheReadTokens || data.cacheReadTokens) + const cacheCreateTokens = toInt(data.totalCacheCreateTokens || data.cacheCreateTokens) + const ephemeral5mTokens = toInt(data.ephemeral5mTokens) + const ephemeral1hTokens = toInt(data.ephemeral1hTokens) + + const cacheCreationTotal = + ephemeral5mTokens > 0 || ephemeral1hTokens > 0 + ? ephemeral5mTokens + ephemeral1hTokens + : cacheCreateTokens + + const usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreationTotal, + cache_read_input_tokens: cacheReadTokens + } + + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + const costInfo = pricingService.calculateCost(usage, model) + const realCost = costInfo && costInfo.totalCost ? costInfo.totalCost : 0 + if (realCost <= 0) { + continue + } + + const service = serviceRatesService.getService(accountType, model) + + let globalRate = globalRateCache.get(service) + if (globalRate === undefined) { + globalRate = await serviceRatesService.getServiceRate(service) + globalRateCache.set(service, globalRate) + } + + let keyRates = {} + try { + keyRates = JSON.parse(keyData.serviceRates || '{}') + } catch (e) { + keyRates = {} + } + const keyRate = keyRates[service] ?? 1.0 + totalCost += realCost * globalRate * keyRate + } + } while (cursor !== '0') + } + + await redis.setWeeklyOpusCost(keyId, totalCost, periodString) + logger.info( + `💰 单 Key 回填完成 (${keyId}):period=${periodString}, cost=$${totalCost.toFixed(6)}` + ) + + return { success: true, cost: totalCost, periodString } + } catch (error) { + logger.error(`❌ 单 Key 回填失败 (${keyId}):`, error) + return { success: false, error: error.message } + } + } +} + +module.exports = new WeeklyClaudeCostInitService() diff --git a/src/utils/anthropicRequestDump.js b/src/utils/anthropicRequestDump.js new file mode 100644 index 0000000..8e75506 --- /dev/null +++ b/src/utils/anthropicRequestDump.js @@ -0,0 +1,126 @@ +const path = require('path') +const logger = require('./logger') +const { getProjectRoot } = require('./projectPaths') +const { safeRotatingAppend } = require('./safeRotatingAppend') + +const REQUEST_DUMP_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP' +const REQUEST_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_REQUEST_DUMP_MAX_BYTES' +const REQUEST_DUMP_FILENAME = 'anthropic-requests-dump.jsonl' + +function isEnabled() { + const raw = process.env[REQUEST_DUMP_ENV] + if (!raw) { + return false + } + return raw === '1' || raw.toLowerCase() === 'true' +} + +function getMaxBytes() { + const raw = process.env[REQUEST_DUMP_MAX_BYTES_ENV] + if (!raw) { + return 2 * 1024 * 1024 + } + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + return 2 * 1024 * 1024 + } + return parsed +} + +function maskSecret(value) { + if (value === null || value === undefined) { + return value + } + const str = String(value) + if (str.length <= 8) { + return '***' + } + return `${str.slice(0, 4)}...${str.slice(-4)}` +} + +function sanitizeHeaders(headers) { + const sensitive = new Set([ + 'authorization', + 'proxy-authorization', + 'x-api-key', + 'cookie', + 'set-cookie', + 'x-forwarded-for', + 'x-real-ip' + ]) + + const out = {} + for (const [k, v] of Object.entries(headers || {})) { + const key = k.toLowerCase() + if (sensitive.has(key)) { + out[key] = maskSecret(v) + continue + } + out[key] = v + } + return out +} + +function safeJsonStringify(payload, maxBytes) { + let json = '' + try { + json = JSON.stringify(payload) + } catch (e) { + return JSON.stringify({ + type: 'anthropic_request_dump_error', + error: 'JSON.stringify_failed', + message: e?.message || String(e) + }) + } + + if (Buffer.byteLength(json, 'utf8') <= maxBytes) { + return json + } + + const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8') + return JSON.stringify({ + type: 'anthropic_request_dump_truncated', + maxBytes, + originalBytes: Buffer.byteLength(json, 'utf8'), + partialJson: truncated + }) +} + +async function dumpAnthropicMessagesRequest(req, meta = {}) { + if (!isEnabled()) { + return + } + + const maxBytes = getMaxBytes() + const filename = path.join(getProjectRoot(), REQUEST_DUMP_FILENAME) + + const record = { + ts: new Date().toISOString(), + requestId: req?.requestId || null, + method: req?.method || null, + url: req?.originalUrl || req?.url || null, + ip: req?.ip || null, + meta, + headers: sanitizeHeaders(req?.headers || {}), + body: req?.body || null + } + + const line = `${safeJsonStringify(record, maxBytes)}\n` + + try { + await safeRotatingAppend(filename, line) + } catch (e) { + logger.warn('Failed to dump Anthropic request', { + filename, + requestId: req?.requestId || null, + error: e?.message || String(e) + }) + } +} + +module.exports = { + dumpAnthropicMessagesRequest, + REQUEST_DUMP_ENV, + REQUEST_DUMP_MAX_BYTES_ENV, + REQUEST_DUMP_FILENAME +} diff --git a/src/utils/anthropicResponseDump.js b/src/utils/anthropicResponseDump.js new file mode 100644 index 0000000..c21605b --- /dev/null +++ b/src/utils/anthropicResponseDump.js @@ -0,0 +1,125 @@ +const path = require('path') +const logger = require('./logger') +const { getProjectRoot } = require('./projectPaths') +const { safeRotatingAppend } = require('./safeRotatingAppend') + +const RESPONSE_DUMP_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP' +const RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTHROPIC_DEBUG_RESPONSE_DUMP_MAX_BYTES' +const RESPONSE_DUMP_FILENAME = 'anthropic-responses-dump.jsonl' + +function isEnabled() { + const raw = process.env[RESPONSE_DUMP_ENV] + if (!raw) { + return false + } + return raw === '1' || raw.toLowerCase() === 'true' +} + +function getMaxBytes() { + const raw = process.env[RESPONSE_DUMP_MAX_BYTES_ENV] + if (!raw) { + return 2 * 1024 * 1024 + } + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + return 2 * 1024 * 1024 + } + return parsed +} + +function safeJsonStringify(payload, maxBytes) { + let json = '' + try { + json = JSON.stringify(payload) + } catch (e) { + return JSON.stringify({ + type: 'anthropic_response_dump_error', + error: 'JSON.stringify_failed', + message: e?.message || String(e) + }) + } + + if (Buffer.byteLength(json, 'utf8') <= maxBytes) { + return json + } + + const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8') + return JSON.stringify({ + type: 'anthropic_response_dump_truncated', + maxBytes, + originalBytes: Buffer.byteLength(json, 'utf8'), + partialJson: truncated + }) +} + +function summarizeAnthropicResponseBody(body) { + const content = Array.isArray(body?.content) ? body.content : [] + const toolUses = content.filter((b) => b && b.type === 'tool_use') + const texts = content + .filter((b) => b && b.type === 'text' && typeof b.text === 'string') + .map((b) => b.text) + .join('') + + return { + id: body?.id || null, + model: body?.model || null, + stop_reason: body?.stop_reason || null, + usage: body?.usage || null, + content_blocks: content.map((b) => (b ? b.type : null)).filter(Boolean), + tool_use_names: toolUses.map((b) => b.name).filter(Boolean), + text_preview: texts ? texts.slice(0, 800) : '' + } +} + +async function dumpAnthropicResponse(req, responseInfo, meta = {}) { + if (!isEnabled()) { + return + } + + const maxBytes = getMaxBytes() + const filename = path.join(getProjectRoot(), RESPONSE_DUMP_FILENAME) + + const record = { + ts: new Date().toISOString(), + requestId: req?.requestId || null, + url: req?.originalUrl || req?.url || null, + meta, + response: responseInfo + } + + const line = `${safeJsonStringify(record, maxBytes)}\n` + try { + await safeRotatingAppend(filename, line) + } catch (e) { + logger.warn('Failed to dump Anthropic response', { + filename, + requestId: req?.requestId || null, + error: e?.message || String(e) + }) + } +} + +async function dumpAnthropicNonStreamResponse(req, statusCode, body, meta = {}) { + return dumpAnthropicResponse( + req, + { kind: 'non-stream', statusCode, summary: summarizeAnthropicResponseBody(body), body }, + meta + ) +} + +async function dumpAnthropicStreamSummary(req, summary, meta = {}) { + return dumpAnthropicResponse(req, { kind: 'stream', summary }, meta) +} + +async function dumpAnthropicStreamError(req, error, meta = {}) { + return dumpAnthropicResponse(req, { kind: 'stream-error', error }, meta) +} + +module.exports = { + dumpAnthropicNonStreamResponse, + dumpAnthropicStreamSummary, + dumpAnthropicStreamError, + RESPONSE_DUMP_ENV, + RESPONSE_DUMP_MAX_BYTES_ENV, + RESPONSE_DUMP_FILENAME +} diff --git a/src/utils/antigravityModel.js b/src/utils/antigravityModel.js new file mode 100644 index 0000000..bf400db --- /dev/null +++ b/src/utils/antigravityModel.js @@ -0,0 +1,158 @@ +const DEFAULT_ANTIGRAVITY_MODEL = 'gemini-2.5-flash' + +const UPSTREAM_TO_ALIAS = { + 'rev19-uic3-1p': 'gemini-2.5-computer-use-preview-10-2025', + 'gemini-3-pro-image': 'gemini-3-pro-image-preview', + 'gemini-3-pro-high': 'gemini-3-pro-preview', + 'gemini-3.1-pro-high': 'gemini-3.1-pro-preview', + 'gemini-3-flash': 'gemini-3-flash-preview', + 'claude-sonnet-4-5': 'gemini-claude-sonnet-4-5', + 'claude-sonnet-4-5-thinking': 'gemini-claude-sonnet-4-5-thinking', + 'claude-opus-4-5-thinking': 'gemini-claude-opus-4-5-thinking', + 'claude-opus-4-6-thinking': 'gemini-claude-opus-4-6-thinking', + chat_20706: '', + chat_23310: '', + 'gemini-2.5-flash-thinking': '', + 'gemini-3-pro-low': '', + 'gemini-3.1-pro-low': '', + 'gemini-2.5-pro': '' +} + +const ALIAS_TO_UPSTREAM = { + 'gemini-2.5-computer-use-preview-10-2025': 'rev19-uic3-1p', + 'gemini-3-pro-image-preview': 'gemini-3-pro-image', + 'gemini-3-pro-preview': 'gemini-3-pro-high', + 'gemini-3.1-pro-preview': 'gemini-3.1-pro-high', + 'gemini-3-flash-preview': 'gemini-3-flash', + 'gemini-claude-sonnet-4-5': 'claude-sonnet-4-5', + 'gemini-claude-sonnet-4-5-thinking': 'claude-sonnet-4-5-thinking', + 'gemini-claude-opus-4-5-thinking': 'claude-opus-4-5-thinking', + 'gemini-claude-opus-4-6-thinking': 'claude-opus-4-6-thinking' +} + +const ANTIGRAVITY_MODEL_METADATA = { + 'gemini-2.5-flash': { + thinking: { min: 0, max: 24576, zeroAllowed: true, dynamicAllowed: true }, + name: 'models/gemini-2.5-flash' + }, + 'gemini-2.5-flash-lite': { + thinking: { min: 0, max: 24576, zeroAllowed: true, dynamicAllowed: true }, + name: 'models/gemini-2.5-flash-lite' + }, + 'gemini-2.5-computer-use-preview-10-2025': { + name: 'models/gemini-2.5-computer-use-preview-10-2025' + }, + 'gemini-3-pro-preview': { + thinking: { + min: 128, + max: 32768, + zeroAllowed: false, + dynamicAllowed: true, + levels: ['low', 'high'] + }, + name: 'models/gemini-3-pro-preview' + }, + 'gemini-3-pro-image-preview': { + thinking: { + min: 128, + max: 32768, + zeroAllowed: false, + dynamicAllowed: true, + levels: ['low', 'high'] + }, + name: 'models/gemini-3-pro-image-preview' + }, + 'gemini-3.1-pro-preview': { + thinking: { + min: 128, + max: 32768, + zeroAllowed: false, + dynamicAllowed: true, + levels: ['low', 'high'] + }, + name: 'models/gemini-3.1-pro-preview' + }, + 'gemini-3-flash-preview': { + thinking: { + min: 128, + max: 32768, + zeroAllowed: false, + dynamicAllowed: true, + levels: ['minimal', 'low', 'medium', 'high'] + }, + name: 'models/gemini-3-flash-preview' + }, + 'gemini-claude-sonnet-4-5-thinking': { + thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true }, + maxCompletionTokens: 64000 + }, + 'gemini-claude-opus-4-5-thinking': { + thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true }, + maxCompletionTokens: 64000 + }, + 'gemini-claude-opus-4-6-thinking': { + thinking: { min: 1024, max: 200000, zeroAllowed: false, dynamicAllowed: true }, + maxCompletionTokens: 64000 + } +} + +function normalizeAntigravityModelInput(model, defaultModel = DEFAULT_ANTIGRAVITY_MODEL) { + if (!model) { + return defaultModel + } + return model.startsWith('models/') ? model.slice('models/'.length) : model +} + +function getAntigravityModelAlias(modelName) { + const normalized = normalizeAntigravityModelInput(modelName) + if (Object.prototype.hasOwnProperty.call(UPSTREAM_TO_ALIAS, normalized)) { + return UPSTREAM_TO_ALIAS[normalized] + } + return normalized +} + +function getAntigravityModelMetadata(modelName) { + const normalized = normalizeAntigravityModelInput(modelName) + if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, normalized)) { + return ANTIGRAVITY_MODEL_METADATA[normalized] + } + if (normalized.startsWith('claude-')) { + const prefixed = `gemini-${normalized}` + if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, prefixed)) { + return ANTIGRAVITY_MODEL_METADATA[prefixed] + } + const thinkingAlias = `${prefixed}-thinking` + if (Object.prototype.hasOwnProperty.call(ANTIGRAVITY_MODEL_METADATA, thinkingAlias)) { + return ANTIGRAVITY_MODEL_METADATA[thinkingAlias] + } + } + return null +} + +function mapAntigravityUpstreamModel(model) { + const normalized = normalizeAntigravityModelInput(model) + let upstream = Object.prototype.hasOwnProperty.call(ALIAS_TO_UPSTREAM, normalized) + ? ALIAS_TO_UPSTREAM[normalized] + : normalized + + if (upstream.startsWith('gemini-claude-')) { + upstream = upstream.replace(/^gemini-/, '') + } + + const mapping = { + // Opus:上游更常见的是 thinking 变体(CLIProxyAPI 也按此处理) + 'claude-opus-4-5': 'claude-opus-4-5-thinking', + 'claude-opus-4-6': 'claude-opus-4-6-thinking', + // Gemini thinking 变体回退 + 'gemini-2.5-flash-thinking': 'gemini-2.5-flash' + } + + return mapping[upstream] || upstream +} + +module.exports = { + normalizeAntigravityModelInput, + getAntigravityModelAlias, + getAntigravityModelMetadata, + mapAntigravityUpstreamModel +} diff --git a/src/utils/antigravityUpstreamDump.js b/src/utils/antigravityUpstreamDump.js new file mode 100644 index 0000000..56120aa --- /dev/null +++ b/src/utils/antigravityUpstreamDump.js @@ -0,0 +1,121 @@ +const path = require('path') +const logger = require('./logger') +const { getProjectRoot } = require('./projectPaths') +const { safeRotatingAppend } = require('./safeRotatingAppend') + +const UPSTREAM_REQUEST_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP' +const UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_REQUEST_DUMP_MAX_BYTES' +const UPSTREAM_REQUEST_DUMP_FILENAME = 'antigravity-upstream-requests-dump.jsonl' + +function isEnabled() { + const raw = process.env[UPSTREAM_REQUEST_DUMP_ENV] + if (!raw) { + return false + } + const normalized = String(raw).trim().toLowerCase() + return normalized === '1' || normalized === 'true' +} + +function getMaxBytes() { + const raw = process.env[UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV] + if (!raw) { + return 2 * 1024 * 1024 + } + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + return 2 * 1024 * 1024 + } + return parsed +} + +function redact(value) { + if (!value) { + return value + } + const s = String(value) + if (s.length <= 10) { + return '***' + } + return `${s.slice(0, 3)}...${s.slice(-4)}` +} + +function safeJsonStringify(payload, maxBytes) { + let json = '' + try { + json = JSON.stringify(payload) + } catch (e) { + return JSON.stringify({ + type: 'antigravity_upstream_dump_error', + error: 'JSON.stringify_failed', + message: e?.message || String(e) + }) + } + + if (Buffer.byteLength(json, 'utf8') <= maxBytes) { + return json + } + + const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8') + return JSON.stringify({ + type: 'antigravity_upstream_dump_truncated', + maxBytes, + originalBytes: Buffer.byteLength(json, 'utf8'), + partialJson: truncated + }) +} + +async function dumpAntigravityUpstreamRequest(requestInfo) { + if (!isEnabled()) { + return + } + + const maxBytes = getMaxBytes() + const filename = path.join(getProjectRoot(), UPSTREAM_REQUEST_DUMP_FILENAME) + + const record = { + ts: new Date().toISOString(), + type: 'antigravity_upstream_request', + requestId: requestInfo?.requestId || null, + model: requestInfo?.model || null, + stream: Boolean(requestInfo?.stream), + url: requestInfo?.url || null, + baseUrl: requestInfo?.baseUrl || null, + params: requestInfo?.params || null, + headers: requestInfo?.headers + ? { + Host: requestInfo.headers.Host || requestInfo.headers.host || null, + 'User-Agent': + requestInfo.headers['User-Agent'] || requestInfo.headers['user-agent'] || null, + Authorization: (() => { + const raw = requestInfo.headers.Authorization || requestInfo.headers.authorization + if (!raw) { + return null + } + const value = String(raw) + const m = value.match(/^Bearer\\s+(.+)$/i) + const token = m ? m[1] : value + return `Bearer ${redact(token)}` + })() + } + : null, + envelope: requestInfo?.envelope || null + } + + const line = `${safeJsonStringify(record, maxBytes)}\n` + try { + await safeRotatingAppend(filename, line) + } catch (e) { + logger.warn('Failed to dump Antigravity upstream request', { + filename, + requestId: requestInfo?.requestId || null, + error: e?.message || String(e) + }) + } +} + +module.exports = { + dumpAntigravityUpstreamRequest, + UPSTREAM_REQUEST_DUMP_ENV, + UPSTREAM_REQUEST_DUMP_MAX_BYTES_ENV, + UPSTREAM_REQUEST_DUMP_FILENAME +} diff --git a/src/utils/antigravityUpstreamResponseDump.js b/src/utils/antigravityUpstreamResponseDump.js new file mode 100644 index 0000000..177b1d1 --- /dev/null +++ b/src/utils/antigravityUpstreamResponseDump.js @@ -0,0 +1,175 @@ +const path = require('path') +const logger = require('./logger') +const { getProjectRoot } = require('./projectPaths') +const { safeRotatingAppend } = require('./safeRotatingAppend') + +const UPSTREAM_RESPONSE_DUMP_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP' +const UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV = 'ANTIGRAVITY_DEBUG_UPSTREAM_RESPONSE_DUMP_MAX_BYTES' +const UPSTREAM_RESPONSE_DUMP_FILENAME = 'antigravity-upstream-responses-dump.jsonl' + +function isEnabled() { + const raw = process.env[UPSTREAM_RESPONSE_DUMP_ENV] + if (!raw) { + return false + } + const normalized = String(raw).trim().toLowerCase() + return normalized === '1' || normalized === 'true' +} + +function getMaxBytes() { + const raw = process.env[UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV] + if (!raw) { + return 2 * 1024 * 1024 + } + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed) || parsed <= 0) { + return 2 * 1024 * 1024 + } + return parsed +} + +function safeJsonStringify(payload, maxBytes) { + let json = '' + try { + json = JSON.stringify(payload) + } catch (e) { + return JSON.stringify({ + type: 'antigravity_upstream_response_dump_error', + error: 'JSON.stringify_failed', + message: e?.message || String(e) + }) + } + + if (Buffer.byteLength(json, 'utf8') <= maxBytes) { + return json + } + + const truncated = Buffer.from(json, 'utf8').subarray(0, maxBytes).toString('utf8') + return JSON.stringify({ + type: 'antigravity_upstream_response_dump_truncated', + maxBytes, + originalBytes: Buffer.byteLength(json, 'utf8'), + partialJson: truncated + }) +} + +/** + * 记录 Antigravity 上游 API 的响应 + * @param {Object} responseInfo - 响应信息 + * @param {string} responseInfo.requestId - 请求 ID + * @param {string} responseInfo.model - 模型名称 + * @param {number} responseInfo.statusCode - HTTP 状态码 + * @param {string} responseInfo.statusText - HTTP 状态文本 + * @param {Object} responseInfo.headers - 响应头 + * @param {string} responseInfo.responseType - 响应类型 (stream/non-stream/error) + * @param {Object} responseInfo.summary - 响应摘要 + * @param {Object} responseInfo.error - 错误信息(如果有) + */ +async function dumpAntigravityUpstreamResponse(responseInfo) { + if (!isEnabled()) { + return + } + + const maxBytes = getMaxBytes() + const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME) + + const record = { + ts: new Date().toISOString(), + type: 'antigravity_upstream_response', + requestId: responseInfo?.requestId || null, + model: responseInfo?.model || null, + statusCode: responseInfo?.statusCode || null, + statusText: responseInfo?.statusText || null, + responseType: responseInfo?.responseType || null, + headers: responseInfo?.headers || null, + summary: responseInfo?.summary || null, + error: responseInfo?.error || null, + rawData: responseInfo?.rawData || null + } + + const line = `${safeJsonStringify(record, maxBytes)}\n` + try { + await safeRotatingAppend(filename, line) + } catch (e) { + logger.warn('Failed to dump Antigravity upstream response', { + filename, + requestId: responseInfo?.requestId || null, + error: e?.message || String(e) + }) + } +} + +/** + * 记录 SSE 流中的每个事件(用于详细调试) + */ +async function dumpAntigravityStreamEvent(eventInfo) { + if (!isEnabled()) { + return + } + + const maxBytes = getMaxBytes() + const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME) + + const record = { + ts: new Date().toISOString(), + type: 'antigravity_stream_event', + requestId: eventInfo?.requestId || null, + eventIndex: eventInfo?.eventIndex || null, + eventType: eventInfo?.eventType || null, + data: eventInfo?.data || null + } + + const line = `${safeJsonStringify(record, maxBytes)}\n` + try { + await safeRotatingAppend(filename, line) + } catch (e) { + // 静默处理,避免日志过多 + } +} + +/** + * 记录流式响应的最终摘要 + */ +async function dumpAntigravityStreamSummary(summaryInfo) { + if (!isEnabled()) { + return + } + + const maxBytes = getMaxBytes() + const filename = path.join(getProjectRoot(), UPSTREAM_RESPONSE_DUMP_FILENAME) + + const record = { + ts: new Date().toISOString(), + type: 'antigravity_stream_summary', + requestId: summaryInfo?.requestId || null, + model: summaryInfo?.model || null, + totalEvents: summaryInfo?.totalEvents || 0, + finishReason: summaryInfo?.finishReason || null, + hasThinking: summaryInfo?.hasThinking || false, + hasToolCalls: summaryInfo?.hasToolCalls || false, + toolCallNames: summaryInfo?.toolCallNames || [], + usage: summaryInfo?.usage || null, + error: summaryInfo?.error || null, + textPreview: summaryInfo?.textPreview || null + } + + const line = `${safeJsonStringify(record, maxBytes)}\n` + try { + await safeRotatingAppend(filename, line) + } catch (e) { + logger.warn('Failed to dump Antigravity stream summary', { + filename, + requestId: summaryInfo?.requestId || null, + error: e?.message || String(e) + }) + } +} + +module.exports = { + dumpAntigravityUpstreamResponse, + dumpAntigravityStreamEvent, + dumpAntigravityStreamSummary, + UPSTREAM_RESPONSE_DUMP_ENV, + UPSTREAM_RESPONSE_DUMP_MAX_BYTES_ENV, + UPSTREAM_RESPONSE_DUMP_FILENAME +} diff --git a/src/utils/cacheMonitor.js b/src/utils/cacheMonitor.js new file mode 100644 index 0000000..ece5e47 --- /dev/null +++ b/src/utils/cacheMonitor.js @@ -0,0 +1,294 @@ +/** + * 缓存监控和管理工具 + * 提供统一的缓存监控、统计和安全清理功能 + */ + +const logger = require('./logger') +const crypto = require('crypto') + +class CacheMonitor { + constructor() { + this.monitors = new Map() // 存储所有被监控的缓存实例 + this.startTime = Date.now() + this.totalHits = 0 + this.totalMisses = 0 + this.totalEvictions = 0 + + // 🔒 安全配置 + this.securityConfig = { + maxCacheAge: 15 * 60 * 1000, // 最大缓存年龄 15 分钟 + forceCleanupInterval: 30 * 60 * 1000, // 强制清理间隔 30 分钟 + memoryThreshold: 100 * 1024 * 1024, // 内存阈值 100MB + sensitiveDataPatterns: [/password/i, /token/i, /secret/i, /key/i, /credential/i] + } + + // 🧹 定期执行安全清理 + this.setupSecurityCleanup() + + // 📊 定期报告统计信息 + this.setupPeriodicReporting() + } + + /** + * 注册缓存实例进行监控 + * @param {string} name - 缓存名称 + * @param {LRUCache} cache - 缓存实例 + */ + registerCache(name, cache) { + if (this.monitors.has(name)) { + logger.warn(`⚠️ Cache ${name} is already registered, updating reference`) + } + + this.monitors.set(name, { + cache, + registeredAt: Date.now(), + lastCleanup: Date.now(), + totalCleanups: 0 + }) + + logger.info(`📦 Registered cache for monitoring: ${name}`) + } + + /** + * 获取所有缓存的综合统计 + */ + getGlobalStats() { + const stats = { + uptime: Math.floor((Date.now() - this.startTime) / 1000), // 秒 + cacheCount: this.monitors.size, + totalSize: 0, + totalHits: 0, + totalMisses: 0, + totalEvictions: 0, + averageHitRate: 0, + caches: {} + } + + for (const [name, monitor] of this.monitors) { + const cacheStats = monitor.cache.getStats() + stats.totalSize += cacheStats.size + stats.totalHits += cacheStats.hits + stats.totalMisses += cacheStats.misses + stats.totalEvictions += cacheStats.evictions + + stats.caches[name] = { + ...cacheStats, + lastCleanup: new Date(monitor.lastCleanup).toISOString(), + totalCleanups: monitor.totalCleanups, + age: Math.floor((Date.now() - monitor.registeredAt) / 1000) // 秒 + } + } + + const totalRequests = stats.totalHits + stats.totalMisses + stats.averageHitRate = + totalRequests > 0 ? `${((stats.totalHits / totalRequests) * 100).toFixed(2)}%` : '0%' + + return stats + } + + /** + * 🔒 执行安全清理 + * 清理过期数据和潜在的敏感信息 + */ + performSecurityCleanup() { + logger.info('🔒 Starting security cleanup for all caches') + + for (const [name, monitor] of this.monitors) { + try { + const { cache } = monitor + const beforeSize = cache.cache.size + + // 执行常规清理 + cache.cleanup() + + // 检查缓存年龄,如果太老则完全清空 + const cacheAge = Date.now() - monitor.registeredAt + if (cacheAge > this.securityConfig.maxCacheAge * 2) { + logger.warn( + `⚠️ Cache ${name} is too old (${Math.floor(cacheAge / 60000)}min), performing full clear` + ) + cache.clear() + } + + monitor.lastCleanup = Date.now() + monitor.totalCleanups++ + + const afterSize = cache.cache.size + if (beforeSize !== afterSize) { + logger.info(`🧹 Cache ${name}: Cleaned ${beforeSize - afterSize} items`) + } + } catch (error) { + logger.error(`❌ Error cleaning cache ${name}:`, error) + } + } + } + + /** + * 📊 生成详细报告 + */ + generateReport() { + const stats = this.getGlobalStats() + + logger.info('═══════════════════════════════════════════') + logger.info('📊 Cache System Performance Report') + logger.info('═══════════════════════════════════════════') + logger.info(`⏱️ Uptime: ${this.formatUptime(stats.uptime)}`) + logger.info(`📦 Active Caches: ${stats.cacheCount}`) + logger.info(`📈 Total Cache Size: ${stats.totalSize} items`) + logger.info(`🎯 Global Hit Rate: ${stats.averageHitRate}`) + logger.info(`✅ Total Hits: ${stats.totalHits.toLocaleString()}`) + logger.info(`❌ Total Misses: ${stats.totalMisses.toLocaleString()}`) + logger.info(`🗑️ Total Evictions: ${stats.totalEvictions.toLocaleString()}`) + logger.info('───────────────────────────────────────────') + + // 详细的每个缓存统计 + for (const [name, cacheStats] of Object.entries(stats.caches)) { + logger.info(`\n📦 ${name}:`) + logger.info( + ` Size: ${cacheStats.size}/${cacheStats.maxSize} | Hit Rate: ${cacheStats.hitRate}` + ) + logger.info( + ` Hits: ${cacheStats.hits} | Misses: ${cacheStats.misses} | Evictions: ${cacheStats.evictions}` + ) + logger.info( + ` Age: ${this.formatUptime(cacheStats.age)} | Cleanups: ${cacheStats.totalCleanups}` + ) + } + logger.info('═══════════════════════════════════════════') + } + + /** + * 🧹 设置定期安全清理 + */ + setupSecurityCleanup() { + // 每 10 分钟执行一次安全清理 + setInterval( + () => { + this.performSecurityCleanup() + }, + 10 * 60 * 1000 + ) + + // 每 30 分钟强制完整清理 + setInterval(() => { + logger.warn('⚠️ Performing forced complete cleanup for security') + for (const [name, monitor] of this.monitors) { + monitor.cache.clear() + logger.info(`🗑️ Force cleared cache: ${name}`) + } + }, this.securityConfig.forceCleanupInterval) + } + + /** + * 📊 设置定期报告 + */ + setupPeriodicReporting() { + // 每 5 分钟生成一次简单统计 + setInterval( + () => { + const stats = this.getGlobalStats() + logger.info( + `📊 Quick Stats - Caches: ${stats.cacheCount}, Size: ${stats.totalSize}, Hit Rate: ${stats.averageHitRate}` + ) + }, + 5 * 60 * 1000 + ) + + // 每 30 分钟生成一次详细报告 + setInterval( + () => { + this.generateReport() + }, + 30 * 60 * 1000 + ) + } + + /** + * 格式化运行时间 + */ + formatUptime(seconds) { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + + if (hours > 0) { + return `${hours}h ${minutes}m ${secs}s` + } else if (minutes > 0) { + return `${minutes}m ${secs}s` + } else { + return `${secs}s` + } + } + + /** + * 🔐 生成安全的缓存键 + * 使用 SHA-256 哈希避免暴露原始数据 + */ + static generateSecureCacheKey(data) { + return crypto.createHash('sha256').update(data).digest('hex') + } + + /** + * 🛡️ 验证缓存数据安全性 + * 检查是否包含敏感信息 + */ + validateCacheSecurity(data) { + const dataStr = typeof data === 'string' ? data : JSON.stringify(data) + + for (const pattern of this.securityConfig.sensitiveDataPatterns) { + if (pattern.test(dataStr)) { + logger.warn('⚠️ Potential sensitive data detected in cache') + return false + } + } + + return true + } + + /** + * 💾 获取内存使用估算 + */ + estimateMemoryUsage() { + let totalBytes = 0 + + for (const [, monitor] of this.monitors) { + const { cache } = monitor.cache + for (const [key, item] of cache) { + // 粗略估算:key 长度 + value 序列化长度 + totalBytes += key.length * 2 // UTF-16 + totalBytes += JSON.stringify(item).length * 2 + } + } + + return { + bytes: totalBytes, + mb: (totalBytes / (1024 * 1024)).toFixed(2), + warning: totalBytes > this.securityConfig.memoryThreshold + } + } + + /** + * 🚨 紧急清理 + * 在内存压力大时使用 + */ + emergencyCleanup() { + logger.error('🚨 EMERGENCY CLEANUP INITIATED') + + for (const [name, monitor] of this.monitors) { + const { cache } = monitor + const beforeSize = cache.cache.size + + // 清理一半的缓存项(LRU 会保留最近使用的) + const targetSize = Math.floor(cache.maxSize / 2) + while (cache.cache.size > targetSize) { + const firstKey = cache.cache.keys().next().value + cache.cache.delete(firstKey) + } + + logger.warn(`🚨 Emergency cleaned ${name}: ${beforeSize} -> ${cache.cache.size} items`) + } + } +} + +// 导出单例 +module.exports = new CacheMonitor() diff --git a/src/utils/commonHelper.js b/src/utils/commonHelper.js new file mode 100644 index 0000000..1af5a00 --- /dev/null +++ b/src/utils/commonHelper.js @@ -0,0 +1,408 @@ +// 通用工具函数集合 +// 抽取自各服务的重复代码,统一管理 + +const crypto = require('crypto') +const config = require('../../config/config') +const LRUCache = require('./lruCache') + +// ============================================ +// 加密相关 - 工厂模式支持不同 salt +// ============================================ + +const ALGORITHM = 'aes-256-cbc' +const IV_LENGTH = 16 + +// 缓存不同 salt 的加密实例 +const _encryptorCache = new Map() + +// 创建加密器实例(每个 salt 独立缓存) +const createEncryptor = (salt) => { + if (_encryptorCache.has(salt)) { + return _encryptorCache.get(salt) + } + + let keyCache = null + const decryptCache = new LRUCache(500) + + const getKey = () => { + if (!keyCache) { + keyCache = crypto.scryptSync(config.security.encryptionKey, salt, 32) + } + return keyCache + } + + const encrypt = (text) => { + if (!text) { + return '' + } + const key = getKey() + const iv = crypto.randomBytes(IV_LENGTH) + const cipher = crypto.createCipheriv(ALGORITHM, key, iv) + let encrypted = cipher.update(text, 'utf8', 'hex') + encrypted += cipher.final('hex') + return `${iv.toString('hex')}:${encrypted}` + } + + const decrypt = (text, useCache = true) => { + if (!text) { + return '' + } + if (!text.includes(':')) { + return text + } + const cacheKey = crypto.createHash('sha256').update(text).digest('hex') + if (useCache) { + const cached = decryptCache.get(cacheKey) + if (cached !== undefined) { + return cached + } + } + try { + const key = getKey() + const [ivHex, encrypted] = text.split(':') + const iv = Buffer.from(ivHex, 'hex') + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + if (useCache) { + decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000) + } + return decrypted + } catch (e) { + return text + } + } + + const instance = { + encrypt, + decrypt, + getKey, + clearCache: () => decryptCache.clear(), + getStats: () => decryptCache.getStats?.() || { size: decryptCache.size } + } + + _encryptorCache.set(salt, instance) + return instance +} + +// 默认加密器(向后兼容) +const defaultEncryptor = createEncryptor('claude-relay-salt') +const { encrypt } = defaultEncryptor +const { decrypt } = defaultEncryptor +const getEncryptionKey = defaultEncryptor.getKey +const clearDecryptCache = defaultEncryptor.clearCache +const getDecryptCacheStats = defaultEncryptor.getStats + +// ============================================ +// 布尔值处理 +// ============================================ + +// 转换为布尔值(宽松模式) +const toBoolean = (value) => + value === true || + value === 'true' || + (typeof value === 'string' && value.toLowerCase() === 'true') + +// 检查是否为真值(null/undefined 返回 false) +const isTruthy = (value) => value !== null && value !== undefined && toBoolean(value) + +// 检查是否可调度(默认 true,只有明确 false 才返回 false) +const isSchedulable = (value) => value !== false && value !== 'false' + +// 检查是否激活 +const isActive = (value) => value === true || value === 'true' + +// 检查账户是否健康(激活且状态正常) +const isAccountHealthy = (account) => { + if (!account) { + return false + } + if (!isTruthy(account.isActive)) { + return false + } + const status = (account.status || 'active').toLowerCase() + return !['error', 'unauthorized', 'blocked', 'temp_error'].includes(status) +} + +// ============================================ +// JSON 处理 +// ============================================ + +// 安全解析 JSON +const safeParseJson = (value, fallback = null) => { + if (!value || typeof value !== 'string') { + return fallback + } + try { + return JSON.parse(value) + } catch { + return fallback + } +} + +// 安全解析 JSON 为对象 +const safeParseJsonObject = (value, fallback = null) => { + const parsed = safeParseJson(value, fallback) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : fallback +} + +// 安全解析 JSON 为数组 +const safeParseJsonArray = (value, fallback = []) => { + const parsed = safeParseJson(value, fallback) + return Array.isArray(parsed) ? parsed : fallback +} + +// ============================================ +// 模型名称处理 +// ============================================ + +// 规范化模型名称(用于统计聚合) +const normalizeModelName = (model) => { + if (!model || model === 'unknown') { + return model + } + // Bedrock 模型: us-east-1.anthropic.claude-3-5-sonnet-v1:0 + if (model.includes('.anthropic.') || model.includes('.claude')) { + return model + .replace(/^[a-z0-9-]+\./, '') + .replace('anthropic.', '') + .replace(/-v\d+:\d+$/, '') + } + return model.replace(/-v\d+:\d+$|:latest$/, '') +} + +// 规范化端点类型 +const normalizeEndpointType = (endpointType) => { + if (!endpointType) { + return 'anthropic' + } + const normalized = String(endpointType).toLowerCase() + return ['openai', 'comm', 'anthropic'].includes(normalized) ? normalized : 'anthropic' +} + +// 检查模型是否在映射表中 +const isModelInMapping = (modelMapping, requestedModel) => { + if (!modelMapping || Object.keys(modelMapping).length === 0) { + return true + } + if (Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)) { + return true + } + const lower = requestedModel.toLowerCase() + return Object.keys(modelMapping).some((k) => k.toLowerCase() === lower) +} + +// 获取映射后的模型名称 +const getMappedModelName = (modelMapping, requestedModel) => { + if (!modelMapping || Object.keys(modelMapping).length === 0) { + return requestedModel + } + if (modelMapping[requestedModel]) { + return modelMapping[requestedModel] + } + const lower = requestedModel.toLowerCase() + for (const [key, value] of Object.entries(modelMapping)) { + if (key.toLowerCase() === lower) { + return value + } + } + return requestedModel +} + +// ============================================ +// 账户调度相关 +// ============================================ + +// 按优先级和最后使用时间排序账户 +const sortAccountsByPriority = (accounts) => + [...accounts].sort((a, b) => { + const priorityA = parseInt(a.priority, 10) || 50 + const priorityB = parseInt(b.priority, 10) || 50 + if (priorityA !== priorityB) { + return priorityA - priorityB + } + const lastUsedA = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : 0 + const lastUsedB = b.lastUsedAt ? new Date(b.lastUsedAt).getTime() : 0 + if (lastUsedA !== lastUsedB) { + return lastUsedA - lastUsedB + } + const createdA = a.createdAt ? new Date(a.createdAt).getTime() : 0 + const createdB = b.createdAt ? new Date(b.createdAt).getTime() : 0 + return createdA - createdB + }) + +// 生成粘性会话 Key +const composeStickySessionKey = (prefix, sessionHash, apiKeyId = null) => { + if (!sessionHash) { + return null + } + return `sticky:${prefix}:${apiKeyId || 'default'}:${sessionHash}` +} + +// 过滤可用账户(激活 + 健康 + 可调度) +const filterAvailableAccounts = (accounts) => + accounts.filter((acc) => acc && isAccountHealthy(acc) && isSchedulable(acc.schedulable)) + +// ============================================ +// 字符串处理 +// ============================================ + +// 截断字符串 +const truncate = (str, maxLen = 100, suffix = '...') => { + if (!str || str.length <= maxLen) { + return str + } + return str.slice(0, maxLen - suffix.length) + suffix +} + +// 掩码敏感信息(保留前后几位) +const maskSensitive = (str, keepStart = 4, keepEnd = 4, maskChar = '*') => { + if (!str || str.length <= keepStart + keepEnd) { + return str + } + const maskLen = Math.min(str.length - keepStart - keepEnd, 8) + return str.slice(0, keepStart) + maskChar.repeat(maskLen) + str.slice(-keepEnd) +} + +// ============================================ +// 数值处理 +// ============================================ + +// 安全解析整数 +const safeParseInt = (value, fallback = 0) => { + const parsed = parseInt(value, 10) + return isNaN(parsed) ? fallback : parsed +} + +// 安全解析浮点数 +const safeParseFloat = (value, fallback = 0) => { + const parsed = parseFloat(value) + return isNaN(parsed) ? fallback : parsed +} + +// 限制数值范围 +const clamp = (value, min, max) => Math.min(Math.max(value, min), max) + +// ============================================ +// 时间处理 +// ============================================ + +// 获取时区偏移后的日期 +const getDateInTimezone = (date = new Date(), offset = config.system?.timezoneOffset || 8) => + new Date(date.getTime() + offset * 3600000) + +// 获取时区日期字符串 YYYY-MM-DD +const getDateStringInTimezone = (date = new Date()) => { + const d = getDateInTimezone(date) + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}` +} + +// 检查是否过期 +const isExpired = (expiresAt) => { + if (!expiresAt) { + return false + } + return new Date(expiresAt).getTime() < Date.now() +} + +// 计算剩余时间(秒) +const getTimeRemaining = (expiresAt) => { + if (!expiresAt) { + return Infinity + } + return Math.max(0, Math.floor((new Date(expiresAt).getTime() - Date.now()) / 1000)) +} + +// ============================================ +// 版本处理 +// ============================================ + +const fs = require('fs') +const path = require('path') + +// 获取应用版本号 +const getAppVersion = () => { + if (process.env.APP_VERSION) { + return process.env.APP_VERSION + } + if (process.env.VERSION) { + return process.env.VERSION + } + try { + const versionFile = path.join(__dirname, '..', '..', 'VERSION') + if (fs.existsSync(versionFile)) { + return fs.readFileSync(versionFile, 'utf8').trim() + } + } catch { + // ignore + } + try { + return require('../../package.json').version + } catch { + // ignore + } + return '1.0.0' +} + +// 版本比较: a > b +const versionGt = (a, b) => { + const pa = a.split('.').map(Number) + const pb = b.split('.').map(Number) + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) > (pb[i] || 0)) { + return true + } + if ((pa[i] || 0) < (pb[i] || 0)) { + return false + } + } + return false +} + +// 版本比较: a >= b +const versionGte = (a, b) => a === b || versionGt(a, b) + +module.exports = { + // 加密 + createEncryptor, + encrypt, + decrypt, + getEncryptionKey, + clearDecryptCache, + getDecryptCacheStats, + // 布尔值 + toBoolean, + isTruthy, + isSchedulable, + isActive, + isAccountHealthy, + // JSON + safeParseJson, + safeParseJsonObject, + safeParseJsonArray, + // 模型 + normalizeModelName, + normalizeEndpointType, + isModelInMapping, + getMappedModelName, + // 调度 + sortAccountsByPriority, + composeStickySessionKey, + filterAvailableAccounts, + // 字符串 + truncate, + maskSensitive, + // 数值 + safeParseInt, + safeParseFloat, + clamp, + // 时间 + getDateInTimezone, + getDateStringInTimezone, + isExpired, + getTimeRemaining, + // 版本 + getAppVersion, + versionGt, + versionGte +} diff --git a/src/utils/contents.js b/src/utils/contents.js new file mode 100644 index 0000000..1f9bd3d --- /dev/null +++ b/src/utils/contents.js @@ -0,0 +1,534 @@ +// Auto-generated from @anthropic-ai/claude-code v1.0.123 +// Prompts are sanitized with __PLACEHOLDER__ markers replacing dynamic content. + +const stringSimilarity = require('string-similarity') + +/** + * @typedef {Object} SimpleSimilarityResult + * @property {number} score + * @property {number} threshold + * @property {boolean} passed + */ +/** + * @param {string} value + * @returns {string} + */ +function normalize(value) { + return value.replace(/\s+/g, ' ').trim() +} + +/** + * @param {unknown} actual + * @param {string} expected + * @param {number} threshold + * @returns {SimpleSimilarityResult} + */ +function simple(actual, expected, threshold) { + if (typeof expected !== 'string' || !expected.trim()) { + throw new Error('Expected prompt text must be a non-empty string') + } + + if (typeof actual !== 'string' || !actual.trim()) { + return { score: 0, threshold, passed: false } + } + + const score = stringSimilarity.compareTwoStrings(normalize(actual), normalize(expected)) + return { score, threshold, passed: score >= threshold } +} + +const DEFAULT_SYSTEM_PROMPT_THRESHOLD = 0.5 +const parsedSystemPromptThreshold = Number(process.env.SYSTEM_PROMPT_THRESHOLD) +const SYSTEM_PROMPT_THRESHOLD = Number.isFinite(parsedSystemPromptThreshold) + ? parsedSystemPromptThreshold + : DEFAULT_SYSTEM_PROMPT_THRESHOLD + +/** + * @typedef {'system'|'output_style'|'tools'|'web'|'agents'|'summaries'|'notes'|'quality'} PromptCategory + */ + +/** + * @typedef {Object} PromptDefinitionBase + * @property {PromptCategory} category + * @property {string} title + * @property {string} text + */ + +const PROMPT_DEFINITIONS = { + haikuSystemPrompt: { + category: 'system', + title: 'Claude 3.5 Haiku System Prompt', + text: "Analyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title that captures the new topic. Format your response as a JSON object with two fields: 'isNewTopic' (boolean) and 'title' (string, or null if isNewTopic is false). Only include these fields, no other text." + }, + claudeOtherSystemPrompt1: { + category: 'system', + title: 'Claude Code System Prompt (Primary)', + text: "You are Claude Code, Anthropic's official CLI for Claude." + }, + claudeOtherSystemPrompt2: { + category: 'system', + title: 'Claude Code System Prompt (Secondary)', + text: 'You are an interactive CLI tool that helps users __PLACEHOLDER__ Use the instructions below and the tools available to you to assist the user.\n\n__PLACEHOLDER__\nIMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.\n\nIf the user asks for help or wants to give feedback inform them of the following: \n- /help: Get help with using Claude Code\n- To give feedback, users should __PLACEHOLDER__\n\n\nWhen the user directly asks about Claude Code (eg. "can Claude Code do...", "does Claude Code have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific Claude Code feature (eg. implement a hook, or write a slash command), use the __PLACEHOLDER__ tool to gather information to answer the question from Claude Code docs. The list of available docs is available at __PLACEHOLDER__.\n\n# Tone and style\nYou should be concise, direct, and to the point.\nYou MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail.\nIMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.\nIMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.\nDo not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.\nAnswer the user\'s question directly, avoiding any elaboration, explanation, introduction, conclusion, or excessive details. One word answers are best. You MUST avoid text before/after your response, such as "The answer is .", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".\n\nHere are some examples to demonstrate appropriate verbosity:\n\nuser: 2 + 2\nassistant: 4\n\n\n\nuser: what is 2+2?\nassistant: 4\n\n\n\nuser: is 11 a prime number?\nassistant: Yes\n\n\n\nuser: what command should I run to list files in the current directory?\nassistant: ls\n\n\n\nuser: what command should I run to watch files in the current directory?\nassistant: [runs ls to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]\nnpm run dev\n\n\n\nuser: How many golf balls fit inside a jetta?\nassistant: 150000\n\n\n\nuser: what files are in the directory src/?\nassistant: [runs ls and sees foo.c, bar.c, baz.c]\nuser: which file contains the implementation of foo?\nassistant: src/foo.c\n\n\nWhen you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user\'s system).\nRemember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.\nOutput text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like or code comments as means to communicate with the user during the session.\nIf you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.\nOnly use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.\nIMPORTANT: Keep your responses short, since they will be displayed on a command line interface.\n\n# Proactiveness\nYou are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:\n- Doing the right thing when asked, including taking actions and follow-up actions\n- Not surprising the user with actions you take without asking\nFor example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.\n\n# Professional objectivity\nPrioritize technical accuracy and truthfulness over validating the user\'s beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it\'s best to investigate to find the truth first rather than instinctively confirming the user\'s beliefs.\n\n# Following conventions\nWhen making changes to files, first understand the file\'s code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.\n- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).\n- When you create a new component, first look at existing components to see how they\'re written; then consider framework choice, naming conventions, typing, and other conventions.\n- When you edit a piece of code, first look at the code\'s surrounding context (especially its imports) to understand the code\'s choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.\n- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.\n\n# Code style\n- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked\n\n\n# Task Management\nYou have access to the tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.\nThese tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.\n\nIt is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.\n\nExamples:\n\n\nuser: Run the build and fix any type errors\nassistant: I\'m going to use the tool to write the following items to the todo list: \n- Run the build\n- Fix any type errors\n\nI\'m now going to run the build using .\n\nLooks like I found 10 type errors. I\'m going to use the tool to write 10 items to the todo list.\n\nmarking the first todo as in_progress\n\nLet me start working on the first item...\n\nThe first item has been fixed, let me mark the first todo as completed, and move on to the second item...\n..\n..\n\nIn the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.\n\n\nuser: Help me write a new feature that allows users to track their usage metrics and export them to various formats\n\nassistant: I\'ll help you implement a usage metrics tracking and export feature. Let me first use the tool to plan this task.\nAdding the following todos to the todo list:\n1. Research existing metrics tracking in the codebase\n2. Design the metrics collection system\n3. Implement core metrics tracking functionality\n4. Create export functionality for different formats\n\nLet me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.\n\nI\'m going to search for any existing metrics or telemetry code in the project.\n\nI\'ve found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I\'ve learned...\n\n[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]\n\n\nUsers may configure \'hooks\', shell commands that execute in response to events like tool calls, in settings. Treat feedback from hooks, including , as coming from the user. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration.\n\n# Doing tasks\nThe user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:\n- Use the tool to plan the task if required\n- Use the available search tools to understand the codebase and the user\'s query. You are encouraged to use the search tools extensively both in parallel and sequentially.\n- Implement the solution using all tools available to you\n- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.\n- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) with if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to CLAUDE.md so that you will know to run it next time.\nNEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.\n\n- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.\n\n\n# Tool usage policy\n- When doing file search, prefer to use the tool in order to reduce context usage.\n- You should proactively use the tool with specialized agents when the task at hand matches the agent\'s description.\n- When returns a message about a redirect to a different host, you should immediately make a new request with the redirect URL provided in the response.\n- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.\n- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple tool calls.\n\nIMPORTANT: Always use the tool to plan and track tasks throughout the conversation.\n# Code References\n\nWhen referencing specific functions or pieces of code include the pattern __PLACEHOLDER__' + }, + claudeOtherSystemPrompt3: { + category: 'system', + title: 'Claude Agent SDK System Prompt', + text: "You are a Claude agent, built on Anthropic's Claude Agent SDK." + }, + claudeOtherSystemPrompt4: { + category: 'system', + title: 'Claude Code Compact System Prompt Agent SDK2', + text: "You are Claude Code, Anthropic's official CLI for Claude, running within the Claude Agent SDK." + }, + claudeOtherSystemPrompt5: { + category: 'system', + title: 'Claude CLI Billing Header', + text: 'x-anthropic-billing-header: __PLACEHOLDER__' + }, + claudeOtherSystemPromptCompact: { + category: 'system', + title: 'Claude Code Compact System Prompt', + text: 'You are a helpful AI assistant tasked with summarizing conversations.' + }, + exploreAgentSystemPrompt: { + category: 'system', + title: 'Claude Code Explore Agent System Prompt', + text: "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude." + }, + outputStyleInsightsPrompt: { + category: 'output_style', + title: 'Output Style Insights Addendum', + text: '## Insights\nIn order to encourage learning, before and after writing code, always provide brief educational explanations about implementation choices using (with backticks):\n"\\`__PLACEHOLDER__ Insight ─────────────────────────────────────\\`\n[2-3 key educational points]\n\\`─────────────────────────────────────────────────\\`"\n\nThese insights should be included in the conversation, not in the codebase. You should generally focus on interesting insights that are specific to the codebase or the code you just wrote, rather than general programming concepts.' + }, + outputStyleExplanatoryPrompt: { + category: 'output_style', + title: 'Output Style Explanatory', + text: 'You are an interactive CLI tool that helps users with software engineering tasks. In addition to software engineering tasks, you should provide educational insights about the codebase along the way.\n\nYou should be clear and educational, providing helpful explanations while remaining focused on the task. Balance educational content with task completion. When providing insights, you may exceed typical length constraints, but remain focused and relevant.\n\n# Explanatory Style Active\n\n## Insights\nIn order to encourage learning, before and after writing code, always provide brief educational explanations about implementation choices using (with backticks):\n"\\`__PLACEHOLDER__ Insight ─────────────────────────────────────\\`\n[2-3 key educational points]\n\\`─────────────────────────────────────────────────\\`"\n\nThese insights should be included in the conversation, not in the codebase. You should generally focus on interesting insights that are specific to the codebase or the code you just wrote, rather than general programming concepts.' + }, + outputStyleLearningPrompt: { + category: 'output_style', + title: 'Output Style Learning', + text: 'You are an interactive CLI tool that helps users with software engineering tasks. In addition to software engineering tasks, you should help users learn more about the codebase through hands-on practice and educational insights.\n\nYou should be collaborative and encouraging. Balance task completion with learning by requesting user input for meaningful design decisions while handling routine implementation yourself. \n\n# Learning Style Active\n## Requesting Human Contributions\nIn order to encourage learning, ask the human to contribute 2-10 line code pieces when generating 20+ lines involving:\n- Design decisions (error handling, data structures)\n- Business logic with multiple valid approaches \n- Key algorithms or interface definitions\n\n**TodoList Integration**: If using a TodoList for the overall task, include a specific todo item like "Request human input on [specific decision]" when planning to request human input. This ensures proper task tracking. Note: TodoList is not required for all tasks.\n\nExample TodoList flow:\n ✓ "Set up component structure with placeholder for logic"\n ✓ "Request human collaboration on decision logic implementation"\n ✓ "Integrate contribution and complete feature"\n\n### Request Format\n\\`\\`\\`\n__PLACEHOLDER__ **Learn by Doing**\n**Context:** [what\'s built and why this decision matters]\n**Your Task:** [specific function/section in file, mention file and TODO(human) but do not include line numbers]\n**Guidance:** [trade-offs and constraints to consider]\n\\`\\`\\`\n\n### Key Guidelines\n- Frame contributions as valuable design decisions, not busy work\n- You must first add a TODO(human) section into the codebase with your editing tools before making the Learn by Doing request \n- Make sure there is one and only one TODO(human) section in the code\n- Don\'t take any action or output anything after the Learn by Doing request. Wait for human implementation before proceeding.\n\n### Example Requests\n\n**Whole Function Example:**\n\\`\\`\\`\n__PLACEHOLDER__ **Learn by Doing**\n\n**Context:** I\'ve set up the hint feature UI with a button that triggers the hint system. The infrastructure is ready: when clicked, it calls selectHintCell() to determine which cell to hint, then highlights that cell with a yellow background and shows possible values. The hint system needs to decide which empty cell would be most helpful to reveal to the user.\n\n**Your Task:** In sudoku.js, implement the selectHintCell(board) function. Look for TODO(human). This function should analyze the board and return {row, col} for the best cell to hint, or null if the puzzle is complete.\n\n**Guidance:** Consider multiple strategies: prioritize cells with only one possible value (naked singles), or cells that appear in rows/columns/boxes with many filled cells. You could also consider a balanced approach that helps without making it too easy. The board parameter is a 9x9 array where 0 represents empty cells.\n\\`\\`\\`\n\n**Partial Function Example:**\n\\`\\`\\`\n__PLACEHOLDER__ **Learn by Doing**\n\n**Context:** I\'ve built a file upload component that validates files before accepting them. The main validation logic is complete, but it needs specific handling for different file type categories in the switch statement.\n\n**Your Task:** In upload.js, inside the validateFile() function\'s switch statement, implement the \'case "document":\' branch. Look for TODO(human). This should validate document files (pdf, doc, docx).\n\n**Guidance:** Consider checking file size limits (maybe 10MB for documents?), validating the file extension matches the MIME type, and returning {valid: boolean, error?: string}. The file object has properties: name, size, type.\n\\`\\`\\`\n\n**Debugging Example:**\n\\`\\`\\`\n__PLACEHOLDER__ **Learn by Doing**\n\n**Context:** The user reported that number inputs aren\'t working correctly in the calculator. I\'ve identified the handleInput() function as the likely source, but need to understand what values are being processed.\n\n**Your Task:** In calculator.js, inside the handleInput() function, add 2-3 console.log statements after the TODO(human) comment to help debug why number inputs fail.\n\n**Guidance:** Consider logging: the raw input value, the parsed result, and any validation state. This will help us understand where the conversion breaks.\n\\`\\`\\`\n\n### After Contributions\nShare one insight connecting their code to broader patterns or system effects. Avoid praise or repetition.\n\n## Insights\n\n## Insights\nIn order to encourage learning, before and after writing code, always provide brief educational explanations about implementation choices using (with backticks):\n"\\`__PLACEHOLDER__ Insight ─────────────────────────────────────\\`\n[2-3 key educational points]\n\\`─────────────────────────────────────────────────\\`"\n\nThese insights should be included in the conversation, not in the codebase. You should generally focus on interesting insights that are specific to the codebase or the code you just wrote, rather than general programming concepts.' + }, + commandPathsPrompt: { + category: 'tools', + title: 'Command Path Extraction', + text: 'Extract any file paths that this command reads or modifies. For commands like "git diff" and "cat", include the paths of files being shown. Use paths verbatim -- don\'t add any slashes or try to resolve them. Do not try to infer paths that were not explicitly listed in the command output.\n\nIMPORTANT: Commands that do not display the contents of the files should not return any filepaths. For eg. "ls", pwd", "find". Even more complicated commands that don\'t display the contents should not be considered: eg "find . -type f -exec ls -la {} + | sort -k5 -nr | head -5"\n\nFirst, determine if the command displays the contents of the files. If it does, then tag should be true. If it does not, then tag should be false.\n\nFormat your response as:\n\ntrue\n\n\n\npath/to/file1\npath/to/file2\n\n\nIf no files are read or modified, return empty filepaths tags:\n\n\n\nDo not include any other text in your response.' + }, + bashOutputSummarizationPrompt: { + category: 'tools', + title: 'Bash Output Summarization', + text: 'You are analyzing output from a bash command to determine if it should be summarized.\n\nYour task is to:\n1. Determine if the output contains mostly repetitive logs, verbose build output, or other "log spew"\n2. If it does, extract only the relevant information (errors, test results, completion status, etc.)\n3. Consider the conversation context - if the user specifically asked to see detailed output, preserve it\n\nYou MUST output your response using XML tags in the following format:\ntrue/false\nreason for why you decided to summarize or not summarize the output\nmarkdown summary as described below (only if should_summarize is true)\n\nIf should_summarize is true, include all three tags with a comprehensive summary.\nIf should_summarize is false, include only the first two tags and omit the summary tag.\n\nSummary: The summary should be extremely comprehensive and detailed in markdown format. Especially consider the converstion context to determine what to focus on.\nFreely copy parts of the output verbatim into the summary if you think it is relevant to the conversation context or what the user is asking for.\nIt\'s fine if the summary is verbose. The summary should contain the following sections: (Make sure to include all of these sections)\n1. Overview: An overview of the output including the most interesting information summarized.\n2. Detailed summary: An extremely detailed summary of the output.\n3. Errors: List of relevant errors that were encountered. Include snippets of the output wherever possible.\n4. Verbatim output: Copy any parts of the provided output verbatim that are relevant to the conversation context. This is critical. Make sure to include ATLEAST 3 snippets of the output verbatim. \n5. DO NOT provide a recommendation. Just summarize the facts.\n\nReason: If providing a reason, it should comprehensively explain why you decided not to summarize the output.\n\nExamples of when to summarize:\n- Verbose build logs with only the final status being important. Eg. if we are running npm run build to test if our code changes build.\n- Test output where only the pass/fail results matter\n- Repetitive debug logs with a few key errors\n\nExamples of when NOT to summarize:\n- User explicitly asked to see the full output\n- Output contains unique, non-repetitive information\n- Error messages that need full stack traces for debugging\n\nCRITICAL: You MUST start your response with the tag as the very first thing. Do not include any other text before the first tag. The summary tag can contain markdown format, but ensure all XML tags are properly closed.' + }, + commandInjectionPrompt2: { + category: 'tools', + title: 'Command Prefix Detection', + text: 'Your task is to process Bash commands that an AI coding agent wants to run.\n\nThis policy spec defines how to determine the prefix of a Bash command:__PLACEHOLDER__' + }, + bugTitlePrompt: { + category: 'tools', + title: 'Bug Title Generation', + text: 'Generate a concise, technical issue title (max 80 chars) for a public GitHub issue based on this bug report for Claude Code.\nClaude Code is an agentic coding CLI based on the Anthropic API.\nThe title should:\n- Include the type of issue [Bug] or [Feature Request] as the first thing in the title\n- Be concise, specific and descriptive of the actual problem\n- Use technical terminology appropriate for a software issue\n- For error messages, extract the key error (e.g., "Missing Tool Result Block" rather than the full message)\n- Be direct and clear for developers to understand the problem\n- If you cannot determine a clear issue, use "Bug Report: [brief description]"\n- Any LLM API errors are from the Anthropic API, not from any other model provider\nYour response will be directly used as the title of the Github issue, and as such should not contain any other commentary or explaination\nExamples of good titles include: "[Bug] Auto-Compact triggers to soon", "[Bug] Anthropic API Error: Missing Tool Result Block", "[Bug] Error: Invalid Model Name for Opus"' + }, + frequentlyModifiedPrompt: { + category: 'tools', + title: 'Frequently Modified Files', + text: "You are an expert at analyzing git history. Given a list of files and their modification counts, return exactly five filenames that are frequently modified and represent core application logic (not auto-generated files, dependencies, or configuration). Make sure filenames are diverse, not all in the same folder, and are a mix of user and other users. Return only the filenames' basenames (without the path) separated by newlines with no explanation." + }, + webFetchUsageNotes: { + category: 'web', + title: 'Web Fetch Usage Notes', + text: '- Takes a URL and a prompt as input\n- Fetches the URL content, converts HTML to markdown\n- Processes the content with the prompt using a small, fast model\n- Returns the model\'s response about the content\n- Use this tool when you need to retrieve and analyze web content\n\nUsage notes:\n - IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions. All MCP-provided tools start with "mcp__".\n - The URL must be a fully-formed valid URL\n - HTTP URLs will be automatically upgraded to HTTPS\n - The prompt should describe what information you want to extract from the page\n - This tool is read-only and does not modify any files\n - Results may be summarized if the content is very large\n - Includes a self-cleaning 15-minute cache for faster responses when repeatedly accessing the same URL\n - When a URL redirects to a different host, the tool will inform you and provide the redirect URL in a special format. You should then make a new WebFetch request with the redirect URL to fetch the content.' + }, + webFetchResponseTemplate: { + category: 'web', + title: 'Web Fetch Response Template', + text: 'Web page content:\n---\n\\__PLACEHOLDER__\n---\n\n\\__PLACEHOLDER__\n\nProvide a concise response based only on the content above. In your response:\n - Enforce a strict 125-character maximum for quotes from any source document. Open Source Software is ok as long as we respect the license.\n - Use quotation marks for exact language from articles; any language outside of the quotation should never be word-for-word the same.\n - You are not a lawyer and never comment on the legality of your own prompts and responses.\n - Never produce or reproduce exact song lyrics.' + }, + webSearchToolUsePrompt: { + category: 'web', + title: 'Web Search Tool Use Prompt', + text: 'You are an assistant for performing a web search tool use' + }, + webSearchUsageNotes: { + category: 'web', + title: 'Web Search Usage Notes', + text: '- Allows Claude to search the web and use the results to inform responses\n- Provides up-to-date information for current events and recent data\n- Returns search result information formatted as search result blocks\n- Use this tool for accessing information beyond Claude\'s knowledge cutoff\n- Searches are performed automatically within a single API call\n\nUsage notes:\n - Domain filtering is supported to include or block specific websites\n - Web search is only available in the US\n - Account for "Today\'s date" in . For example, if says "Today\'s date: 2025-07-01", and the user wants the latest docs, do not use 2024 in the search query. Use 2025.' + }, + generalPurposeAgentPrompt: { + category: 'agents', + title: 'General Purpose Agent System Prompt', + text: "You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, you should use the tools available to complete the task. Do what has been asked; nothing more, nothing less. When you complete the task simply respond with a detailed writeup.\n\nYour strengths:\n- Searching for code, configurations, and patterns across large codebases\n- Analyzing multiple files to understand system architecture\n- Investigating complex questions that require exploring many files\n- Performing multi-step research tasks\n\nGuidelines:\n- For file searches: Use Grep or Glob when you need to search broadly. Use Read when you know the specific file path.\n- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.\n- Be thorough: Check multiple locations, consider different naming conventions, look for related files.\n- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.\n- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.\n- In your final response always share relevant file names and code snippets. Any file paths you return in your response MUST be absolute. Do NOT use relative paths.\n- For clear communication, avoid using emojis." + }, + outputStyleSetupAgentPrompt: { + category: 'agents', + title: 'Output Style Setup Agent Prompt', + text: 'Your job is to create a custom output style, which modifies the Claude Code system prompt, based on the user\'s description.\n\nFor example, Claude Code\'s default output style directs Claude to focus "on software engineering tasks", giving Claude guidance like "When you have completed a task, you MUST run the lint and typecheck commands".\n\n# Step 1: Understand Requirements\nExtract preferences from the user\'s request such as:\n- Response length (concise, detailed, comprehensive, etc)\n- Tone (formal, casual, educational, professional, etc)\n- Output display (bullet points, numbered lists, sections, etc)\n- Focus areas (task completion, learning, quality, speed, etc)\n- Workflow (sequence of specific tools to use, steps to follow, etc)\n- Filesystem setup (specific files to look for, track state in, etc)\n - The style instructions should mention to create the files if they don\'t exist.\n\nIf the user\'s request is underspecified, use your best judgment of what the\nrequirements should be.\n\n# Step 2: Generate Configuration\nCreate a configuration with:\n- A brief description explaining the benefit to display to the user\n- The additional content for the system prompt \n\n# Step 3: Choose File Location\nDefault to the user-level output styles directory (~/.claude/output-styles/) unless the user specifies to save to the project-level directory (.claude/output-styles/).\nGenerate a short, descriptive filename, which becomes the style name (e.g., "code-reviewer.md" for "Code Reviewer" style).\n\n# Step 4: Save the File\nFormat as markdown with frontmatter:\n\\`\\`\\`markdown\n---\ndescription: Brief description for the picker\n---\n\n[The additional content that will be added to the system prompt]\n\\`\\`\\`\n\nAfter creating the file, ALWAYS:\n1. **Validate the file**: Use Read tool to verify the file was created correctly with valid frontmatter and proper markdown formatting\n2. **Check file length**: Report the file size in characters/tokens to ensure it\'s reasonable for a system prompt (aim for under 2000 characters)\n3. **Verify frontmatter**: Ensure the YAML frontmatter can be parsed correctly and contains required \'description\' field\n\n## Output Style Examples\n\n**Concise**:\n- Keep responses brief and to the point\n- Focus on actionable steps over explanations\n- Use bullet points for clarity\n- Minimize context unless requested\n\n**Educational**:\n- Include learning explanations\n- Explain the "why" behind decisions\n- Add insights about best practices\n- Balance education with task completion\n\n**Code Reviewer**:\n- Provide structured feedback\n- Include specific analysis criteria\n- Use consistent formatting\n- Focus on code quality and improvements\n\n# Step 5: Report the result\nInform the user that the style has been created, including:\n- The file path where it was saved\n- Confirmation that validation passed (file format is correct and parseable)\n- The file length in characters for reference\n\n# General Guidelines\n- Include concrete examples when they would clarify behavior\n- Balance comprehensiveness with clarity - every instruction should add value. The system prompt itself should not take up too much context.' + }, + statusLineSetupAgentPrompt: { + category: 'agents', + title: 'Status Line Setup Agent Prompt', + text: 'You are a status line setup agent for Claude Code. Your job is to create or update the statusLine command in the user\'s Claude Code settings.\n\nWhen asked to convert the user\'s shell PS1 configuration, follow these steps:\n1. Read the user\'s shell configuration files in this order of preference:\n - ~/.zshrc\n - ~/.bashrc \n - ~/.bash_profile\n - ~/.profile\n\n2. Extract the PS1 value using this regex pattern: /(?:^|\\n)\\s*(?:export\\s+)?PS1\\s*=\\s*["\']([^"\']+)["\']/m\n\n3. Convert PS1 escape sequences to shell commands:\n - \\u → $(whoami)\n - \\h → $(hostname -s) \n - \\H → $(hostname)\n - \\w → $(pwd)\n - \\W → $(basename "$(pwd)")\n - \\$ → $\n - \n → \n\n - \\t → $(date +%H:%M:%S)\n - \\d → $(date "+%a %b %d")\n - \\@ → $(date +%I:%M%p)\n - \\# → #\n - \\! → !\n\n4. When using ANSI color codes, be sure to use `printf`. Do not remove colors. Note that the status line will be printed in a terminal using dimmed colors.\n\n5. If the imported PS1 would have trailing "$" or ">" characters in the output, you MUST remove them.\n\n6. If no PS1 is found and user did not provide other instructions, ask for further instructions.\n\nHow to use the statusLine command:\n1. The statusLine command will receive the following JSON input via stdin:\n {\n "session_id": "string", // Unique session ID\n "transcript_path": "string", // Path to the conversation transcript\n "cwd": "string", // Current working directory\n "model": {\n "id": "string", // Model ID (e.g., "claude-3-5-sonnet-20241022")\n "display_name": "string" // Display name (e.g., "Claude 3.5 Sonnet")\n },\n "workspace": {\n "current_dir": "string", // Current working directory path\n "project_dir": "string" // Project root directory path\n },\n "version": "string", // Claude Code app version (e.g., "1.0.71")\n "output_style": {\n "name": "string", // Output style name (e.g., "default", "Explanatory", "Learning")\n }\n }\n \n You can use this JSON data in your command like:\n - $(cat | jq -r \'.model.display_name\')\n - $(cat | jq -r \'.workspace.current_dir\')\n - $(cat | jq -r \'.output_style.name\')\n \n Or store it in a variable first:\n - input=$(cat); echo "$(echo "$input" | jq -r \'.model.display_name\') in $(echo "$input" | jq -r \'.workspace.current_dir\')"\n\n2. For longer commands, you can save a new file in the user\'s ~/.claude directory, e.g.:\n - ~/.claude/statusline-command.sh and reference that file in the settings.\n\n3. Update the user\'s ~/.claude/settings.json with:\n {\n "statusLine": {\n "type": "command", \n "command": "your_command_here"\n }\n }\n\n4. If ~/.claude/settings.json is a symlink, update the target file instead.\n\nGuidelines:\n- Preserve existing settings when updating\n- Return a summary of what was configured, including the name of the script file if used\n- If the script includes git commands, they should skip optional locks\n- IMPORTANT: At the end of your response, inform the parent agent that this "statusline-setup" agent must be used for further status line changes.\n Also ensure that the user is informed that they can ask Claude to continue to make changes to the status line.__PLACEHOLDER__' + }, + agentCreationPrompt: { + category: 'agents', + title: 'Agent Creation Prompt', + text: 'Create an agent configuration based on this request: "\\__PLACEHOLDER__".\\__PLACEHOLDER__\n Return ONLY the JSON object, no other text.' + }, + compactTitlePrompt: { + category: 'summaries', + title: 'Compact Title Prompt', + text: 'Summarize this coding conversation in under 50 characters.\nCapture the main task, key files, problems addressed, and current status.' + }, + compactSummaryPrompt: { + category: 'summaries', + title: 'Compact Summary Prompt', + text: "Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.\nThis summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context.\n\nBefore providing your final summary, wrap your analysis in tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process:\n\n1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify:\n - The user's explicit requests and intents\n - Your approach to addressing the user's requests\n - Key decisions, technical concepts and code patterns\n - Specific details like:\n - file names\n - full code snippets\n - function signatures\n - file edits\n - Errors that you ran into and how you fixed them\n - Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.\n2. Double-check for technical accuracy and completeness, addressing each required element thoroughly.\n\nYour summary should include the following sections:\n\n1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail\n2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed.\n3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important.\n4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently.\n5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts.\n6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent.\n6. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on.\n7. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable.\n8. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first.\n If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation.\n\nHere's an example of how your output should be structured:\n\n\n\n[Your thought process, ensuring all points are covered thoroughly and accurately]\n\n\n\n1. Primary Request and Intent:\n [Detailed description]\n\n2. Key Technical Concepts:\n - [Concept 1]\n - [Concept 2]\n - [...]\n\n3. Files and Code Sections:\n - [File Name 1]\n - [Summary of why this file is important]\n - [Summary of the changes made to this file, if any]\n - [Important Code Snippet]\n - [File Name 2]\n - [Important Code Snippet]\n - [...]\n\n4. Errors and fixes:\n - [Detailed description of error 1]:\n - [How you fixed the error]\n - [User feedback on the error if any]\n - [...]\n\n5. Problem Solving:\n [Description of solved problems and ongoing troubleshooting]\n\n6. All user messages: \n - [Detailed non tool use user message]\n - [...]\n\n7. Pending Tasks:\n - [Task 1]\n - [Task 2]\n - [...]\n\n8. Current Work:\n [Precise description of current work]\n\n9. Optional Next Step:\n [Optional Next step to take]\n\n\n\n\nPlease provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response. \n\nThere may be additional summarization instructions provided in the included context. If so, remember to follow these instructions when creating the above summary. Examples of instructions include:\n\n## Compact Instructions\nWhen summarizing the conversation focus on typescript code changes and also remember the mistakes you made and how you fixed them.\n\n\n\n# Summary instructions\nWhen you are using compact - please focus on test output and code changes. Include file reads verbatim.\n\n\n\nAdditional Instructions:\n__PLACEHOLDER__" + }, + compactTitleResponsePrompt: { + category: 'summaries', + title: 'Compact Title Response Prompt', + text: 'Respond with the title for the conversation and nothing else.' + }, + sessionNotesPrompt: { + category: 'notes', + title: 'Session Notes Update Prompt', + text: 'IMPORTANT: This message and these instructions are NOT part of the actual user conversation. Do NOT include any references to "note-taking", "session notes extraction", or these update instructions in the notes content.\n\nBased on the user conversation above (EXCLUDING this note-taking instruction message as well as system prompt, claude.md entries, or any past session summaries), update the session notes file.\n\nThe file __PLACEHOLDER__ has already been read for you. Here are its current contents:\n\n__PLACEHOLDER__\n\n\nYour ONLY task is to use the MultiEdit tool EXACTLY ONCE to update the notes file, then stop. Do not call any other tools.\n\nCRITICAL RULES FOR EDITING:\n- The file must maintain its exact structure with all sections, headers, and italic descriptions intact\n-- NEVER modify, delete, or add section headers (## Task specification, ## Worklog, etc.)\n-- NEVER modify or delete the italic text descriptions under each section header\n-- ONLY update the content BELOW the italic descriptions within each existing section\n-- Do NOT add any new sections, summaries, or information outside the existing structure\n- Do NOT reference this note-taking process or instructions anywhere in the notes\n- It\'s OK to skip updating a section if there are no substantial new insights to add\n- Write DETAILED, INFO-DENSE content for each section - include specifics like file paths, function names, error messages, exact commands, technical details, etc.\n- Do not include information that\'s already in the CLAUDE.md files included in the context\n- Keep each section under ~\\__PLACEHOLDER__ tokens/words - if a section is approaching this limit, condense it by cycling out less important details while preserving the most critical information\n- Do not repeat information from past session summaries - only use the current user conversation starting with the first non system-reminder user message.\n- Focus on actionable, specific information that would help someone understand or recreate the work discussed in the conversation\n\nUse the MultiEdit tool with file_path: __PLACEHOLDER__\n\nREMEMBER: Use MultiEdit tool once and stop. Do not continue after the edit. Only include insights from the actual user conversation, never from these note-taking instructions.' + }, + sessionQualityPrompt: { + category: 'quality', + title: 'Session Quality Assessment Prompt', + text: 'Think step-by-step about:\n1. Does the user seem frustrated at the Asst based on their messages? Look for signs like repeated corrections, negative language, etc.\n2. Has the user explicitly asked to SEND/CREATE/PUSH a pull request to GitHub? This means they want to actually submit a PR to a repository, not just work on code together or prepare changes. Look for explicit requests like: "create a pr", "send a pull request", "push a pr", "open a pr", "submit a pr to github", etc. Do NOT count mentions of working on a PR together, preparing for a PR, or discussing PR content.\n\nBased on your analysis, output:\ntrue/false\ntrue/false' + } +} + +/** + * @typedef {keyof typeof PROMPT_DEFINITIONS} PromptId + */ + +/** + * @typedef {PromptDefinitionBase & { id: PromptId }} PromptDefinition + */ + +/** + * @type {Record} + */ +const promptCatalogByCategory = { + system: [], + output_style: [], + tools: [], + web: [], + agents: [], + summaries: [], + notes: [], + quality: [] +} + +for (const [id, definition] of Object.entries(PROMPT_DEFINITIONS)) { + const entry = { id, ...definition } + promptCatalogByCategory[entry.category].push(entry) +} + +for (const category of [ + 'system', + 'output_style', + 'tools', + 'web', + 'agents', + 'summaries', + 'notes', + 'quality' +]) { + promptCatalogByCategory[category].sort((a, b) => a.title.localeCompare(b.title)) +} + +/** + * @type {Record} + */ +const promptMap = Object.fromEntries( + Object.entries(PROMPT_DEFINITIONS).map(([id, definition]) => [id, definition.text]) +) + +const PLACEHOLDER_TOKEN = '__PLACEHOLDER__' +const PLACEHOLDER_PATTERN = /__PLACEHOLDER__/g +const TRAILING_PLACEHOLDER_ANCHOR_LENGTH = 30 + +/** + * @param {string} value + * @returns {string} + */ +const collapseWhitespace = (value) => value.replace(/\s+/g, ' ').trim() +const ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g +/** + * @param {string} value + * @returns {string} + */ +const escapeRegex = (value) => value.replace(ESCAPE_REGEX, '\\$&') +/** + * @param {string} value + * @returns {string} + */ +const toFlexibleWhitespacePattern = (value) => + value + .split(/\s+/) + .filter(Boolean) + .map((part) => escapeRegex(part)) + .join('\\s*') + +/** + * @param {unknown} value + * @returns {string} + */ +function normalizePrompt(value) { + if (typeof value !== 'string') { + return '' + } + return collapseWhitespace(value.replace(PLACEHOLDER_PATTERN, ' ')) +} + +/** + * @type {Record} + */ +const normalizedPromptMap = Object.fromEntries( + Object.entries(promptMap).map(([id, text]) => [id, normalizePrompt(text)]) +) + +/** + * @type {[PromptId, string][]} + */ +const normalizedPromptEntries = Object.entries(normalizedPromptMap) +/** + * @type {[PromptId, string][]} + */ +const promptEntries = Object.entries(promptMap) + +/** + * @typedef {Object} TemplateSimilarityResult + * @property {number} bestScore + * @property {PromptId} [templateId] + * @property {string} maskedRaw + * @property {number} threshold + */ + +/** + * @param {string} template + * @returns {string|null} + */ +function getTrailingPlaceholderAnchor(template) { + const trimmed = template.trimEnd() + if (!trimmed.endsWith(PLACEHOLDER_TOKEN)) { + return null + } + + const placeholderIndex = trimmed.lastIndexOf(PLACEHOLDER_TOKEN) + if (placeholderIndex < 0) { + return null + } + + const anchorStart = Math.max(0, placeholderIndex - TRAILING_PLACEHOLDER_ANCHOR_LENGTH) + const anchor = trimmed.slice(anchorStart, placeholderIndex) + const normalizedAnchor = collapseWhitespace(anchor) + + return normalizedAnchor || null +} + +/** + * @param {string} rawValue + * @param {string} template + * @returns {string} + */ +function trimRawValueByTrailingPlaceholder(rawValue, template) { + if (!rawValue) { + return rawValue + } + + const trimmedTemplate = template.trimEnd() + if (!trimmedTemplate.endsWith(PLACEHOLDER_TOKEN)) { + return rawValue + } + + const placeholderIndex = trimmedTemplate.lastIndexOf(PLACEHOLDER_TOKEN) + if (placeholderIndex < 0) { + return rawValue + } + + const anchorStart = Math.max(0, placeholderIndex - TRAILING_PLACEHOLDER_ANCHOR_LENGTH) + const anchorSegment = trimmedTemplate.slice(anchorStart, placeholderIndex) + if (!anchorSegment.trim()) { + return rawValue + } + + const directIndex = rawValue.indexOf(anchorSegment) + if (directIndex !== -1) { + return rawValue.slice(0, directIndex + anchorSegment.length) + } + + const pattern = toFlexibleWhitespacePattern(anchorSegment) + if (!pattern) { + return rawValue + } + + const regex = new RegExp(pattern) + const match = regex.exec(rawValue) + if (match && typeof match.index === 'number') { + return rawValue.slice(0, match.index + match[0].length) + } + + return rawValue +} + +/** + * @param {string} normalizedValue + * @param {string} template + * @returns {string} + */ +function trimTrailingPlaceholder(normalizedValue, template) { + const anchor = getTrailingPlaceholderAnchor(template) + if (!anchor) { + return normalizedValue + } + + const matchIndex = normalizedValue.lastIndexOf(anchor) + if (matchIndex === -1) { + return normalizedValue + } + + return normalizedValue.slice(0, matchIndex + anchor.length).trim() +} + +/** + * @param {string} normalizedValue + * @param {string} template + * @param {string} normalizedTemplate + * @returns {string} + */ +function normalizeValueForTemplate(normalizedValue, template, normalizedTemplate) { + const trimmedTemplate = template.trimEnd() + const trimmedValue = trimTrailingPlaceholder(normalizedValue, trimmedTemplate) + const parts = trimmedTemplate.split(PLACEHOLDER_TOKEN).map((part) => collapseWhitespace(part)) + + if (parts.length <= 1) { + return trimmedValue + } + + if (matchesTemplateIgnoringPlaceholders(trimmedValue, parts)) { + return normalizedTemplate + } + + return trimmedValue +} + +/** + * @param {string} normalizedValue + * @param {string[]} parts + * @returns {boolean} + */ +function matchesTemplateIgnoringPlaceholders(normalizedValue, parts) { + const valueNoSpace = normalizedValue.replace(/\s+/g, '') + let cursor = 0 + + for (const part of parts) { + if (!part) { + continue + } + + const partNoSpace = part.replace(/\s+/g, '') + const index = valueNoSpace.indexOf(partNoSpace, cursor) + if (index === -1) { + return false + } + + cursor = index + partNoSpace.length + } + + return true +} + +/** + * @param {unknown} value + * @returns {TemplateSimilarityResult} + */ +function bestSimilarityByTemplates(value) { + const rawValue = typeof value === 'string' ? value : '' + const normalizedValue = normalizePrompt(rawValue) + let bestScore = 0 + /** @type {PromptId|undefined} */ + let bestTemplateId + let maskedRaw = normalizedValue + + for (const [templateId, templateText] of promptEntries) { + const normalizedTemplate = normalizedPromptMap[templateId] + if (!normalizedTemplate) { + continue + } + + const trimmedRawValue = trimRawValueByTrailingPlaceholder(rawValue, templateText) + const normalizedPreparedInput = normalizePrompt(trimmedRawValue) + const preparedValue = normalizeValueForTemplate( + normalizedPreparedInput, + templateText, + normalizedTemplate + ) + const { score } = simple(preparedValue, normalizedTemplate, SYSTEM_PROMPT_THRESHOLD) + + if (score > bestScore) { + bestScore = score + bestTemplateId = templateId + maskedRaw = preparedValue + } + } + + return { bestScore, templateId: bestTemplateId, maskedRaw, threshold: SYSTEM_PROMPT_THRESHOLD } +} + +/** + * @param {unknown} value + * @returns {string} + */ +function normalizeSystemText(value) { + if (typeof value !== 'string') { + return '' + } + + const collapsed = collapseWhitespace(value) + + for (const [promptId, template] of Object.entries(promptMap)) { + if (!template.includes(PLACEHOLDER_TOKEN)) { + continue + } + + const parts = template.split(PLACEHOLDER_TOKEN).map((part) => collapseWhitespace(part)) + const pattern = parts.map((part) => (part ? escapeRegex(part) : '')).join('(.+?)') + + if (!pattern) { + continue + } + + const regex = new RegExp(`^${pattern}$`, 'i') + if (regex.test(collapsed)) { + return normalizedPromptMap[promptId] + } + } + + return normalizePrompt(collapsed) +} + +/** + * @param {unknown} value + * @returns {{bestScore: number}} + */ +function bestSimilarity(value) { + const { bestScore } = bestSimilarityByTemplates(value) + return { bestScore } +} + +module.exports = { + simple, + SYSTEM_PROMPT_THRESHOLD, + promptMap, + normalizePrompt, + normalizedPromptMap, + normalizedPromptEntries, + bestSimilarityByTemplates, + normalizeSystemText, + bestSimilarity +} diff --git a/src/utils/costCalculator.js b/src/utils/costCalculator.js new file mode 100644 index 0000000..c745997 --- /dev/null +++ b/src/utils/costCalculator.js @@ -0,0 +1,447 @@ +const pricingService = require('../services/pricingService') +const logger = require('./logger') + +const warnedDetailedPricingFallbackModels = new Set() + +// Claude模型价格配置 (USD per 1M tokens) - 备用定价 +const MODEL_PRICING = { + // Claude 3.5 Sonnet + 'claude-3-5-sonnet-20241022': { + input: 3.0, + output: 15.0, + cacheWrite: 3.75, + cacheRead: 0.3 + }, + 'claude-sonnet-4-20250514': { + input: 3.0, + output: 15.0, + cacheWrite: 3.75, + cacheRead: 0.3 + }, + 'claude-sonnet-4-5-20250929': { + input: 3.0, + output: 15.0, + cacheWrite: 3.75, + cacheRead: 0.3 + }, + + // Claude 3.5 Haiku + 'claude-3-5-haiku-20241022': { + input: 0.25, + output: 1.25, + cacheWrite: 0.3, + cacheRead: 0.03 + }, + + // Claude 3 Opus + 'claude-3-opus-20240229': { + input: 15.0, + output: 75.0, + cacheWrite: 18.75, + cacheRead: 1.5 + }, + + // Claude Opus 4.1 (新模型) + 'claude-opus-4-1-20250805': { + input: 15.0, + output: 75.0, + cacheWrite: 18.75, + cacheRead: 1.5 + }, + + // Claude 3 Sonnet + 'claude-3-sonnet-20240229': { + input: 3.0, + output: 15.0, + cacheWrite: 3.75, + cacheRead: 0.3 + }, + + // Claude 3 Haiku + 'claude-3-haiku-20240307': { + input: 0.25, + output: 1.25, + cacheWrite: 0.3, + cacheRead: 0.03 + }, + + // 默认定价(用于未知模型) + unknown: { + input: 3.0, + output: 15.0, + cacheWrite: 3.75, + cacheRead: 0.3 + } +} + +class CostCalculator { + static isFiniteNumber(value) { + return typeof value === 'number' && Number.isFinite(value) + } + + static isDetailedPricingRequest(usage, model = 'unknown') { + return ( + (usage.cache_creation && typeof usage.cache_creation === 'object') || + (typeof model === 'string' && model.includes('[1m]')) + ) + } + + static isValidPricingServiceResult(result) { + return ( + result && + result.hasPricing === true && + result.pricing && + this.isFiniteNumber(result.pricing.input) && + this.isFiniteNumber(result.pricing.output) && + this.isFiniteNumber(result.pricing.cacheCreate) && + this.isFiniteNumber(result.pricing.cacheRead) && + this.isFiniteNumber(result.inputCost) && + this.isFiniteNumber(result.outputCost) && + this.isFiniteNumber(result.cacheCreateCost) && + this.isFiniteNumber(result.cacheReadCost) && + this.isFiniteNumber(result.totalCost) + ) + } + + static isOpenAIModel(model, pricingData = null) { + if (typeof model === 'string' && (model.includes('gpt') || model.includes('o1'))) { + return true + } + + return pricingData?.litellm_provider === 'openai' + } + + static getPricingSource(model, pricingData) { + if (pricingData) { + return 'dynamic' + } + + if (MODEL_PRICING[model]) { + return 'static' + } + + return 'unknown-fallback' + } + + static logDetailedPricingFallback(model, usage, result) { + const warnKey = typeof model === 'string' && model ? model : 'unknown' + + if (warnedDetailedPricingFallbackModels.has(warnKey)) { + return + } + + warnedDetailedPricingFallbackModels.add(warnKey) + + const hasDetailedCache = !!(usage.cache_creation && typeof usage.cache_creation === 'object') + const isLongContextModel = typeof model === 'string' && model.includes('[1m]') + + logger.warn( + `💰 Missing detailed pricing for model ${warnKey}; using fallback pricing ` + + `(hasPricing=${result?.hasPricing === true}, cacheCreation=${hasDetailedCache}, longContext=${isLongContextModel})` + ) + } + + static buildDetailedPricingResult(usage, model, result) { + return { + model, + pricing: { + input: result.pricing.input * 1000000, // 转换为 per 1M tokens + output: result.pricing.output * 1000000, + cacheWrite: result.pricing.cacheCreate * 1000000, + cacheRead: result.pricing.cacheRead * 1000000 + }, + usingDynamicPricing: true, + isLongContextRequest: result.isLongContextRequest || false, + usage: { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreateTokens: usage.cache_creation_input_tokens || 0, + cacheReadTokens: usage.cache_read_input_tokens || 0, + totalTokens: + (usage.input_tokens || 0) + + (usage.output_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + + (usage.cache_read_input_tokens || 0) + }, + costs: { + input: result.inputCost, + output: result.outputCost, + cacheCreate: result.cacheCreateCost, + cacheWrite: result.cacheCreateCost, + cacheRead: result.cacheReadCost, + ephemeral5m: result.ephemeral5mCost || 0, + ephemeral1h: result.ephemeral1hCost || 0, + total: result.totalCost + }, + formatted: { + input: this.formatCost(result.inputCost), + output: this.formatCost(result.outputCost), + cacheCreate: this.formatCost(result.cacheCreateCost), + cacheWrite: this.formatCost(result.cacheCreateCost), + cacheRead: this.formatCost(result.cacheReadCost), + ephemeral5m: this.formatCost(result.ephemeral5mCost || 0), + ephemeral1h: this.formatCost(result.ephemeral1hCost || 0), + total: this.formatCost(result.totalCost) + }, + debug: { + isOpenAIModel: this.isOpenAIModel(model), + hasCacheCreatePrice: !!result.pricing.cacheCreate, + cacheCreateTokens: usage.cache_creation_input_tokens || 0, + cacheWritePriceUsed: result.pricing.cacheCreate * 1000000, + isLongContextModel: typeof model === 'string' && model.includes('[1m]'), + isLongContextRequest: result.isLongContextRequest || false, + usedFallbackPricing: false, + pricingSource: 'dynamic' + } + } + } + + static buildLegacyCostResult(usage, model = 'unknown', serviceTier = null, options = {}) { + const safeModel = typeof model === 'string' && model ? model : 'unknown' + + const inputTokens = usage.input_tokens || 0 + const outputTokens = usage.output_tokens || 0 + const cacheCreateTokens = usage.cache_creation_input_tokens || 0 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + + const pricingData = pricingService.getModelPricing(safeModel) + const pricingSource = this.getPricingSource(safeModel, pricingData) + let pricing + let usingDynamicPricing = false + + if (pricingData) { + const usePriority = serviceTier === 'priority' && pricingData.supports_service_tier + + const inputPrice = + ((usePriority && pricingData.input_cost_per_token_priority) || + pricingData.input_cost_per_token || + 0) * 1000000 + const outputPrice = + ((usePriority && pricingData.output_cost_per_token_priority) || + pricingData.output_cost_per_token || + 0) * 1000000 + const cacheReadPrice = + ((usePriority && pricingData.cache_read_input_token_cost_priority) || + pricingData.cache_read_input_token_cost || + 0) * 1000000 + + let cacheWritePrice = (pricingData.cache_creation_input_token_cost || 0) * 1000000 + + if ( + this.isOpenAIModel(safeModel, pricingData) && + !pricingData.cache_creation_input_token_cost && + cacheCreateTokens > 0 + ) { + cacheWritePrice = inputPrice + } + + pricing = { + input: inputPrice, + output: outputPrice, + cacheWrite: cacheWritePrice, + cacheRead: cacheReadPrice + } + usingDynamicPricing = true + } else { + pricing = MODEL_PRICING[safeModel] || MODEL_PRICING['unknown'] + } + + const inputCost = (inputTokens / 1000000) * pricing.input + const outputCost = (outputTokens / 1000000) * pricing.output + const cacheWriteCost = (cacheCreateTokens / 1000000) * pricing.cacheWrite + const cacheReadCost = (cacheReadTokens / 1000000) * pricing.cacheRead + + const totalCost = inputCost + outputCost + cacheWriteCost + cacheReadCost + + return { + model: safeModel, + pricing, + usingDynamicPricing, + usage: { + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens: inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + }, + costs: { + input: inputCost, + output: outputCost, + cacheCreate: cacheWriteCost, + cacheWrite: cacheWriteCost, + cacheRead: cacheReadCost, + ephemeral5m: 0, + ephemeral1h: 0, + total: totalCost + }, + formatted: { + input: this.formatCost(inputCost), + output: this.formatCost(outputCost), + cacheCreate: this.formatCost(cacheWriteCost), + cacheWrite: this.formatCost(cacheWriteCost), + cacheRead: this.formatCost(cacheReadCost), + ephemeral5m: this.formatCost(0), + ephemeral1h: this.formatCost(0), + total: this.formatCost(totalCost) + }, + debug: { + isOpenAIModel: this.isOpenAIModel(safeModel, pricingData), + hasCacheCreatePrice: !!pricingData?.cache_creation_input_token_cost, + cacheCreateTokens, + cacheWritePriceUsed: pricing.cacheWrite, + isLongContextModel: typeof safeModel === 'string' && safeModel.includes('[1m]'), + isLongContextRequest: false, + usedFallbackPricing: + options.usedFallbackPricing === true || pricingSource === 'unknown-fallback', + pricingSource + } + } + } + + /** + * 计算单次请求的费用 + * @param {Object} usage - 使用量数据 + * @param {number} usage.input_tokens - 输入token数量 + * @param {number} usage.output_tokens - 输出token数量 + * @param {number} usage.cache_creation_input_tokens - 缓存创建token数量 + * @param {number} usage.cache_read_input_tokens - 缓存读取token数量 + * @param {string} model - 模型名称 + * @returns {Object} 费用详情 + */ + static calculateCost(usage, model = 'unknown', serviceTier = null) { + // 如果 usage 包含详细的 cache_creation 对象或是 1M 模型,优先使用 pricingService + if (this.isDetailedPricingRequest(usage, model)) { + const result = pricingService.calculateCost(usage, model) + if (this.isValidPricingServiceResult(result)) { + return this.buildDetailedPricingResult(usage, model, result) + } + + this.logDetailedPricingFallback(model, usage, result) + + return this.buildLegacyCostResult(usage, model, serviceTier, { + usedFallbackPricing: true + }) + } + + return this.buildLegacyCostResult(usage, model, serviceTier) + } + + /** + * 计算聚合使用量的费用 + * @param {Object} aggregatedUsage - 聚合使用量数据 + * @param {string} model - 模型名称 + * @returns {Object} 费用详情 + */ + static calculateAggregatedCost(aggregatedUsage, model = 'unknown') { + const usage = { + input_tokens: aggregatedUsage.inputTokens || aggregatedUsage.totalInputTokens || 0, + output_tokens: aggregatedUsage.outputTokens || aggregatedUsage.totalOutputTokens || 0, + cache_creation_input_tokens: + aggregatedUsage.cacheCreateTokens || aggregatedUsage.totalCacheCreateTokens || 0, + cache_read_input_tokens: + aggregatedUsage.cacheReadTokens || aggregatedUsage.totalCacheReadTokens || 0 + } + + // 如果有 ephemeral 拆分数据,构建 cache_creation 子对象 + const eph5m = aggregatedUsage.ephemeral5mTokens || aggregatedUsage.totalEphemeral5mTokens || 0 + const eph1h = aggregatedUsage.ephemeral1hTokens || aggregatedUsage.totalEphemeral1hTokens || 0 + if (eph5m > 0 || eph1h > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: eph5m, + ephemeral_1h_input_tokens: eph1h + } + } + + return this.calculateCost(usage, model) + } + + /** + * 获取模型定价信息 + * @param {string} model - 模型名称 + * @returns {Object} 定价信息 + */ + static getModelPricing(model = 'unknown') { + // 特殊处理:gpt-5.5 回退到 gpt-5(如果没有专门定价) + if (model === 'gpt-5.5' && !MODEL_PRICING['gpt-5.5']) { + const gpt5Pricing = MODEL_PRICING['gpt-5'] + if (gpt5Pricing) { + console.log(`Using gpt-5 pricing as fallback for ${model}`) + return gpt5Pricing + } + } + // 特殊处理:gpt-5.6 系列(sol/terra/luna)在收录专门定价前回退到 gpt-5 + if (model.startsWith('gpt-5.6') && !MODEL_PRICING[model]) { + const gpt5Pricing = MODEL_PRICING['gpt-5'] + if (gpt5Pricing) { + console.log(`Using gpt-5 pricing as fallback for ${model}`) + return gpt5Pricing + } + } + return MODEL_PRICING[model] || MODEL_PRICING['unknown'] + } + + /** + * 获取所有支持的模型和定价 + * @returns {Object} 所有模型定价 + */ + static getAllModelPricing() { + return { ...MODEL_PRICING } + } + + /** + * 验证模型是否支持 + * @param {string} model - 模型名称 + * @returns {boolean} 是否支持 + */ + static isModelSupported(model) { + return !!MODEL_PRICING[model] + } + + /** + * 格式化费用显示 + * @param {number} cost - 费用金额 + * @param {number} decimals - 小数位数 + * @returns {string} 格式化的费用字符串 + */ + static formatCost(cost, decimals = 6) { + if (cost >= 1) { + return `$${cost.toFixed(2)}` + } else if (cost >= 0.001) { + return `$${cost.toFixed(4)}` + } else { + return `$${cost.toFixed(decimals)}` + } + } + + /** + * 计算费用节省(使用缓存的节省) + * @param {Object} usage - 使用量数据 + * @param {string} model - 模型名称 + * @returns {Object} 节省信息 + */ + static calculateCacheSavings(usage, model = 'unknown') { + const pricing = this.getModelPricing(model) // 已包含 gpt-5.5 回退逻辑 + const cacheReadTokens = usage.cache_read_input_tokens || 0 + + // 如果这些token不使用缓存,需要按正常input价格计费 + const normalCost = (cacheReadTokens / 1000000) * pricing.input + const cacheCost = (cacheReadTokens / 1000000) * pricing.cacheRead + const savings = normalCost - cacheCost + const savingsPercentage = normalCost > 0 ? (savings / normalCost) * 100 : 0 + + return { + normalCost, + cacheCost, + savings, + savingsPercentage, + formatted: { + normalCost: this.formatCost(normalCost), + cacheCost: this.formatCost(cacheCost), + savings: this.formatCost(savings), + savingsPercentage: `${savingsPercentage.toFixed(1)}%` + } + } + } +} + +module.exports = CostCalculator diff --git a/src/utils/dateHelper.js b/src/utils/dateHelper.js new file mode 100644 index 0000000..7a8a333 --- /dev/null +++ b/src/utils/dateHelper.js @@ -0,0 +1,100 @@ +const config = require('../../config/config') + +/** + * 格式化日期时间为指定时区的本地时间字符串 + * @param {Date|number} date - Date对象或时间戳(秒或毫秒) + * @param {boolean} includeTimezone - 是否在输出中包含时区信息 + * @returns {string} 格式化后的时间字符串 + */ +function formatDateWithTimezone(date, includeTimezone = true) { + // 处理不同类型的输入 + let dateObj + if (typeof date === 'number') { + // 判断是秒还是毫秒时间戳 + // Unix时间戳(秒)通常小于 10^10,毫秒时间戳通常大于 10^12 + if (date < 10000000000) { + dateObj = new Date(date * 1000) // 秒转毫秒 + } else { + dateObj = new Date(date) // 已经是毫秒 + } + } else if (date instanceof Date) { + dateObj = date + } else { + dateObj = new Date(date) + } + + // 获取配置的时区偏移(小时) + const timezoneOffset = config.system.timezoneOffset || 8 // 默认 UTC+8 + + // 计算本地时间 + const offsetMs = timezoneOffset * 3600000 // 转换为毫秒 + const localTime = new Date(dateObj.getTime() + offsetMs) + + // 格式化为 YYYY-MM-DD HH:mm:ss + const year = localTime.getUTCFullYear() + const month = String(localTime.getUTCMonth() + 1).padStart(2, '0') + const day = String(localTime.getUTCDate()).padStart(2, '0') + const hours = String(localTime.getUTCHours()).padStart(2, '0') + const minutes = String(localTime.getUTCMinutes()).padStart(2, '0') + const seconds = String(localTime.getUTCSeconds()).padStart(2, '0') + + let formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + + // 添加时区信息 + if (includeTimezone) { + const sign = timezoneOffset >= 0 ? '+' : '' + formattedDate += ` (UTC${sign}${timezoneOffset})` + } + + return formattedDate +} + +/** + * 获取指定时区的ISO格式时间字符串 + * @param {Date|number} date - Date对象或时间戳 + * @returns {string} ISO格式的时间字符串 + */ +function getISOStringWithTimezone(date) { + // 先获取本地格式的时间(不含时区后缀) + const localTimeStr = formatDateWithTimezone(date, false) + + // 获取时区偏移 + const timezoneOffset = config.system.timezoneOffset || 8 + + // 构建ISO格式,添加时区偏移 + const sign = timezoneOffset >= 0 ? '+' : '-' + const absOffset = Math.abs(timezoneOffset) + const offsetHours = String(Math.floor(absOffset)).padStart(2, '0') + const offsetMinutes = String(Math.round((absOffset % 1) * 60)).padStart(2, '0') + + // 将空格替换为T,并添加时区 + return `${localTimeStr.replace(' ', 'T')}${sign}${offsetHours}:${offsetMinutes}` +} + +/** + * 计算时间差并格式化为人类可读的字符串 + * @param {number} seconds - 秒数 + * @returns {string} 格式化的时间差字符串 + */ +function formatDuration(seconds) { + if (seconds < 60) { + return `${seconds}秒` + } else if (seconds < 3600) { + const minutes = Math.floor(seconds / 60) + return `${minutes}分钟` + } else if (seconds < 86400) { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时` + } else { + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + return hours > 0 ? `${days}天${hours}小时` : `${days}天` + } +} + +module.exports = { + formatDateWithTimezone, + getISOStringWithTimezone, + formatDuration +} diff --git a/src/utils/errorSanitizer.js b/src/utils/errorSanitizer.js new file mode 100644 index 0000000..7cb927f --- /dev/null +++ b/src/utils/errorSanitizer.js @@ -0,0 +1,265 @@ +/** + * 错误消息清理工具 - 白名单错误码制 + * 所有错误映射到预定义的标准错误码,原始消息只记日志不返回前端 + */ + +const logger = require('./logger') + +// 标准错误码定义 +const ERROR_CODES = { + E001: { message: 'Service temporarily unavailable', status: 503 }, + E002: { message: 'Network connection failed', status: 502 }, + E003: { message: 'Authentication failed', status: 401 }, + E004: { message: 'Rate limit exceeded', status: 429 }, + E005: { message: 'Invalid request', status: 400 }, + E006: { message: 'Model not available', status: 503 }, + E007: { message: 'Upstream service error', status: 502 }, + E008: { message: 'Request timeout', status: 504 }, + E009: { message: 'Permission denied', status: 403 }, + E010: { message: 'Resource not found', status: 404 }, + E011: { message: 'Account temporarily unavailable', status: 503 }, + E012: { message: 'Server overloaded', status: 529 }, + E013: { message: 'Invalid API key', status: 401 }, + E014: { message: 'Quota exceeded', status: 429 }, + E015: { message: 'Internal server error', status: 500 } +} + +// 错误特征匹配规则(按优先级排序) +const ERROR_MATCHERS = [ + // 网络层错误 + { pattern: /ENOTFOUND|DNS|getaddrinfo/i, code: 'E002' }, + { pattern: /ECONNREFUSED|ECONNRESET|connection refused/i, code: 'E002' }, + { pattern: /ETIMEDOUT|timeout/i, code: 'E008' }, + { pattern: /ECONNABORTED|aborted/i, code: 'E002' }, + + // 认证错误 + { pattern: /unauthorized|invalid.*token|token.*invalid|invalid.*key/i, code: 'E003' }, + { pattern: /invalid.*api.*key|api.*key.*invalid/i, code: 'E013' }, + { pattern: /authentication|auth.*fail/i, code: 'E003' }, + + // 权限错误 + { pattern: /forbidden|permission.*denied|access.*denied/i, code: 'E009' }, + { pattern: /does not have.*permission/i, code: 'E009' }, + + // 限流错误 + { pattern: /rate.*limit|too many requests|429/i, code: 'E004' }, + { pattern: /quota.*exceeded|usage.*limit/i, code: 'E014' }, + + // 过载错误 + { pattern: /overloaded|529|capacity/i, code: 'E012' }, + + // 账户错误 + { pattern: /account.*disabled|organization.*disabled/i, code: 'E011' }, + { pattern: /too many active sessions/i, code: 'E011' }, + + // 模型错误 + { pattern: /model.*not.*found|model.*unavailable|unsupported.*model/i, code: 'E006' }, + + // 请求错误 + { pattern: /bad.*request|invalid.*request|invalid.*argument|malformed/i, code: 'E005' }, + { pattern: /not.*found|404/i, code: 'E010' }, + + // 上游错误 + { pattern: /upstream|502|bad.*gateway/i, code: 'E007' }, + { pattern: /503|service.*unavailable/i, code: 'E001' } +] + +/** + * 根据原始错误匹配标准错误码 + * @param {Error|string|object} error - 原始错误 + * @param {object} options - 选项 + * @param {string} options.context - 错误上下文(用于日志) + * @param {boolean} options.logOriginal - 是否记录原始错误(默认true) + * @returns {{ code: string, message: string, status: number }} + */ +function mapToErrorCode(error, options = {}) { + const { context = 'unknown', logOriginal = true } = options + + // 提取原始错误信息 + const originalMessage = extractOriginalMessage(error) + const errorCode = error?.code || error?.response?.status + const statusCode = error?.response?.status || error?.status || error?.statusCode + + // 记录原始错误到日志(供调试) + if (logOriginal && originalMessage) { + logger.debug(`[ErrorSanitizer] Original error (${context}):`, { + message: originalMessage, + code: errorCode, + status: statusCode + }) + } + + // 匹配错误码 + let matchedCode = 'E015' // 默认:内部服务器错误 + + // 先按 HTTP 状态码快速匹配 + if (statusCode) { + if (statusCode === 401) { + matchedCode = 'E003' + } else if (statusCode === 403) { + matchedCode = 'E009' + } else if (statusCode === 404) { + matchedCode = 'E010' + } else if (statusCode === 429) { + matchedCode = 'E004' + } else if (statusCode === 502) { + matchedCode = 'E007' + } else if (statusCode === 503) { + matchedCode = 'E001' + } else if (statusCode === 504) { + matchedCode = 'E008' + } else if (statusCode === 529) { + matchedCode = 'E012' + } + } + + // 再按消息内容精确匹配(可能覆盖状态码匹配) + if (originalMessage) { + for (const matcher of ERROR_MATCHERS) { + if (matcher.pattern.test(originalMessage)) { + matchedCode = matcher.code + break + } + } + } + + // 按错误 code 匹配(网络错误) + if (errorCode) { + const codeStr = String(errorCode).toUpperCase() + if (codeStr === 'ENOTFOUND' || codeStr === 'EAI_AGAIN') { + matchedCode = 'E002' + } else if (codeStr === 'ECONNREFUSED' || codeStr === 'ECONNRESET') { + matchedCode = 'E002' + } else if (codeStr === 'ETIMEDOUT' || codeStr === 'ESOCKETTIMEDOUT') { + matchedCode = 'E008' + } else if (codeStr === 'ECONNABORTED') { + matchedCode = 'E002' + } + } + + const result = ERROR_CODES[matchedCode] + return { + code: matchedCode, + message: result.message, + status: result.status + } +} + +/** + * 提取原始错误消息 + */ +function extractOriginalMessage(error) { + if (!error) { + return '' + } + if (typeof error === 'string') { + return error + } + if (error.message) { + return error.message + } + if (error.error?.message) { + return error.error.message + } + if (error.response?.data?.error?.message) { + return error.response.data.error.message + } + if (error.response?.data?.error) { + return String(error.response.data.error) + } + if (error.response?.data?.message) { + return error.response.data.message + } + return '' +} + +/** + * 创建安全的错误响应对象 + * @param {Error|string|object} error - 原始错误 + * @param {object} options - 选项 + * @returns {{ error: { code: string, message: string }, status: number }} + */ +function createSafeErrorResponse(error, options = {}) { + const mapped = mapToErrorCode(error, options) + return { + error: { + code: mapped.code, + message: mapped.message + }, + status: mapped.status + } +} + +/** + * 创建安全的 SSE 错误事件 + * @param {Error|string|object} error - 原始错误 + * @param {object} options - 选项 + * @returns {string} - SSE 格式的错误事件 + */ +function createSafeSSEError(error, options = {}) { + const mapped = mapToErrorCode(error, options) + return `event: error\ndata: ${JSON.stringify({ + error: mapped.message, + code: mapped.code, + timestamp: new Date().toISOString() + })}\n\n` +} + +/** + * 获取安全的错误消息(用于替换 error.message) + * @param {Error|string|object} error - 原始错误 + * @param {object} options - 选项 + * @returns {string} + */ +function getSafeMessage(error, options = {}) { + return mapToErrorCode(error, options).message +} + +// 兼容旧接口 +function sanitizeErrorMessage(message) { + if (!message) { + return 'Service temporarily unavailable' + } + return mapToErrorCode({ message }, { logOriginal: false }).message +} + +function sanitizeUpstreamError(errorData) { + return createSafeErrorResponse(errorData, { logOriginal: false }) +} + +function extractErrorMessage(body) { + return extractOriginalMessage(body) +} + +function isAccountDisabledError(statusCode, body) { + if (statusCode !== 400) { + return false + } + const message = extractOriginalMessage(body) + if (!message) { + return false + } + const lower = message.toLowerCase() + return ( + lower.includes('organization has been disabled') || + lower.includes('account has been disabled') || + lower.includes('account is disabled') || + lower.includes('no account supporting') || + lower.includes('account not found') || + lower.includes('invalid account') || + lower.includes('too many active sessions') + ) +} + +module.exports = { + ERROR_CODES, + mapToErrorCode, + createSafeErrorResponse, + createSafeSSEError, + getSafeMessage, + // 兼容旧接口 + sanitizeErrorMessage, + sanitizeUpstreamError, + extractErrorMessage, + isAccountDisabledError +} diff --git a/src/utils/featureFlags.js b/src/utils/featureFlags.js new file mode 100644 index 0000000..c2dd4f0 --- /dev/null +++ b/src/utils/featureFlags.js @@ -0,0 +1,46 @@ +let config = {} +try { + // config/config.js 可能在某些环境不存在(例如仅拷贝了 config.example.js) + // 为保证可运行,这里做容错处理 + // eslint-disable-next-line global-require + config = require('../../config/config') +} catch (error) { + config = {} +} + +const parseBooleanEnv = (value) => { + if (typeof value === 'boolean') { + return value + } + if (typeof value !== 'string') { + return false + } + const normalized = value.trim().toLowerCase() + return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on' +} + +/** + * 是否允许执行"余额脚本"(安全开关) + * ⚠️ 安全警告:vm模块非安全沙箱,默认禁用。如需启用请显式设置 BALANCE_SCRIPT_ENABLED=true + * 仅在完全信任管理员且了解RCE风险时才启用此功能 + */ +const isBalanceScriptEnabled = () => { + if ( + process.env.BALANCE_SCRIPT_ENABLED !== undefined && + process.env.BALANCE_SCRIPT_ENABLED !== '' + ) { + return parseBooleanEnv(process.env.BALANCE_SCRIPT_ENABLED) + } + + const fromConfig = + config?.accountBalance?.enableBalanceScript ?? + config?.features?.balanceScriptEnabled ?? + config?.security?.enableBalanceScript + + // 默认禁用,需显式启用 + return typeof fromConfig === 'boolean' ? fromConfig : false +} + +module.exports = { + isBalanceScriptEnabled +} diff --git a/src/utils/geminiSchemaCleaner.js b/src/utils/geminiSchemaCleaner.js new file mode 100644 index 0000000..fa4d1b3 --- /dev/null +++ b/src/utils/geminiSchemaCleaner.js @@ -0,0 +1,265 @@ +function appendHint(description, hint) { + if (!hint) { + return description || '' + } + if (!description) { + return hint + } + return `${description} (${hint})` +} + +function getRefHint(refValue) { + const ref = String(refValue || '') + if (!ref) { + return '' + } + const idx = ref.lastIndexOf('/') + const name = idx >= 0 ? ref.slice(idx + 1) : ref + return name ? `See: ${name}` : '' +} + +function normalizeType(typeValue) { + if (typeof typeValue === 'string' && typeValue) { + return { type: typeValue, hint: '' } + } + if (!Array.isArray(typeValue) || typeValue.length === 0) { + return { type: '', hint: '' } + } + const raw = typeValue.map((t) => (t === null || t === undefined ? '' : String(t))).filter(Boolean) + const hasNull = raw.includes('null') + const nonNull = raw.filter((t) => t !== 'null') + const primary = nonNull[0] || 'string' + const hintParts = [] + if (nonNull.length > 1) { + hintParts.push(`Accepts: ${nonNull.join(' | ')}`) + } + if (hasNull) { + hintParts.push('nullable') + } + return { type: primary, hint: hintParts.join('; ') } +} + +const CONSTRAINT_KEYS = [ + 'minLength', + 'maxLength', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'pattern', + 'minItems', + 'maxItems' +] + +function scoreSchema(schema) { + if (!schema || typeof schema !== 'object') { + return { score: 0, type: '' } + } + const t = typeof schema.type === 'string' ? schema.type : '' + if (t === 'object' || (schema.properties && typeof schema.properties === 'object')) { + return { score: 3, type: t || 'object' } + } + if (t === 'array' || schema.items) { + return { score: 2, type: t || 'array' } + } + if (t && t !== 'null') { + return { score: 1, type: t } + } + return { score: 0, type: t || 'null' } +} + +function pickBestFromAlternatives(alternatives) { + let bestIndex = 0 + let bestScore = -1 + const types = [] + for (let i = 0; i < alternatives.length; i += 1) { + const alt = alternatives[i] + const { score, type } = scoreSchema(alt) + if (type) { + types.push(type) + } + if (score > bestScore) { + bestScore = score + bestIndex = i + } + } + return { best: alternatives[bestIndex], types: Array.from(new Set(types)).filter(Boolean) } +} + +function cleanJsonSchemaForGemini(schema) { + if (schema === null || schema === undefined) { + return { type: 'object', properties: {} } + } + if (typeof schema !== 'object') { + return { type: 'object', properties: {} } + } + if (Array.isArray(schema)) { + return { type: 'object', properties: {} } + } + + // $ref:Gemini/Antigravity 不支持,转换为 hint + if (typeof schema.$ref === 'string' && schema.$ref) { + return { + type: 'object', + description: appendHint(schema.description || '', getRefHint(schema.$ref)), + properties: {} + } + } + + // anyOf / oneOf:选择最可能的 schema,保留类型提示 + const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : null + const oneOf = Array.isArray(schema.oneOf) ? schema.oneOf : null + const alts = anyOf && anyOf.length ? anyOf : oneOf && oneOf.length ? oneOf : null + if (alts) { + const { best, types } = pickBestFromAlternatives(alts) + const cleaned = cleanJsonSchemaForGemini(best) + const mergedDescription = appendHint(cleaned.description || '', schema.description || '') + const typeHint = types.length > 1 ? `Accepts: ${types.join(' || ')}` : '' + return { + ...cleaned, + description: appendHint(mergedDescription, typeHint) + } + } + + // allOf:合并 properties/required + if (Array.isArray(schema.allOf) && schema.allOf.length) { + const merged = {} + let mergedDesc = schema.description || '' + const mergedReq = new Set() + const mergedProps = {} + for (const item of schema.allOf) { + const cleaned = cleanJsonSchemaForGemini(item) + if (cleaned.description) { + mergedDesc = appendHint(mergedDesc, cleaned.description) + } + if (Array.isArray(cleaned.required)) { + for (const r of cleaned.required) { + if (typeof r === 'string' && r) { + mergedReq.add(r) + } + } + } + if (cleaned.properties && typeof cleaned.properties === 'object') { + Object.assign(mergedProps, cleaned.properties) + } + if (cleaned.type && !merged.type) { + merged.type = cleaned.type + } + if (cleaned.items && !merged.items) { + merged.items = cleaned.items + } + if (Array.isArray(cleaned.enum) && !merged.enum) { + merged.enum = cleaned.enum + } + } + if (Object.keys(mergedProps).length) { + merged.type = merged.type || 'object' + merged.properties = mergedProps + const req = Array.from(mergedReq).filter((r) => mergedProps[r]) + if (req.length) { + merged.required = req + } + } + if (mergedDesc) { + merged.description = mergedDesc + } + return cleanJsonSchemaForGemini(merged) + } + + const result = {} + const constraintHints = [] + + // description + if (typeof schema.description === 'string') { + result.description = schema.description + } + + for (const key of CONSTRAINT_KEYS) { + const value = schema[key] + if (value === undefined || value === null || typeof value === 'object') { + continue + } + constraintHints.push(`${key}: ${value}`) + } + + // const -> enum + if (schema.const !== undefined && !Array.isArray(schema.enum)) { + result.enum = [schema.const] + } + + // enum + if (Array.isArray(schema.enum)) { + const en = schema.enum.filter( + (v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' + ) + if (en.length) { + result.enum = en + } + } + + // type(flatten 数组 type) + const { type: normalizedType, hint: typeHint } = normalizeType(schema.type) + if (normalizedType) { + result.type = normalizedType + } + if (typeHint) { + result.description = appendHint(result.description || '', typeHint) + } + + if (result.enum && result.enum.length > 1 && result.enum.length <= 10) { + const list = result.enum.map((item) => String(item)).join(', ') + result.description = appendHint(result.description || '', `Allowed: ${list}`) + } + + if (constraintHints.length) { + result.description = appendHint(result.description || '', constraintHints.join(', ')) + } + + // additionalProperties:Gemini/Antigravity 不接受布尔值,直接删除并用 hint 记录 + if (schema.additionalProperties === false) { + result.description = appendHint(result.description || '', 'No extra properties allowed') + } + + // properties + if ( + schema.properties && + typeof schema.properties === 'object' && + !Array.isArray(schema.properties) + ) { + const props = {} + for (const [name, propSchema] of Object.entries(schema.properties)) { + props[name] = cleanJsonSchemaForGemini(propSchema) + } + result.type = result.type || 'object' + result.properties = props + } + + // items + if (schema.items !== undefined) { + result.type = result.type || 'array' + result.items = cleanJsonSchemaForGemini(schema.items) + } + + // required(最后再清理无效字段) + if (Array.isArray(schema.required) && result.properties) { + const req = schema.required.filter( + (r) => + typeof r === 'string' && r && Object.prototype.hasOwnProperty.call(result.properties, r) + ) + if (req.length) { + result.required = req + } + } + + // 只保留 Gemini 兼容字段:其他($schema/$id/$defs/definitions/format/constraints/pattern...)一律丢弃 + + if (!result.type) { + result.type = result.properties ? 'object' : result.items ? 'array' : 'object' + } + if (result.type === 'object' && !result.properties) { + result.properties = {} + } + return result +} + +module.exports = { + cleanJsonSchemaForGemini +} diff --git a/src/utils/headerFilter.js b/src/utils/headerFilter.js new file mode 100644 index 0000000..89d77fb --- /dev/null +++ b/src/utils/headerFilter.js @@ -0,0 +1,122 @@ +/** + * 统一的 CDN Headers 过滤列表 + * + * 用于各服务在原有过滤逻辑基础上,额外移除 Cloudflare CDN 和代理相关的 headers + * 避免触发上游 API(如 88code)的安全检查 + */ + +// Cloudflare CDN headers(橙色云代理模式会添加这些) +const cdnHeaders = [ + 'x-real-ip', + 'x-forwarded-for', + 'x-forwarded-proto', + 'x-forwarded-host', + 'x-forwarded-port', + 'x-accel-buffering', + 'cf-ray', + 'cf-connecting-ip', + 'cf-ipcountry', + 'cf-visitor', + 'cf-request-id', + 'cdn-loop', + 'true-client-ip' +] + +/** + * 为 OpenAI/Responses API 过滤 headers + * 在原有 skipHeaders 基础上添加 CDN headers + */ +function filterForOpenAI(headers) { + const skipHeaders = [ + 'host', + 'content-length', + 'authorization', + 'x-api-key', + 'x-cr-api-key', + 'connection', + 'upgrade', + 'sec-websocket-key', + 'sec-websocket-version', + 'sec-websocket-extensions', + ...cdnHeaders // 添加 CDN headers + ] + + const filtered = {} + for (const [key, value] of Object.entries(headers)) { + if (!skipHeaders.includes(key.toLowerCase())) { + filtered[key] = value + } + } + return filtered +} + +/** + * 为 Claude/Anthropic API 过滤 headers + * 使用白名单模式,只允许指定的 headers 通过 + */ +function filterForClaude(headers) { + // 白名单模式:只允许以下 headers + const allowedHeaders = [ + 'accept', + 'x-stainless-retry-count', + 'x-stainless-timeout', + 'x-stainless-lang', + 'x-stainless-package-version', + 'x-stainless-os', + 'x-stainless-arch', + 'x-stainless-runtime', + 'x-stainless-runtime-version', + 'x-stainless-helper-method', + 'anthropic-dangerous-direct-browser-access', + 'anthropic-version', + 'x-app', + 'anthropic-beta', + 'accept-language', + 'sec-fetch-mode', + // 注意:不透传 accept-encoding,避免客户端发送的 zstd 等 Node.js 不支持的编码 + // 被转发到上游,导致 axios 无法解压响应(Node 18 zlib 不支持 zstd) + 'user-agent', + 'content-type', + 'connection' + ] + + const filtered = {} + Object.keys(headers || {}).forEach((key) => { + const lowerKey = key.toLowerCase() + if (allowedHeaders.includes(lowerKey)) { + filtered[key] = headers[key] + } + }) + + return filtered +} + +/** + * 为 Gemini API 过滤 headers(如果需要转发客户端 headers 时使用) + * 目前 Gemini 服务不转发客户端 headers,仅提供此方法备用 + */ +function filterForGemini(headers) { + const skipHeaders = [ + 'host', + 'content-length', + 'authorization', + 'x-api-key', + 'connection', + ...cdnHeaders // 添加 CDN headers + ] + + const filtered = {} + for (const [key, value] of Object.entries(headers)) { + if (!skipHeaders.includes(key.toLowerCase())) { + filtered[key] = value + } + } + return filtered +} + +module.exports = { + cdnHeaders, + filterForOpenAI, + filterForClaude, + filterForGemini +} diff --git a/src/utils/inputValidator.js b/src/utils/inputValidator.js new file mode 100644 index 0000000..ca9232a --- /dev/null +++ b/src/utils/inputValidator.js @@ -0,0 +1,291 @@ +/** + * 输入验证工具类 + * 提供各种输入验证和清理功能,防止注入攻击 + */ +class InputValidator { + /** + * 验证用户名 + * @param {string} username - 用户名 + * @returns {string} 验证后的用户名 + * @throws {Error} 如果用户名无效 + */ + validateUsername(username) { + if (!username || typeof username !== 'string') { + throw new Error('用户名必须是非空字符串') + } + + const trimmed = username.trim() + + // 长度检查 + if (trimmed.length < 3 || trimmed.length > 64) { + throw new Error('用户名长度必须在3-64个字符之间') + } + + // 格式检查:只允许字母、数字、下划线、连字符 + const usernameRegex = /^[a-zA-Z0-9_-]+$/ + if (!usernameRegex.test(trimmed)) { + throw new Error('用户名只能包含字母、数字、下划线和连字符') + } + + // 不能以连字符开头或结尾 + if (trimmed.startsWith('-') || trimmed.endsWith('-')) { + throw new Error('用户名不能以连字符开头或结尾') + } + + return trimmed + } + + /** + * 验证电子邮件 + * @param {string} email - 电子邮件地址 + * @returns {string} 验证后的电子邮件 + * @throws {Error} 如果电子邮件无效 + */ + validateEmail(email) { + if (!email || typeof email !== 'string') { + throw new Error('电子邮件必须是非空字符串') + } + + const trimmed = email.trim().toLowerCase() + + // 基本格式验证 + const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ + if (!emailRegex.test(trimmed)) { + throw new Error('电子邮件格式无效') + } + + // 长度限制 + if (trimmed.length > 254) { + throw new Error('电子邮件地址过长') + } + + return trimmed + } + + /** + * 验证密码强度 + * @param {string} password - 密码 + * @returns {boolean} 验证结果 + */ + validatePassword(password) { + if (!password || typeof password !== 'string') { + throw new Error('密码必须是非空字符串') + } + + // 最小长度 + if (password.length < 8) { + throw new Error('密码至少需要8个字符') + } + + // 最大长度(防止DoS攻击) + if (password.length > 128) { + throw new Error('密码不能超过128个字符') + } + + return true + } + + /** + * 验证角色 + * @param {string} role - 用户角色 + * @returns {string} 验证后的角色 + * @throws {Error} 如果角色无效 + */ + validateRole(role) { + const validRoles = ['admin', 'user', 'viewer'] + + if (!role || typeof role !== 'string') { + throw new Error('角色必须是非空字符串') + } + + const trimmed = role.trim().toLowerCase() + + if (!validRoles.includes(trimmed)) { + throw new Error(`角色必须是以下之一: ${validRoles.join(', ')}`) + } + + return trimmed + } + + /** + * 验证Webhook URL + * @param {string} url - Webhook URL + * @returns {string} 验证后的URL + * @throws {Error} 如果URL无效 + */ + validateWebhookUrl(url) { + if (!url || typeof url !== 'string') { + throw new Error('Webhook URL必须是非空字符串') + } + + const trimmed = url.trim() + + // URL格式验证 + try { + const urlObj = new URL(trimmed) + + // 只允许HTTP和HTTPS协议 + if (!['http:', 'https:'].includes(urlObj.protocol)) { + throw new Error('Webhook URL必须使用HTTP或HTTPS协议') + } + + // 防止SSRF攻击:禁止访问内网地址 + const hostname = urlObj.hostname.toLowerCase() + const dangerousHosts = [ + 'localhost', + '127.0.0.1', + '0.0.0.0', + '::1', + '169.254.169.254', // AWS元数据服务 + 'metadata.google.internal' // GCP元数据服务 + ] + + if (dangerousHosts.includes(hostname)) { + throw new Error('Webhook URL不能指向内部服务') + } + + // 检查是否是内网IP + const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/ + if (ipRegex.test(hostname)) { + const parts = hostname.split('.').map(Number) + + // 检查私有IP范围 + if ( + parts[0] === 10 || // 10.0.0.0/8 + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || // 172.16.0.0/12 + (parts[0] === 192 && parts[1] === 168) // 192.168.0.0/16 + ) { + throw new Error('Webhook URL不能指向私有IP地址') + } + } + + return trimmed + } catch (error) { + if (error.message.includes('Webhook URL')) { + throw error + } + throw new Error('Webhook URL格式无效') + } + } + + /** + * 验证显示名称 + * @param {string} displayName - 显示名称 + * @returns {string} 验证后的显示名称 + * @throws {Error} 如果显示名称无效 + */ + validateDisplayName(displayName) { + if (!displayName || typeof displayName !== 'string') { + throw new Error('显示名称必须是非空字符串') + } + + const trimmed = displayName.trim() + + // 长度检查 + if (trimmed.length < 1 || trimmed.length > 100) { + throw new Error('显示名称长度必须在1-100个字符之间') + } + + // 禁止特殊控制字符(排除常见的换行和制表符) + // eslint-disable-next-line no-control-regex + const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/ + if (controlCharRegex.test(trimmed)) { + throw new Error('显示名称不能包含控制字符') + } + + return trimmed + } + + /** + * 清理HTML标签(防止XSS) + * @param {string} input - 输入字符串 + * @returns {string} 清理后的字符串 + */ + sanitizeHtml(input) { + if (!input || typeof input !== 'string') { + return '' + } + + return input + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(/\//g, '/') + } + + /** + * 验证API Key名称 + * @param {string} name - API Key名称 + * @returns {string} 验证后的名称 + * @throws {Error} 如果名称无效 + */ + validateApiKeyName(name) { + if (!name || typeof name !== 'string') { + throw new Error('API Key名称必须是非空字符串') + } + + const trimmed = name.trim() + + // 长度检查 + if (trimmed.length < 1 || trimmed.length > 100) { + throw new Error('API Key名称长度必须在1-100个字符之间') + } + + // 禁止特殊控制字符(排除常见的换行和制表符) + // eslint-disable-next-line no-control-regex + const controlCharRegex = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/ + if (controlCharRegex.test(trimmed)) { + throw new Error('API Key名称不能包含控制字符') + } + + return trimmed + } + + /** + * 验证分页参数 + * @param {number} page - 页码 + * @param {number} limit - 每页数量 + * @returns {{page: number, limit: number}} 验证后的分页参数 + */ + validatePagination(page, limit) { + const pageNum = parseInt(page, 10) || 1 + const limitNum = parseInt(limit, 10) || 20 + + if (pageNum < 1) { + throw new Error('页码必须大于0') + } + + if (limitNum < 1 || limitNum > 100) { + throw new Error('每页数量必须在1-100之间') + } + + return { + page: pageNum, + limit: limitNum + } + } + + /** + * 验证UUID格式 + * @param {string} uuid - UUID字符串 + * @returns {string} 验证后的UUID + * @throws {Error} 如果UUID无效 + */ + validateUuid(uuid) { + if (!uuid || typeof uuid !== 'string') { + throw new Error('UUID必须是非空字符串') + } + + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + if (!uuidRegex.test(uuid)) { + throw new Error('UUID格式无效') + } + + return uuid.toLowerCase() + } +} + +module.exports = new InputValidator() diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..817aa1b --- /dev/null +++ b/src/utils/logger.js @@ -0,0 +1,450 @@ +const winston = require('winston') +const DailyRotateFile = require('winston-daily-rotate-file') +const config = require('../../config/config') +const { formatDateWithTimezone } = require('../utils/dateHelper') +const path = require('path') +const fs = require('fs') +const os = require('os') + +// 安全的 JSON 序列化函数,处理循环引用和特殊字符 +const safeStringify = (obj, maxDepth = Infinity) => { + const seen = new WeakSet() + + const replacer = (key, value, depth = 0) => { + if (depth > maxDepth) { + return '[Max Depth Reached]' + } + + // 处理字符串值,清理可能导致JSON解析错误的特殊字符 + if (typeof value === 'string') { + try { + // 移除或转义可能导致JSON解析错误的字符 + const cleanValue = value + // eslint-disable-next-line no-control-regex + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') // 移除控制字符 + .replace(/[\uD800-\uDFFF]/g, '') // 移除孤立的代理对字符 + // eslint-disable-next-line no-control-regex + .replace(/\u0000/g, '') // 移除NUL字节 + + return cleanValue + } catch (error) { + return '[Invalid String Data]' + } + } + + if (value !== null && typeof value === 'object') { + if (seen.has(value)) { + return '[Circular Reference]' + } + seen.add(value) + + // 过滤掉常见的循环引用对象 + if (value.constructor) { + const constructorName = value.constructor.name + if ( + ['Socket', 'TLSSocket', 'HTTPParser', 'IncomingMessage', 'ServerResponse'].includes( + constructorName + ) + ) { + return `[${constructorName} Object]` + } + } + + // 递归处理对象属性 + if (Array.isArray(value)) { + return value.map((item, index) => replacer(index, item, depth + 1)) + } else { + const result = {} + for (const [k, v] of Object.entries(value)) { + // 确保键名也是安全的 + // eslint-disable-next-line no-control-regex + const safeKey = typeof k === 'string' ? k.replace(/[\u0000-\u001F\u007F]/g, '') : k + result[safeKey] = replacer(safeKey, v, depth + 1) + } + return result + } + } + + return value + } + + try { + const processed = replacer('', obj) + const result = JSON.stringify(processed) + // 体积保护: 超过 50KB 时对大字段做截断,保留顶层结构 + if (result.length > 50000 && processed && typeof processed === 'object') { + const truncated = { ...processed, _truncated: true, _totalChars: result.length } + // 第一轮: 截断单个大字段 + for (const [k, v] of Object.entries(truncated)) { + if (k.startsWith('_')) { + continue + } + const fieldStr = typeof v === 'string' ? v : JSON.stringify(v) + if (fieldStr && fieldStr.length > 10000) { + truncated[k] = `${fieldStr.substring(0, 10000)}...[truncated]` + } + } + // 第二轮: 如果总长度仍超 50KB,逐字段缩减到 2KB + let secondResult = JSON.stringify(truncated) + if (secondResult.length > 50000) { + for (const [k, v] of Object.entries(truncated)) { + if (k.startsWith('_')) { + continue + } + const fieldStr = typeof v === 'string' ? v : JSON.stringify(v) + if (fieldStr && fieldStr.length > 2000) { + truncated[k] = `${fieldStr.substring(0, 2000)}...[truncated]` + } + } + secondResult = JSON.stringify(truncated) + } + return secondResult + } + return result + } catch (error) { + // 如果JSON.stringify仍然失败,使用更保守的方法 + try { + return JSON.stringify({ + error: 'Failed to serialize object', + message: error.message, + type: typeof obj, + keys: obj && typeof obj === 'object' ? Object.keys(obj) : undefined + }) + } catch (finalError) { + return '{"error":"Critical serialization failure","message":"Unable to serialize any data"}' + } + } +} + +// 控制台不显示的 metadata 字段(已在 message 中或低价值) +const CONSOLE_SKIP_KEYS = new Set(['type', 'level', 'message', 'timestamp', 'stack']) + +// 控制台格式: 树形展示 metadata +const createConsoleFormat = () => + winston.format.combine( + winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), + winston.format.errors({ stack: true }), + winston.format.colorize(), + winston.format.printf(({ level: _level, message, timestamp, stack, ...rest }) => { + // 时间戳只取时分秒 + const shortTime = timestamp ? timestamp.split(' ').pop() : '' + + let logMessage = `${shortTime} ${message}` + + // 收集要显示的 metadata + const entries = Object.entries(rest).filter(([k]) => !CONSOLE_SKIP_KEYS.has(k)) + + if (entries.length > 0) { + const indent = ' '.repeat(shortTime.length + 1) + entries.forEach(([key, value], i) => { + const isLast = i === entries.length - 1 + const branch = isLast ? '└─' : '├─' + const displayValue = + value !== null && typeof value === 'object' ? safeStringify(value) : String(value) + logMessage += `\n${indent}${branch} ${key}: ${displayValue}` + }) + } + + if (stack) { + logMessage += `\n${stack}` + } + return logMessage + }) + ) + +// 文件格式: NDJSON(完整结构化数据) +const createFileFormat = () => + winston.format.combine( + winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), + winston.format.errors({ stack: true }), + winston.format.printf(({ level, message, timestamp, stack, ...rest }) => { + const entry = { ts: timestamp, lvl: level, msg: message } + // 合并所有 metadata + for (const [k, v] of Object.entries(rest)) { + if (k !== 'level' && k !== 'message' && k !== 'timestamp' && k !== 'stack') { + entry[k] = v + } + } + if (stack) { + entry.stack = stack + } + return safeStringify(entry) + }) + ) + +const fileFormat = createFileFormat() +const consoleFormat = createConsoleFormat() +const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID + +// 📁 确保日志目录存在并设置权限 +if (!fs.existsSync(config.logging.dirname)) { + fs.mkdirSync(config.logging.dirname, { recursive: true, mode: 0o755 }) +} + +// 🔄 增强的日志轮转配置 +const createRotateTransport = (filename, level = null) => { + const transport = new DailyRotateFile({ + filename: path.join(config.logging.dirname, filename), + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: config.logging.maxSize, + maxFiles: config.logging.maxFiles, + auditFile: path.join(config.logging.dirname, `.${filename.replace('%DATE%', 'audit')}.json`), + format: fileFormat + }) + + if (level) { + transport.level = level + } + + // 监听轮转事件(测试环境关闭以避免 Jest 退出后输出) + if (!isTestEnv) { + transport.on('rotate', (oldFilename, newFilename) => { + console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`) + }) + + transport.on('new', (newFilename) => { + console.log(`📄 New log file created: ${newFilename}`) + }) + + transport.on('archive', (zipFilename) => { + console.log(`🗜️ Log archived: ${zipFilename}`) + }) + } + + return transport +} + +const dailyRotateFileTransport = createRotateTransport('claude-relay-%DATE%.log') +const errorFileTransport = createRotateTransport('claude-relay-error-%DATE%.log', 'error') + +// 🔒 创建专门的安全日志记录器 +const securityLogger = winston.createLogger({ + level: 'warn', + format: fileFormat, + transports: [createRotateTransport('claude-relay-security-%DATE%.log', 'warn')], + silent: false +}) + +// 🔐 创建专门的认证详细日志记录器(记录完整的认证响应) +const authDetailLogger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp({ format: () => formatDateWithTimezone(new Date(), false) }), + winston.format.printf(({ level, message, timestamp, data }) => { + // 使用更深的深度和格式化的JSON输出 + const jsonData = data ? JSON.stringify(data, null, 2) : '{}' + return `[${timestamp}] ${level.toUpperCase()}: ${message}\n${jsonData}\n${'='.repeat(80)}` + }) + ), + transports: [createRotateTransport('claude-relay-auth-detail-%DATE%.log', 'info')], + silent: false +}) + +// 🌟 增强的 Winston logger +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || config.logging.level, + format: fileFormat, + transports: [ + // 📄 文件输出 + dailyRotateFileTransport, + errorFileTransport, + + // 🖥️ 控制台输出 + new winston.transports.Console({ + format: consoleFormat, + handleExceptions: false, + handleRejections: false + }) + ], + + // 🚨 异常处理 + exceptionHandlers: [ + new winston.transports.File({ + filename: path.join(config.logging.dirname, 'exceptions.log'), + format: fileFormat, + maxsize: 10485760, // 10MB + maxFiles: 5 + }), + new winston.transports.Console({ + format: consoleFormat + }) + ], + + // 🔄 未捕获异常处理 + rejectionHandlers: [ + new winston.transports.File({ + filename: path.join(config.logging.dirname, 'rejections.log'), + format: fileFormat, + maxsize: 10485760, // 10MB + maxFiles: 5 + }), + new winston.transports.Console({ + format: consoleFormat + }) + ], + + // 防止进程退出 + exitOnError: false +}) + +// 🎯 增强的自定义方法 +logger.success = (message, metadata = {}) => { + logger.info(`✅ ${message}`, { type: 'success', ...metadata }) +} + +logger.start = (message, metadata = {}) => { + logger.info(`🚀 ${message}`, { type: 'startup', ...metadata }) +} + +logger.request = (method, url, status, duration, metadata = {}) => { + const emoji = status >= 400 ? '🔴' : status >= 300 ? '🟡' : '🟢' + const level = status >= 400 ? 'error' : status >= 300 ? 'warn' : 'info' + + logger[level](`${emoji} ${method} ${url} - ${status} (${duration}ms)`, { + type: 'request', + method, + url, + status, + duration, + ...metadata + }) +} + +logger.api = (message, metadata = {}) => { + logger.info(`🔗 ${message}`, { type: 'api', ...metadata }) +} + +logger.security = (message, metadata = {}) => { + const securityData = { + type: 'security', + timestamp: new Date().toISOString(), + pid: process.pid, + hostname: os.hostname(), + ...metadata + } + + // 记录到主日志 + logger.warn(`🔒 ${message}`, securityData) + + // 记录到专门的安全日志文件 + try { + securityLogger.warn(`🔒 ${message}`, securityData) + } catch (error) { + // 如果安全日志文件不可用,只记录到主日志 + console.warn('Security logger not available:', error.message) + } +} + +logger.database = (message, metadata = {}) => { + logger.debug(`💾 ${message}`, { type: 'database', ...metadata }) +} + +logger.performance = (message, metadata = {}) => { + logger.info(`⚡ ${message}`, { type: 'performance', ...metadata }) +} + +logger.audit = (message, metadata = {}) => { + logger.info(`📋 ${message}`, { + type: 'audit', + timestamp: new Date().toISOString(), + pid: process.pid, + ...metadata + }) +} + +// 🔧 性能监控方法 +logger.timer = (label) => { + const start = Date.now() + return { + end: (message = '', metadata = {}) => { + const duration = Date.now() - start + logger.performance(`${label} ${message}`, { duration, ...metadata }) + return duration + } + } +} + +// 📊 日志统计 +logger.stats = { + requests: 0, + errors: 0, + warnings: 0 +} + +// 重写原始方法以统计 +const originalError = logger.error +const originalWarn = logger.warn +const originalInfo = logger.info + +logger.error = function (message, ...args) { + logger.stats.errors++ + return originalError.call(this, message, ...args) +} + +logger.warn = function (message, ...args) { + logger.stats.warnings++ + return originalWarn.call(this, message, ...args) +} + +logger.info = function (message, ...args) { + // 检查是否是请求类型的日志 + if (args.length > 0 && typeof args[0] === 'object' && args[0].type === 'request') { + logger.stats.requests++ + } + return originalInfo.call(this, message, ...args) +} + +// 📈 获取日志统计 +logger.getStats = () => ({ ...logger.stats }) + +// 🧹 清理统计 +logger.resetStats = () => { + logger.stats.requests = 0 + logger.stats.errors = 0 + logger.stats.warnings = 0 +} + +// 📡 健康检查 +logger.healthCheck = () => { + try { + const testMessage = 'Logger health check' + logger.debug(testMessage) + return { healthy: true, timestamp: new Date().toISOString() } + } catch (error) { + return { healthy: false, error: error.message, timestamp: new Date().toISOString() } + } +} + +// 🔐 记录认证详细信息的方法 +logger.authDetail = (message, data = {}) => { + try { + // 记录到主日志(简化版) + logger.info(`🔐 ${message}`, { + type: 'auth-detail', + summary: { + hasAccessToken: !!data.access_token, + hasRefreshToken: !!data.refresh_token, + scopes: data.scope || data.scopes, + organization: data.organization?.name, + account: data.account?.email_address + } + }) + + // 记录到专门的认证详细日志文件(完整数据) + authDetailLogger.info(message, { data }) + } catch (error) { + logger.error('Failed to log auth detail:', error) + } +} + +// 🎬 启动日志记录系统 +logger.start('Logger initialized', { + level: process.env.LOG_LEVEL || config.logging.level, + directory: config.logging.dirname, + maxSize: config.logging.maxSize, + maxFiles: config.logging.maxFiles, + envOverride: process.env.LOG_LEVEL ? true : false +}) + +module.exports = logger diff --git a/src/utils/lruCache.js b/src/utils/lruCache.js new file mode 100644 index 0000000..993089b --- /dev/null +++ b/src/utils/lruCache.js @@ -0,0 +1,134 @@ +/** + * LRU (Least Recently Used) 缓存实现 + * 用于缓存解密结果,提高性能同时控制内存使用 + */ +class LRUCache { + constructor(maxSize = 500) { + this.maxSize = maxSize + this.cache = new Map() + this.hits = 0 + this.misses = 0 + this.evictions = 0 + this.lastCleanup = Date.now() + this.cleanupInterval = 5 * 60 * 1000 // 5分钟清理一次过期项 + } + + /** + * 获取缓存值 + * @param {string} key - 缓存键 + * @returns {*} 缓存的值,如果不存在则返回 undefined + */ + get(key) { + // 定期清理 + if (Date.now() - this.lastCleanup > this.cleanupInterval) { + this.cleanup() + } + + const item = this.cache.get(key) + if (!item) { + this.misses++ + return undefined + } + + // 检查是否过期 + if (item.expiry && Date.now() > item.expiry) { + this.cache.delete(key) + this.misses++ + return undefined + } + + // 更新访问时间,将元素移到最后(最近使用) + this.cache.delete(key) + this.cache.set(key, { + ...item, + lastAccessed: Date.now() + }) + + this.hits++ + return item.value + } + + /** + * 设置缓存值 + * @param {string} key - 缓存键 + * @param {*} value - 要缓存的值 + * @param {number} ttl - 生存时间(毫秒),默认5分钟 + */ + set(key, value, ttl = 5 * 60 * 1000) { + // 如果缓存已满,删除最少使用的项 + if (this.cache.size >= this.maxSize && !this.cache.has(key)) { + const firstKey = this.cache.keys().next().value + this.cache.delete(firstKey) + this.evictions++ + } + + this.cache.set(key, { + value, + createdAt: Date.now(), + lastAccessed: Date.now(), + expiry: ttl ? Date.now() + ttl : null + }) + } + + /** + * 清理过期项 + */ + cleanup() { + const now = Date.now() + let cleanedCount = 0 + + for (const [key, item] of this.cache.entries()) { + if (item.expiry && now > item.expiry) { + this.cache.delete(key) + cleanedCount++ + } + } + + this.lastCleanup = now + if (cleanedCount > 0) { + console.log(`🧹 LRU Cache: Cleaned ${cleanedCount} expired items`) + } + } + + /** + * 清空缓存 + */ + clear() { + const { size } = this.cache + this.cache.clear() + this.hits = 0 + this.misses = 0 + this.evictions = 0 + console.log(`🗑️ LRU Cache: Cleared ${size} items`) + } + + /** + * 获取缓存统计信息 + */ + getStats() { + const total = this.hits + this.misses + const hitRate = total > 0 ? ((this.hits / total) * 100).toFixed(2) : 0 + + return { + size: this.cache.size, + maxSize: this.maxSize, + hits: this.hits, + misses: this.misses, + evictions: this.evictions, + hitRate: `${hitRate}%`, + total + } + } + + /** + * 打印缓存统计信息 + */ + printStats() { + const stats = this.getStats() + console.log( + `📊 LRU Cache Stats: Size: ${stats.size}/${stats.maxSize}, Hit Rate: ${stats.hitRate}, Hits: ${stats.hits}, Misses: ${stats.misses}, Evictions: ${stats.evictions}` + ) + } +} + +module.exports = LRUCache diff --git a/src/utils/metadataUserIdHelper.js b/src/utils/metadataUserIdHelper.js new file mode 100644 index 0000000..8d3b990 --- /dev/null +++ b/src/utils/metadataUserIdHelper.js @@ -0,0 +1,100 @@ +/** + * metadata.user_id 统一解析/构建工具 + * + * 兼容两种格式: + * - 旧格式 (pre-v2.1.78): user_{deviceId}_account_{accountUuid}_session_{sessionId} + * - 新格式 (v2.1.78+): {"device_id":"...","account_uuid":"...","session_id":"..."} + * + * 纯函数,无外部依赖。 + */ + +const OLD_FORMAT_REGEX = /^user_([a-fA-F0-9]{64})_account_(.*?)_session_([a-f0-9-]+)$/ + +/** + * 解析 metadata.user_id 字符串 + * @param {*} userId - user_id 值 + * @returns {{ deviceId: string, accountUuid: string, sessionId: string, isJsonFormat: boolean } | null} + */ +function parse(userId) { + if (typeof userId !== 'string' || !userId) { + return null + } + + // 尝试 JSON 格式 + if (userId.startsWith('{')) { + try { + const obj = JSON.parse(userId) + const deviceId = obj.device_id + const sessionId = obj.session_id + if ( + typeof deviceId !== 'string' || + !deviceId || + typeof sessionId !== 'string' || + !sessionId + ) { + return null + } + return { + deviceId, + accountUuid: typeof obj.account_uuid === 'string' ? obj.account_uuid : '', + sessionId, + isJsonFormat: true + } + } catch { + return null + } + } + + // 尝试旧格式 + const match = userId.match(OLD_FORMAT_REGEX) + if (match) { + return { + deviceId: match[1], + accountUuid: match[2], + sessionId: match[3], + isJsonFormat: false + } + } + + return null +} + +/** + * 便捷方法:提取 sessionId + * @param {*} userId - user_id 值 + * @returns {string | null} + */ +function extractSessionId(userId) { + const parsed = parse(userId) + return parsed ? parsed.sessionId : null +} + +/** + * 根据解析结果重建 user_id 字符串,保留原始格式 + * @param {{ deviceId: string, accountUuid: string, sessionId: string, isJsonFormat: boolean }} parts + * @returns {string} + */ +function build(parts) { + const { deviceId, accountUuid, sessionId, isJsonFormat } = parts + + if (isJsonFormat) { + return JSON.stringify({ + device_id: deviceId, + account_uuid: accountUuid || '', + session_id: sessionId + }) + } + + return `user_${deviceId}_account_${accountUuid || ''}_session_${sessionId}` +} + +/** + * 检查 user_id 是否为合法格式(旧格式或新 JSON 格式) + * @param {*} userId - user_id 值 + * @returns {boolean} + */ +function isValid(userId) { + return parse(userId) !== null +} + +module.exports = { parse, extractSessionId, build, isValid } diff --git a/src/utils/modelHelper.js b/src/utils/modelHelper.js new file mode 100644 index 0000000..a4d42f6 --- /dev/null +++ b/src/utils/modelHelper.js @@ -0,0 +1,270 @@ +/** + * Model Helper Utility + * + * Provides utilities for parsing vendor-prefixed model names. + * Supports parsing model strings like "ccr,model_name" to extract vendor type and base model. + */ + +// 仅保留原仓库既有的模型前缀:CCR 路由 +// Gemini/Antigravity 采用“路径分流”,避免在 model 字段里混入 vendor 前缀造成混乱 +const SUPPORTED_VENDOR_PREFIXES = ['ccr'] + +/** + * Parse vendor-prefixed model string + * @param {string} modelStr - Model string, potentially with vendor prefix (e.g., "ccr,gemini-2.5-pro") + * @returns {{vendor: string|null, baseModel: string}} - Parsed vendor and base model + */ +function parseVendorPrefixedModel(modelStr) { + if (!modelStr || typeof modelStr !== 'string') { + return { vendor: null, baseModel: modelStr || '' } + } + + // Trim whitespace and convert to lowercase for comparison + const trimmed = modelStr.trim() + const lowerTrimmed = trimmed.toLowerCase() + + for (const vendorPrefix of SUPPORTED_VENDOR_PREFIXES) { + if (!lowerTrimmed.startsWith(`${vendorPrefix},`)) { + continue + } + + const parts = trimmed.split(',') + if (parts.length < 2) { + break + } + + // Extract base model (everything after the first comma, rejoined in case model name contains commas) + const baseModel = parts.slice(1).join(',').trim() + return { + vendor: vendorPrefix, + baseModel + } + } + + // No recognized vendor prefix found + return { + vendor: null, + baseModel: trimmed + } +} + +/** + * Check if a model string has a vendor prefix + * @param {string} modelStr - Model string to check + * @returns {boolean} - True if the model has a vendor prefix + */ +function hasVendorPrefix(modelStr) { + const { vendor } = parseVendorPrefixedModel(modelStr) + return vendor !== null +} + +/** + * Get the effective model name for scheduling and processing + * This removes vendor prefixes to get the actual model name used for API calls + * @param {string} modelStr - Original model string + * @returns {string} - Effective model name without vendor prefix + */ +function getEffectiveModel(modelStr) { + const { baseModel } = parseVendorPrefixedModel(modelStr) + return baseModel +} + +/** + * Get the vendor type from a model string + * @param {string} modelStr - Model string to parse + * @returns {string|null} - Vendor type ('ccr') or null if no prefix + */ +function getVendorType(modelStr) { + const { vendor } = parseVendorPrefixedModel(modelStr) + return vendor +} + +/** + * Check if the model is Opus 4.5 or newer. + * + * VERSION LOGIC (as of 2025-12-05): + * - Opus 4.5+ (including 5.0, 6.0, etc.) → returns true (Pro account eligible) + * - Opus 4.4 and below (including 3.x, 4.0, 4.1) → returns false (Max account only) + * + * Supported naming formats: + * - New format: claude-opus-{major}[-{minor}][-date], e.g., claude-opus-4-5-20251101 + * - New format: claude-opus-{major}.{minor}, e.g., claude-opus-4.5 + * - Old format: claude-{version}-opus[-date], e.g., claude-3-opus-20240229 + * - Special: opus-latest, claude-opus-latest → always returns true + * + * @param {string} modelName - Model name + * @returns {boolean} - Whether the model is Opus 4.5 or newer + */ +function isOpus45OrNewer(modelName) { + if (!modelName) { + return false + } + + const lowerModel = modelName.toLowerCase() + if (!lowerModel.includes('opus')) { + return false + } + + // Handle 'latest' special case + if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) { + return true + } + + // Old format: claude-{version}-opus (version before opus) + // e.g., claude-3-opus-20240229, claude-3.5-opus + const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[.-](\d+))?[- ]opus/) + if (oldFormatMatch) { + const majorVersion = parseInt(oldFormatMatch[1], 10) + const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0 + + // Old format version refers to Claude major version + // majorVersion > 4: 5.x, 6.x, ... → true + // majorVersion === 4 && minorVersion >= 5: 4.5, 4.6, ... → true + // Others (3.x, 4.0-4.4): → false + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } + return false + } + + // New format 1: opus-{major}.{minor} (dot-separated) + // e.g., claude-opus-4.5, opus-4.5 + const dotFormatMatch = lowerModel.match(/opus[- ]?(\d+)\.(\d+)/) + if (dotFormatMatch) { + const majorVersion = parseInt(dotFormatMatch[1], 10) + const minorVersion = parseInt(dotFormatMatch[2], 10) + + // Same version logic as old format + // opus-5.0, opus-6.0 → true + // opus-4.5, opus-4.6 → true + // opus-4.0, opus-4.4 → false + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } + return false + } + + // New format 2: opus-{major}[-{minor}][-date] (hyphen-separated) + // e.g., claude-opus-4-5-20251101, claude-opus-4-20250514, claude-opus-4-1-20250805 + // If opus-{major} is followed by 8-digit date, there's no minor version + + // Extract content after 'opus' + const opusIndex = lowerModel.indexOf('opus') + const afterOpus = lowerModel.substring(opusIndex + 4) + + // Match: -{major}-{minor}-{date} or -{major}-{date} or -{major} + // IMPORTANT: Minor version regex is (\d{1,2}) not (\d+) + // This prevents matching 8-digit dates as minor version + // Example: opus-4-20250514 → major=4, minor=undefined (not 20250514) + // Example: opus-4-5-20251101 → major=4, minor=5 + // Future-proof: Supports up to 2-digit minor versions (0-99) + const versionMatch = afterOpus.match(/^[- ](\d+)(?:[- ](\d{1,2})(?=[- ]\d{8}|$))?/) + + if (versionMatch) { + const majorVersion = parseInt(versionMatch[1], 10) + const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0 + + // Same version logic: >= 4.5 returns true + // opus-5-0-date, opus-6-date → true + // opus-4-5-date, opus-4-10-date → true (supports 2-digit minor) + // opus-4-date (no minor, treated as 4.0) → false + // opus-4-1-date, opus-4-4-date → false + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } + return false + } + + // Other cases containing 'opus' but cannot parse version, assume legacy + return false +} + +/** + * 判断某个 model 名称是否属于 Anthropic Claude 系列模型。 + * + * 用于 API Key 维度的限额/统计(Claude 周费用)。这里刻意覆盖以下命名: + * - 标准 Anthropic 模型:claude-*,包括 claude-3-opus、claude-sonnet-*、claude-haiku-* 等 + * - Bedrock 模型:{region}.anthropic.claude-... / anthropic.claude-... + * - 少数情况下 model 字段可能只包含家族关键词(sonnet/haiku/opus),也视为 Claude 系列 + * + * 注意:会先去掉支持的 vendor 前缀(例如 "ccr,")。 + */ +function isClaudeFamilyModel(modelName) { + if (!modelName || typeof modelName !== 'string') { + return false + } + + const { baseModel } = parseVendorPrefixedModel(modelName) + const m = (baseModel || '').trim().toLowerCase() + if (!m) { + return false + } + + // Bedrock 模型格式 + if ( + m.includes('.anthropic.claude-') || + m.startsWith('anthropic.claude-') || + m.includes('.claude-') + ) { + return true + } + + // 标准 Anthropic 模型 ID + if (m.startsWith('claude-') || m.includes('claude-')) { + return true + } + + // 兜底:某些下游链路里 model 字段可能不带 "claude-" 前缀,但仍包含家族关键词。 + if (m.includes('opus') || m.includes('sonnet') || m.includes('haiku')) { + return true + } + + return false +} + +/** + * 参与「按模型独立限流」的模型家族。 + * + * Anthropic 对这些模型分别下发独立的(通常是周级)限额:命中其中一个的 429, + * 只代表该模型不可用,不代表整个账号耗尽配额。因此必须记入该家族专属的限流桶, + * 而不能改写为账号级限流(那会把账号上的其它模型一并停掉)。 + */ +const RATE_LIMITED_MODEL_FAMILIES = ['opus', 'sonnet', 'haiku', 'fable'] + +/** + * 解析模型名所属的限流家族(会先去除 vendor 前缀)。 + * @param {string} modelName - 模型名,如 claude-sonnet-4-5 + * @returns {string|null} - 'opus' | 'sonnet' | 'haiku' | 'fable',无法识别时返回 null + */ +function getRateLimitModelFamily(modelName) { + if (!modelName || typeof modelName !== 'string') { + return null + } + + const baseModel = (getEffectiveModel(modelName) || '').toLowerCase() + if (!baseModel) { + return null + } + + return RATE_LIMITED_MODEL_FAMILIES.find((family) => baseModel.includes(family)) || null +} + +module.exports = { + parseVendorPrefixedModel, + hasVendorPrefix, + getEffectiveModel, + getVendorType, + isOpus45OrNewer, + isClaudeFamilyModel, + RATE_LIMITED_MODEL_FAMILIES, + getRateLimitModelFamily +} diff --git a/src/utils/oauthHelper.js b/src/utils/oauthHelper.js new file mode 100644 index 0000000..4fa3842 --- /dev/null +++ b/src/utils/oauthHelper.js @@ -0,0 +1,897 @@ +/** + * OAuth助手工具 + * 基于claude-code-login.js中的OAuth流程实现 + */ + +const crypto = require('crypto') +const ProxyHelper = require('./proxyHelper') +const axios = require('axios') +const logger = require('./logger') + +// OAuth 配置常量 - 从claude-code-login.js提取 +// 注:console.anthropic.com 已迁移至 platform.claude.com,旧域名对 refresh_token grant 返回 404 +const OAUTH_CONFIG = { + AUTHORIZE_URL: 'https://claude.ai/oauth/authorize', + TOKEN_URL: 'https://platform.claude.com/v1/oauth/token', + CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', + REDIRECT_URI: 'https://platform.claude.com/oauth/code/callback', + SCOPES: + 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload', + // Cookie/API 流程使用的 scope(不含 org:create_api_key,该 scope 仅适用于浏览器授权) + SCOPES_API: + 'user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload', + SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限 +} + +// Cookie自动授权配置常量 +const COOKIE_OAUTH_CONFIG = { + CLAUDE_AI_URL: 'https://claude.ai', + ORGANIZATIONS_URL: 'https://claude.ai/api/organizations', + AUTHORIZE_URL_TEMPLATE: 'https://claude.ai/v1/oauth/{organization_uuid}/authorize' +} + +/** + * 生成随机的 state 参数 + * @returns {string} 随机生成的 state (base64url编码) + */ +function generateState() { + return crypto.randomBytes(32).toString('base64url') +} + +/** + * 生成随机的 code verifier(PKCE) + * 符合 RFC 7636 标准:32字节随机数 → base64url编码 → 43字符 + * @returns {string} base64url 编码的随机字符串 + */ +function generateCodeVerifier() { + return crypto.randomBytes(32).toString('base64url') +} + +/** + * 生成 code challenge(PKCE) + * @param {string} codeVerifier - code verifier 字符串 + * @returns {string} SHA256 哈希后的 base64url 编码字符串 + */ +function generateCodeChallenge(codeVerifier) { + return crypto.createHash('sha256').update(codeVerifier).digest('base64url') +} + +/** + * 生成授权 URL + * @param {string} codeChallenge - PKCE code challenge + * @param {string} state - state 参数 + * @returns {string} 完整的授权 URL + */ +function generateAuthUrl(codeChallenge, state) { + const params = new URLSearchParams({ + code: 'true', + client_id: OAUTH_CONFIG.CLIENT_ID, + response_type: 'code', + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + scope: OAUTH_CONFIG.SCOPES, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state + }) + + return `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}` +} + +/** + * 生成OAuth授权URL和相关参数 + * @returns {{authUrl: string, codeVerifier: string, state: string, codeChallenge: string}} + */ +function generateOAuthParams() { + const state = generateState() + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + + const authUrl = generateAuthUrl(codeChallenge, state) + + return { + authUrl, + codeVerifier, + state, + codeChallenge + } +} + +/** + * 生成 Setup Token 授权 URL + * @param {string} codeChallenge - PKCE code challenge + * @param {string} state - state 参数 + * @returns {string} 完整的授权 URL + */ +function generateSetupTokenAuthUrl(codeChallenge, state) { + const params = new URLSearchParams({ + code: 'true', + client_id: OAUTH_CONFIG.CLIENT_ID, + response_type: 'code', + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + scope: OAUTH_CONFIG.SCOPES_SETUP, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state + }) + + return `${OAUTH_CONFIG.AUTHORIZE_URL}?${params.toString()}` +} + +/** + * 生成Setup Token授权URL和相关参数 + * @returns {{authUrl: string, codeVerifier: string, state: string, codeChallenge: string}} + */ +function generateSetupTokenParams() { + const state = generateState() + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + + const authUrl = generateSetupTokenAuthUrl(codeChallenge, state) + + return { + authUrl, + codeVerifier, + state, + codeChallenge + } +} + +/** + * 创建代理agent(使用统一的代理工具) + * @param {object|null} proxyConfig - 代理配置对象 + * @returns {object|null} 代理agent或null + */ +function createProxyAgent(proxyConfig) { + return ProxyHelper.createProxyAgent(proxyConfig) +} + +/** + * 使用授权码交换访问令牌 + * @param {string} authorizationCode - 授权码 + * @param {string} codeVerifier - PKCE code verifier + * @param {string} state - state 参数 + * @param {object|null} proxyConfig - 代理配置(可选) + * @returns {Promise} Claude格式的token响应 + */ +async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig = null) { + // 清理授权码,移除URL片段 + const cleanedCode = authorizationCode.split('#')[0]?.split('&')[0] ?? authorizationCode + + const params = { + grant_type: 'authorization_code', + client_id: OAUTH_CONFIG.CLIENT_ID, + code: cleanedCode, + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + code_verifier: codeVerifier, + state + } + + // 创建代理agent + const agent = createProxyAgent(proxyConfig) + + try { + if (agent) { + logger.info( + `🌐 Using proxy for OAuth token exchange: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for OAuth token exchange') + } + + logger.debug('🔄 Attempting OAuth token exchange', { + url: OAUTH_CONFIG.TOKEN_URL, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...`, + hasProxy: !!proxyConfig, + proxyType: proxyConfig?.type || 'none' + }) + + const axiosConfig = { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'https://claude.ai/', + Origin: 'https://claude.ai' + }, + timeout: 30000 + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, axiosConfig) + + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('OAuth token exchange response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 OAuth token exchange response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + + logger.success('OAuth token exchange successful', { + status: response.status, + hasAccessToken: !!response.data?.access_token, + hasRefreshToken: !!response.data?.refresh_token, + scopes: response.data?.scope, + // 尝试提取可能的套餐信息字段 + subscription: response.data?.subscription, + plan: response.data?.plan, + tier: response.data?.tier, + accountType: response.data?.account_type, + features: response.data?.features, + limits: response.data?.limits + }) + + const { data } = response + + // 解析组织与账户信息 + const organizationInfo = data.organization || null + const accountInfo = data.account || null + const extInfo = extractExtInfo(data) + + // 返回Claude格式的token数据,包含可能的套餐信息 + const result = { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, + scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], + isMax: true, + organization: organizationInfo, + account: accountInfo, + extInfo + } + + // 如果响应中包含套餐信息,添加到返回结果中 + if (data.subscription || data.plan || data.tier || data.account_type) { + result.subscriptionInfo = { + subscription: data.subscription, + plan: data.plan, + tier: data.tier, + accountType: data.account_type, + features: data.features, + limits: data.limits + } + logger.info('🎯 Found subscription info in OAuth response:', result.subscriptionInfo) + } + + return result + } catch (error) { + // 处理axios错误响应 + if (error.response) { + // 服务器返回了错误状态码 + const { status } = error.response + const errorData = error.response.data + + logger.error('❌ OAuth token exchange failed with server error', { + status, + statusText: error.response.statusText, + headers: error.response.headers, + data: errorData, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...` + }) + + // 尝试从错误响应中提取有用信息 + let errorMessage = `HTTP ${status}` + + if (errorData) { + if (typeof errorData === 'string') { + errorMessage += `: ${errorData}` + } else if (errorData.error) { + errorMessage += `: ${errorData.error}` + if (errorData.error_description) { + errorMessage += ` - ${errorData.error_description}` + } + } else { + errorMessage += `: ${JSON.stringify(errorData)}` + } + } + + throw new Error(`Token exchange failed: ${errorMessage}`) + } else if (error.request) { + // 请求被发送但没有收到响应 + logger.error('❌ OAuth token exchange failed with network error', { + message: error.message, + code: error.code, + hasProxy: !!proxyConfig + }) + throw new Error('Token exchange failed: No response from server (network error or timeout)') + } else { + // 其他错误 + logger.error('❌ OAuth token exchange failed with unknown error', { + message: error.message, + stack: error.stack + }) + throw new Error(`Token exchange failed: ${error.message}`) + } + } +} + +/** + * 解析回调 URL 或授权码 + * @param {string} input - 完整的回调 URL 或直接的授权码 + * @returns {string} 授权码 + */ +function parseCallbackUrl(input) { + if (!input || typeof input !== 'string') { + throw new Error('请提供有效的授权码或回调 URL') + } + + const trimmedInput = input.trim() + + // 情况1: 尝试作为完整URL解析 + if (trimmedInput.startsWith('http://') || trimmedInput.startsWith('https://')) { + try { + const urlObj = new URL(trimmedInput) + const authorizationCode = urlObj.searchParams.get('code') + + if (!authorizationCode) { + throw new Error('回调 URL 中未找到授权码 (code 参数)') + } + + return authorizationCode + } catch (error) { + if (error.message.includes('回调 URL 中未找到授权码')) { + throw error + } + throw new Error('无效的 URL 格式,请检查回调 URL 是否正确') + } + } + + // 情况2: 直接的授权码(可能包含URL fragments) + // 参考claude-code-login.js的处理方式:移除URL fragments和参数 + const cleanedCode = trimmedInput.split('#')[0]?.split('&')[0] ?? trimmedInput + + // 验证授权码格式(Claude的授权码通常是base64url格式) + if (!cleanedCode || cleanedCode.length < 10) { + throw new Error('授权码格式无效,请确保复制了完整的 Authorization Code') + } + + // 基本格式验证:授权码应该只包含字母、数字、下划线、连字符 + const validCodePattern = /^[A-Za-z0-9_-]+$/ + if (!validCodePattern.test(cleanedCode)) { + throw new Error('授权码包含无效字符,请检查是否复制了正确的 Authorization Code') + } + + return cleanedCode +} + +/** + * 使用授权码交换Setup Token + * @param {string} authorizationCode - 授权码 + * @param {string} codeVerifier - PKCE code verifier + * @param {string} state - state 参数 + * @param {object|null} proxyConfig - 代理配置(可选) + * @returns {Promise} Claude格式的token响应 + */ +async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig = null) { + // 清理授权码,移除URL片段 + const cleanedCode = authorizationCode.split('#')[0]?.split('&')[0] ?? authorizationCode + + const params = { + grant_type: 'authorization_code', + client_id: OAUTH_CONFIG.CLIENT_ID, + code: cleanedCode, + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + code_verifier: codeVerifier, + state, + expires_in: 31536000 // Setup Token 可以设置较长的过期时间 + } + + // 创建代理agent + const agent = createProxyAgent(proxyConfig) + + try { + if (agent) { + logger.info( + `🌐 Using proxy for Setup Token exchange: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } else { + logger.debug('🌐 No proxy configured for Setup Token exchange') + } + + logger.debug('🔄 Attempting Setup Token exchange', { + url: OAUTH_CONFIG.TOKEN_URL, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...`, + hasProxy: !!proxyConfig, + proxyType: proxyConfig?.type || 'none' + }) + + const axiosConfig = { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + Accept: 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + Referer: 'https://claude.ai/', + Origin: 'https://claude.ai' + }, + timeout: 30000 + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.post(OAUTH_CONFIG.TOKEN_URL, params, axiosConfig) + + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('Setup Token exchange response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 Setup Token exchange response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + + logger.success('Setup Token exchange successful', { + status: response.status, + hasAccessToken: !!response.data?.access_token, + scopes: response.data?.scope, + // 尝试提取可能的套餐信息字段 + subscription: response.data?.subscription, + plan: response.data?.plan, + tier: response.data?.tier, + accountType: response.data?.account_type, + features: response.data?.features, + limits: response.data?.limits + }) + + const { data } = response + + // 解析组织与账户信息 + const organizationInfo = data.organization || null + const accountInfo = data.account || null + const extInfo = extractExtInfo(data) + + // 返回Claude格式的token数据,包含可能的套餐信息 + const result = { + accessToken: data.access_token, + refreshToken: '', + expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, + scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], + isMax: true, + organization: organizationInfo, + account: accountInfo, + extInfo + } + + // 如果响应中包含套餐信息,添加到返回结果中 + if (data.subscription || data.plan || data.tier || data.account_type) { + result.subscriptionInfo = { + subscription: data.subscription, + plan: data.plan, + tier: data.tier, + accountType: data.account_type, + features: data.features, + limits: data.limits + } + logger.info('🎯 Found subscription info in Setup Token response:', result.subscriptionInfo) + } + + return result + } catch (error) { + // 使用与标准OAuth相同的错误处理逻辑 + if (error.response) { + const { status } = error.response + const errorData = error.response.data + + logger.error('❌ Setup Token exchange failed with server error', { + status, + statusText: error.response.statusText, + data: errorData, + codeLength: cleanedCode.length, + codePrefix: `${cleanedCode.substring(0, 10)}...` + }) + + let errorMessage = `HTTP ${status}` + if (errorData) { + if (typeof errorData === 'string') { + errorMessage += `: ${errorData}` + } else if (errorData.error) { + errorMessage += `: ${errorData.error}` + if (errorData.error_description) { + errorMessage += ` - ${errorData.error_description}` + } + } else { + errorMessage += `: ${JSON.stringify(errorData)}` + } + } + + throw new Error(`Setup Token exchange failed: ${errorMessage}`) + } else if (error.request) { + logger.error('❌ Setup Token exchange failed with network error', { + message: error.message, + code: error.code, + hasProxy: !!proxyConfig + }) + throw new Error( + 'Setup Token exchange failed: No response from server (network error or timeout)' + ) + } else { + logger.error('❌ Setup Token exchange failed with unknown error', { + message: error.message, + stack: error.stack + }) + throw new Error(`Setup Token exchange failed: ${error.message}`) + } + } +} + +/** + * 格式化为Claude标准格式 + * @param {object} tokenData - token数据 + * @returns {object} claudeAiOauth格式的数据 + */ +function formatClaudeCredentials(tokenData) { + return { + claudeAiOauth: { + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + expiresAt: tokenData.expiresAt, + scopes: tokenData.scopes, + isMax: tokenData.isMax, + organization: tokenData.organization || null, + account: tokenData.account || null, + extInfo: tokenData.extInfo || null + } + } +} + +/** + * 从令牌响应中提取扩展信息 + * @param {object} data - 令牌响应 + * @returns {object|null} 包含组织与账户UUID的扩展信息 + */ +function extractExtInfo(data) { + if (!data || typeof data !== 'object') { + return null + } + + const organization = data.organization || null + const account = data.account || null + + const ext = {} + + const orgUuid = + organization?.uuid || + organization?.id || + organization?.organization_uuid || + organization?.organization_id + const accountUuid = account?.uuid || account?.id || account?.account_uuid || account?.account_id + + if (orgUuid) { + ext.org_uuid = orgUuid + } + + if (accountUuid) { + ext.account_uuid = accountUuid + } + + return Object.keys(ext).length > 0 ? ext : null +} + +// ============================================================================= +// Cookie自动授权相关方法 (基于Clove项目实现) +// ============================================================================= + +/** + * 构建带Cookie的请求头 + * @param {string} sessionKey - sessionKey值 + * @returns {object} 请求头对象 + */ +function buildCookieHeaders(sessionKey) { + return { + Accept: 'application/json', + 'Accept-Language': 'en-US,en;q=0.9', + 'Cache-Control': 'no-cache', + Cookie: `sessionKey=${sessionKey}`, + Origin: COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL, + Referer: `${COOKIE_OAUTH_CONFIG.CLAUDE_AI_URL}/new`, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } +} + +/** + * 使用Cookie获取组织UUID和能力列表 + * @param {string} sessionKey - sessionKey值 + * @param {object|null} proxyConfig - 代理配置(可选) + * @returns {Promise<{organizationUuid: string, capabilities: string[]}>} + */ +async function getOrganizationInfo(sessionKey, proxyConfig = null) { + const headers = buildCookieHeaders(sessionKey) + const agent = createProxyAgent(proxyConfig) + + try { + if (agent) { + logger.info(`🌐 Using proxy for organization info: ${ProxyHelper.maskProxyInfo(proxyConfig)}`) + } + + logger.debug('🔄 Fetching organization info with Cookie', { + url: COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL, + hasProxy: !!proxyConfig + }) + + const axiosConfig = { + headers, + timeout: 30000, + maxRedirects: 0 // 禁止自动重定向,以便检测Cloudflare拦截(302) + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.get(COOKIE_OAUTH_CONFIG.ORGANIZATIONS_URL, axiosConfig) + + if (!response.data || !Array.isArray(response.data)) { + throw new Error('获取组织信息失败:响应格式无效') + } + + // 找到具有chat能力且能力最多的组织 + let bestOrg = null + let maxCapabilities = [] + + for (const org of response.data) { + const capabilities = org.capabilities || [] + + // 必须有chat能力 + if (!capabilities.includes('chat')) { + continue + } + + // 选择能力最多的组织 + if (capabilities.length > maxCapabilities.length) { + bestOrg = org + maxCapabilities = capabilities + } + } + + if (!bestOrg || !bestOrg.uuid) { + throw new Error('未找到具有chat能力的组织') + } + + logger.success('Found organization', { + uuid: bestOrg.uuid, + capabilities: maxCapabilities + }) + + return { + organizationUuid: bestOrg.uuid, + capabilities: maxCapabilities + } + } catch (error) { + if (error.response) { + const { status } = error.response + + if (status === 403 || status === 401) { + throw new Error('Cookie授权失败:无效的sessionKey或已过期') + } + + if (status === 302) { + throw new Error('请求被Cloudflare拦截,请稍后重试') + } + + throw new Error(`获取组织信息失败:HTTP ${status}`) + } else if (error.request) { + throw new Error('获取组织信息失败:网络错误或超时') + } + + throw error + } +} + +/** + * 使用Cookie自动获取授权code + * @param {string} sessionKey - sessionKey值 + * @param {string} organizationUuid - 组织UUID + * @param {string} scope - 授权scope + * @param {object|null} proxyConfig - 代理配置(可选) + * @returns {Promise<{authorizationCode: string, codeVerifier: string, state: string}>} + */ +async function authorizeWithCookie(sessionKey, organizationUuid, scope, proxyConfig = null) { + // 生成PKCE参数 + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = generateState() + + // 构建授权URL + const authorizeUrl = COOKIE_OAUTH_CONFIG.AUTHORIZE_URL_TEMPLATE.replace( + '{organization_uuid}', + organizationUuid + ) + + // 构建请求payload + const payload = { + response_type: 'code', + client_id: OAUTH_CONFIG.CLIENT_ID, + organization_uuid: organizationUuid, + redirect_uri: OAUTH_CONFIG.REDIRECT_URI, + scope, + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256' + } + + const headers = { + ...buildCookieHeaders(sessionKey), + 'Content-Type': 'application/json' + } + + const agent = createProxyAgent(proxyConfig) + + try { + if (agent) { + logger.info( + `🌐 Using proxy for Cookie authorization: ${ProxyHelper.maskProxyInfo(proxyConfig)}` + ) + } + + logger.debug('🔄 Requesting authorization with Cookie', { + url: authorizeUrl, + scope, + hasProxy: !!proxyConfig + }) + + const axiosConfig = { + headers, + timeout: 30000, + maxRedirects: 0 // 禁止自动重定向,以便检测Cloudflare拦截(302) + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.post(authorizeUrl, payload, axiosConfig) + + // 从响应中获取redirect_uri + const redirectUri = response.data?.redirect_uri + + if (!redirectUri) { + throw new Error('授权响应中未找到redirect_uri') + } + + logger.debug('📎 Got redirect URI', { redirectUri: `${redirectUri.substring(0, 80)}...` }) + + // 解析redirect_uri获取authorization code + const url = new URL(redirectUri) + const authorizationCode = url.searchParams.get('code') + const responseState = url.searchParams.get('state') + + if (!authorizationCode) { + throw new Error('redirect_uri中未找到授权码') + } + + // 构建完整的授权码(包含state,如果有的话) + const fullCode = responseState ? `${authorizationCode}#${responseState}` : authorizationCode + + logger.success('Got authorization code via Cookie', { + codeLength: authorizationCode.length, + codePrefix: `${authorizationCode.substring(0, 10)}...` + }) + + return { + authorizationCode: fullCode, + codeVerifier, + state + } + } catch (error) { + if (error.response) { + const { status } = error.response + + if (status === 403 || status === 401) { + throw new Error('Cookie授权失败:无效的sessionKey或已过期') + } + + if (status === 302) { + throw new Error('请求被Cloudflare拦截,请稍后重试') + } + + const errorData = error.response.data + let errorMessage = `HTTP ${status}` + + if (errorData) { + if (typeof errorData === 'string') { + errorMessage += `: ${errorData}` + } else if (errorData.error) { + errorMessage += `: ${errorData.error}` + } + } + + throw new Error(`授权请求失败:${errorMessage}`) + } else if (error.request) { + throw new Error('授权请求失败:网络错误或超时') + } + + throw error + } +} + +/** + * 完整的Cookie自动授权流程 + * @param {string} sessionKey - sessionKey值 + * @param {object|null} proxyConfig - 代理配置(可选) + * @param {boolean} isSetupToken - 是否为Setup Token模式 + * @returns {Promise<{claudeAiOauth: object, organizationUuid: string, capabilities: string[]}>} + */ +async function oauthWithCookie(sessionKey, proxyConfig = null, isSetupToken = false) { + logger.info('🍪 Starting Cookie-based OAuth flow', { + isSetupToken, + hasProxy: !!proxyConfig + }) + + // 步骤1:获取组织信息 + logger.debug('Step 1/3: Fetching organization info...') + const { organizationUuid, capabilities } = await getOrganizationInfo(sessionKey, proxyConfig) + + // 步骤2:确定scope并获取授权code + const scope = isSetupToken ? OAUTH_CONFIG.SCOPES_SETUP : OAUTH_CONFIG.SCOPES_API + + logger.debug('Step 2/3: Getting authorization code...', { scope }) + const { authorizationCode, codeVerifier, state } = await authorizeWithCookie( + sessionKey, + organizationUuid, + scope, + proxyConfig + ) + + // 步骤3:交换token + logger.debug('Step 3/3: Exchanging token...') + const tokenData = isSetupToken + ? await exchangeSetupTokenCode(authorizationCode, codeVerifier, state, proxyConfig) + : await exchangeCodeForTokens(authorizationCode, codeVerifier, state, proxyConfig) + + logger.success('Cookie-based OAuth flow completed', { + isSetupToken, + organizationUuid, + hasAccessToken: !!tokenData.accessToken, + hasRefreshToken: !!tokenData.refreshToken + }) + + return { + claudeAiOauth: tokenData, + organizationUuid, + capabilities + } +} + +module.exports = { + OAUTH_CONFIG, + COOKIE_OAUTH_CONFIG, + generateOAuthParams, + generateSetupTokenParams, + exchangeCodeForTokens, + exchangeSetupTokenCode, + parseCallbackUrl, + formatClaudeCredentials, + extractExtInfo, + generateState, + generateCodeVerifier, + generateCodeChallenge, + generateAuthUrl, + generateSetupTokenAuthUrl, + createProxyAgent, + // Cookie自动授权相关方法 + buildCookieHeaders, + getOrganizationInfo, + authorizeWithCookie, + oauthWithCookie +} diff --git a/src/utils/performanceOptimizer.js b/src/utils/performanceOptimizer.js new file mode 100644 index 0000000..29b91ed --- /dev/null +++ b/src/utils/performanceOptimizer.js @@ -0,0 +1,168 @@ +/** + * 性能优化工具模块 + * 提供 HTTP keep-alive 连接池、定价数据缓存等优化功能 + */ + +const https = require('https') +const http = require('http') +const fs = require('fs') +const LRUCache = require('./lruCache') + +// 连接池配置(从环境变量读取) +const STREAM_MAX_SOCKETS = parseInt(process.env.HTTPS_MAX_SOCKETS_STREAM) || 65535 +const NON_STREAM_MAX_SOCKETS = parseInt(process.env.HTTPS_MAX_SOCKETS_NON_STREAM) || 16384 +const MAX_FREE_SOCKETS = parseInt(process.env.HTTPS_MAX_FREE_SOCKETS) || 2048 +const FREE_SOCKET_TIMEOUT = parseInt(process.env.HTTPS_FREE_SOCKET_TIMEOUT) || 30000 + +// 流式请求 agent:高 maxSockets,timeout=0(不限制) +const httpsAgentStream = new https.Agent({ + keepAlive: true, + maxSockets: STREAM_MAX_SOCKETS, + maxFreeSockets: MAX_FREE_SOCKETS, + timeout: 0, + freeSocketTimeout: FREE_SOCKET_TIMEOUT +}) + +// 非流式请求 agent:较小 maxSockets +const httpsAgentNonStream = new https.Agent({ + keepAlive: true, + maxSockets: NON_STREAM_MAX_SOCKETS, + maxFreeSockets: MAX_FREE_SOCKETS, + timeout: 0, // 不限制,由请求层 REQUEST_TIMEOUT 控制 + freeSocketTimeout: FREE_SOCKET_TIMEOUT +}) + +// HTTP agent(非流式) +const httpAgent = new http.Agent({ + keepAlive: true, + maxSockets: NON_STREAM_MAX_SOCKETS, + maxFreeSockets: MAX_FREE_SOCKETS, + timeout: 0, // 不限制,由请求层 REQUEST_TIMEOUT 控制 + freeSocketTimeout: FREE_SOCKET_TIMEOUT +}) + +// 定价数据缓存(按文件路径区分) +const pricingDataCache = new Map() +const PRICING_CACHE_TTL = 5 * 60 * 1000 // 5分钟 + +// Redis 配置缓存(短 TTL) +const configCache = new LRUCache(100) +const CONFIG_CACHE_TTL = 30 * 1000 // 30秒 + +/** + * 获取流式请求的 HTTPS agent + */ +function getHttpsAgentForStream() { + return httpsAgentStream +} + +/** + * 获取非流式请求的 HTTPS agent + */ +function getHttpsAgentForNonStream() { + return httpsAgentNonStream +} + +/** + * 获取定价数据(带缓存,按路径区分) + * @param {string} pricingFilePath - 定价文件路径 + * @returns {Object|null} 定价数据 + */ +function getPricingData(pricingFilePath) { + const now = Date.now() + const cached = pricingDataCache.get(pricingFilePath) + + // 检查缓存是否有效 + if (cached && now - cached.loadTime < PRICING_CACHE_TTL) { + return cached.data + } + + // 重新加载 + try { + if (!fs.existsSync(pricingFilePath)) { + return null + } + const data = JSON.parse(fs.readFileSync(pricingFilePath, 'utf8')) + pricingDataCache.set(pricingFilePath, { data, loadTime: now }) + return data + } catch (error) { + return null + } +} + +/** + * 清除定价数据缓存(用于热更新) + * @param {string} pricingFilePath - 可选,指定路径则只清除该路径缓存 + */ +function clearPricingCache(pricingFilePath = null) { + if (pricingFilePath) { + pricingDataCache.delete(pricingFilePath) + } else { + pricingDataCache.clear() + } +} + +/** + * 获取缓存的配置 + * @param {string} key - 缓存键 + * @returns {*} 缓存值 + */ +function getCachedConfig(key) { + return configCache.get(key) +} + +/** + * 设置配置缓存 + * @param {string} key - 缓存键 + * @param {*} value - 值 + * @param {number} ttl - TTL(毫秒) + */ +function setCachedConfig(key, value, ttl = CONFIG_CACHE_TTL) { + configCache.set(key, value, ttl) +} + +/** + * 删除配置缓存 + * @param {string} key - 缓存键 + */ +function deleteCachedConfig(key) { + configCache.cache.delete(key) +} + +/** + * 获取连接池统计信息 + */ +function getAgentStats() { + return { + httpsStream: { + sockets: Object.keys(httpsAgentStream.sockets).length, + freeSockets: Object.keys(httpsAgentStream.freeSockets).length, + requests: Object.keys(httpsAgentStream.requests).length, + maxSockets: STREAM_MAX_SOCKETS + }, + httpsNonStream: { + sockets: Object.keys(httpsAgentNonStream.sockets).length, + freeSockets: Object.keys(httpsAgentNonStream.freeSockets).length, + requests: Object.keys(httpsAgentNonStream.requests).length, + maxSockets: NON_STREAM_MAX_SOCKETS + }, + http: { + sockets: Object.keys(httpAgent.sockets).length, + freeSockets: Object.keys(httpAgent.freeSockets).length, + requests: Object.keys(httpAgent.requests).length + }, + configCache: configCache.getStats() + } +} + +module.exports = { + getHttpsAgentForStream, + getHttpsAgentForNonStream, + getHttpAgent: () => httpAgent, + getPricingData, + clearPricingCache, + getCachedConfig, + setCachedConfig, + deleteCachedConfig, + getAgentStats +} diff --git a/src/utils/projectPaths.js b/src/utils/projectPaths.js new file mode 100644 index 0000000..c2f3076 --- /dev/null +++ b/src/utils/projectPaths.js @@ -0,0 +1,10 @@ +const path = require('path') + +// 该文件位于 src/utils 下,向上两级即项目根目录。 +function getProjectRoot() { + return path.resolve(__dirname, '..', '..') +} + +module.exports = { + getProjectRoot +} diff --git a/src/utils/proxyHelper.js b/src/utils/proxyHelper.js new file mode 100644 index 0000000..d825638 --- /dev/null +++ b/src/utils/proxyHelper.js @@ -0,0 +1,272 @@ +const { SocksProxyAgent } = require('socks-proxy-agent') +const { HttpsProxyAgent } = require('https-proxy-agent') +const logger = require('./logger') +const config = require('../../config/config') + +/** + * 统一的代理创建工具 + * 支持 SOCKS5 和 HTTP/HTTPS 代理,可配置 IPv4/IPv6 + */ +class ProxyHelper { + // 缓存代理 Agent,避免重复创建浪费连接 + static _agentCache = new Map() + + /** + * 创建代理 Agent + * @param {object|string|null} proxyConfig - 代理配置对象或 JSON 字符串 + * @param {object} options - 额外选项 + * @param {boolean|number} options.useIPv4 - 是否使用 IPv4 (true=IPv4, false=IPv6, undefined=auto) + * @returns {Agent|null} 代理 Agent 实例或 null + */ + static createProxyAgent(proxyConfig, options = {}) { + if (!proxyConfig) { + return null + } + + try { + // 解析代理配置 + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + + // 验证必要字段 + if (!proxy.type || !proxy.host || !proxy.port) { + logger.warn('⚠️ Invalid proxy configuration: missing required fields (type, host, port)') + return null + } + + // 获取 IPv4/IPv6 配置 + const useIPv4 = ProxyHelper._getIPFamilyPreference(options.useIPv4) + + // 配置连接池与 Keep-Alive + const proxySettings = config.proxy || {} + const agentCommonOptions = {} + + if (typeof proxySettings.keepAlive === 'boolean') { + agentCommonOptions.keepAlive = proxySettings.keepAlive + } + + if ( + typeof proxySettings.maxSockets === 'number' && + Number.isFinite(proxySettings.maxSockets) && + proxySettings.maxSockets > 0 + ) { + agentCommonOptions.maxSockets = proxySettings.maxSockets + } + + if ( + typeof proxySettings.maxFreeSockets === 'number' && + Number.isFinite(proxySettings.maxFreeSockets) && + proxySettings.maxFreeSockets >= 0 + ) { + agentCommonOptions.maxFreeSockets = proxySettings.maxFreeSockets + } + + if ( + typeof proxySettings.timeout === 'number' && + Number.isFinite(proxySettings.timeout) && + proxySettings.timeout > 0 + ) { + agentCommonOptions.timeout = proxySettings.timeout + } + + // 缓存键:保证相同配置的代理可复用 + const cacheKey = JSON.stringify({ + type: proxy.type, + host: proxy.host, + port: proxy.port, + username: proxy.username, + password: proxy.password, + family: useIPv4, + keepAlive: agentCommonOptions.keepAlive, + maxSockets: agentCommonOptions.maxSockets, + maxFreeSockets: agentCommonOptions.maxFreeSockets, + timeout: agentCommonOptions.timeout + }) + + if (ProxyHelper._agentCache.has(cacheKey)) { + return ProxyHelper._agentCache.get(cacheKey) + } + + // 构建认证信息 + const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '' + let agent = null + + // 根据代理类型创建 Agent + if (proxy.type === 'socks5') { + const socksUrl = `socks5h://${auth}${proxy.host}:${proxy.port}` + const socksOptions = { ...agentCommonOptions } + + // 设置 IP 协议族(如果指定) + if (useIPv4 !== null) { + socksOptions.family = useIPv4 ? 4 : 6 + } + + agent = new SocksProxyAgent(socksUrl, socksOptions) + } else if (proxy.type === 'http' || proxy.type === 'https') { + const proxyUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}` + const httpOptions = { ...agentCommonOptions } + + // HttpsProxyAgent 支持 family 参数(通过底层的 agent-base) + if (useIPv4 !== null) { + httpOptions.family = useIPv4 ? 4 : 6 + } + + agent = new HttpsProxyAgent(proxyUrl, httpOptions) + } else { + logger.warn(`⚠️ Unsupported proxy type: ${proxy.type}`) + return null + } + + if (agent) { + ProxyHelper._agentCache.set(cacheKey, agent) + } + + return agent + } catch (error) { + logger.warn('⚠️ Failed to create proxy agent:', error.message) + return null + } + } + + /** + * 获取 IP 协议族偏好设置 + * @param {boolean|number|string} preference - 用户偏好设置 + * @returns {boolean|null} true=IPv4, false=IPv6, null=auto + * @private + */ + static _getIPFamilyPreference(preference) { + // 如果没有指定偏好,使用配置文件或默认值 + if (preference === undefined) { + // 从配置文件读取默认设置,默认使用 IPv4 + const defaultUseIPv4 = config.proxy?.useIPv4 + if (defaultUseIPv4 !== undefined) { + return defaultUseIPv4 + } + // 默认值:IPv4(兼容性更好) + return true + } + + // 处理各种输入格式 + if (typeof preference === 'boolean') { + return preference + } + if (typeof preference === 'number') { + return preference === 4 ? true : preference === 6 ? false : null + } + if (typeof preference === 'string') { + const lower = preference.toLowerCase() + if (lower === 'ipv4' || lower === '4') { + return true + } + if (lower === 'ipv6' || lower === '6') { + return false + } + if (lower === 'auto' || lower === 'both') { + return null + } + } + + // 无法识别的值,返回默认(IPv4) + return true + } + + /** + * 验证代理配置 + * @param {object|string} proxyConfig - 代理配置 + * @returns {boolean} 是否有效 + */ + static validateProxyConfig(proxyConfig) { + if (!proxyConfig) { + return false + } + + try { + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + + // 检查必要字段 + if (!proxy.type || !proxy.host || !proxy.port) { + return false + } + + // 检查支持的类型 + if (!['socks5', 'http', 'https'].includes(proxy.type)) { + return false + } + + // 检查端口范围 + const port = parseInt(proxy.port) + if (isNaN(port) || port < 1 || port > 65535) { + return false + } + + return true + } catch (error) { + return false + } + } + + /** + * 获取代理配置的描述信息 + * @param {object|string} proxyConfig - 代理配置 + * @returns {string} 代理描述 + */ + static getProxyDescription(proxyConfig) { + if (!proxyConfig) { + return 'No proxy' + } + + try { + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + const hasAuth = proxy.username && proxy.password + return `${proxy.type}://${proxy.host}:${proxy.port}${hasAuth ? ' (with auth)' : ''}` + } catch (error) { + return 'Invalid proxy config' + } + } + + /** + * 脱敏代理配置信息用于日志记录 + * @param {object|string} proxyConfig - 代理配置 + * @returns {string} 脱敏后的代理信息 + */ + static maskProxyInfo(proxyConfig) { + if (!proxyConfig) { + return 'No proxy' + } + + try { + const proxy = typeof proxyConfig === 'string' ? JSON.parse(proxyConfig) : proxyConfig + + let proxyDesc = `${proxy.type}://${proxy.host}:${proxy.port}` + + // 如果有认证信息,进行脱敏处理 + if (proxy.username && proxy.password) { + const maskedUsername = + proxy.username.length <= 2 + ? proxy.username + : proxy.username[0] + + '*'.repeat(Math.max(1, proxy.username.length - 2)) + + proxy.username.slice(-1) + const maskedPassword = '*'.repeat(Math.min(8, proxy.password.length)) + proxyDesc += ` (auth: ${maskedUsername}:${maskedPassword})` + } + + return proxyDesc + } catch (error) { + return 'Invalid proxy config' + } + } + + /** + * 创建代理 Agent(兼容旧的函数接口) + * @param {object|string|null} proxyConfig - 代理配置 + * @param {boolean} useIPv4 - 是否使用 IPv4 + * @returns {Agent|null} 代理 Agent 实例或 null + * @deprecated 使用 createProxyAgent 替代 + */ + static createProxy(proxyConfig, useIPv4 = true) { + logger.warn('⚠️ ProxyHelper.createProxy is deprecated, use createProxyAgent instead') + return ProxyHelper.createProxyAgent(proxyConfig, { useIPv4 }) + } +} + +module.exports = ProxyHelper diff --git a/src/utils/rateLimitHelper.js b/src/utils/rateLimitHelper.js new file mode 100644 index 0000000..4de07d9 --- /dev/null +++ b/src/utils/rateLimitHelper.js @@ -0,0 +1,115 @@ +const redis = require('../models/redis') +const pricingService = require('../services/pricingService') +const CostCalculator = require('./costCalculator') + +function toNumber(value) { + const num = Number(value) + return Number.isFinite(num) ? num : 0 +} + +// keyId 和 accountType 用于计算倍率成本 +// preCalculatedCost: 可选的 { realCost, ratedCost },由调用方提供以避免重复计算 +async function updateRateLimitCounters( + rateLimitInfo, + usageSummary, + model, + keyId = null, + accountType = null, + preCalculatedCost = null +) { + if (!rateLimitInfo) { + return { totalTokens: 0, totalCost: 0, ratedCost: 0 } + } + + const client = redis.getClient() + if (!client) { + throw new Error('Redis 未连接,无法更新限流计数') + } + + const inputTokens = toNumber(usageSummary.inputTokens) + const outputTokens = toNumber(usageSummary.outputTokens) + const cacheCreateTokens = toNumber(usageSummary.cacheCreateTokens) + const cacheReadTokens = toNumber(usageSummary.cacheReadTokens) + + const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens + + if (totalTokens > 0 && rateLimitInfo.tokenCountKey) { + await client.incrby(rateLimitInfo.tokenCountKey, Math.round(totalTokens)) + } + + let totalCost = 0 + let ratedCost = 0 + + if ( + preCalculatedCost && + typeof preCalculatedCost.ratedCost === 'number' && + preCalculatedCost.ratedCost > 0 + ) { + // 使用调用方已计算好的费用(避免重复计算,且能正确处理 1h 缓存、Fast Mode 等特殊计费) + // eslint-disable-next-line prefer-destructuring + ratedCost = preCalculatedCost.ratedCost + totalCost = preCalculatedCost.realCost || 0 + } else if ( + preCalculatedCost && + typeof preCalculatedCost.realCost === 'number' && + preCalculatedCost.realCost > 0 + ) { + // 有 realCost 但 ratedCost 为 0 或缺失,使用 realCost + totalCost = preCalculatedCost.realCost + ratedCost = preCalculatedCost.realCost + } else { + // Legacy fallback:调用方未提供费用时自行计算(不支持 1h 缓存等特殊计费) + const usagePayload = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + } + + try { + const costInfo = pricingService.calculateCost(usagePayload, model) + const { totalCost: calculatedCost } = costInfo || {} + if (typeof calculatedCost === 'number') { + totalCost = calculatedCost + } + } catch (error) { + // 忽略此处错误,后续使用备用计算 + totalCost = 0 + } + + if (totalCost === 0) { + try { + const fallback = CostCalculator.calculateCost(usagePayload, model) + const { costs } = fallback || {} + if (costs && typeof costs.total === 'number') { + totalCost = costs.total + } + } catch (error) { + totalCost = 0 + } + } + + // 计算倍率成本(用于限流计数) + ratedCost = totalCost + if (totalCost > 0 && keyId) { + try { + const apiKeyService = require('../services/apiKeyService') + const serviceRatesService = require('../services/serviceRatesService') + const service = serviceRatesService.getService(accountType, model) + ratedCost = await apiKeyService.calculateRatedCost(keyId, service, totalCost) + } catch (error) { + ratedCost = totalCost + } + } + } + + if (ratedCost > 0 && rateLimitInfo.costCountKey) { + await client.incrbyfloat(rateLimitInfo.costCountKey, ratedCost) + } + + return { totalTokens, totalCost, ratedCost } +} + +module.exports = { + updateRateLimitCounters +} diff --git a/src/utils/requestDetailHelper.js b/src/utils/requestDetailHelper.js new file mode 100644 index 0000000..d099e00 --- /dev/null +++ b/src/utils/requestDetailHelper.js @@ -0,0 +1,717 @@ +const SENSITIVE_KEY_PATTERN = + /(authorization|proxy-authorization|api[_-]?key|access[_-]?token|refresh[_-]?token|token|secret|password|cookie|set-cookie|client_secret|private[_-]?key|proxy)/i +const DEFAULT_MAX_STRING_CHARS = 80 +const DEFAULT_MAX_ARRAY_ITEMS = 24 +const DEFAULT_MAX_DEPTH = 6 +const DEFAULT_MAX_TOTAL_CHARS = 12000 +const ENCRYPTED_CONTENT_KEY = 'encrypted_content' +const TOOLS_KEY = 'tools' +const PREVIEW_TRUNCATION_SUFFIX_PATTERN = /\.\.\.\[(?:truncated )?(\d+) chars\]$/ +const OPENAI_RELATED_ACCOUNT_TYPES = new Set(['openai', 'openai-responses', 'azure-openai']) +const CACHE_HIT_FORMULA = 'cacheReadTokens / (inputTokens + cacheReadTokens + cacheCreateTokens)' + +function toFiniteNumber(value) { + if (value === undefined || value === null || value === '') { + return null + } + + const num = Number(value) + if (!Number.isFinite(num)) { + return null + } + + return num +} + +function maskSensitiveValue(value) { + if (value === null || value === undefined) { + return value + } + + const str = String(value) + if (str.length <= 8) { + return '[REDACTED]' + } + + return `${str.slice(0, 3)}***${str.slice(-3)}` +} + +function truncateString(value, maxChars = DEFAULT_MAX_STRING_CHARS) { + if (typeof value !== 'string') { + return value + } + + if (value.length <= maxChars) { + return value + } + + return `${value.slice(0, maxChars)}...[${value.length - maxChars} chars]` +} + +function getValueCharLength(value) { + if (value === null || value === undefined) { + return 0 + } + + if (typeof value === 'string') { + return value.length + } + + try { + const json = JSON.stringify(value) + if (typeof json === 'string') { + return json.length + } + } catch (error) { + // Fall back to String(value) below when JSON serialization fails. + } + + return String(value).length +} + +function createOmittedValue(value) { + return `...[${getValueCharLength(value)} chars]` +} + +function normalizeNonEmptyString(value) { + if (typeof value !== 'string') { + return null + } + + const trimmed = value.trim() + return trimmed ? trimmed : null +} + +function normalizeInteger(value) { + const num = toFiniteNumber(value) + if (num === null) { + return null + } + + return Math.trunc(num) +} + +function formatReasoningBudget(value) { + return `budget:${value}` +} + +function createReasoningInfo(reasoningDisplay = null, reasoningSource = null) { + return { + reasoningDisplay: reasoningDisplay || null, + reasoningSource: reasoningSource || null + } +} + +function summarizeToolEntry(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return sanitizeValue(value, { + seen: new WeakSet(), + keyPath: '', + depth: 0 + }) + } + + const summary = {} + if (typeof value.type === 'string' && value.type) { + summary.type = value.type + } + + const name = + typeof value.name === 'string' + ? value.name + : typeof value.function?.name === 'string' + ? value.function.name + : null + + if (name) { + summary.name = name + } + + return summary +} + +function extractOpenAIReasoningInfo(payload) { + const effort = normalizeNonEmptyString(payload?.reasoning?.effort) + if (effort) { + return createReasoningInfo(effort, 'reasoning.effort') + } + + const rootEffort = normalizeNonEmptyString(payload?.reasoning_effort) + if (rootEffort) { + return createReasoningInfo(rootEffort, 'reasoning_effort') + } + + return createReasoningInfo() +} + +function extractAnthropicReasoningInfo(payload) { + const outputEffort = normalizeNonEmptyString(payload?.output_config?.effort) + if (outputEffort) { + return createReasoningInfo(outputEffort, 'output_config.effort') + } + + const thinking = payload?.thinking + + if (thinking === true) { + return createReasoningInfo('enabled', 'thinking') + } + + const thinkingString = normalizeNonEmptyString(thinking) + if (thinkingString) { + return createReasoningInfo(thinkingString, 'thinking') + } + + if (!thinking || typeof thinking !== 'object' || Array.isArray(thinking)) { + return createReasoningInfo() + } + + const thinkingType = normalizeNonEmptyString(thinking.type) + const thinkingEnabled = typeof thinking.enabled === 'boolean' ? thinking.enabled : null + const thinkingBudget = normalizeInteger(thinking.budget_tokens) + + if (thinkingType === 'disabled' || thinkingType === 'none' || thinkingEnabled === false) { + return createReasoningInfo('none', 'thinking') + } + + if (thinkingType && thinkingBudget !== null) { + return createReasoningInfo( + `${thinkingType} / ${formatReasoningBudget(thinkingBudget)}`, + 'thinking.type,thinking.budget_tokens' + ) + } + + if (thinkingType) { + return createReasoningInfo(thinkingType, 'thinking.type') + } + + if (thinkingEnabled === true && thinkingBudget !== null) { + return createReasoningInfo( + `enabled / ${formatReasoningBudget(thinkingBudget)}`, + 'thinking.enabled,thinking.budget_tokens' + ) + } + + if (thinkingEnabled === true) { + return createReasoningInfo('enabled', 'thinking.enabled') + } + + if (thinkingBudget !== null) { + return createReasoningInfo(formatReasoningBudget(thinkingBudget), 'thinking.budget_tokens') + } + + return createReasoningInfo() +} + +function extractGeminiReasoningInfo(payload) { + const thinkingConfig = payload?.generationConfig?.thinkingConfig + if (!thinkingConfig || typeof thinkingConfig !== 'object' || Array.isArray(thinkingConfig)) { + return createReasoningInfo() + } + + const thinkingLevel = normalizeNonEmptyString( + thinkingConfig.thinkingLevel || thinkingConfig.thinking_level + ) + if (thinkingLevel) { + return createReasoningInfo(thinkingLevel, 'generationConfig.thinkingConfig.thinkingLevel') + } + + const thinkingBudget = normalizeInteger( + thinkingConfig.thinkingBudget ?? thinkingConfig.thinking_budget + ) + if (thinkingBudget === -1) { + return createReasoningInfo('dynamic', 'generationConfig.thinkingConfig.thinkingBudget') + } + + if (thinkingBudget === 0) { + return createReasoningInfo('none', 'generationConfig.thinkingConfig.thinkingBudget') + } + + if (thinkingBudget !== null) { + return createReasoningInfo( + formatReasoningBudget(thinkingBudget), + 'generationConfig.thinkingConfig.thinkingBudget' + ) + } + + if (thinkingConfig.includeThoughts === false || thinkingConfig.include_thoughts === false) { + return createReasoningInfo('none', 'generationConfig.thinkingConfig.includeThoughts') + } + + return createReasoningInfo() +} + +function extractRequestReasoningInfo(payload) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return createReasoningInfo() + } + + const extractors = [ + extractOpenAIReasoningInfo, + extractAnthropicReasoningInfo, + extractGeminiReasoningInfo + ] + + for (const extractor of extractors) { + const result = extractor(payload) + if (result.reasoningDisplay) { + return result + } + } + + return createReasoningInfo() +} + +function parsePreviewJson(preview) { + if (typeof preview !== 'string' || !preview) { + return null + } + + const directCandidate = preview.trim() + try { + return JSON.parse(directCandidate) + } catch (error) { + // fall through to suffix stripping below + } + + const suffixMatch = directCandidate.match(PREVIEW_TRUNCATION_SUFFIX_PATTERN) + if (!suffixMatch) { + return null + } + + const withoutSuffix = directCandidate.slice(0, -suffixMatch[0].length) + try { + return JSON.parse(withoutSuffix) + } catch (error) { + return null + } +} + +function extractPreviewReasoningInfo(preview) { + if (typeof preview !== 'string' || !preview) { + return createReasoningInfo() + } + + const parsed = parsePreviewJson(preview) + if (parsed) { + return extractRequestReasoningInfo(parsed) + } + + const openAIEffort = preview.match(/"reasoning"\s*:\s*\{[\s\S]{0,240}?"effort"\s*:\s*"([^"]+)"/) + if (openAIEffort?.[1]) { + return createReasoningInfo(openAIEffort[1], 'reasoning.effort') + } + + const legacyOpenAIEffort = preview.match(/"reasoning_effort"\s*:\s*"([^"]+)"/) + if (legacyOpenAIEffort?.[1]) { + return createReasoningInfo(legacyOpenAIEffort[1], 'reasoning_effort') + } + + const anthropicOutputEffort = preview.match( + /"output_config"\s*:\s*\{[\s\S]{0,240}?"effort"\s*:\s*"([^"]+)"/ + ) + if (anthropicOutputEffort?.[1]) { + return createReasoningInfo(anthropicOutputEffort[1], 'output_config.effort') + } + + const thinkingSegmentIndex = preview.indexOf('"thinking"') + if (thinkingSegmentIndex >= 0) { + const thinkingSegment = preview.slice(thinkingSegmentIndex, thinkingSegmentIndex + 320) + const thinkingType = thinkingSegment.match(/"type"\s*:\s*"([^"]+)"/)?.[1] || null + const thinkingBudget = thinkingSegment.match(/"budget_tokens"\s*:\s*(-?\d+)/)?.[1] || null + + if (thinkingType && thinkingBudget !== null) { + return createReasoningInfo( + `${thinkingType} / ${formatReasoningBudget(Number(thinkingBudget))}`, + 'thinking.type,thinking.budget_tokens' + ) + } + if (thinkingType) { + return createReasoningInfo(thinkingType, 'thinking.type') + } + if (thinkingBudget !== null) { + return createReasoningInfo( + formatReasoningBudget(Number(thinkingBudget)), + 'thinking.budget_tokens' + ) + } + } + + const geminiSegmentIndex = preview.indexOf('"thinkingConfig"') + if (geminiSegmentIndex >= 0) { + const geminiSegment = preview.slice(geminiSegmentIndex, geminiSegmentIndex + 320) + const thinkingLevel = geminiSegment.match(/"thinkingLevel"\s*:\s*"([^"]+)"/)?.[1] || null + const thinkingBudget = geminiSegment.match(/"thinkingBudget"\s*:\s*(-?\d+)/)?.[1] || null + + if (thinkingLevel) { + return createReasoningInfo(thinkingLevel, 'generationConfig.thinkingConfig.thinkingLevel') + } + if (thinkingBudget !== null) { + const budgetValue = Number(thinkingBudget) + const display = + budgetValue === -1 + ? 'dynamic' + : budgetValue === 0 + ? 'none' + : formatReasoningBudget(budgetValue) + return createReasoningInfo(display, 'generationConfig.thinkingConfig.thinkingBudget') + } + } + + return createReasoningInfo() +} + +function resolveRequestDetailReasoning(detail = {}) { + const storedDisplay = normalizeNonEmptyString(detail.reasoningDisplay) + const storedSource = normalizeNonEmptyString(detail.reasoningSource) + if (storedDisplay) { + return createReasoningInfo(storedDisplay, storedSource) + } + + const snapshot = detail.requestBodySnapshot + if (snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot)) { + if (typeof snapshot.preview === 'string') { + const previewResult = extractPreviewReasoningInfo(snapshot.preview) + if (previewResult.reasoningDisplay) { + return previewResult + } + } + + return extractRequestReasoningInfo(snapshot) + } + + return createReasoningInfo() +} + +function sanitizeValue(value, ctx) { + const { + keyPath = '', + seen, + depth = 0, + maxDepth = DEFAULT_MAX_DEPTH, + maxArrayItems = DEFAULT_MAX_ARRAY_ITEMS, + maxStringChars = DEFAULT_MAX_STRING_CHARS + } = ctx + + if (value === null || value === undefined) { + return value + } + + if (typeof value === 'string') { + if (SENSITIVE_KEY_PATTERN.test(keyPath)) { + return maskSensitiveValue(value) + } + return truncateString(value, maxStringChars) + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return value + } + + if (typeof value === 'bigint') { + return value.toString() + } + + if (typeof value === 'function') { + return '[Function]' + } + + if (depth >= maxDepth) { + if (Array.isArray(value)) { + return `[Array(${value.length})]` + } + return '[Object]' + } + + if (typeof value === 'object') { + if (seen.has(value)) { + return '[Circular]' + } + seen.add(value) + + if (Array.isArray(value)) { + const result = value.slice(0, maxArrayItems).map((item, index) => + sanitizeValue(item, { + ...ctx, + keyPath: `${keyPath}[${index}]`, + depth: depth + 1 + }) + ) + + if (value.length > maxArrayItems) { + result.push(`...[${value.length - maxArrayItems} more items]`) + } + + return result + } + + const result = {} + for (const [key, childValue] of Object.entries(value)) { + const childPath = keyPath ? `${keyPath}.${key}` : key + if (key === ENCRYPTED_CONTENT_KEY) { + result[key] = createOmittedValue(childValue) + continue + } + + if (key === TOOLS_KEY) { + if (Array.isArray(childValue)) { + result[key] = childValue.slice(0, maxArrayItems).map((item) => summarizeToolEntry(item)) + + if (childValue.length > maxArrayItems) { + result[key].push(`...[${childValue.length - maxArrayItems} more items]`) + } + } else if (childValue && typeof childValue === 'object') { + result[key] = summarizeToolEntry(childValue) + } else { + result[key] = sanitizeValue(childValue, { + ...ctx, + keyPath: childPath, + depth: depth + 1 + }) + } + continue + } + + if (SENSITIVE_KEY_PATTERN.test(key)) { + result[key] = maskSensitiveValue(childValue) + continue + } + + result[key] = sanitizeValue(childValue, { + ...ctx, + keyPath: childPath, + depth: depth + 1 + }) + } + + return result + } + + return String(value) +} + +function enforceTotalSize(snapshot, maxTotalChars = DEFAULT_MAX_TOTAL_CHARS) { + let json = '' + try { + json = JSON.stringify(snapshot) + } catch (error) { + return { + error: 'snapshot_stringify_failed', + message: error?.message || String(error) + } + } + + if (json.length <= maxTotalChars) { + return snapshot + } + + return { + summary: 'request body snapshot truncated', + originalChars: json.length, + maxChars: maxTotalChars, + preview: truncateString(json, maxTotalChars) + } +} + +function sanitizeRequestBodySnapshot(body, options = {}) { + if (body === undefined) { + return null + } + + const seen = new WeakSet() + const sanitized = sanitizeValue(body, { + seen, + maxDepth: options.maxDepth || DEFAULT_MAX_DEPTH, + maxArrayItems: options.maxArrayItems || DEFAULT_MAX_ARRAY_ITEMS, + maxStringChars: options.maxStringChars || DEFAULT_MAX_STRING_CHARS, + keyPath: '', + depth: 0 + }) + + return enforceTotalSize(sanitized, options.maxTotalChars || DEFAULT_MAX_TOTAL_CHARS) +} + +function getRequestEndpoint(req) { + if (!req) { + return null + } + + const originalUrl = req.originalUrl || req.url || req.path || null + if (!originalUrl) { + return null + } + + const queryIndex = originalUrl.indexOf('?') + return queryIndex >= 0 ? originalUrl.slice(0, queryIndex) : originalUrl +} + +function toTimestampMs(value) { + const numericValue = toFiniteNumber(value) + if (numericValue !== null) { + return numericValue + } + + if (value instanceof Date) { + const dateValue = value.getTime() + return Number.isFinite(dateValue) ? dateValue : null + } + + if (typeof value !== 'string') { + return null + } + + const parsed = Date.parse(value) + return Number.isFinite(parsed) ? parsed : null +} + +function createRequestDetailMeta(req, overrides = {}) { + const nowMs = Date.now() + const statusCode = toFiniteNumber(overrides.statusCode) + const durationMs = toFiniteNumber(overrides.durationMs) + const requestStartedAt = toFiniteNumber(overrides.requestStartedAt) + const reqStartedAt = toFiniteNumber(req?.requestStartedAt) + const effectiveStart = requestStartedAt ?? reqStartedAt + const requestBody = overrides.requestBody !== undefined ? overrides.requestBody : req?.body + + return { + requestId: overrides.requestId || req?.requestId || null, + endpoint: overrides.endpoint || getRequestEndpoint(req), + method: overrides.method || req?.method || null, + statusCode: statusCode ?? req?.res?.statusCode ?? 200, + stream: + typeof overrides.stream === 'boolean' + ? overrides.stream + : Boolean(requestBody && requestBody.stream === true), + durationMs: durationMs ?? (effectiveStart ? Math.max(0, nowMs - effectiveStart) : null), + requestStartedAt: effectiveStart ? new Date(effectiveStart).toISOString() : null, + requestBody + } +} + +function finalizeRequestDetailMeta(requestMeta = null) { + if (!requestMeta || typeof requestMeta !== 'object') { + return null + } + + const requestStartedAtMs = toTimestampMs(requestMeta.requestStartedAt) + const durationMs = + requestStartedAtMs !== null + ? Math.max(0, Date.now() - requestStartedAtMs) + : toFiniteNumber(requestMeta.durationMs) + + return { + ...requestMeta, + durationMs + } +} + +function extractOpenAICacheReadTokens(usage = {}) { + if (!usage || typeof usage !== 'object') { + return 0 + } + + const candidates = [ + usage.input_tokens_details?.cached_tokens, + usage.input_tokens_details?.cached_token, + usage.prompt_tokens_details?.cached_tokens, + usage.prompt_tokens_details?.cached_token + ] + + for (const value of candidates) { + if (value === undefined || value === null || value === '') { + continue + } + + const parsed = Number(value) + if (!Number.isNaN(parsed)) { + return Math.max(0, parsed) + } + } + + return 0 +} + +function isOpenAIRelatedEndpoint(endpoint) { + if (typeof endpoint !== 'string') { + return false + } + + if (endpoint.startsWith('/azure/') || endpoint.startsWith('/droid/openai/')) { + return true + } + + if (!endpoint.startsWith('/openai/')) { + return false + } + + return !( + endpoint === '/openai/claude' || + endpoint === '/openai/gemini' || + endpoint.startsWith('/openai/claude/') || + endpoint.startsWith('/openai/gemini/') + ) +} + +function getRequestDetailCacheMetrics(detail = {}) { + const read = Math.max(0, Number(detail.cacheReadTokens) || 0) + const create = Math.max(0, Number(detail.cacheCreateTokens) || 0) + const input = Math.max(0, Number(detail.inputTokens) || 0) + const isOpenAIRelated = + OPENAI_RELATED_ACCOUNT_TYPES.has(detail.accountType) || isOpenAIRelatedEndpoint(detail.endpoint) + const denominator = input + read + create + + if (denominator <= 0) { + return { + isOpenAIRelated, + cacheCreateNotApplicable: isOpenAIRelated, + numerator: read, + denominator: 0, + formula: CACHE_HIT_FORMULA, + cacheHitFormula: CACHE_HIT_FORMULA, + rate: 0 + } + } + + return { + isOpenAIRelated, + cacheCreateNotApplicable: isOpenAIRelated, + numerator: read, + denominator, + formula: CACHE_HIT_FORMULA, + cacheHitFormula: CACHE_HIT_FORMULA, + rate: Number(((read / denominator) * 100).toFixed(2)) + } +} + +function calculateCacheHitRate( + cacheReadTokensOrDetail = 0, + cacheCreateTokens = 0, + inputTokens = 0 +) { + if (typeof cacheReadTokensOrDetail === 'object' && cacheReadTokensOrDetail !== null) { + return getRequestDetailCacheMetrics(cacheReadTokensOrDetail).rate + } + + const read = Math.max(0, Number(cacheReadTokensOrDetail) || 0) + const create = Math.max(0, Number(cacheCreateTokens) || 0) + const input = Math.max(0, Number(inputTokens) || 0) + const denominator = input + read + create + + if (denominator <= 0) { + return 0 + } + + return Number(((read / denominator) * 100).toFixed(2)) +} + +module.exports = { + sanitizeRequestBodySnapshot, + extractRequestReasoningInfo, + resolveRequestDetailReasoning, + createRequestDetailMeta, + finalizeRequestDetailMeta, + extractOpenAICacheReadTokens, + isOpenAIRelatedEndpoint, + CACHE_HIT_FORMULA, + getRequestDetailCacheMetrics, + calculateCacheHitRate +} diff --git a/src/utils/runtimeAddon.js b/src/utils/runtimeAddon.js new file mode 100644 index 0000000..7c2e78e --- /dev/null +++ b/src/utils/runtimeAddon.js @@ -0,0 +1,121 @@ +const fs = require('fs') +const path = require('path') +const logger = require('./logger') + +const ADDON_DIRECTORIES = [ + path.join(process.cwd(), '.local', 'ext'), + path.join(process.cwd(), '.local', 'extensions') +] + +class RuntimeAddonBus { + constructor() { + this._handlers = new Map() + this._initialized = false + } + + register(eventId, handler) { + if (!eventId || typeof handler !== 'function') { + return + } + + if (!this._handlers.has(eventId)) { + this._handlers.set(eventId, []) + } + + this._handlers.get(eventId).push(handler) + } + + emitSync(eventId, payload) { + this._ensureInitialized() + + if (!eventId) { + return payload + } + + const handlers = this._handlers.get(eventId) + if (!handlers || handlers.length === 0) { + return payload + } + + let current = payload + + for (const handler of handlers) { + try { + const result = handler(current) + if (typeof result !== 'undefined') { + current = result + } + } catch (error) { + this._log('warn', `本地扩展处理 ${eventId} 失败: ${error.message}`, error) + } + } + + return current + } + + _ensureInitialized() { + if (this._initialized) { + return + } + + this._initialized = true + const loadedPaths = new Set() + + for (const dir of ADDON_DIRECTORIES) { + if (!dir || !fs.existsSync(dir)) { + continue + } + + let entries = [] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch (error) { + this._log('warn', `读取本地扩展目录 ${dir} 失败: ${error.message}`, error) + continue + } + + for (const entry of entries) { + if (!entry.isFile()) { + continue + } + + if (!entry.name.endsWith('.js')) { + continue + } + + const targetPath = path.join(dir, entry.name) + + if (loadedPaths.has(targetPath)) { + continue + } + + loadedPaths.add(targetPath) + + try { + const registrar = require(targetPath) + if (typeof registrar === 'function') { + registrar(this) + } + } catch (error) { + this._log('warn', `加载本地扩展 ${entry.name} 失败: ${error.message}`, error) + } + } + } + } + + _log(level, message, error) { + const targetLevel = typeof level === 'string' ? level : 'info' + const loggerMethod = + logger && typeof logger[targetLevel] === 'function' ? logger[targetLevel].bind(logger) : null + + if (loggerMethod) { + loggerMethod(message, error) + } else if (targetLevel === 'error') { + console.error(message, error) + } else { + console.log(message, error) + } + } +} + +module.exports = new RuntimeAddonBus() diff --git a/src/utils/safeRotatingAppend.js b/src/utils/safeRotatingAppend.js new file mode 100644 index 0000000..21afecc --- /dev/null +++ b/src/utils/safeRotatingAppend.js @@ -0,0 +1,88 @@ +/** + * ============================================================================ + * 安全 JSONL 追加工具(带文件大小限制与自动轮转) + * ============================================================================ + * + * 用于所有调试 Dump 模块,避免日志文件无限增长导致 I/O 拥塞。 + * + * 策略: + * - 每次写入前检查目标文件大小 + * - 超过阈值时,将现有文件重命名为 .bak(覆盖旧 .bak) + * - 然后写入新文件 + */ + +const fs = require('fs/promises') +const logger = require('./logger') + +// 默认文件大小上限:10MB +const DEFAULT_MAX_FILE_SIZE_BYTES = 10 * 1024 * 1024 +const MAX_FILE_SIZE_ENV = 'DUMP_MAX_FILE_SIZE_BYTES' + +/** + * 获取文件大小上限(可通过环境变量覆盖) + */ +function getMaxFileSize() { + const raw = process.env[MAX_FILE_SIZE_ENV] + if (raw) { + const parsed = Number.parseInt(raw, 10) + if (Number.isFinite(parsed) && parsed > 0) { + return parsed + } + } + return DEFAULT_MAX_FILE_SIZE_BYTES +} + +/** + * 获取文件大小,文件不存在时返回 0 + */ +async function getFileSize(filepath) { + try { + const stat = await fs.stat(filepath) + return stat.size + } catch (e) { + // 文件不存在或无法读取 + return 0 + } +} + +/** + * 安全追加写入 JSONL 文件,支持自动轮转 + * + * @param {string} filepath - 目标文件绝对路径 + * @param {string} line - 要写入的单行(应以 \n 结尾) + * @param {Object} options - 可选配置 + * @param {number} options.maxFileSize - 文件大小上限(字节),默认从环境变量或 10MB + */ +async function safeRotatingAppend(filepath, line, options = {}) { + const maxFileSize = options.maxFileSize || getMaxFileSize() + + const currentSize = await getFileSize(filepath) + + // 如果当前文件已达到或超过阈值,轮转 + if (currentSize >= maxFileSize) { + const backupPath = `${filepath}.bak` + try { + // 先删除旧备份(如果存在) + await fs.unlink(backupPath).catch(() => {}) + // 重命名当前文件为备份 + await fs.rename(filepath, backupPath) + } catch (renameErr) { + // 轮转失败时记录警告日志,继续写入原文件 + logger.warn('⚠️ Log rotation failed, continuing to write to original file', { + filepath, + backupPath, + error: renameErr?.message || String(renameErr) + }) + } + } + + // 追加写入 + await fs.appendFile(filepath, line, { encoding: 'utf8' }) +} + +module.exports = { + safeRotatingAppend, + getMaxFileSize, + MAX_FILE_SIZE_ENV, + DEFAULT_MAX_FILE_SIZE_BYTES +} diff --git a/src/utils/sessionHelper.js b/src/utils/sessionHelper.js new file mode 100644 index 0000000..5b94262 --- /dev/null +++ b/src/utils/sessionHelper.js @@ -0,0 +1,165 @@ +const crypto = require('crypto') +const logger = require('./logger') +const metadataUserIdHelper = require('./metadataUserIdHelper') + +class SessionHelper { + /** + * 生成会话哈希,用于sticky会话保持 + * 基于Anthropic的prompt caching机制,优先使用metadata中的session ID + * @param {Object} requestBody - 请求体 + * @returns {string|null} - 32字符的会话哈希,如果无法生成则返回null + */ + generateSessionHash(requestBody) { + if (!requestBody || typeof requestBody !== 'object') { + return null + } + + // 1. 最高优先级:使用metadata中的session ID(直接使用,无需hash) + if (requestBody.metadata && requestBody.metadata.user_id) { + const sessionId = metadataUserIdHelper.extractSessionId(requestBody.metadata.user_id) + if (sessionId) { + logger.debug(`📋 Session ID extracted from metadata.user_id: ${sessionId}`) + return sessionId + } + } + + let cacheableContent = '' + const system = requestBody.system || '' + const messages = requestBody.messages || [] + + // 2. 提取带有cache_control: {"type": "ephemeral"}的内容 + // 检查system中的cacheable内容 + if (Array.isArray(system)) { + for (const part of system) { + if (part && part.cache_control && part.cache_control.type === 'ephemeral') { + cacheableContent += part.text || '' + } + } + } + + // 检查messages中的cacheable内容 + for (const msg of messages) { + const content = msg.content || '' + let hasCacheControl = false + + if (Array.isArray(content)) { + for (const part of content) { + if (part && part.cache_control && part.cache_control.type === 'ephemeral') { + hasCacheControl = true + break + } + } + } else if ( + typeof content === 'string' && + msg.cache_control && + msg.cache_control.type === 'ephemeral' + ) { + hasCacheControl = true + } + + if (hasCacheControl) { + for (const message of messages) { + let messageText = '' + if (typeof message.content === 'string') { + messageText = message.content + } else if (Array.isArray(message.content)) { + messageText = message.content + .filter((part) => part.type === 'text') + .map((part) => part.text || '') + .join('') + } + + if (messageText) { + cacheableContent += messageText + break + } + } + break + } + } + + // 3. 如果有cacheable内容,直接使用 + if (cacheableContent) { + const hash = crypto + .createHash('sha256') + .update(cacheableContent) + .digest('hex') + .substring(0, 32) + logger.debug(`📋 Session hash generated from cacheable content: ${hash}`) + return hash + } + + // 4. Fallback: 使用system内容 + if (system) { + let systemText = '' + if (typeof system === 'string') { + systemText = system + } else if (Array.isArray(system)) { + systemText = system.map((part) => part.text || '').join('') + } + + if (systemText) { + const hash = crypto.createHash('sha256').update(systemText).digest('hex').substring(0, 32) + logger.debug(`📋 Session hash generated from system content: ${hash}`) + return hash + } + } + + // 5. 最后fallback: 使用第一条消息内容 + if (messages.length > 0) { + const firstMessage = messages[0] + let firstMessageText = '' + + if (typeof firstMessage.content === 'string') { + firstMessageText = firstMessage.content + } else if (Array.isArray(firstMessage.content)) { + if (!firstMessage.content) { + logger.error('📋 Session hash generated from first message failed: ', firstMessage) + } + + firstMessageText = firstMessage.content + .filter((part) => part.type === 'text') + .map((part) => part.text || '') + .join('') + } + + if (firstMessageText) { + const hash = crypto + .createHash('sha256') + .update(firstMessageText) + .digest('hex') + .substring(0, 32) + logger.debug(`📋 Session hash generated from first message: ${hash}`) + return hash + } + } + + // 无法生成会话哈希 + logger.debug('📋 Unable to generate session hash - no suitable content found') + return null + } + + /** + * 获取会话的Redis键名 + * @param {string} sessionHash - 会话哈希 + * @returns {string} - Redis键名 + */ + getSessionRedisKey(sessionHash) { + return `sticky_session:${sessionHash}` + } + + /** + * 验证会话哈希格式 + * @param {string} sessionHash - 会话哈希 + * @returns {boolean} - 是否有效 + */ + isValidSessionHash(sessionHash) { + return ( + typeof sessionHash === 'string' && + sessionHash.length === 32 && + /^[a-f0-9]{32}$/.test(sessionHash) + ) + } +} + +module.exports = new SessionHelper() diff --git a/src/utils/signatureCache.js b/src/utils/signatureCache.js new file mode 100644 index 0000000..7f691b8 --- /dev/null +++ b/src/utils/signatureCache.js @@ -0,0 +1,183 @@ +/** + * Signature Cache - 签名缓存模块 + * + * 用于缓存 Antigravity thinking block 的 thoughtSignature。 + * Claude Code 客户端可能剥离非标准字段,导致多轮对话时签名丢失。 + * 此模块按 sessionId + thinkingText 存储签名,便于后续请求恢复。 + * + * 参考实现: + * - CLIProxyAPI: internal/cache/signature_cache.go + * - antigravity-claude-proxy: src/format/signature-cache.js + */ + +const crypto = require('crypto') +const logger = require('./logger') + +// 配置常量 +const SIGNATURE_CACHE_TTL_MS = 60 * 60 * 1000 // 1 小时(同 CLIProxyAPI) +const MAX_ENTRIES_PER_SESSION = 100 // 每会话最大缓存条目 +const MIN_SIGNATURE_LENGTH = 50 // 最小有效签名长度 +const TEXT_HASH_LENGTH = 16 // 文本哈希长度(SHA256 前 16 位) + +// 主缓存:sessionId -> Map +const signatureCache = new Map() + +/** + * 生成文本内容的稳定哈希值 + * @param {string} text - 待哈希的文本 + * @returns {string} 16 字符的十六进制哈希 + */ +function hashText(text) { + if (!text || typeof text !== 'string') { + return '' + } + const hash = crypto.createHash('sha256').update(text).digest('hex') + return hash.slice(0, TEXT_HASH_LENGTH) +} + +/** + * 获取或创建会话缓存 + * @param {string} sessionId - 会话 ID + * @returns {Map} 会话的签名缓存 Map + */ +function getOrCreateSessionCache(sessionId) { + if (!signatureCache.has(sessionId)) { + signatureCache.set(sessionId, new Map()) + } + return signatureCache.get(sessionId) +} + +/** + * 检查签名是否有效 + * @param {string} signature - 待检查的签名 + * @returns {boolean} 签名是否有效 + */ +function isValidSignature(signature) { + return typeof signature === 'string' && signature.length >= MIN_SIGNATURE_LENGTH +} + +/** + * 缓存 thinking 签名 + * @param {string} sessionId - 会话 ID + * @param {string} thinkingText - thinking 内容文本 + * @param {string} signature - thoughtSignature + */ +function cacheSignature(sessionId, thinkingText, signature) { + if (!sessionId || !thinkingText || !signature) { + return + } + + if (!isValidSignature(signature)) { + return + } + + const sessionCache = getOrCreateSessionCache(sessionId) + const textHash = hashText(thinkingText) + + if (!textHash) { + return + } + + // 淘汰策略:超过限制时删除最老的 1/4 条目 + if (sessionCache.size >= MAX_ENTRIES_PER_SESSION) { + const entries = Array.from(sessionCache.entries()) + entries.sort((a, b) => a[1].timestamp - b[1].timestamp) + const toRemove = Math.max(1, Math.floor(entries.length / 4)) + for (let i = 0; i < toRemove; i++) { + sessionCache.delete(entries[i][0]) + } + logger.debug( + `[SignatureCache] Evicted ${toRemove} old entries for session ${sessionId.slice(0, 8)}...` + ) + } + + sessionCache.set(textHash, { + signature, + timestamp: Date.now() + }) + + logger.debug( + `[SignatureCache] Cached signature for session ${sessionId.slice(0, 8)}..., hash ${textHash}` + ) +} + +/** + * 获取缓存的签名 + * @param {string} sessionId - 会话 ID + * @param {string} thinkingText - thinking 内容文本 + * @returns {string|null} 缓存的签名,未找到或过期则返回 null + */ +function getCachedSignature(sessionId, thinkingText) { + if (!sessionId || !thinkingText) { + return null + } + + const sessionCache = signatureCache.get(sessionId) + if (!sessionCache) { + return null + } + + const textHash = hashText(thinkingText) + if (!textHash) { + return null + } + + const entry = sessionCache.get(textHash) + if (!entry) { + return null + } + + // 检查是否过期 + if (Date.now() - entry.timestamp > SIGNATURE_CACHE_TTL_MS) { + sessionCache.delete(textHash) + logger.debug(`[SignatureCache] Entry expired for hash ${textHash}`) + return null + } + + logger.debug( + `[SignatureCache] Cache hit for session ${sessionId.slice(0, 8)}..., hash ${textHash}` + ) + return entry.signature +} + +/** + * 清除会话缓存 + * @param {string} sessionId - 要清除的会话 ID,为空则清除全部 + */ +function clearSignatureCache(sessionId = null) { + if (sessionId) { + signatureCache.delete(sessionId) + logger.debug(`[SignatureCache] Cleared cache for session ${sessionId.slice(0, 8)}...`) + } else { + signatureCache.clear() + logger.debug('[SignatureCache] Cleared all caches') + } +} + +/** + * 获取缓存统计信息(调试用) + * @returns {Object} { sessionCount, totalEntries } + */ +function getCacheStats() { + let totalEntries = 0 + for (const sessionCache of signatureCache.values()) { + totalEntries += sessionCache.size + } + return { + sessionCount: signatureCache.size, + totalEntries + } +} + +module.exports = { + cacheSignature, + getCachedSignature, + clearSignatureCache, + getCacheStats, + isValidSignature, + // 内部函数导出(用于测试或扩展) + hashText, + MIN_SIGNATURE_LENGTH, + MAX_ENTRIES_PER_SESSION, + SIGNATURE_CACHE_TTL_MS +} diff --git a/src/utils/sseParser.js b/src/utils/sseParser.js new file mode 100644 index 0000000..466e052 --- /dev/null +++ b/src/utils/sseParser.js @@ -0,0 +1,118 @@ +/** + * Server-Sent Events (SSE) 解析工具 + * + * 用于解析标准 SSE 格式的数据流 + * 当前主要用于 Gemini API 的流式响应处理 + * + * @module sseParser + */ + +/** + * 解析单行 SSE 数据 + * + * @param {string} line - SSE 格式的行(如:"data: {json}\n") + * @returns {Object} 解析结果 + * @returns {'data'|'control'|'other'|'invalid'} .type - 行类型 + * @returns {Object|null} .data - 解析后的 JSON 数据(仅 type='data' 时) + * @returns {string} .line - 原始行内容 + * @returns {string} [.jsonStr] - JSON 字符串 + * @returns {Error} [.error] - 解析错误(仅 type='invalid' 时) + * + * @example + * // 数据行 + * parseSSELine('data: {"key":"value"}') + * // => { type: 'data', data: {key: 'value'}, line: '...', jsonStr: '...' } + * + * @example + * // 控制行 + * parseSSELine('data: [DONE]') + * // => { type: 'control', data: null, line: '...', jsonStr: '[DONE]' } + */ +function parseSSELine(line) { + if (!line.startsWith('data: ')) { + return { type: 'other', line, data: null } + } + + const jsonStr = line.substring(6).trim() + + if (!jsonStr || jsonStr === '[DONE]') { + return { type: 'control', line, data: null, jsonStr } + } + + try { + const data = JSON.parse(jsonStr) + return { type: 'data', line, data, jsonStr } + } catch (e) { + return { type: 'invalid', line, data: null, jsonStr, error: e } + } +} + +/** + * 增量 SSE 解析器类 + * 用于处理流式数据,避免每次都 split 整个 buffer + */ +class IncrementalSSEParser { + constructor() { + this.buffer = '' + } + + /** + * 添加数据块并返回完整的事件 + * @param {string} chunk - 数据块 + * @returns {Array} 解析出的完整事件数组 + */ + feed(chunk) { + this.buffer += chunk + const events = [] + + // 查找完整的事件(以 \n\n 分隔) + let idx + while ((idx = this.buffer.indexOf('\n\n')) !== -1) { + const event = this.buffer.slice(0, idx) + this.buffer = this.buffer.slice(idx + 2) + + if (event.trim()) { + // 解析事件中的每一行 + const lines = event.split('\n') + for (const line of lines) { + if (line.startsWith('data: ')) { + const jsonStr = line.slice(6) + if (jsonStr && jsonStr !== '[DONE]') { + try { + events.push({ type: 'data', data: JSON.parse(jsonStr) }) + } catch (e) { + events.push({ type: 'invalid', raw: jsonStr, error: e }) + } + } else if (jsonStr === '[DONE]') { + events.push({ type: 'done' }) + } + } else if (line.startsWith('event: ')) { + events.push({ type: 'event', name: line.slice(7).trim() }) + } + } + } + } + + return events + } + + /** + * 获取剩余的 buffer 内容 + * @returns {string} + */ + getRemaining() { + return this.buffer + } + + /** + * 重置解析器 + */ + reset() { + this.buffer = '' + } +} + +module.exports = { + parseSSELine, + IncrementalSSEParser +} diff --git a/src/utils/statsHelper.js b/src/utils/statsHelper.js new file mode 100644 index 0000000..ba75bec --- /dev/null +++ b/src/utils/statsHelper.js @@ -0,0 +1,105 @@ +/** + * 统计计算工具函数 + * 提供百分位数计算、等待时间统计等通用统计功能 + */ + +/** + * 计算百分位数(使用 nearest-rank 方法) + * @param {number[]} sortedArray - 已排序的数组(升序) + * @param {number} percentile - 百分位数 (0-100) + * @returns {number} 百分位值 + * + * 边界情况说明: + * - percentile=0: 返回最小值 (index=0) + * - percentile=100: 返回最大值 (index=len-1) + * - percentile=50 且 len=2: 返回第一个元素(nearest-rank 向下取) + * + * 算法说明(nearest-rank 方法): + * - index = ceil(percentile / 100 * len) - 1 + * - 示例:len=100, P50 → ceil(50) - 1 = 49(第50个元素,0-indexed) + * - 示例:len=100, P99 → ceil(99) - 1 = 98(第99个元素) + */ +function getPercentile(sortedArray, percentile) { + const len = sortedArray.length + if (len === 0) { + return 0 + } + if (len === 1) { + return sortedArray[0] + } + + // 边界处理:percentile <= 0 返回最小值 + if (percentile <= 0) { + return sortedArray[0] + } + // 边界处理:percentile >= 100 返回最大值 + if (percentile >= 100) { + return sortedArray[len - 1] + } + + const index = Math.ceil((percentile / 100) * len) - 1 + return sortedArray[index] +} + +/** + * 计算等待时间分布统计 + * @param {number[]} waitTimes - 等待时间数组(无需预先排序) + * @returns {Object|null} 统计对象,空数组返回 null + * + * 返回对象包含: + * - sampleCount: 样本数量(始终包含,便于调用方判断可靠性) + * - count: 样本数量(向后兼容) + * - min: 最小值 + * - max: 最大值 + * - avg: 平均值(四舍五入) + * - p50: 50百分位数(中位数) + * - p90: 90百分位数 + * - p99: 99百分位数 + * - sampleSizeWarning: 样本量不足时的警告信息(样本 < 10) + * - p90Unreliable: P90 统计不可靠标记(样本 < 10) + * - p99Unreliable: P99 统计不可靠标记(样本 < 100) + * + * 可靠性标记说明(详见 design.md Decision 6): + * - 样本 < 10: P90 和 P99 都不可靠 + * - 样本 < 100: P99 不可靠(P90 需要 10 个样本,P99 需要 100 个样本) + * - 即使标记为不可靠,仍返回计算值供参考 + */ +function calculateWaitTimeStats(waitTimes) { + if (!waitTimes || waitTimes.length === 0) { + return null + } + + const sorted = [...waitTimes].sort((a, b) => a - b) + const sum = sorted.reduce((a, b) => a + b, 0) + const len = sorted.length + + const stats = { + sampleCount: len, // 新增:始终包含样本数 + count: len, // 向后兼容 + min: sorted[0], + max: sorted[len - 1], + avg: Math.round(sum / len), + p50: getPercentile(sorted, 50), + p90: getPercentile(sorted, 90), + p99: getPercentile(sorted, 99) + } + + // 渐进式可靠性标记(详见 design.md Decision 6) + // 样本 < 10: P90 不可靠(P90 至少需要 ceil(100/10) = 10 个样本) + if (len < 10) { + stats.sampleSizeWarning = 'Results may be inaccurate due to small sample size' + stats.p90Unreliable = true + } + + // 样本 < 100: P99 不可靠(P99 至少需要 ceil(100/1) = 100 个样本) + if (len < 100) { + stats.p99Unreliable = true + } + + return stats +} + +module.exports = { + getPercentile, + calculateWaitTimeStats +} diff --git a/src/utils/streamHelper.js b/src/utils/streamHelper.js new file mode 100644 index 0000000..3d6c679 --- /dev/null +++ b/src/utils/streamHelper.js @@ -0,0 +1,36 @@ +/** + * Stream Helper Utilities + * 流处理辅助工具函数 + */ + +/** + * 检查响应流是否仍然可写(客户端连接是否有效) + * @param {import('http').ServerResponse} stream - HTTP响应流 + * @returns {boolean} 如果流可写返回true,否则返回false + */ +function isStreamWritable(stream) { + if (!stream) { + return false + } + + // 检查流是否已销毁 + if (stream.destroyed) { + return false + } + + // 检查底层socket是否已销毁 + if (stream.socket?.destroyed) { + return false + } + + // 检查流是否已结束写入 + if (stream.writableEnded) { + return false + } + + return true +} + +module.exports = { + isStreamWritable +} diff --git a/src/utils/tempUnavailablePolicy.js b/src/utils/tempUnavailablePolicy.js new file mode 100644 index 0000000..04d98b5 --- /dev/null +++ b/src/utils/tempUnavailablePolicy.js @@ -0,0 +1,56 @@ +const isEmptyValue = (value) => value === undefined || value === null || value === '' + +const parseBooleanLike = (value) => { + if (value === true || value === false) { + return value + } + if (value === 1 || value === '1') { + return true + } + if (value === 0 || value === '0') { + return false + } + + const normalized = String(value || '') + .trim() + .toLowerCase() + return normalized === 'true' || normalized === 'yes' || normalized === 'on' +} + +const normalizeOptionalNonNegativeInteger = (value) => { + if (isEmptyValue(value)) { + return null + } + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 0) { + return null + } + return Math.floor(parsed) +} + +const normalizeTempUnavailablePolicyInput = (value = {}) => ({ + disableTempUnavailable: parseBooleanLike(value.disableTempUnavailable), + tempUnavailable503TtlSeconds: normalizeOptionalNonNegativeInteger( + value.tempUnavailable503TtlSeconds + ), + tempUnavailable5xxTtlSeconds: normalizeOptionalNonNegativeInteger( + value.tempUnavailable5xxTtlSeconds + ) +}) + +const normalizeTempUnavailablePolicyFromAccountData = (accountData = {}) => { + const normalized = normalizeTempUnavailablePolicyInput(accountData) + return { + disableTempUnavailable: normalized.disableTempUnavailable, + ttl503Seconds: normalized.tempUnavailable503TtlSeconds, + ttl5xxSeconds: normalized.tempUnavailable5xxTtlSeconds + } +} + +module.exports = { + isEmptyValue, + parseBooleanLike, + normalizeOptionalNonNegativeInteger, + normalizeTempUnavailablePolicyInput, + normalizeTempUnavailablePolicyFromAccountData +} diff --git a/src/utils/testPayloadHelper.js b/src/utils/testPayloadHelper.js new file mode 100644 index 0000000..66df830 --- /dev/null +++ b/src/utils/testPayloadHelper.js @@ -0,0 +1,364 @@ +const crypto = require('crypto') +const { mapToErrorCode } = require('./errorSanitizer') + +// 将原始错误信息映射为安全的标准错误码消息 +const sanitizeErrorMsg = (msg) => { + const mapped = mapToErrorCode({ message: msg }, { logOriginal: false }) + return `[${mapped.code}] ${mapped.message}` +} + +/** + * 生成随机十六进制字符串 + * @param {number} bytes - 字节数 + * @returns {string} 十六进制字符串 + */ +function randomHex(bytes = 32) { + return crypto.randomBytes(bytes).toString('hex') +} + +/** + * 生成 Claude Code 风格的会话字符串 + * @returns {string} 会话字符串,格式: user_{64位hex}_account__session_{uuid} + */ +function generateSessionString() { + const hex64 = randomHex(32) // 32 bytes => 64 hex characters + const uuid = crypto.randomUUID() + return `user_${hex64}_account__session_${uuid}` +} + +/** + * 生成 Claude 测试请求体 + * @param {string} model - 模型名称 + * @param {object} options - 可选配置 + * @param {boolean} options.stream - 是否流式(默认false) + * @param {string} options.prompt - 自定义提示词(默认 'hi') + * @param {number} options.maxTokens - 最大输出 token(默认 1000) + * @returns {object} 测试请求体 + */ +function createClaudeTestPayload(model = 'claude-sonnet-4-5-20250929', options = {}) { + const { stream, prompt = 'hi', maxTokens = 1000 } = options + const payload = { + model, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: prompt, + cache_control: { + type: 'ephemeral' + } + } + ] + } + ], + system: [ + { + type: 'text', + text: "You are Claude Code, Anthropic's official CLI for Claude.", + cache_control: { + type: 'ephemeral' + } + } + ], + metadata: { + user_id: generateSessionString() + }, + max_tokens: maxTokens, + temperature: 1 + } + + if (stream) { + payload.stream = true + } + + return payload +} + +/** + * 发送流式测试请求并处理SSE响应 + * @param {object} options - 配置选项 + * @param {string} options.apiUrl - API URL + * @param {string} options.authorization - Authorization header值 + * @param {object} options.responseStream - Express响应流 + * @param {object} [options.payload] - 请求体(默认使用createClaudeTestPayload) + * @param {object} [options.proxyAgent] - 代理agent + * @param {number} [options.timeout] - 超时时间(默认30000) + * @param {object} [options.extraHeaders] - 额外的请求头 + * @returns {Promise} + */ +async function sendStreamTestRequest(options) { + const axios = require('axios') + const logger = require('./logger') + + const { + apiUrl, + authorization, + responseStream, + payload = createClaudeTestPayload('claude-sonnet-4-5-20250929', { stream: true }), + proxyAgent = null, + timeout = 30000, + extraHeaders = {}, + sanitize = false + } = options + + const sendSSE = (type, data = {}) => { + if (!responseStream.destroyed && !responseStream.writableEnded) { + try { + responseStream.write(`data: ${JSON.stringify({ type, ...data })}\n\n`) + } catch { + // ignore + } + } + } + + const endTest = (success, error = null) => { + if (!responseStream.destroyed && !responseStream.writableEnded) { + try { + responseStream.write( + `data: ${JSON.stringify({ type: 'test_complete', success, error: error || undefined })}\n\n` + ) + responseStream.end() + } catch { + // ignore + } + } + } + + // 设置响应头 + if (!responseStream.headersSent) { + responseStream.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + }) + } + + sendSSE('test_start', { message: 'Test started' }) + + const requestConfig = { + method: 'POST', + url: apiUrl, + data: payload, + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + 'User-Agent': 'claude-cli/2.0.52 (external, cli)', + ...(authorization ? { authorization } : {}), + ...extraHeaders + }, + timeout, + responseType: 'stream', + validateStatus: () => true + } + + if (proxyAgent) { + requestConfig.httpAgent = proxyAgent + requestConfig.httpsAgent = proxyAgent + requestConfig.proxy = false + } + + try { + const response = await axios(requestConfig) + logger.debug(`🌊 Test response status: ${response.status}`) + + // 处理非200响应 + if (response.status !== 200) { + return new Promise((resolve) => { + const chunks = [] + response.data.on('data', (chunk) => chunks.push(chunk)) + response.data.on('end', () => { + const errorData = Buffer.concat(chunks).toString() + let errorMsg = `API Error: ${response.status}` + try { + const json = JSON.parse(errorData) + errorMsg = extractErrorMessage(json, errorMsg) + } catch { + if (errorData.length < 200) { + errorMsg = errorData || errorMsg + } + } + endTest(false, sanitize ? sanitizeErrorMsg(errorMsg) : errorMsg) + resolve() + }) + response.data.on('error', (err) => { + endTest(false, sanitize ? sanitizeErrorMsg(err.message) : err.message) + resolve() + }) + }) + } + + // 处理成功的流式响应 + return new Promise((resolve) => { + let buffer = '' + + response.data.on('data', (chunk) => { + buffer += chunk.toString() + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.startsWith('data:')) { + continue + } + const jsonStr = line.substring(5).trim() + if (!jsonStr || jsonStr === '[DONE]') { + continue + } + + try { + const data = JSON.parse(jsonStr) + + if (data.type === 'content_block_delta' && data.delta?.text) { + sendSSE('content', { text: data.delta.text }) + } + if (data.type === 'message_stop') { + sendSSE('message_stop') + } + if (data.type === 'error' || data.error) { + const errMsg = data.error?.message || data.message || data.error || 'Unknown error' + sendSSE('error', { error: errMsg }) + } + } catch { + // ignore parse errors + } + } + }) + + response.data.on('end', () => { + if (!responseStream.destroyed && !responseStream.writableEnded) { + endTest(true) + } + resolve() + }) + + response.data.on('error', (err) => { + endTest(false, err.message) + resolve() + }) + }) + } catch (error) { + logger.error('❌ Stream test request failed:', error.message) + endTest(false, error.message) + } +} + +/** + * 生成 Gemini 测试请求体 + * @param {string} model - 模型名称 + * @param {object} options - 可选配置 + * @param {string} options.prompt - 自定义提示词(默认 'hi') + * @param {number} options.maxTokens - 最大输出 token(默认 100) + * @returns {object} 测试请求体 + */ +function createGeminiTestPayload(_model = 'gemini-2.5-pro', options = {}) { + const { prompt = 'hi', maxTokens = 100 } = options + return { + contents: [ + { + role: 'user', + parts: [{ text: prompt }] + } + ], + generationConfig: { + maxOutputTokens: maxTokens, + temperature: 1 + } + } +} + +/** + * 生成 OpenAI Responses 测试请求体 + * @param {string} model - 模型名称 + * @param {object} options - 可选配置 + * @param {string} options.prompt - 自定义提示词(默认 'hi') + * @param {number} options.maxTokens - 最大输出 token(默认 100) + * @returns {object} 测试请求体 + */ +function createOpenAITestPayload(model = 'gpt-5', options = {}) { + const { prompt = 'hi', maxTokens = 100, stream = true } = options + return { + model, + input: [ + { + role: 'user', + content: prompt + } + ], + max_output_tokens: maxTokens, + stream + } +} + +/** + * 生成 Chat Completions 测试请求体(用于 Azure OpenAI 等 Chat Completions 端点) + * @param {string} model - 模型名称 + * @param {object} options - 可选配置 + * @param {string} options.prompt - 自定义提示词(默认 'hi') + * @param {number} options.maxTokens - 最大输出 token(默认 100) + * @returns {object} 测试请求体 + */ +function createChatCompletionsTestPayload(model = 'gpt-4o-mini', options = {}) { + const { prompt = 'hi', maxTokens = 100 } = options + return { + model, + messages: [ + { + role: 'user', + content: prompt + } + ], + max_tokens: maxTokens + } +} + +/** + * 从各种格式的错误响应中提取可读错误信息 + * 支持格式: {message}, {error:{message}}, {msg:{error:{message}}}, {error:"string"} 等 + * @param {object} json - 解析后的 JSON 错误响应 + * @param {string} fallback - 提取失败时的回退信息 + * @returns {string} 错误信息 + */ +function extractErrorMessage(json, fallback) { + if (!json || typeof json !== 'object') { + return fallback + } + // 直接 message + if (json.message && typeof json.message === 'string') { + return json.message + } + // {error: {message: "..."}} + if (json.error?.message) { + return json.error.message + } + // {msg: {error: {message: "..."}}} (relay 包装格式) + if (json.msg?.error?.message) { + return json.msg.error.message + } + if (json.msg?.message) { + return json.msg.message + } + // {error: "string"} + if (typeof json.error === 'string') { + return json.error + } + // {msg: "string"} + if (typeof json.msg === 'string') { + return json.msg + } + return fallback +} + +module.exports = { + randomHex, + generateSessionString, + createClaudeTestPayload, + createGeminiTestPayload, + createOpenAITestPayload, + createChatCompletionsTestPayload, + extractErrorMessage, + sanitizeErrorMsg, + sendStreamTestRequest +} diff --git a/src/utils/tokenMask.js b/src/utils/tokenMask.js new file mode 100644 index 0000000..aa31705 --- /dev/null +++ b/src/utils/tokenMask.js @@ -0,0 +1,108 @@ +/** + * Token 脱敏工具 + * 用于在日志中安全显示 token,只显示70%的内容,其余用*代替 + */ + +/** + * 对 token 进行脱敏处理 + * @param {string} token - 需要脱敏的 token + * @param {number} visiblePercent - 可见部分的百分比,默认 70 + * @returns {string} 脱敏后的 token + */ +function maskToken(token, visiblePercent = 70) { + if (!token || typeof token !== 'string') { + return '[EMPTY]' + } + + const { length } = token + + // 对于非常短的 token,至少隐藏一部分 + if (length <= 2) { + return '*'.repeat(length) + } + + if (length <= 5) { + return token.slice(0, 1) + '*'.repeat(length - 1) + } + + if (length <= 10) { + const visibleLength = Math.min(5, length - 2) + const front = token.slice(0, visibleLength) + return front + '*'.repeat(length - visibleLength) + } + + // 计算可见字符数量 + const visibleLength = Math.floor(length * (visiblePercent / 100)) + + // 在前部和尾部分配可见字符 + const frontLength = Math.ceil(visibleLength * 0.6) + const backLength = visibleLength - frontLength + + // 构建脱敏后的 token + const front = token.slice(0, frontLength) + const back = token.slice(-backLength) + const middle = '*'.repeat(length - visibleLength) + + return `${front}${middle}${back}` +} + +/** + * 对包含 token 的对象进行脱敏处理 + * @param {Object} obj - 包含 token 的对象 + * @param {Array} tokenFields - 需要脱敏的字段名列表 + * @returns {Object} 脱敏后的对象副本 + */ +function maskTokensInObject( + obj, + tokenFields = ['accessToken', 'refreshToken', 'access_token', 'refresh_token'] +) { + if (!obj || typeof obj !== 'object') { + return obj + } + + const masked = { ...obj } + + tokenFields.forEach((field) => { + if (masked[field]) { + masked[field] = maskToken(masked[field]) + } + }) + + return masked +} + +/** + * 格式化 token 刷新日志 + * @param {string} accountId - 账户 ID + * @param {string} accountName - 账户名称 + * @param {Object} tokens - 包含 access_token 和 refresh_token 的对象 + * @param {string} status - 刷新状态 (success/failed) + * @param {string} message - 额外的消息 + * @returns {Object} 格式化的日志对象 + */ +function formatTokenRefreshLog(accountId, accountName, tokens, status, message = '') { + const log = { + timestamp: new Date().toISOString(), + event: 'token_refresh', + accountId, + accountName, + status, + message + } + + if (tokens) { + log.tokens = { + accessToken: tokens.accessToken ? maskToken(tokens.accessToken) : '[NOT_PROVIDED]', + refreshToken: tokens.refreshToken ? maskToken(tokens.refreshToken) : '[NOT_PROVIDED]', + expiresAt: tokens.expiresAt || '[NOT_PROVIDED]' + } + } + + return log +} + +module.exports = { + maskToken, + maskTokensInObject, + formatTokenRefreshLog +} diff --git a/src/utils/tokenRefreshLogger.js b/src/utils/tokenRefreshLogger.js new file mode 100644 index 0000000..f903148 --- /dev/null +++ b/src/utils/tokenRefreshLogger.js @@ -0,0 +1,175 @@ +const winston = require('winston') +const path = require('path') +const fs = require('fs') +const { maskToken } = require('./tokenMask') + +// 确保日志目录存在 +const logDir = path.join(process.cwd(), 'logs') +if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir, { recursive: true }) +} + +// 创建专用的 token 刷新日志记录器 +const tokenRefreshLogger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss.SSS' + }), + winston.format.json(), + winston.format.printf((info) => JSON.stringify(info, null, 2)) + ), + transports: [ + // 文件传输 - 每日轮转 + new winston.transports.File({ + filename: path.join(logDir, 'token-refresh.log'), + maxsize: 10 * 1024 * 1024, // 10MB + maxFiles: 30, // 保留30天 + tailable: true + }), + // 错误单独记录 + new winston.transports.File({ + filename: path.join(logDir, 'token-refresh-error.log'), + level: 'error', + maxsize: 10 * 1024 * 1024, + maxFiles: 30 + }) + ], + // 错误处理 + exitOnError: false +}) + +// 在开发环境添加控制台输出 +if (process.env.NODE_ENV !== 'production') { + tokenRefreshLogger.add( + new winston.transports.Console({ + format: winston.format.combine(winston.format.colorize(), winston.format.simple()) + }) + ) +} + +/** + * 记录 token 刷新开始 + */ +function logRefreshStart(accountId, accountName, platform = 'claude', reason = '') { + tokenRefreshLogger.info({ + event: 'token_refresh_start', + accountId, + accountName, + platform, + reason, + timestamp: new Date().toISOString() + }) +} + +/** + * 记录 token 刷新成功 + */ +function logRefreshSuccess(accountId, accountName, platform = 'claude', tokenData = {}) { + const maskedTokenData = { + accessToken: tokenData.accessToken ? maskToken(tokenData.accessToken) : '[NOT_PROVIDED]', + refreshToken: tokenData.refreshToken ? maskToken(tokenData.refreshToken) : '[NOT_PROVIDED]', + expiresAt: tokenData.expiresAt || tokenData.expiry_date || '[NOT_PROVIDED]', + scopes: tokenData.scopes || tokenData.scope || '[NOT_PROVIDED]' + } + + tokenRefreshLogger.info({ + event: 'token_refresh_success', + accountId, + accountName, + platform, + tokenData: maskedTokenData, + timestamp: new Date().toISOString() + }) +} + +/** + * 记录 token 刷新失败 + */ +function logRefreshError(accountId, accountName, platform = 'claude', error, attemptNumber = 1) { + const errorInfo = { + message: error.message || error.toString(), + code: error.code || 'UNKNOWN', + statusCode: error.response?.status || 'N/A', + responseData: error.response?.data || 'N/A' + } + + tokenRefreshLogger.error({ + event: 'token_refresh_error', + accountId, + accountName, + platform, + error: errorInfo, + attemptNumber, + timestamp: new Date().toISOString() + }) +} + +/** + * 记录 token 刷新跳过(由于并发锁) + */ +function logRefreshSkipped(accountId, accountName, platform = 'claude', reason = 'locked') { + tokenRefreshLogger.info({ + event: 'token_refresh_skipped', + accountId, + accountName, + platform, + reason, + timestamp: new Date().toISOString() + }) +} + +/** + * 记录 token 使用情况 + */ +function logTokenUsage(accountId, accountName, platform = 'claude', expiresAt, isExpired) { + tokenRefreshLogger.debug({ + event: 'token_usage_check', + accountId, + accountName, + platform, + expiresAt, + isExpired, + remainingMinutes: expiresAt ? Math.floor((new Date(expiresAt) - Date.now()) / 60000) : 'N/A', + timestamp: new Date().toISOString() + }) +} + +/** + * 记录批量刷新任务 + */ +function logBatchRefreshStart(totalAccounts, platform = 'all') { + tokenRefreshLogger.info({ + event: 'batch_refresh_start', + totalAccounts, + platform, + timestamp: new Date().toISOString() + }) +} + +/** + * 记录批量刷新结果 + */ +function logBatchRefreshComplete(results) { + tokenRefreshLogger.info({ + event: 'batch_refresh_complete', + results: { + total: results.total || 0, + success: results.success || 0, + failed: results.failed || 0, + skipped: results.skipped || 0 + }, + timestamp: new Date().toISOString() + }) +} + +module.exports = { + logger: tokenRefreshLogger, + logRefreshStart, + logRefreshSuccess, + logRefreshError, + logRefreshSkipped, + logTokenUsage, + logBatchRefreshStart, + logBatchRefreshComplete +} diff --git a/src/utils/unstableUpstreamHelper.js b/src/utils/unstableUpstreamHelper.js new file mode 100644 index 0000000..6fa58ac --- /dev/null +++ b/src/utils/unstableUpstreamHelper.js @@ -0,0 +1,81 @@ +const logger = require('./logger') + +function parseList(envValue) { + if (!envValue) { + return [] + } + return envValue + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) +} + +const unstableTypes = new Set(parseList(process.env.UNSTABLE_ERROR_TYPES)) +const unstableKeywords = parseList(process.env.UNSTABLE_ERROR_KEYWORDS) +const unstableStatusCodes = new Set([408, 499, 502, 503, 504, 522]) + +function normalizeErrorPayload(payload) { + if (!payload) { + return {} + } + + if (typeof payload === 'string') { + try { + return normalizeErrorPayload(JSON.parse(payload)) + } catch (e) { + return { message: payload } + } + } + + if (payload.error && typeof payload.error === 'object') { + return { + type: payload.error.type || payload.error.error || payload.error.code, + code: payload.error.code || payload.error.error || payload.error.type, + message: payload.error.message || payload.error.msg || payload.message || payload.error.error + } + } + + return { + type: payload.type || payload.code, + code: payload.code || payload.type, + message: payload.message || '' + } +} + +function isUnstableUpstreamError(statusCode, payload) { + const normalizedStatus = Number(statusCode) + if (Number.isFinite(normalizedStatus) && normalizedStatus >= 500) { + return true + } + if (Number.isFinite(normalizedStatus) && unstableStatusCodes.has(normalizedStatus)) { + return true + } + + const { type, code, message } = normalizeErrorPayload(payload) + const lowerType = (type || '').toString().toLowerCase() + const lowerCode = (code || '').toString().toLowerCase() + const lowerMessage = (message || '').toString().toLowerCase() + + if (lowerType === 'server_error' || lowerCode === 'server_error') { + return true + } + if (unstableTypes.has(lowerType) || unstableTypes.has(lowerCode)) { + return true + } + if (unstableKeywords.length > 0) { + return unstableKeywords.some((kw) => lowerMessage.includes(kw)) + } + + return false +} + +function logUnstable(accountLabel, statusCode) { + logger.warn( + `Detected unstable upstream error (${statusCode}) for account ${accountLabel}, marking temporarily unavailable` + ) +} + +module.exports = { + isUnstableUpstreamError, + logUnstable +} diff --git a/src/utils/upstreamErrorHelper.js b/src/utils/upstreamErrorHelper.js new file mode 100644 index 0000000..1fadb31 --- /dev/null +++ b/src/utils/upstreamErrorHelper.js @@ -0,0 +1,528 @@ +const logger = require('./logger') +const { normalizeTempUnavailablePolicyFromAccountData } = require('./tempUnavailablePolicy') + +const TEMP_UNAVAILABLE_PREFIX = 'temp_unavailable' +const ERROR_HISTORY_PREFIX = 'error_history' +const ERROR_HISTORY_MAX = 5000 +const ERROR_HISTORY_TTL = 3 * 24 * 60 * 60 // 3天 + +// 默认 TTL(秒) +const DEFAULT_TTL = { + server_error: 300, // 5xx: 5分钟 + service_unavailable: 60, // 503: 1分钟(默认更短,避免短暂抖动导致长时间不可路由) + overload: 600, // 529: 10分钟 + auth_error: 1800, // 401/403: 30分钟 + timeout: 300, // 504/网络超时: 5分钟 + rate_limit: 300 // 429: 5分钟(优先使用响应头解析值) +} + +// 上游 retry-after 派生 TTL 的上限(秒)。 +// temp-unavailable 只用于「短暂抖动」的冷却;而周级限额的 retry-after 可达数天, +// 直接采纳会把账号整整下线数天,且账号哈希看起来完全正常(该键是独立的 TTL 键), +// 极难排查。真正的长时限额应由 markAccountRateLimited / 各模型家族限流桶承担。 +const DEFAULT_MAX_CUSTOM_TTL = 1800 // 30 分钟 + +// 延迟加载配置,避免循环依赖 +let _configCache = null +const getConfig = () => { + if (!_configCache) { + try { + _configCache = require('../../config/config') + } catch { + _configCache = {} + } + } + return _configCache +} + +const getTtlConfig = () => { + const config = getConfig() + const parseEnvPositiveInt = (name) => { + const value = parseInt(process.env[name], 10) + return Number.isFinite(value) && value > 0 ? value : null + } + + return { + service_unavailable: + config.upstreamError?.serviceUnavailableTtlSeconds ?? + parseEnvPositiveInt('UPSTREAM_ERROR_503_TTL_SECONDS') ?? + DEFAULT_TTL.service_unavailable, + server_error: config.upstreamError?.serverErrorTtlSeconds ?? DEFAULT_TTL.server_error, + overload: config.upstreamError?.overloadTtlSeconds ?? DEFAULT_TTL.overload, + auth_error: config.upstreamError?.authErrorTtlSeconds ?? DEFAULT_TTL.auth_error, + timeout: config.upstreamError?.timeoutTtlSeconds ?? DEFAULT_TTL.timeout, + rate_limit: DEFAULT_TTL.rate_limit, + max_custom: + config.upstreamError?.maxCustomTtlSeconds ?? + parseEnvPositiveInt('UPSTREAM_ERROR_MAX_CUSTOM_TTL_SECONDS') ?? + DEFAULT_MAX_CUSTOM_TTL + } +} + +// 延迟加载 redis,避免循环依赖 +let _redis = null +const getRedis = () => { + if (!_redis) { + _redis = require('../models/redis') + } + return _redis +} + +// 可读取账号级临时暂停配置的 Redis key 前缀映射 +const ACCOUNT_KEY_PREFIX_BY_TYPE = { + 'claude-official': 'claude:account:', + claude: 'claude:account:' +} + +const EMPTY_TEMP_UNAVAILABLE_POLICY = { + disableTempUnavailable: false, + ttl503Seconds: null, + ttl5xxSeconds: null +} + +const getAccountTempUnavailablePolicy = async (accountId, accountType) => { + try { + const accountPrefix = ACCOUNT_KEY_PREFIX_BY_TYPE[accountType] + if (!accountPrefix) { + return EMPTY_TEMP_UNAVAILABLE_POLICY + } + + const redis = getRedis() + const client = redis.getClientSafe() + const accountData = await client.hgetall(`${accountPrefix}${accountId}`) + if (!accountData || Object.keys(accountData).length === 0) { + return EMPTY_TEMP_UNAVAILABLE_POLICY + } + + return normalizeTempUnavailablePolicyFromAccountData(accountData) + } catch (error) { + logger.warn( + `⚠️ [UpstreamError] Failed to load account temp-unavailable policy for ${accountType}:${accountId}: ${error.message}` + ) + return EMPTY_TEMP_UNAVAILABLE_POLICY + } +} + +const resolveAccountTtlOverride = ({ policy, statusCode, errorType }) => { + if (!policy) { + return { skip: false, ttlOverrideSeconds: null, reason: '' } + } + + if (policy.disableTempUnavailable) { + return { + skip: true, + ttlOverrideSeconds: null, + reason: 'account_temp_unavailable_disabled' + } + } + + if (statusCode === 503 && policy.ttl503Seconds !== null) { + if (policy.ttl503Seconds <= 0) { + return { + skip: true, + ttlOverrideSeconds: null, + reason: 'account_503_ttl_disabled' + } + } + return { + skip: false, + ttlOverrideSeconds: policy.ttl503Seconds, + reason: 'account_503_ttl_override' + } + } + + if (errorType === 'server_error' && policy.ttl5xxSeconds !== null) { + if (policy.ttl5xxSeconds <= 0) { + return { + skip: true, + ttlOverrideSeconds: null, + reason: 'account_5xx_ttl_disabled' + } + } + return { + skip: false, + ttlOverrideSeconds: policy.ttl5xxSeconds, + reason: 'account_5xx_ttl_override' + } + } + + return { skip: false, ttlOverrideSeconds: null, reason: '' } +} + +// 根据 HTTP 状态码分类错误类型 +const classifyError = (statusCode) => { + if (statusCode === 529) { + return 'overload' + } + if (statusCode === 503) { + return 'service_unavailable' + } + if (statusCode === 504) { + return 'timeout' + } + if (statusCode === 401 || statusCode === 403) { + return 'auth_error' + } + if (statusCode === 429) { + return 'rate_limit' + } + if (statusCode >= 500) { + return 'server_error' + } + return null +} + +// 解析 429 响应头中的重置时间(返回秒数) +const parseRetryAfter = (headers) => { + if (!headers) { + return null + } + + // 标准 Retry-After 头(秒数或 HTTP 日期) + const retryAfter = headers['retry-after'] + if (retryAfter) { + const seconds = parseInt(retryAfter, 10) + if (!isNaN(seconds) && seconds > 0) { + return seconds + } + const date = new Date(retryAfter) + if (!isNaN(date.getTime())) { + const diff = Math.ceil((date.getTime() - Date.now()) / 1000) + if (diff > 0) { + return diff + } + } + } + + // Anthropic 限流重置头(ISO 时间) + const anthropicReset = headers['anthropic-ratelimit-unified-reset'] + if (anthropicReset) { + const date = new Date(anthropicReset) + if (!isNaN(date.getTime())) { + const diff = Math.ceil((date.getTime() - Date.now()) / 1000) + if (diff > 0) { + return diff + } + } + } + + // OpenAI/Codex 限流重置头 + const xReset = headers['x-ratelimit-reset-requests'] || headers['x-codex-ratelimit-reset'] + if (xReset) { + const seconds = parseInt(xReset, 10) + if (!isNaN(seconds) && seconds > 0) { + return seconds + } + } + + return null +} + +// 记录错误历史到 Redis List +const recordErrorHistory = async ( + accountId, + accountType, + statusCode, + errorType, + context = null +) => { + try { + const redis = getRedis() + const client = redis.getClientSafe() + const redisKey = `${ERROR_HISTORY_PREFIX}:${accountType}:${accountId}` + + const entry = JSON.stringify({ + time: new Date().toISOString(), + status: statusCode, + errorType, + context: context + ? { + ...context, + errorBody: + typeof context.errorBody === 'string' + ? context.errorBody.slice(0, 2000) + : context.errorBody + ? JSON.stringify(context.errorBody).slice(0, 2000) + : undefined + } + : null + }) + + const pipeline = client.pipeline() + pipeline.lpush(redisKey, entry) + pipeline.ltrim(redisKey, 0, ERROR_HISTORY_MAX - 1) + pipeline.expire(redisKey, ERROR_HISTORY_TTL) + await pipeline.exec() + } catch (err) { + logger.warn(`⚠️ [ErrorHistory] Failed to record error history for ${accountId}: ${err.message}`) + } +} + +// 查询错误历史(分页) +const getErrorHistory = async (accountType, accountId, offset = 0, limit = 50) => { + try { + const redis = getRedis() + const client = redis.getClientSafe() + const o = Math.max(0, Math.floor(offset)) + const l = Math.min(500, Math.max(1, Math.floor(limit))) + const redisKey = `${ERROR_HISTORY_PREFIX}:${accountType}:${accountId}` + const list = await client.lrange(redisKey, o, o + l - 1) + return list + .map((item) => { + try { + return JSON.parse(item) + } catch { + return null + } + }) + .filter((item) => item?.time) + } catch (error) { + logger.error(`❌ [ErrorHistory] Failed to get error history for ${accountId}:`, error) + return [] + } +} + +// 清除错误历史 +const clearErrorHistory = async (accountType, accountId) => { + try { + const redis = getRedis() + const client = redis.getClientSafe() + const redisKey = `${ERROR_HISTORY_PREFIX}:${accountType}:${accountId}` + await client.del(redisKey) + } catch (error) { + logger.error(`❌ [ErrorHistory] Failed to clear error history for ${accountId}:`, error) + } +} + +// 标记账户为临时不可用 +const markTempUnavailable = async ( + accountId, + accountType, + statusCode, + customTtl = null, + context = null +) => { + try { + const errorType = classifyError(statusCode) + if (!errorType) { + return { success: false, reason: 'not_a_pausable_error' } + } + + const policy = await getAccountTempUnavailablePolicy(accountId, accountType) + const policyDecision = resolveAccountTtlOverride({ + policy, + statusCode, + errorType + }) + + const key = `${TEMP_UNAVAILABLE_PREFIX}:${accountType}:${accountId}` + if (policyDecision.skip) { + const redis = getRedis() + const client = redis.getClientSafe() + await client.del(key).catch(() => {}) + logger.info( + `⏭️ [UpstreamError] Skip temp-unavailable for account ${accountId} (${accountType}), reason: ${policyDecision.reason}` + ) + return { success: true, skipped: true, reason: policyDecision.reason } + } + + const ttlConfig = getTtlConfig() + const parsedCustomTtl = Number(customTtl) + let ttlSeconds + if (Number.isFinite(parsedCustomTtl) && parsedCustomTtl > 0) { + // 上游 retry-after 可能是周级限额(数天),必须钳制,否则账号会被长时间下线 + const requestedTtl = Math.ceil(parsedCustomTtl) + ttlSeconds = Math.min(requestedTtl, ttlConfig.max_custom) + if (ttlSeconds < requestedTtl) { + logger.warn( + `⚠️ [UpstreamError] Upstream retry-after ${requestedTtl}s for account ${accountId} (${accountType}) exceeds temp-unavailable cap, clamping to ${ttlSeconds}s` + ) + } + } else { + ttlSeconds = ttlConfig[errorType] + } + if ( + Number.isFinite(policyDecision.ttlOverrideSeconds) && + policyDecision.ttlOverrideSeconds > 0 + ) { + ttlSeconds = policyDecision.ttlOverrideSeconds + } + const markedAtIso = new Date().toISOString() + const expiresAtIso = new Date(Date.now() + ttlSeconds * 1000).toISOString() + + const redis = getRedis() + const client = redis.getClientSafe() + await client.setex( + key, + ttlSeconds, + JSON.stringify({ + statusCode, + errorType, + markedAt: markedAtIso, + ttlSeconds, + cooldownSeconds: ttlSeconds, + expiresAt: expiresAtIso + }) + ) + + logger.warn( + `⏱️ [UpstreamError] Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s (${statusCode} ${errorType}), recovers at ${expiresAtIso}` + ) + + // 异步记录错误历史,不阻塞主流程 + recordErrorHistory(accountId, accountType, statusCode, errorType, context).catch(() => {}) + + return { success: true, ttlSeconds, errorType, expiresAt: expiresAtIso } + } catch (error) { + logger.error( + `❌ [UpstreamError] Failed to mark account ${accountId} temporarily unavailable:`, + error + ) + return { success: false } + } +} + +// 检查账户是否临时不可用 +const isTempUnavailable = async (accountId, accountType) => { + try { + const redis = getRedis() + const client = redis.getClientSafe() + const key = `${TEMP_UNAVAILABLE_PREFIX}:${accountType}:${accountId}` + const ttl = await client.ttl(key) + + if (ttl === -2) { + return false + } + + if (ttl === -1) { + // 理论上该 key 必须带 TTL;如果无 TTL,自动清理以避免“永久不可用” + logger.warn( + `⚠️ [UpstreamError] Found temp_unavailable key without TTL for account ${accountId} (${accountType}), auto-clearing` + ) + await client.del(key) + return false + } + + return ttl > 0 + } catch (error) { + logger.error( + `❌ [UpstreamError] Failed to check temp unavailable status for ${accountId}:`, + error + ) + return false + } +} + +// 清除临时不可用状态 +const clearTempUnavailable = async (accountId, accountType) => { + try { + const redis = getRedis() + const client = redis.getClientSafe() + const key = `${TEMP_UNAVAILABLE_PREFIX}:${accountType}:${accountId}` + await client.del(key) + } catch (error) { + logger.error(`❌ [UpstreamError] Failed to clear temp unavailable for ${accountId}:`, error) + } +} + +// 批量查询所有临时不可用状态(用于前端展示) +const getAllTempUnavailable = async () => { + try { + const redis = getRedis() + const client = redis.getClientSafe() + const pattern = `${TEMP_UNAVAILABLE_PREFIX}:*` + const keys = await client.keys(pattern) + if (!keys.length) { + return {} + } + + const pipeline = client.pipeline() + for (const key of keys) { + pipeline.get(key) + pipeline.ttl(key) + } + const results = await pipeline.exec() + const cleanupPipeline = client.pipeline() + + const statuses = {} + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + // key format: temp_unavailable:{accountType}:{accountId} + const parts = key.split(':') + const accountType = parts[1] + const accountId = parts.slice(2).join(':') + const [getErr, value] = results[i * 2] + const [ttlErr, ttl] = results[i * 2 + 1] + if (getErr || ttlErr || !value) { + continue + } + + if (ttl === -1) { + // 自愈:清理无 TTL 的异常键,避免账户被永久阻塞 + cleanupPipeline.del(key) + continue + } + + try { + const data = JSON.parse(value) + const compositeKey = `${accountType}:${accountId}` + const cooldownSecondsRaw = Number(data.cooldownSeconds) + const ttlSecondsRaw = Number(data.ttlSeconds) + const configuredCooldownSeconds = Number.isFinite(cooldownSecondsRaw) + ? Math.max(0, Math.floor(cooldownSecondsRaw)) + : Number.isFinite(ttlSecondsRaw) + ? Math.max(0, Math.floor(ttlSecondsRaw)) + : null + + statuses[compositeKey] = { + accountId, + accountType, + statusCode: data.statusCode, + errorType: data.errorType, + markedAt: data.markedAt, + ttl: ttl > 0 ? ttl : 0, + remainingSeconds: ttl > 0 ? ttl : 0, + cooldownSeconds: configuredCooldownSeconds, + expiresAt: data.expiresAt || null + } + } catch { + // ignore parse errors + } + } + + await cleanupPipeline.exec().catch(() => {}) + return statuses + } catch (error) { + logger.error('❌ [UpstreamError] Failed to get all temp unavailable statuses:', error) + return {} + } +} + +// 清洗上游错误数据,去除内部路由标识(如 [codex/codex]) +const sanitizeErrorForClient = (errorData) => { + if (!errorData || typeof errorData !== 'object') { + return errorData + } + try { + const str = JSON.stringify(errorData) + const cleaned = str.replace(/ \[[^\]/]+\/[^\]]+\]/g, '') + return JSON.parse(cleaned) + } catch { + return errorData + } +} + +module.exports = { + markTempUnavailable, + isTempUnavailable, + clearTempUnavailable, + getAllTempUnavailable, + classifyError, + parseRetryAfter, + sanitizeErrorForClient, + recordErrorHistory, + getErrorHistory, + clearErrorHistory, + TEMP_UNAVAILABLE_PREFIX, + ERROR_HISTORY_PREFIX +} diff --git a/src/utils/warmupInterceptor.js b/src/utils/warmupInterceptor.js new file mode 100644 index 0000000..430d622 --- /dev/null +++ b/src/utils/warmupInterceptor.js @@ -0,0 +1,202 @@ +'use strict' + +const { v4: uuidv4 } = require('uuid') + +/** + * 预热请求拦截器 + * 检测并拦截低价值请求(标题生成、Warmup等),直接返回模拟响应 + */ + +/** + * 检测是否为预热请求 + * @param {Object} body - 请求体 + * @returns {boolean} + */ +function isWarmupRequest(body) { + if (!body) { + return false + } + + // 检查 messages + if (body.messages && Array.isArray(body.messages)) { + for (const msg of body.messages) { + // 处理 content 为数组的情况 + if (Array.isArray(msg.content)) { + for (const content of msg.content) { + if (content.type === 'text' && typeof content.text === 'string') { + if (isTitleOrWarmupText(content.text)) { + return true + } + } + } + } + // 处理 content 为字符串的情况 + if (typeof msg.content === 'string') { + if (isTitleOrWarmupText(msg.content)) { + return true + } + } + } + } + + // 检查 system prompt + if (body.system) { + const systemText = extractSystemText(body.system) + if (isTitleExtractionSystemPrompt(systemText)) { + return true + } + } + + return false +} + +/** + * 检查文本是否为标题生成或Warmup请求 + */ +function isTitleOrWarmupText(text) { + if (!text) { + return false + } + return ( + text.includes('Please write a 5-10 word title for the following conversation:') || + text === 'Warmup' + ) +} + +/** + * 检查system prompt是否为标题提取类型 + */ +function isTitleExtractionSystemPrompt(systemText) { + if (!systemText) { + return false + } + return systemText.includes( + 'nalyze if this message indicates a new conversation topic. If it does, extract a 2-3 word title' + ) +} + +/** + * 从system字段提取文本 + */ +function extractSystemText(system) { + if (typeof system === 'string') { + return system + } + if (Array.isArray(system)) { + return system.map((s) => (typeof s === 'object' ? s.text || '' : String(s))).join('') + } + return '' +} + +/** + * 生成模拟的非流式响应 + * @param {string} model - 模型名称 + * @returns {Object} + */ +function buildMockWarmupResponse(model) { + return { + id: `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}`, + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'New Conversation' }], + model: model || 'claude-3-5-sonnet-20241022', + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 2 + } + } +} + +/** + * 发送模拟的流式响应 + * @param {Object} res - Express response对象 + * @param {string} model - 模型名称 + */ +function sendMockWarmupStream(res, model) { + const effectiveModel = model || 'claude-3-5-sonnet-20241022' + const messageId = `msg_warmup_${uuidv4().replace(/-/g, '').slice(0, 20)}` + + const events = [ + { + event: 'message_start', + data: { + message: { + content: [], + id: messageId, + model: effectiveModel, + role: 'assistant', + stop_reason: null, + stop_sequence: null, + type: 'message', + usage: { input_tokens: 10, output_tokens: 0 } + }, + type: 'message_start' + } + }, + { + event: 'content_block_start', + data: { + content_block: { text: '', type: 'text' }, + index: 0, + type: 'content_block_start' + } + }, + { + event: 'content_block_delta', + data: { + delta: { text: 'New', type: 'text_delta' }, + index: 0, + type: 'content_block_delta' + } + }, + { + event: 'content_block_delta', + data: { + delta: { text: ' Conversation', type: 'text_delta' }, + index: 0, + type: 'content_block_delta' + } + }, + { + event: 'content_block_stop', + data: { index: 0, type: 'content_block_stop' } + }, + { + event: 'message_delta', + data: { + delta: { stop_reason: 'end_turn', stop_sequence: null }, + type: 'message_delta', + usage: { input_tokens: 10, output_tokens: 2 } + } + }, + { + event: 'message_stop', + data: { type: 'message_stop' } + } + ] + + let index = 0 + const sendNext = () => { + if (index >= events.length) { + res.end() + return + } + + const { event, data } = events[index] + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`) + index++ + + // 模拟网络延迟 + setTimeout(sendNext, 20) + } + + sendNext() +} + +module.exports = { + isWarmupRequest, + buildMockWarmupResponse, + sendMockWarmupStream +} diff --git a/src/utils/webhookNotifier.js b/src/utils/webhookNotifier.js new file mode 100644 index 0000000..0a2704b --- /dev/null +++ b/src/utils/webhookNotifier.js @@ -0,0 +1,115 @@ +const logger = require('./logger') +const webhookService = require('../services/webhookService') +const { getISOStringWithTimezone } = require('./dateHelper') + +class WebhookNotifier { + constructor() { + // 保留此类用于兼容性,实际功能委托给webhookService + } + + /** + * 发送账号异常通知 + * @param {Object} notification - 通知内容 + * @param {string} notification.accountId - 账号ID + * @param {string} notification.accountName - 账号名称 + * @param {string} notification.platform - 平台类型 (claude-oauth, claude-console, gemini) + * @param {string} notification.status - 异常状态 (unauthorized, blocked, error) + * @param {string} notification.errorCode - 异常代码 + * @param {string} notification.reason - 异常原因 + * @param {string} notification.timestamp - 时间戳 + */ + async sendAccountAnomalyNotification(notification) { + try { + // 使用新的webhookService发送通知 + await webhookService.sendNotification('accountAnomaly', { + accountId: notification.accountId, + accountName: notification.accountName, + platform: notification.platform, + status: notification.status, + errorCode: + notification.errorCode || this._getErrorCode(notification.platform, notification.status), + reason: notification.reason, + timestamp: notification.timestamp || getISOStringWithTimezone(new Date()) + }) + } catch (error) { + logger.error('Failed to send account anomaly notification:', error) + } + } + + /** + * 测试Webhook连通性(兼容旧接口) + * @param {string} url - Webhook URL + * @param {string} type - 平台类型(可选) + */ + async testWebhook(url, type = 'custom') { + try { + // 创建临时平台配置 + const platform = { + type, + url, + enabled: true, + timeout: 10000 + } + + const result = await webhookService.testWebhook(platform) + return result + } catch (error) { + return { success: false, error: error.message } + } + } + + /** + * 发送账号事件通知 + * @param {string} eventType - 事件类型 (account.created, account.updated, account.deleted, account.status_changed) + * @param {Object} data - 事件数据 + */ + async sendAccountEvent(eventType, data) { + try { + // 使用webhookService发送通知 + await webhookService.sendNotification('accountEvent', { + eventType, + ...data, + timestamp: data.timestamp || getISOStringWithTimezone(new Date()) + }) + } catch (error) { + logger.error(`Failed to send account event (${eventType}):`, error) + } + } + + /** + * 获取错误代码映射 + * @param {string} platform - 平台类型 + * @param {string} status - 状态 + * @param {string} _reason - 原因 (未使用) + */ + _getErrorCode(platform, status, _reason) { + const errorCodes = { + 'claude-oauth': { + unauthorized: 'CLAUDE_OAUTH_UNAUTHORIZED', + blocked: 'CLAUDE_OAUTH_BLOCKED', + error: 'CLAUDE_OAUTH_ERROR', + disabled: 'CLAUDE_OAUTH_MANUALLY_DISABLED' + }, + 'claude-console': { + blocked: 'CLAUDE_CONSOLE_BLOCKED', + error: 'CLAUDE_CONSOLE_ERROR', + disabled: 'CLAUDE_CONSOLE_MANUALLY_DISABLED' + }, + gemini: { + error: 'GEMINI_ERROR', + unauthorized: 'GEMINI_UNAUTHORIZED', + disabled: 'GEMINI_MANUALLY_DISABLED' + }, + openai: { + error: 'OPENAI_ERROR', + unauthorized: 'OPENAI_UNAUTHORIZED', + blocked: 'OPENAI_RATE_LIMITED', + disabled: 'OPENAI_MANUALLY_DISABLED' + } + } + + return errorCodes[platform]?.[status] || 'UNKNOWN_ERROR' + } +} + +module.exports = new WebhookNotifier() diff --git a/src/utils/workosOAuthHelper.js b/src/utils/workosOAuthHelper.js new file mode 100644 index 0000000..47f603f --- /dev/null +++ b/src/utils/workosOAuthHelper.js @@ -0,0 +1,184 @@ +const axios = require('axios') +const config = require('../../config/config') +const logger = require('./logger') +const ProxyHelper = require('./proxyHelper') + +const WORKOS_CONFIG = config.droid || {} + +const WORKOS_DEVICE_AUTHORIZE_URL = + WORKOS_CONFIG.deviceAuthorizeUrl || 'https://api.workos.com/user_management/authorize/device' +const WORKOS_TOKEN_URL = + WORKOS_CONFIG.tokenUrl || 'https://api.workos.com/user_management/authenticate' +const WORKOS_CLIENT_ID = WORKOS_CONFIG.clientId || 'client_01HNM792M5G5G1A2THWPXKFMXB' + +const DEFAULT_POLL_INTERVAL = 5 + +class WorkOSDeviceAuthError extends Error { + constructor(message, code, options = {}) { + super(message) + this.name = 'WorkOSDeviceAuthError' + this.code = code || 'unknown_error' + this.retryAfter = options.retryAfter || null + } +} + +/** + * 启动设备码授权流程 + * @param {object|null} proxyConfig - 代理配置 + * @returns {Promise} WorkOS 返回的数据 + */ +async function startDeviceAuthorization(proxyConfig = null) { + const form = new URLSearchParams({ + client_id: WORKOS_CLIENT_ID + }) + + const agent = ProxyHelper.createProxyAgent(proxyConfig) + + try { + logger.info('🔐 请求 WorkOS 设备码授权', { + url: WORKOS_DEVICE_AUTHORIZE_URL, + hasProxy: !!agent + }) + + const axiosConfig = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + timeout: 15000 + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.post(WORKOS_DEVICE_AUTHORIZE_URL, form.toString(), axiosConfig) + + const data = response.data || {} + + if (!data.device_code || !data.verification_uri) { + throw new Error('WorkOS 返回数据缺少必要字段 (device_code / verification_uri)') + } + + logger.success('成功获取 WorkOS 设备码授权信息', { + verificationUri: data.verification_uri, + userCode: data.user_code + }) + + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri, + verificationUriComplete: data.verification_uri_complete || data.verification_uri, + expiresIn: data.expires_in || 300, + interval: data.interval || DEFAULT_POLL_INTERVAL + } + } catch (error) { + if (error.response) { + logger.error('❌ WorkOS 设备码授权失败', { + status: error.response.status, + data: error.response.data + }) + throw new WorkOSDeviceAuthError( + error.response.data?.error_description || + error.response.data?.error || + 'WorkOS 设备码授权失败', + error.response.data?.error + ) + } + + logger.error('❌ 请求 WorkOS 设备码授权异常', { + message: error.message + }) + throw new WorkOSDeviceAuthError(error.message) + } +} + +/** + * 轮询授权结果 + * @param {string} deviceCode - 设备码 + * @param {object|null} proxyConfig - 代理配置 + * @returns {Promise} WorkOS 返回的 token 数据 + */ +async function pollDeviceAuthorization(deviceCode, proxyConfig = null) { + if (!deviceCode) { + throw new WorkOSDeviceAuthError('缺少设备码,无法查询授权结果', 'missing_device_code') + } + + const form = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: deviceCode, + client_id: WORKOS_CLIENT_ID + }) + + const agent = ProxyHelper.createProxyAgent(proxyConfig) + + try { + const axiosConfig = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + timeout: 15000 + } + + if (agent) { + axiosConfig.httpAgent = agent + axiosConfig.httpsAgent = agent + axiosConfig.proxy = false + } + + const response = await axios.post(WORKOS_TOKEN_URL, form.toString(), axiosConfig) + + const data = response.data || {} + + if (!data.access_token) { + throw new WorkOSDeviceAuthError('WorkOS 返回结果缺少 access_token', 'missing_access_token') + } + + logger.success('🤖 Droid 授权完成,获取到访问令牌', { + hasRefreshToken: !!data.refresh_token + }) + + return data + } catch (error) { + if (error.response) { + const responseData = error.response.data || {} + const errorCode = responseData.error || `http_${error.response.status}` + const errorDescription = + responseData.error_description || responseData.error || 'WorkOS 授权失败' + + if (errorCode === 'authorization_pending' || errorCode === 'slow_down') { + const retryAfter = + Number(responseData.interval) || + Number(error.response.headers?.['retry-after']) || + DEFAULT_POLL_INTERVAL + + throw new WorkOSDeviceAuthError(errorDescription, errorCode, { + retryAfter + }) + } + + if (errorCode === 'expired_token') { + throw new WorkOSDeviceAuthError(errorDescription, errorCode) + } + + logger.error('❌ WorkOS 设备授权轮询失败', { + status: error.response.status, + data: responseData + }) + throw new WorkOSDeviceAuthError(errorDescription, errorCode) + } + + logger.error('❌ WorkOS 设备授权轮询异常', { + message: error.message + }) + throw new WorkOSDeviceAuthError(error.message) + } +} + +module.exports = { + startDeviceAuthorization, + pollDeviceAuthorization, + WorkOSDeviceAuthError +} diff --git a/src/validators/clientDefinitions.js b/src/validators/clientDefinitions.js new file mode 100644 index 0000000..fe2be5f --- /dev/null +++ b/src/validators/clientDefinitions.js @@ -0,0 +1,123 @@ +/** + * 客户端定义配置 + * 定义所有支持的客户端类型和它们的属性 + * + * allowedPathPrefixes: 允许访问的路径前缀白名单 + * - 当启用客户端限制时,只有匹配白名单的路径才允许访问 + * - 防止通过其他兼容端点(如 /v1/chat/completions)绕过客户端限制 + */ + +const CLIENT_DEFINITIONS = { + CLAUDE_CODE: { + id: 'claude_code', + name: 'Claude Code', + displayName: 'Claude Code CLI', + description: 'Claude Code command-line interface', + icon: '🤖', + // Claude Code 仅允许访问 Claude 原生端点,禁止访问 OpenAI 兼容端点 + allowedPathPrefixes: [ + '/api/v1/messages', + '/api/v1/models', + '/api/v1/me', + '/api/v1/usage', + '/api/v1/key-info', + '/api/v1/organizations', + '/claude/v1/messages', + '/claude/v1/models', + '/antigravity/api/', + '/gemini-cli/api/', + '/api/event_logging', + '/v1/messages', + '/v1/models', + '/v1/me', + '/v1/usage', + '/v1/key-info', + '/v1/organizations' + ] + }, + + GEMINI_CLI: { + id: 'gemini_cli', + name: 'Gemini CLI', + displayName: 'Gemini Command Line Tool', + description: 'Google Gemini API command-line interface', + icon: '💎', + // Gemini CLI 仅允许访问 Gemini 端点 + allowedPathPrefixes: ['/gemini/'] + }, + + CODEX_CLI: { + id: 'codex_cli', + name: 'Codex CLI', + displayName: 'Codex Command Line Tool', + description: 'Cursor/Codex command-line interface', + icon: '🔷', + // Codex CLI 仅允许访问 OpenAI Responses 和 Azure 端点 + allowedPathPrefixes: ['/openai/responses', '/openai/v1/responses', '/azure/'] + }, + + DROID_CLI: { + id: 'droid_cli', + name: 'Droid CLI', + displayName: 'Factory Droid CLI', + description: 'Factory Droid platform command-line interface', + icon: '🤖', + // Droid CLI 仅允许访问 Droid 端点 + allowedPathPrefixes: ['/droid/'] + } +} + +// 导出客户端ID枚举 +const CLIENT_IDS = { + CLAUDE_CODE: 'claude_code', + GEMINI_CLI: 'gemini_cli', + CODEX_CLI: 'codex_cli', + DROID_CLI: 'droid_cli' +} + +// 获取所有客户端定义 +function getAllClientDefinitions() { + return Object.values(CLIENT_DEFINITIONS) +} + +// 根据ID获取客户端定义 +function getClientDefinitionById(clientId) { + return Object.values(CLIENT_DEFINITIONS).find((client) => client.id === clientId) +} + +// 检查客户端ID是否有效 +function isValidClientId(clientId) { + return Object.values(CLIENT_IDS).includes(clientId) +} + +/** + * 检查路径是否允许指定客户端访问 + * @param {string} clientId - 客户端ID + * @param {string} path - 请求路径 (originalUrl 或 path) + * @returns {boolean} 是否允许 + */ +function isPathAllowedForClient(clientId, path) { + const definition = getClientDefinitionById(clientId) + if (!definition) { + return false + } + + // 如果没有定义 allowedPathPrefixes,则不限制路径(向后兼容) + if (!definition.allowedPathPrefixes || definition.allowedPathPrefixes.length === 0) { + return true + } + + const normalizedPath = (path || '').toLowerCase() + return definition.allowedPathPrefixes.some((prefix) => + normalizedPath.startsWith(prefix.toLowerCase()) + ) +} + +module.exports = { + CLIENT_DEFINITIONS, + CLIENT_IDS, + getAllClientDefinitions, + getClientDefinitionById, + isValidClientId, + isPathAllowedForClient +} diff --git a/src/validators/clientValidator.js b/src/validators/clientValidator.js new file mode 100644 index 0000000..29a5e8f --- /dev/null +++ b/src/validators/clientValidator.js @@ -0,0 +1,166 @@ +/** + * 客户端验证器 + * 用于验证请求是否来自特定的客户端 + */ + +const logger = require('../utils/logger') +const { + CLIENT_IDS, + getAllClientDefinitions, + getClientDefinitionById, + isPathAllowedForClient +} = require('./clientDefinitions') +const ClaudeCodeValidator = require('./clients/claudeCodeValidator') +const GeminiCliValidator = require('./clients/geminiCliValidator') +const CodexCliValidator = require('./clients/codexCliValidator') +const DroidCliValidator = require('./clients/droidCliValidator') + +// 客户端ID到验证器的映射表 +const VALIDATOR_MAP = { + [CLIENT_IDS.CLAUDE_CODE]: ClaudeCodeValidator, + [CLIENT_IDS.GEMINI_CLI]: GeminiCliValidator, + [CLIENT_IDS.CODEX_CLI]: CodexCliValidator, + [CLIENT_IDS.DROID_CLI]: DroidCliValidator +} + +/** + * 客户端验证器类 + */ +class ClientValidator { + /** + * 获取客户端验证器 + * @param {string} clientId - 客户端ID + * @returns {Object|null} 验证器实例 + */ + static getValidator(clientId) { + const validator = VALIDATOR_MAP[clientId] + if (!validator) { + logger.warn(`Unknown client ID: ${clientId}`) + return null + } + return validator + } + + /** + * 获取所有支持的客户端ID列表 + * @returns {Array} 客户端ID列表 + */ + static getSupportedClients() { + return Object.keys(VALIDATOR_MAP) + } + + /** + * 验证单个客户端 + * @param {string} clientId - 客户端ID + * @param {Object} req - Express请求对象 + * @returns {boolean} 验证结果 + */ + static validateClient(clientId, req) { + const validator = this.getValidator(clientId) + + if (!validator) { + logger.warn(`No validator found for client: ${clientId}`) + return false + } + + try { + return validator.validate(req) + } catch (error) { + logger.error(`Error validating client ${clientId}:`, error) + return false + } + } + + /** + * 验证请求是否来自允许的客户端列表中的任一客户端 + * 包含路径白名单检查,防止通过其他兼容端点绕过客户端限制 + * @param {Array} allowedClients - 允许的客户端ID列表 + * @param {Object} req - Express请求对象 + * @returns {Object} 验证结果对象 + */ + static validateRequest(allowedClients, req) { + const userAgent = req.headers['user-agent'] || '' + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + const requestPath = req.originalUrl || req.path || '' + + // 记录验证开始 + logger.api(`🔍 Starting client validation for User-Agent: "${userAgent}"`) + logger.api(` Allowed clients: ${allowedClients.join(', ')}`) + logger.api(` Request path: ${requestPath}`) + logger.api(` Request from IP: ${clientIP}`) + + // 遍历所有允许的客户端进行验证 + for (const clientId of allowedClients) { + const validator = this.getValidator(clientId) + + if (!validator) { + logger.warn(`Skipping unknown client ID: ${clientId}`) + continue + } + + // 路径白名单检查:先检查路径是否允许该客户端访问 + if (!isPathAllowedForClient(clientId, requestPath)) { + logger.debug(`Path "${requestPath}" not allowed for ${validator.getName()}, skipping`) + continue + } + + logger.debug(`Checking against ${validator.getName()}...`) + + try { + if (validator.validate(req)) { + // 验证成功 + logger.api(`✅ Client validated: ${validator.getName()} (${clientId})`) + logger.api(` Matched User-Agent: "${userAgent}"`) + logger.api(` Allowed path: "${requestPath}"`) + + return { + allowed: true, + matchedClient: clientId, + clientName: validator.getName(), + clientInfo: getClientDefinitionById(clientId) + } + } + } catch (error) { + logger.error(`Error during validation for ${clientId}:`, error) + continue + } + } + + // 没有匹配的客户端 + logger.api( + `❌ No matching client found for User-Agent: "${userAgent}" and path: "${requestPath}"` + ) + return { + allowed: false, + matchedClient: null, + reason: 'No matching client found or path not allowed', + userAgent, + requestPath + } + } + + /** + * 获取客户端信息 + * @param {string} clientId - 客户端ID + * @returns {Object} 客户端信息 + */ + static getClientInfo(clientId) { + const validator = this.getValidator(clientId) + if (!validator) { + return null + } + + return validator.getInfo() + } + + /** + * 获取所有可用的客户端信息 + * @returns {Array} 客户端信息数组 + */ + static getAvailableClients() { + // 直接从 CLIENT_DEFINITIONS 返回所有客户端信息 + return getAllClientDefinitions() + } +} + +module.exports = ClientValidator diff --git a/src/validators/clients/claudeCodeValidator.js b/src/validators/clients/claudeCodeValidator.js new file mode 100644 index 0000000..4987258 --- /dev/null +++ b/src/validators/clients/claudeCodeValidator.js @@ -0,0 +1,216 @@ +const logger = require('../../utils/logger') +const { CLIENT_DEFINITIONS } = require('../clientDefinitions') +const { bestSimilarityByTemplates, SYSTEM_PROMPT_THRESHOLD } = require('../../utils/contents') +const metadataUserIdHelper = require('../../utils/metadataUserIdHelper') + +// 0 = 所有条目都必须匹配(默认严格模式),N > 0 = 至少 N 条匹配即可通过 +const parsedMinMatches = parseInt(process.env.CLAUDE_CODE_MIN_SYSTEM_PROMPT_MATCHES) +const MIN_SYSTEM_PROMPT_MATCHES = + Number.isInteger(parsedMinMatches) && parsedMinMatches >= 0 ? parsedMinMatches : 0 + +/** + * Claude Code CLI 验证器 + * 验证请求是否来自 Claude Code CLI + */ +class ClaudeCodeValidator { + /** + * 获取客户端ID + */ + static getId() { + return CLIENT_DEFINITIONS.CLAUDE_CODE.id + } + + /** + * 获取客户端名称 + */ + static getName() { + return CLIENT_DEFINITIONS.CLAUDE_CODE.name + } + + /** + * 获取客户端描述 + */ + static getDescription() { + return CLIENT_DEFINITIONS.CLAUDE_CODE.description + } + + /** + * 获取客户端图标 + */ + static getIcon() { + return CLIENT_DEFINITIONS.CLAUDE_CODE.icon || '🤖' + } + + /** + * 检查请求中 system 条目的系统提示词匹配情况 + * @param {Object} body - 请求体 + * @param {number} [customThreshold] - 自定义相似度阈值 + * @param {number} [minMatchCount] - 最少需要匹配的条目数;0 表示全部都必须匹配(严格模式) + * @returns {boolean} + */ + static hasClaudeCodeSystemPrompt(body, customThreshold, minMatchCount) { + if (!body || typeof body !== 'object') { + return false + } + + const model = typeof body.model === 'string' ? body.model : null + if (!model) { + return false + } + + const systemEntries = Array.isArray(body.system) ? body.system : null + if (!systemEntries) { + return false + } + + const threshold = + typeof customThreshold === 'number' && Number.isFinite(customThreshold) + ? customThreshold + : SYSTEM_PROMPT_THRESHOLD + + // minRequired: 0 = 所有条目都必须匹配;N > 0 = 至少 N 条匹配即可 + const minRequired = + typeof minMatchCount === 'number' && Number.isInteger(minMatchCount) && minMatchCount > 0 + ? minMatchCount + : 0 + + let matchCount = 0 + + for (const entry of systemEntries) { + const rawText = typeof entry?.text === 'string' ? entry.text : '' + const { bestScore, templateId, maskedRaw } = bestSimilarityByTemplates(rawText) + + if (bestScore >= threshold) { + matchCount++ + if (minRequired > 0 && matchCount >= minRequired) { + return true + } + } else if (minRequired === 0) { + // 严格模式:任意一条不匹配就失败 + logger.error( + `Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}` + ) + const preview = typeof maskedRaw === 'string' ? maskedRaw.slice(0, 200) : '' + logger.warn( + `Claude system prompt detail: templateId=${templateId || 'unknown'}, preview=${preview}${ + maskedRaw && maskedRaw.length > 200 ? '…' : '' + }` + ) + return false + } + } + + if (minRequired === 0) { + return true + } + + logger.debug( + `Claude system prompt not detected: matchCount=${matchCount}, minRequired=${minRequired}` + ) + return matchCount >= minRequired + } + + /** + * 判断是否存在 Claude Code 系统提示词(至少一条匹配即返回 true) + * @param {Object} body - 请求体 + * @param {number} [customThreshold] - 自定义阈值 + * @returns {boolean} + */ + static includesClaudeCodeSystemPrompt(body, customThreshold) { + return this.hasClaudeCodeSystemPrompt(body, customThreshold, 1) + } + + /** + * 验证请求是否来自 Claude Code CLI + * @param {Object} req - Express 请求对象 + * @returns {boolean} 验证结果 + */ + static validate(req) { + try { + const userAgent = req.headers['user-agent'] || '' + const path = req.path || '' + + const claudeCodePattern = /^claude-cli\/\d+\.\d+\.\d+/i + + if (!claudeCodePattern.test(userAgent)) { + // 不是 Claude Code 的请求,此验证器不处理 + return false + } + + // 2. Claude Code 检测到,对于特定路径进行额外的严格验证 + if (!path.includes('messages')) { + // 其他路径,只要 User-Agent 匹配就认为是 Claude Code + logger.debug(`Claude Code detected for path: ${path}, allowing access`) + return true + } + + // 3. 检查系统提示词匹配(由 CLAUDE_CODE_MIN_SYSTEM_PROMPT_MATCHES 控制匹配策略) + if (!this.hasClaudeCodeSystemPrompt(req.body, undefined, MIN_SYSTEM_PROMPT_MATCHES)) { + logger.debug('Claude Code validation failed - missing or invalid Claude Code system prompt') + return false + } + + // 4. 检查必需的头部(值不为空即可) + const xApp = req.headers['x-app'] + const anthropicBeta = req.headers['anthropic-beta'] + const anthropicVersion = req.headers['anthropic-version'] + + if (!xApp || xApp.trim() === '') { + logger.debug('Claude Code validation failed - missing or empty x-app header') + return false + } + + if (!anthropicBeta || anthropicBeta.trim() === '') { + logger.debug('Claude Code validation failed - missing or empty anthropic-beta header') + return false + } + + if (!anthropicVersion || anthropicVersion.trim() === '') { + logger.debug('Claude Code validation failed - missing or empty anthropic-version header') + return false + } + + logger.debug( + `Claude Code headers - x-app: ${xApp}, anthropic-beta: ${anthropicBeta}, anthropic-version: ${anthropicVersion}` + ) + + // 5. 验证 body 中的 metadata.user_id + if (!req.body || !req.body.metadata || !req.body.metadata.user_id) { + logger.debug('Claude Code validation failed - missing metadata.user_id in body') + return false + } + + const userId = req.body.metadata.user_id + if (!metadataUserIdHelper.isValid(userId)) { + logger.debug( + `Claude Code validation failed - invalid user_id format: ${typeof userId === 'string' ? userId.slice(0, 80) : typeof userId}` + ) + return false + } + + // 6. 额外日志记录(用于调试) + logger.debug(`Claude Code validation passed - UA: ${userAgent}, userId: ${userId}`) + + // 所有必要检查通过 + return true + } catch (error) { + logger.error('Error in ClaudeCodeValidator:', error) + // 验证出错时默认拒绝 + return false + } + } + + /** + * 获取验证器信息 + */ + static getInfo() { + return { + id: this.getId(), + name: this.getName(), + description: this.getDescription(), + icon: CLIENT_DEFINITIONS.CLAUDE_CODE.icon + } + } +} + +module.exports = ClaudeCodeValidator diff --git a/src/validators/clients/codexCliValidator.js b/src/validators/clients/codexCliValidator.js new file mode 100644 index 0000000..aed6732 --- /dev/null +++ b/src/validators/clients/codexCliValidator.js @@ -0,0 +1,153 @@ +const logger = require('../../utils/logger') +const { CLIENT_DEFINITIONS } = require('../clientDefinitions') + +/** + * Codex CLI 验证器 + * 验证请求是否来自 Codex CLI + */ +class CodexCliValidator { + /** + * 获取客户端ID + */ + static getId() { + return CLIENT_DEFINITIONS.CODEX_CLI.id + } + + /** + * 获取客户端名称 + */ + static getName() { + return CLIENT_DEFINITIONS.CODEX_CLI.name + } + + /** + * 获取客户端描述 + */ + static getDescription() { + return CLIENT_DEFINITIONS.CODEX_CLI.description + } + + /** + * 验证请求是否来自 Codex CLI + * @param {Object} req - Express 请求对象 + * @returns {boolean} 验证结果 + */ + static validate(req) { + try { + const userAgent = req.headers['user-agent'] || '' + const originator = req.headers['originator'] || '' + const sessionId = req.headers['session_id'] + + // 1. 基础 User-Agent 检查 + // Codex CLI 的 UA 格式: + // - codex_vscode/0.35.0 (Windows 10.0.26100; x86_64) unknown (Cursor; 0.4.10) + // - codex_cli_rs/0.38.0 (Ubuntu 22.4.0; x86_64) WindowsTerminal + // - codex_exec/0.89.0 (Mac OS 26.2.0; arm64) xterm-256color (非交互式/脚本模式) + const codexCliPattern = /^(codex_vscode|codex_cli_rs|codex_exec)\/[\d.]+/i + const uaMatch = userAgent.match(codexCliPattern) + + if (!uaMatch) { + logger.debug(`Codex CLI validation failed - UA mismatch: ${userAgent}`) + return false + } + + // 2. 对于特定路径,进行额外的严格验证 + // 对于 /openai 和 /azure 路径需要完整验证 + const strictValidationPaths = ['/openai', '/azure'] + const needsStrictValidation = + req.path && strictValidationPaths.some((path) => req.path.startsWith(path)) + + if (!needsStrictValidation) { + // 其他路径,只要 User-Agent 匹配就认为是 Codex CLI + logger.debug(`Codex CLI detected for path: ${req.path}, allowing access`) + return true + } + + // 3. 验证 originator 头必须与 UA 中的客户端类型匹配 + const clientType = uaMatch[1].toLowerCase() + if (originator.toLowerCase() !== clientType) { + logger.debug( + `Codex CLI validation failed - originator mismatch. UA: ${clientType}, originator: ${originator}` + ) + return false + } + + // 4. 检查 session_id - 必须存在且长度大于20 + if (!sessionId || sessionId.length <= 20) { + logger.debug(`Codex CLI validation failed - session_id missing or too short: ${sessionId}`) + return false + } + + // 5. 对于 /openai/responses 和 /azure/response 路径,额外检查 body 中的 instructions 字段 + if ( + req.path && + (req.path.includes('/openai/responses') || req.path.includes('/azure/response')) + ) { + if (!req.body || !req.body.instructions) { + logger.debug(`Codex CLI validation failed - missing instructions in body for ${req.path}`) + return false + } + + const expectedPrefix = + 'You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI' + if (!req.body.instructions.startsWith(expectedPrefix)) { + logger.debug(`Codex CLI validation failed - invalid instructions prefix for ${req.path}`) + logger.debug(`Expected: "${expectedPrefix}..."`) + logger.debug(`Received: "${req.body.instructions.substring(0, 100)}..."`) + return false + } + + // 额外检查 model 字段应该是 gpt-5.5 + if (req.body.model && req.body.model !== 'gpt-5.5') { + logger.debug(`Codex CLI validation warning - unexpected model: ${req.body.model}`) + // 只记录警告,不拒绝请求 + } + } + + // 所有必要检查通过 + logger.debug(`Codex CLI validation passed for UA: ${userAgent}`) + return true + } catch (error) { + logger.error('Error in CodexCliValidator:', error) + // 验证出错时默认拒绝 + return false + } + } + + /** + * 比较版本号 + * @returns {number} -1: v1 < v2, 0: v1 = v2, 1: v1 > v2 + */ + static compareVersions(v1, v2) { + const parts1 = v1.split('.').map(Number) + const parts2 = v2.split('.').map(Number) + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const part1 = parts1[i] || 0 + const part2 = parts2[i] || 0 + + if (part1 < part2) { + return -1 + } + if (part1 > part2) { + return 1 + } + } + + return 0 + } + + /** + * 获取验证器信息 + */ + static getInfo() { + return { + id: this.getId(), + name: this.getName(), + description: this.getDescription(), + icon: CLIENT_DEFINITIONS.CODEX_CLI.icon + } + } +} + +module.exports = CodexCliValidator diff --git a/src/validators/clients/droidCliValidator.js b/src/validators/clients/droidCliValidator.js new file mode 100644 index 0000000..7fde7aa --- /dev/null +++ b/src/validators/clients/droidCliValidator.js @@ -0,0 +1,57 @@ +const logger = require('../../utils/logger') +const { CLIENT_DEFINITIONS } = require('../clientDefinitions') + +/** + * Droid CLI 验证器 + * 检查请求是否来自 Factory Droid CLI + */ +class DroidCliValidator { + static getId() { + return CLIENT_DEFINITIONS.DROID_CLI.id + } + + static getName() { + return CLIENT_DEFINITIONS.DROID_CLI.name + } + + static getDescription() { + return CLIENT_DEFINITIONS.DROID_CLI.description + } + + static validate(req) { + try { + const userAgent = req.headers['user-agent'] || '' + const factoryClientHeader = (req.headers['x-factory-client'] || '').toString().toLowerCase() + + const uaMatch = /factory-cli\/(\d+\.\d+\.\d+)/i.exec(userAgent) + const hasFactoryClientHeader = + typeof factoryClientHeader === 'string' && + (factoryClientHeader.includes('droid') || factoryClientHeader.includes('factory-cli')) + + if (!uaMatch && !hasFactoryClientHeader) { + logger.debug(`Droid CLI validation failed - UA mismatch: ${userAgent}`) + return false + } + + // 允许,通过基础验证 + logger.debug( + `Droid CLI validation passed (UA: ${userAgent || 'N/A'}, header: ${factoryClientHeader || 'N/A'})` + ) + return true + } catch (error) { + logger.error('Error in DroidCliValidator:', error) + return false + } + } + + static getInfo() { + return { + id: this.getId(), + name: this.getName(), + description: this.getDescription(), + icon: CLIENT_DEFINITIONS.DROID_CLI.icon + } + } +} + +module.exports = DroidCliValidator diff --git a/src/validators/clients/geminiCliValidator.js b/src/validators/clients/geminiCliValidator.js new file mode 100644 index 0000000..0d43838 --- /dev/null +++ b/src/validators/clients/geminiCliValidator.js @@ -0,0 +1,111 @@ +const logger = require('../../utils/logger') +const { CLIENT_DEFINITIONS } = require('../clientDefinitions') + +/** + * Gemini CLI 验证器 + * 验证请求是否来自 Gemini CLI + */ +class GeminiCliValidator { + /** + * 获取客户端ID + */ + static getId() { + return CLIENT_DEFINITIONS.GEMINI_CLI.id + } + + /** + * 获取客户端名称 + */ + static getName() { + return CLIENT_DEFINITIONS.GEMINI_CLI.name + } + + /** + * 获取客户端描述 + */ + static getDescription() { + return CLIENT_DEFINITIONS.GEMINI_CLI.description + } + + /** + * 获取客户端图标 + */ + static getIcon() { + return CLIENT_DEFINITIONS.GEMINI_CLI.icon || '💎' + } + + /** + * 验证请求是否来自 Gemini CLI + * @param {Object} req - Express 请求对象 + * @returns {boolean} 验证结果 + */ + static validate(req) { + try { + const userAgent = req.headers['user-agent'] || '' + const path = req.originalUrl || '' + + // 1. 必须是 /gemini 开头的路径 + if (!path.startsWith('/gemini')) { + // 非 /gemini 路径不属于 Gemini + return false + } + + // 2. 对于 /gemini 路径,检查是否包含 generateContent + if (path.includes('generateContent')) { + // 包含 generateContent 的路径需要验证 User-Agent + const geminiCliPattern = /^GeminiCLI\/v?[\d.]+/i + if (!geminiCliPattern.test(userAgent)) { + logger.debug( + `Gemini CLI validation failed - UA mismatch for generateContent: ${userAgent}` + ) + return false + } + } + + // 所有必要检查通过 + logger.debug(`Gemini CLI validation passed for path: ${path}`) + return true + } catch (error) { + logger.error('Error in GeminiCliValidator:', error) + // 验证出错时默认拒绝 + return false + } + } + + /** + * 比较版本号 + * @returns {number} -1: v1 < v2, 0: v1 = v2, 1: v1 > v2 + */ + static compareVersions(v1, v2) { + const parts1 = v1.split('.').map(Number) + const parts2 = v2.split('.').map(Number) + + for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) { + const part1 = parts1[i] || 0 + const part2 = parts2[i] || 0 + + if (part1 < part2) { + return -1 + } + if (part1 > part2) { + return 1 + } + } + + return 0 + } + + /** + * 获取验证器信息 + */ + static getInfo() { + return { + id: this.getId(), + name: this.getName(), + description: this.getDescription(), + icon: CLIENT_DEFINITIONS.GEMINI_CLI.icon + } + } +} + +module.exports = GeminiCliValidator diff --git a/tests/accountBalanceService.test.js b/tests/accountBalanceService.test.js new file mode 100644 index 0000000..479424a --- /dev/null +++ b/tests/accountBalanceService.test.js @@ -0,0 +1,218 @@ +// Mock logger,避免测试输出污染控制台 +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +})) + +const accountBalanceServiceModule = require('../src/services/account/accountBalanceService') + +const { AccountBalanceService } = accountBalanceServiceModule + +describe('AccountBalanceService', () => { + const originalBalanceScriptEnabled = process.env.BALANCE_SCRIPT_ENABLED + + afterEach(() => { + if (originalBalanceScriptEnabled === undefined) { + delete process.env.BALANCE_SCRIPT_ENABLED + } else { + process.env.BALANCE_SCRIPT_ENABLED = originalBalanceScriptEnabled + } + }) + + const mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() + } + + const buildMockRedis = () => ({ + getLocalBalance: jest.fn().mockResolvedValue(null), + setLocalBalance: jest.fn().mockResolvedValue(undefined), + getAccountBalance: jest.fn().mockResolvedValue(null), + setAccountBalance: jest.fn().mockResolvedValue(undefined), + deleteAccountBalance: jest.fn().mockResolvedValue(undefined), + getBalanceScriptConfig: jest.fn().mockResolvedValue(null), + getAccountUsageStats: jest.fn().mockResolvedValue({ + total: { requests: 10 }, + daily: { requests: 2, cost: 20 }, + monthly: { requests: 5 } + }), + getDateInTimezone: (date) => new Date(date.getTime() + 8 * 3600 * 1000) + }) + + it('should normalize platform aliases', () => { + const service = new AccountBalanceService({ redis: buildMockRedis(), logger: mockLogger }) + expect(service.normalizePlatform('claude-official')).toBe('claude') + expect(service.normalizePlatform('azure-openai')).toBe('azure_openai') + expect(service.normalizePlatform('gemini-api')).toBe('gemini-api') + }) + + it('should build local quota/balance from dailyQuota and local dailyCost', async () => { + const mockRedis = buildMockRedis() + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + + service._computeMonthlyCost = jest.fn().mockResolvedValue(30) + service._computeTotalCost = jest.fn().mockResolvedValue(123.45) + + const account = { id: 'acct-1', name: 'A', dailyQuota: '100', quotaResetTime: '00:00' } + const result = await service._getAccountBalanceForAccount(account, 'claude-console', { + queryApi: false, + useCache: true + }) + + expect(result.success).toBe(true) + expect(result.data.source).toBe('local') + expect(result.data.balance.amount).toBeCloseTo(80, 6) + expect(result.data.quota.percentage).toBeCloseTo(20, 6) + expect(result.data.statistics.totalCost).toBeCloseTo(123.45, 6) + expect(mockRedis.setLocalBalance).toHaveBeenCalled() + }) + + it('should use cached balance when account has no dailyQuota', async () => { + const mockRedis = buildMockRedis() + mockRedis.getAccountBalance.mockResolvedValue({ + status: 'success', + balance: 12.34, + currency: 'USD', + quota: null, + errorMessage: '', + lastRefreshAt: '2025-01-01T00:00:00Z', + ttlSeconds: 120 + }) + + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + service._computeMonthlyCost = jest.fn().mockResolvedValue(0) + service._computeTotalCost = jest.fn().mockResolvedValue(0) + + const account = { id: 'acct-2', name: 'B' } + const result = await service._getAccountBalanceForAccount(account, 'openai', { + queryApi: false, + useCache: true + }) + + expect(result.data.source).toBe('cache') + expect(result.data.balance.amount).toBeCloseTo(12.34, 6) + expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z') + }) + + it('should not cache provider errors and fallback to local when queryApi=true', async () => { + const mockRedis = buildMockRedis() + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + + service._computeMonthlyCost = jest.fn().mockResolvedValue(0) + service._computeTotalCost = jest.fn().mockResolvedValue(0) + + service.registerProvider('openai', { + queryBalance: () => { + throw new Error('boom') + } + }) + + const account = { id: 'acct-3', name: 'C' } + const result = await service._getAccountBalanceForAccount(account, 'openai', { + queryApi: true, + useCache: false + }) + + expect(mockRedis.setAccountBalance).not.toHaveBeenCalled() + expect(result.data.source).toBe('local') + expect(result.data.status).toBe('error') + expect(result.data.error).toBe('boom') + }) + + it('should ignore script config when balance script is disabled', async () => { + process.env.BALANCE_SCRIPT_ENABLED = 'false' + + const mockRedis = buildMockRedis() + mockRedis.getBalanceScriptConfig.mockResolvedValue({ + scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })' + }) + + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + service._computeMonthlyCost = jest.fn().mockResolvedValue(0) + service._computeTotalCost = jest.fn().mockResolvedValue(0) + + const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 1, currency: 'USD' }) } + service.registerProvider('openai', provider) + + const scriptSpy = jest.spyOn(service, '_getBalanceFromScript') + + const account = { id: 'acct-script-off', name: 'S' } + const result = await service._getAccountBalanceForAccount(account, 'openai', { + queryApi: true, + useCache: false + }) + + expect(provider.queryBalance).toHaveBeenCalled() + expect(scriptSpy).not.toHaveBeenCalled() + expect(result.data.source).toBe('api') + }) + + it('should prefer script when configured and enabled', async () => { + process.env.BALANCE_SCRIPT_ENABLED = 'true' + + const mockRedis = buildMockRedis() + mockRedis.getBalanceScriptConfig.mockResolvedValue({ + scriptBody: '({ request: { url: "http://example.com" }, extractor: function(){ return {} } })' + }) + + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + service._computeMonthlyCost = jest.fn().mockResolvedValue(0) + service._computeTotalCost = jest.fn().mockResolvedValue(0) + + const provider = { queryBalance: jest.fn().mockResolvedValue({ balance: 2, currency: 'USD' }) } + service.registerProvider('openai', provider) + + jest.spyOn(service, '_getBalanceFromScript').mockResolvedValue({ + status: 'success', + balance: 3, + currency: 'USD', + quota: null, + queryMethod: 'script', + rawData: { ok: true }, + lastRefreshAt: '2025-01-01T00:00:00Z', + errorMessage: '' + }) + + const account = { id: 'acct-script-on', name: 'T' } + const result = await service._getAccountBalanceForAccount(account, 'openai', { + queryApi: true, + useCache: false + }) + + expect(provider.queryBalance).not.toHaveBeenCalled() + expect(result.data.source).toBe('api') + expect(result.data.balance.amount).toBeCloseTo(3, 6) + expect(result.data.lastRefreshAt).toBe('2025-01-01T00:00:00Z') + }) + + it('should count low balance once per account in summary', async () => { + const mockRedis = buildMockRedis() + const service = new AccountBalanceService({ redis: mockRedis, logger: mockLogger }) + + service.getSupportedPlatforms = () => ['claude-console'] + service.getAllAccountsByPlatform = async () => [{ id: 'acct-4', name: 'D' }] + service._getAccountBalanceForAccount = async () => ({ + success: true, + data: { + accountId: 'acct-4', + platform: 'claude-console', + balance: { amount: 5, currency: 'USD', formattedAmount: '$5.00' }, + quota: { percentage: 95 }, + statistics: { totalCost: 1 }, + source: 'local', + lastRefreshAt: '2025-01-01T00:00:00Z', + cacheExpiresAt: null, + status: 'success', + error: null + } + }) + + const summary = await service.getBalanceSummary() + expect(summary.lowBalanceCount).toBe(1) + expect(summary.platforms['claude-console'].lowBalanceCount).toBe(1) + }) +}) diff --git a/tests/adminApiKeysPayloadRulesRoute.test.js b/tests/adminApiKeysPayloadRulesRoute.test.js new file mode 100644 index 0000000..57f3c79 --- /dev/null +++ b/tests/adminApiKeysPayloadRulesRoute.test.js @@ -0,0 +1,171 @@ +const mockRouter = { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn() +} + +jest.mock( + 'express', + () => ({ + Router: () => mockRouter + }), + { virtual: true } +) + +jest.mock('../src/middleware/auth', () => ({ + authenticateAdmin: jest.fn((_req, _res, next) => next()) +})) + +jest.mock('../src/services/apiKeyService', () => ({ + updateApiKey: jest.fn() +})) + +jest.mock('../src/models/redis', () => ({})) + +jest.mock('../src/utils/logger', () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + success: jest.fn() +})) + +jest.mock('../src/utils/costCalculator', () => ({ + calculateCost: jest.fn(), + formatCost: jest.fn() +})) + +jest.mock( + '../config/config', + () => ({ + system: { + timezoneOffset: 8 + } + }), + { virtual: true } +) + +jest.mock('../src/services/requestBodyRuleService', () => ({ + validateAndNormalizeRules: jest.fn() +})) + +const apiKeyService = require('../src/services/apiKeyService') +const requestBodyRuleService = require('../src/services/requestBodyRuleService') +require('../src/routes/admin/apiKeys') + +function createResponse() { + const res = { + statusCode: 200, + body: null, + json: jest.fn((payload) => { + res.body = payload + return res + }), + status: jest.fn((code) => { + res.statusCode = code + return res + }) + } + + return res +} + +function findPutHandler(path) { + const route = mockRouter.put.mock.calls.find((call) => call[0] === path) + return route?.[2] +} + +describe('admin api keys route payload rule updates', () => { + beforeEach(() => { + apiKeyService.updateApiKey.mockReset() + apiKeyService.updateApiKey.mockResolvedValue() + + requestBodyRuleService.validateAndNormalizeRules.mockReset() + requestBodyRuleService.validateAndNormalizeRules.mockImplementation((rules) => ({ + valid: true, + rules + })) + }) + + test('does not clear stored payload rules when the toggle is disabled without sending rules', async () => { + const handler = findPutHandler('/api-keys/:keyId') + const res = createResponse() + + await handler( + { + params: { keyId: 'key-1' }, + body: { + name: 'Renamed Key', + enableOpenAIResponsesPayloadRules: false + } + }, + res + ) + + expect(requestBodyRuleService.validateAndNormalizeRules).not.toHaveBeenCalled() + + const updates = apiKeyService.updateApiKey.mock.calls[0][1] + expect(updates).toEqual({ + name: 'Renamed Key', + enableOpenAIResponsesPayloadRules: false + }) + expect(updates).not.toHaveProperty('openaiResponsesPayloadRules') + + expect(res.status).not.toHaveBeenCalled() + expect(res.body).toEqual({ + success: true, + message: 'API key updated successfully' + }) + }) + + test('accepts payload rules even when the toggle is disabled', async () => { + const handler = findPutHandler('/api-keys/:keyId') + const res = createResponse() + const rules = [{ path: 'model', valueType: 'string', value: 'gpt-5' }] + + await handler( + { + params: { keyId: 'key-2' }, + body: { + enableOpenAIResponsesPayloadRules: false, + openaiResponsesPayloadRules: rules + } + }, + res + ) + + expect(requestBodyRuleService.validateAndNormalizeRules).toHaveBeenCalledWith(rules) + expect(apiKeyService.updateApiKey).toHaveBeenCalledWith('key-2', { + enableOpenAIResponsesPayloadRules: false, + openaiResponsesPayloadRules: rules + }) + + expect(res.status).not.toHaveBeenCalled() + expect(res.body.success).toBe(true) + }) + + test('allows explicitly clearing payload rules with an empty array', async () => { + const handler = findPutHandler('/api-keys/:keyId') + const res = createResponse() + + await handler( + { + params: { keyId: 'key-3' }, + body: { + openaiResponsesPayloadRules: [] + } + }, + res + ) + + expect(requestBodyRuleService.validateAndNormalizeRules).toHaveBeenCalledWith([]) + expect(apiKeyService.updateApiKey).toHaveBeenCalledWith('key-3', { + openaiResponsesPayloadRules: [] + }) + + expect(res.status).not.toHaveBeenCalled() + expect(res.body.success).toBe(true) + }) +}) diff --git a/tests/apiKeyServiceOpenAIResponsesConfig.test.js b/tests/apiKeyServiceOpenAIResponsesConfig.test.js new file mode 100644 index 0000000..7a968a4 --- /dev/null +++ b/tests/apiKeyServiceOpenAIResponsesConfig.test.js @@ -0,0 +1,240 @@ +jest.mock( + '../config/config', + () => ({ + security: { + apiKeyPrefix: 'cr_' + } + }), + { virtual: true } +) + +jest.mock('../src/models/redis', () => ({ + setApiKey: jest.fn(), + getApiKey: jest.fn(), + incrementTokenUsage: jest.fn(), + incrementDailyCost: jest.fn(), + incrementAccountUsage: jest.fn(), + addUsageRecord: jest.fn() +})) + +jest.mock('../src/services/costRankService', () => ({ + addKeyToIndexes: jest.fn() +})) + +jest.mock('../src/services/apiKeyIndexService', () => ({ + addToIndex: jest.fn(), + updateIndex: jest.fn() +})) + +jest.mock('../src/utils/logger', () => ({ + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + database: jest.fn(), + debug: jest.fn() +})) + +jest.mock('../src/services/serviceRatesService', () => ({ + getService: jest.fn(), + getServiceRate: jest.fn() +})) +jest.mock('../src/services/requestDetailService', () => ({ + captureRequestDetail: jest.fn() +})) +jest.mock('../src/services/billingEventPublisher', () => ({ + publishBillingEvent: jest.fn() +})) +jest.mock('../src/utils/costCalculator', () => ({ + calculateCost: jest.fn() +})) +jest.mock('../src/utils/modelHelper', () => ({ + isClaudeFamilyModel: jest.fn(() => false) +})) +jest.mock('../src/utils/requestDetailHelper', () => ({ + finalizeRequestDetailMeta: jest.fn((value) => value) +})) + +const redis = require('../src/models/redis') +const serviceRatesService = require('../src/services/serviceRatesService') +const requestDetailService = require('../src/services/requestDetailService') +const billingEventPublisher = require('../src/services/billingEventPublisher') +const CostCalculator = require('../src/utils/costCalculator') +const apiKeyService = require('../src/services/apiKeyService') + +describe('apiKeyService openai responses config', () => { + beforeEach(() => { + jest.clearAllMocks() + redis.getApiKey.mockResolvedValue({ + id: 'key-1', + name: 'Key', + serviceRates: '{}' + }) + redis.setApiKey.mockResolvedValue() + redis.incrementTokenUsage.mockResolvedValue() + redis.incrementDailyCost.mockResolvedValue() + redis.incrementAccountUsage.mockResolvedValue() + redis.addUsageRecord.mockResolvedValue() + serviceRatesService.getService.mockReturnValue('claude') + serviceRatesService.getServiceRate.mockResolvedValue(1) + requestDetailService.captureRequestDetail.mockResolvedValue({ captured: true }) + billingEventPublisher.publishBillingEvent.mockResolvedValue() + }) + + test('generateApiKey stores default toggle values', async () => { + redis.setApiKey.mockResolvedValue() + + const result = await apiKeyService.generateApiKey({ name: 'Test Key' }) + const [, storedKeyData] = redis.setApiKey.mock.calls[0] + + expect(storedKeyData.enableOpenAIResponsesCodexAdaptation).toBe('true') + expect(storedKeyData.enableOpenAIResponsesPayloadRules).toBe('false') + expect(storedKeyData.openaiResponsesPayloadRules).toBe('[]') + + expect(result.enableOpenAIResponsesCodexAdaptation).toBe(true) + expect(result.enableOpenAIResponsesPayloadRules).toBe(false) + expect(result.openaiResponsesPayloadRules).toEqual([]) + }) + + test('updateApiKey serializes toggle and payload rule fields', async () => { + redis.getApiKey.mockResolvedValue({ + id: 'key-1', + apiKey: 'hashed-key', + name: 'Old Key', + isActive: 'true', + tags: '[]' + }) + redis.setApiKey.mockResolvedValue() + + await apiKeyService.updateApiKey('key-1', { + enableOpenAIResponsesCodexAdaptation: false, + enableOpenAIResponsesPayloadRules: true, + openaiResponsesPayloadRules: [{ path: 'model', valueType: 'string', value: 'gpt-5' }] + }) + + const [, storedKeyData] = redis.setApiKey.mock.calls[0] + expect(storedKeyData.enableOpenAIResponsesCodexAdaptation).toBe('false') + expect(storedKeyData.enableOpenAIResponsesPayloadRules).toBe('true') + expect(storedKeyData.openaiResponsesPayloadRules).toBe( + JSON.stringify([{ path: 'model', valueType: 'string', value: 'gpt-5' }]) + ) + }) + + test('getApiKeyById returns parsed toggle and rule values', async () => { + redis.getApiKey.mockResolvedValue({ + id: 'key-1', + name: 'Key', + apiKey: 'hashed-key', + tokenLimit: '0', + isActive: 'true', + createdAt: '2025-01-01T00:00:00.000Z', + lastUsedAt: '', + expiresAt: '', + userId: '', + userUsername: '', + createdBy: 'admin', + permissions: '[]', + dailyCostLimit: '0', + totalCostLimit: '0', + claudeAccountId: '', + claudeConsoleAccountId: '', + geminiAccountId: '', + openaiAccountId: '', + bedrockAccountId: '', + droidAccountId: '', + azureOpenaiAccountId: '', + ccrAccountId: '', + enableOpenAIResponsesCodexAdaptation: 'false', + enableOpenAIResponsesPayloadRules: 'true', + openaiResponsesPayloadRules: JSON.stringify([ + { path: 'model', valueType: 'string', value: 'gpt-5' } + ]) + }) + + const result = await apiKeyService.getApiKeyById('key-1') + + expect(result.enableOpenAIResponsesCodexAdaptation).toBe(false) + expect(result.enableOpenAIResponsesPayloadRules).toBe(true) + expect(result.openaiResponsesPayloadRules).toEqual([ + { path: 'model', valueType: 'string', value: 'gpt-5' } + ]) + }) + + test('recordUsageWithDetails uses CostCalculator unknown fallback for missing model pricing', async () => { + CostCalculator.calculateCost.mockReturnValue({ + costs: { + input: 0.051618, + output: 0.000765, + cacheCreate: 0, + cacheWrite: 0, + cacheRead: 0.0006144, + total: 0.0529974 + }, + debug: { + usedFallbackPricing: true, + pricingSource: 'unknown-fallback', + isLongContextRequest: false + }, + usingDynamicPricing: false + }) + + const result = await apiKeyService.recordUsageWithDetails( + 'key-1', + { + input_tokens: 17206, + output_tokens: 51, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 2048 + }, + 'mimo-v2.5-pro', + 'acct-1', + 'claude-console', + { + requestId: 'req-1', + endpoint: '/api/v1/messages', + method: 'POST', + statusCode: 200 + } + ) + + expect(CostCalculator.calculateCost).toHaveBeenCalledWith( + { + input_tokens: 17206, + output_tokens: 51, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 2048 + }, + 'mimo-v2.5-pro' + ) + expect(result.realCost).toBeCloseTo(0.0529974, 10) + expect(result.ratedCost).toBeCloseTo(0.0529974, 10) + expect(redis.incrementDailyCost.mock.calls[0][0]).toBe('key-1') + expect(redis.incrementDailyCost.mock.calls[0][1]).toBeCloseTo(0.0529974, 10) + expect(redis.incrementDailyCost.mock.calls[0][2]).toBeCloseTo(0.0529974, 10) + expect(redis.addUsageRecord).toHaveBeenCalledWith( + 'key-1', + expect.objectContaining({ + model: 'mimo-v2.5-pro', + cost: 0.052997, + realCost: 0.052997, + usedFallbackPricing: true, + pricingSource: 'unknown-fallback', + costBreakdown: expect.objectContaining({ + input: 0.051618, + output: 0.000765, + cacheRead: 0.0006144, + total: 0.0529974 + }) + }) + ) + expect(requestDetailService.captureRequestDetail).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'req-1', + model: 'mimo-v2.5-pro', + cost: 0.052997, + realCost: 0.052997, + usedFallbackPricing: true, + pricingSource: 'unknown-fallback' + }) + ) + }) +}) diff --git a/tests/claudeAccountFableRateLimit.test.js b/tests/claudeAccountFableRateLimit.test.js new file mode 100644 index 0000000..19a0c35 --- /dev/null +++ b/tests/claudeAccountFableRateLimit.test.js @@ -0,0 +1,123 @@ +// Regression test for Fable-5 rate-limit isolation. +// +// Bug: a `claude-fable-5` 429 (a separate, weekly-scale limit) was routed into the +// account-wide rate-limit bucket (markAccountRateLimited → schedulable='false'), +// taking the whole OAuth account offline. Subsequent `claude-opus-4-8` requests then +// failed with "No available Claude accounts support the requested model". +// +// Fix: Fable limits get their own model-scoped bucket (markAccountFableRateLimited), +// mirroring the Opus bucket, that does NOT disable account-wide scheduling and does +// NOT set the general rate-limit state that gates every other model. + +const mockStore = new Map() + +jest.mock('../src/models/redis', () => ({ + getClaudeAccount: jest.fn(async (id) => mockStore.get(id) || {}), + setClaudeAccount: jest.fn(async (id, data) => { + mockStore.set(id, { ...data }) + }), + client: { + hdel: jest.fn(async () => 1) + } +})) + +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() +})) + +// Side-effectful deps the Fable rate-limit methods never touch. +jest.mock('../src/services/tokenRefreshService', () => ({})) +jest.mock('../src/utils/tokenRefreshLogger', () => ({})) +jest.mock('../src/utils/webhookNotifier', () => ({ + sendAccountAnomalyNotification: jest.fn() +})) +jest.mock('../src/utils/upstreamErrorHelper', () => ({ + recordErrorHistory: jest.fn(() => ({ catch: jest.fn() })), + markTempUnavailable: jest.fn(() => ({ catch: jest.fn() })), + parseRetryAfter: jest.fn(() => null) +})) +jest.mock('../src/utils/proxyHelper', () => ({})) +jest.mock('axios', () => ({})) + +// The service constructor starts a cache-cleanup setInterval at load. Unref it so the +// timer never keeps the Jest process alive (avoids needing --forceExit for this file). +const _realSetInterval = global.setInterval +global.setInterval = (fn, ms, ...args) => { + const timer = _realSetInterval(fn, ms, ...args) + if (timer && typeof timer.unref === 'function') { + timer.unref() + } + return timer +} +const claudeAccountService = require('../src/services/account/claudeAccountService') +global.setInterval = _realSetInterval + +const ACCOUNT_ID = 'acct-fable-test' +const DAY = 24 * 60 * 60 * 1000 + +const seedActiveAccount = () => { + mockStore.clear() + mockStore.set(ACCOUNT_ID, { + id: ACCOUNT_ID, + name: 'test-account', + isActive: 'true', + status: 'active', + schedulable: 'true' + }) +} + +describe('Fable rate-limit isolation (claudeAccountService)', () => { + beforeEach(() => { + seedActiveAccount() + }) + + it('records the Fable bucket without flipping the account-wide kill switch', async () => { + // ~3 days out — a weekly-scale limit, exactly the shape that regressed opus. + const resetTs = Math.floor((Date.now() + 3 * DAY) / 1000) + + await claudeAccountService.markAccountFableRateLimited(ACCOUNT_ID, resetTs) + + const stored = mockStore.get(ACCOUNT_ID) + expect(stored.fableRateLimitedAt).toBeTruthy() + expect(stored.fableRateLimitEndAt).toBeTruthy() + // The heart of the bug: a Fable limit must NOT disable scheduling account-wide, + // and must NOT write the general rate-limit state that blocks every other model. + expect(stored.schedulable).not.toBe('false') + expect(stored.rateLimitStatus).toBeUndefined() + expect(stored.rateLimitAutoStopped).toBeUndefined() + expect(stored.rateLimitEndAt).toBeUndefined() + }) + + it('is fable-limited but NOT general (account-wide) rate-limited', async () => { + const resetTs = Math.floor((Date.now() + 3 * DAY) / 1000) + await claudeAccountService.markAccountFableRateLimited(ACCOUNT_ID, resetTs) + + expect(await claudeAccountService.isAccountFableRateLimited(ACCOUNT_ID)).toBe(true) + // Opus (and every non-fable model) is gated by the GENERAL bucket; a fable limit + // must leave it false so opus-4-8 keeps scheduling on this account. + expect(await claudeAccountService.isAccountRateLimited(ACCOUNT_ID)).toBe(false) + // And it must not leak into the Opus bucket either. + expect(await claudeAccountService.isAccountOpusRateLimited(ACCOUNT_ID)).toBe(false) + }) + + it('auto-clears an expired Fable limit', async () => { + const pastTs = Math.floor((Date.now() - 60 * 60 * 1000) / 1000) + await claudeAccountService.markAccountFableRateLimited(ACCOUNT_ID, pastTs) + + expect(await claudeAccountService.isAccountFableRateLimited(ACCOUNT_ID)).toBe(false) + expect(mockStore.get(ACCOUNT_ID).fableRateLimitEndAt).toBeUndefined() + }) + + it('surfaces fable status via getAccountFableRateLimitInfo', async () => { + const resetTs = Math.floor((Date.now() + 3 * DAY) / 1000) + await claudeAccountService.markAccountFableRateLimited(ACCOUNT_ID, resetTs) + + const info = await claudeAccountService.getAccountFableRateLimitInfo(ACCOUNT_ID) + expect(info.isRateLimited).toBe(true) + expect(info.minutesRemaining).toBeGreaterThan(0) + }) +}) diff --git a/tests/claudeAccountModelRateLimit.test.js b/tests/claudeAccountModelRateLimit.test.js new file mode 100644 index 0000000..c30ff1c --- /dev/null +++ b/tests/claudeAccountModelRateLimit.test.js @@ -0,0 +1,123 @@ +// Regression test for per-model rate-limit buckets. +// +// Bug: a model-specific (weekly) 429 — originally claude-fable-5, later claude-sonnet-4-5 — +// was routed into the account-wide bucket (markAccountRateLimited -> schedulable='false'), +// taking the whole OAuth account offline so claude-opus-4-8 failed with +// "No available Claude accounts support the requested model". +// +// Fix: every model family (opus/sonnet/haiku/fable) gets its own bucket that never +// disables account-wide scheduling and never sets the general rate-limit state. + +const mockStore = new Map() + +jest.mock('../src/models/redis', () => ({ + getClaudeAccount: jest.fn(async (id) => mockStore.get(id) || {}), + setClaudeAccount: jest.fn(async (id, data) => { + mockStore.set(id, { ...data }) + }), + client: { hdel: jest.fn(async () => 1) } +})) + +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() +})) + +jest.mock('../src/services/tokenRefreshService', () => ({})) +jest.mock('../src/utils/tokenRefreshLogger', () => ({})) +jest.mock('../src/utils/webhookNotifier', () => ({ sendAccountAnomalyNotification: jest.fn() })) +jest.mock('../src/utils/upstreamErrorHelper', () => ({ + recordErrorHistory: jest.fn(() => ({ catch: jest.fn() })), + markTempUnavailable: jest.fn(() => ({ catch: jest.fn() })), + parseRetryAfter: jest.fn(() => null) +})) +jest.mock('../src/utils/proxyHelper', () => ({})) +jest.mock('axios', () => ({})) + +// The service constructor starts a cache-cleanup setInterval at load; unref it so the +// timer never keeps Jest alive. +const _realSetInterval = global.setInterval +global.setInterval = (fn, ms, ...args) => { + const timer = _realSetInterval(fn, ms, ...args) + if (timer && typeof timer.unref === 'function') { + timer.unref() + } + return timer +} +const claudeAccountService = require('../src/services/account/claudeAccountService') +global.setInterval = _realSetInterval + +const ACCOUNT_ID = 'acct-model-test' +const DAY = 24 * 60 * 60 * 1000 +const futureTs = () => Math.floor((Date.now() + 3 * DAY) / 1000) + +const seed = () => { + mockStore.clear() + mockStore.set(ACCOUNT_ID, { + id: ACCOUNT_ID, + name: 'test-account', + isActive: 'true', + status: 'active', + schedulable: 'true' + }) +} + +describe('per-model rate-limit buckets (claudeAccountService)', () => { + beforeEach(seed) + + it.each(['opus', 'sonnet', 'haiku', 'fable'])( + 'records a %s limit without flipping the account-wide kill switch', + async (family) => { + await claudeAccountService.markAccountModelRateLimited(ACCOUNT_ID, family, futureTs()) + + const stored = mockStore.get(ACCOUNT_ID) + expect(stored[`${family}RateLimitedAt`]).toBeTruthy() + expect(stored[`${family}RateLimitEndAt`]).toBeTruthy() + // The heart of the bug: a model limit must NOT disable account-wide scheduling, + // and must NOT write the general rate-limit state that blocks every other model. + expect(stored.schedulable).not.toBe('false') + expect(stored.rateLimitStatus).toBeUndefined() + expect(stored.rateLimitAutoStopped).toBeUndefined() + expect(stored.rateLimitEndAt).toBeUndefined() + } + ) + + it('a sonnet limit leaves opus schedulable and the general bucket untouched', async () => { + await claudeAccountService.markAccountModelRateLimited(ACCOUNT_ID, 'sonnet', futureTs()) + + expect(await claudeAccountService.isAccountModelRateLimited(ACCOUNT_ID, 'sonnet')).toBe(true) + // This is exactly what regressed opus in production. + expect(await claudeAccountService.isAccountModelRateLimited(ACCOUNT_ID, 'opus')).toBe(false) + expect(await claudeAccountService.isAccountRateLimited(ACCOUNT_ID)).toBe(false) + }) + + it('auto-clears an expired model limit', async () => { + const pastTs = Math.floor((Date.now() - 60 * 60 * 1000) / 1000) + await claudeAccountService.markAccountModelRateLimited(ACCOUNT_ID, 'sonnet', pastTs) + + expect(await claudeAccountService.isAccountModelRateLimited(ACCOUNT_ID, 'sonnet')).toBe(false) + expect(mockStore.get(ACCOUNT_ID).sonnetRateLimitEndAt).toBeUndefined() + }) + + it('reports remaining minutes via getAccountModelRateLimitInfo', async () => { + await claudeAccountService.markAccountModelRateLimited(ACCOUNT_ID, 'haiku', futureTs()) + const info = await claudeAccountService.getAccountModelRateLimitInfo(ACCOUNT_ID, 'haiku') + expect(info.isRateLimited).toBe(true) + expect(info.minutesRemaining).toBeGreaterThan(0) + }) + + it('keeps the legacy Opus/Fable wrappers writing the same Redis fields', async () => { + await claudeAccountService.markAccountOpusRateLimited(ACCOUNT_ID, futureTs()) + await claudeAccountService.markAccountFableRateLimited(ACCOUNT_ID, futureTs()) + + const stored = mockStore.get(ACCOUNT_ID) + expect(stored.opusRateLimitEndAt).toBeTruthy() + expect(stored.fableRateLimitEndAt).toBeTruthy() + expect(await claudeAccountService.isAccountOpusRateLimited(ACCOUNT_ID)).toBe(true) + expect(await claudeAccountService.isAccountFableRateLimited(ACCOUNT_ID)).toBe(true) + expect(stored.schedulable).not.toBe('false') + }) +}) diff --git a/tests/claudeConsoleAccounts.test.js b/tests/claudeConsoleAccounts.test.js new file mode 100644 index 0000000..8b9cb52 --- /dev/null +++ b/tests/claudeConsoleAccounts.test.js @@ -0,0 +1,73 @@ +const express = require('express') +const request = require('supertest') + +jest.mock('../src/middleware/auth', () => ({ + authenticateAdmin: (req, res, next) => next() +})) + +jest.mock('../src/services/relay/claudeConsoleRelayService', () => ({ + testAccountConnection: jest.fn(async (accountId, res) => + res.status(200).json({ success: true, accountId }) + ) +})) + +jest.mock('../src/services/account/claudeConsoleAccountService', () => ({})) +jest.mock('../src/services/accountGroupService', () => ({})) +jest.mock('../src/services/apiKeyService', () => ({})) +jest.mock('../src/models/redis', () => ({})) +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() +})) +jest.mock('../src/utils/webhookNotifier', () => ({})) +jest.mock('../src/routes/admin/utils', () => ({ + formatAccountExpiry: jest.fn((account) => account), + mapExpiryField: jest.fn((updates) => updates) +})) + +const claudeConsoleRelayService = require('../src/services/relay/claudeConsoleRelayService') +const claudeConsoleAccountsRouter = require('../src/routes/admin/claudeConsoleAccounts') + +describe('POST /admin/claude-console-accounts/:accountId/test', () => { + const buildApp = () => { + const app = express() + app.use(express.json()) + app.use('/admin', claudeConsoleAccountsRouter) + return app + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns 400 when model is missing', async () => { + const app = buildApp() + + const response = await request(app) + .post('/admin/claude-console-accounts/account-1/test') + .send({}) + + expect(response.status).toBe(400) + expect(response.body).toEqual({ error: 'model is required' }) + expect(claudeConsoleRelayService.testAccountConnection).not.toHaveBeenCalled() + }) + + it('passes model through to relay service when provided', async () => { + const app = buildApp() + + const response = await request(app) + .post('/admin/claude-console-accounts/account-1/test') + .send({ model: 'claude-sonnet-4-6' }) + + expect(response.status).toBe(200) + expect(claudeConsoleRelayService.testAccountConnection).toHaveBeenCalledTimes(1) + expect(claudeConsoleRelayService.testAccountConnection).toHaveBeenCalledWith( + 'account-1', + expect.any(Object), + 'claude-sonnet-4-6' + ) + }) +}) diff --git a/tests/claudeConsoleRelayService.test.js b/tests/claudeConsoleRelayService.test.js new file mode 100644 index 0000000..f81ad8a --- /dev/null +++ b/tests/claudeConsoleRelayService.test.js @@ -0,0 +1,93 @@ +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +})) + +jest.mock('../src/services/account/claudeConsoleAccountService', () => ({ + getAccount: jest.fn(), + _createProxyAgent: jest.fn() +})) + +jest.mock('../config/config', () => ({}), { + virtual: true +}) +jest.mock('../src/models/redis', () => ({})) + +jest.mock('../src/utils/testPayloadHelper', () => ({ + createClaudeTestPayload: jest.fn(), + sendStreamTestRequest: jest.fn() +})) + +const claudeConsoleRelayService = require('../src/services/relay/claudeConsoleRelayService') +const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService') +const { createClaudeTestPayload, sendStreamTestRequest } = require('../src/utils/testPayloadHelper') + +describe('claudeConsoleRelayService.testAccountConnection', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('passes selected model stream payload and bearer auth for non sk-ant key', async () => { + claudeConsoleAccountService.getAccount.mockResolvedValue({ + name: 'Console A1', + apiUrl: 'https://console.example.com', + apiKey: 'test-key', + proxy: null, + userAgent: null + }) + claudeConsoleAccountService._createProxyAgent.mockReturnValue(undefined) + + const payload = { + model: 'claude-sonnet-4-6', + stream: true + } + createClaudeTestPayload.mockReturnValue(payload) + sendStreamTestRequest.mockResolvedValue(undefined) + + const res = {} + await claudeConsoleRelayService.testAccountConnection('a1', res, 'claude-sonnet-4-6') + + expect(createClaudeTestPayload).toHaveBeenCalledWith('claude-sonnet-4-6', { stream: true }) + expect(sendStreamTestRequest).toHaveBeenCalledWith( + expect.objectContaining({ + payload, + authorization: 'Bearer test-key' + }) + ) + }) + + it('passes selected model stream payload and x-api-key for sk-ant key', async () => { + claudeConsoleAccountService.getAccount.mockResolvedValue({ + name: 'Console A1', + apiUrl: 'https://console.example.com', + apiKey: 'sk-ant-test-key', + proxy: null, + userAgent: null + }) + claudeConsoleAccountService._createProxyAgent.mockReturnValue(undefined) + + const payload = { + model: 'claude-sonnet-4-6', + stream: true + } + createClaudeTestPayload.mockReturnValue(payload) + sendStreamTestRequest.mockResolvedValue(undefined) + + const res = {} + await claudeConsoleRelayService.testAccountConnection('a1', res, 'claude-sonnet-4-6') + + expect(createClaudeTestPayload).toHaveBeenCalledWith('claude-sonnet-4-6', { stream: true }) + const requestOptions = sendStreamTestRequest.mock.calls[0][0] + expect(requestOptions).toEqual( + expect.objectContaining({ + payload, + extraHeaders: expect.objectContaining({ + 'x-api-key': 'sk-ant-test-key' + }) + }) + ) + expect(requestOptions).not.toHaveProperty('authorization') + }) +}) diff --git a/tests/concurrencyQueue.integration.test.js b/tests/concurrencyQueue.integration.test.js new file mode 100644 index 0000000..fce1587 --- /dev/null +++ b/tests/concurrencyQueue.integration.test.js @@ -0,0 +1,860 @@ +/** + * 并发请求排队功能集成测试 + * + * 测试分为三个层次: + * 1. Mock 测试 - 测试核心逻辑,不需要真实 Redis + * 2. Redis 方法测试 - 测试 Redis 操作的原子性和正确性 + * 3. 端到端场景测试 - 测试完整的排队流程 + * + * 运行方式: + * - npm test -- concurrencyQueue.integration # 运行所有测试(Mock 部分) + * - REDIS_TEST=1 npm test -- concurrencyQueue.integration # 包含真实 Redis 测试 + */ + +// Mock logger to avoid console output during tests +jest.mock('../src/utils/logger', () => ({ + api: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + database: jest.fn(), + security: jest.fn() +})) + +const redis = require('../src/models/redis') +const claudeRelayConfigService = require('../src/services/claudeRelayConfigService') + +// Helper: sleep function +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +// Helper: 创建模拟的 req/res 对象 +function createMockReqRes() { + const listeners = {} + const req = { + destroyed: false, + once: jest.fn((event, handler) => { + listeners[`req:${event}`] = handler + }), + removeListener: jest.fn((event) => { + delete listeners[`req:${event}`] + }), + // 触发事件的辅助方法 + emit: (event) => { + const handler = listeners[`req:${event}`] + if (handler) { + handler() + } + } + } + + const res = { + once: jest.fn((event, handler) => { + listeners[`res:${event}`] = handler + }), + removeListener: jest.fn((event) => { + delete listeners[`res:${event}`] + }), + emit: (event) => { + const handler = listeners[`res:${event}`] + if (handler) { + handler() + } + } + } + + return { req, res, listeners } +} + +// ============================================ +// 第一部分:Mock 测试 - waitForConcurrencySlot 核心逻辑 +// ============================================ +describe('ConcurrencyQueue Integration Tests', () => { + describe('Part 1: waitForConcurrencySlot Logic (Mocked)', () => { + // 导入 auth 模块中的 waitForConcurrencySlot + // 由于它是内部函数,我们需要通过测试其行为来验证 + // 这里我们模拟整个流程 + + let mockRedis + + beforeEach(() => { + jest.clearAllMocks() + + // 创建 Redis mock + mockRedis = { + concurrencyCount: {}, + queueCount: {}, + stats: {}, + waitTimes: {}, + globalWaitTimes: [] + } + + // Mock Redis 并发方法 + jest.spyOn(redis, 'incrConcurrency').mockImplementation(async (keyId, requestId, _lease) => { + if (!mockRedis.concurrencyCount[keyId]) { + mockRedis.concurrencyCount[keyId] = new Set() + } + mockRedis.concurrencyCount[keyId].add(requestId) + return mockRedis.concurrencyCount[keyId].size + }) + + jest.spyOn(redis, 'decrConcurrency').mockImplementation(async (keyId, requestId) => { + if (mockRedis.concurrencyCount[keyId]) { + mockRedis.concurrencyCount[keyId].delete(requestId) + return mockRedis.concurrencyCount[keyId].size + } + return 0 + }) + + // Mock 排队计数方法 + jest.spyOn(redis, 'incrConcurrencyQueue').mockImplementation(async (keyId) => { + mockRedis.queueCount[keyId] = (mockRedis.queueCount[keyId] || 0) + 1 + return mockRedis.queueCount[keyId] + }) + + jest.spyOn(redis, 'decrConcurrencyQueue').mockImplementation(async (keyId) => { + mockRedis.queueCount[keyId] = Math.max(0, (mockRedis.queueCount[keyId] || 0) - 1) + return mockRedis.queueCount[keyId] + }) + + jest + .spyOn(redis, 'getConcurrencyQueueCount') + .mockImplementation(async (keyId) => mockRedis.queueCount[keyId] || 0) + + // Mock 统计方法 + jest.spyOn(redis, 'incrConcurrencyQueueStats').mockImplementation(async (keyId, field) => { + if (!mockRedis.stats[keyId]) { + mockRedis.stats[keyId] = {} + } + mockRedis.stats[keyId][field] = (mockRedis.stats[keyId][field] || 0) + 1 + return mockRedis.stats[keyId][field] + }) + + jest.spyOn(redis, 'recordQueueWaitTime').mockResolvedValue(undefined) + jest.spyOn(redis, 'recordGlobalQueueWaitTime').mockResolvedValue(undefined) + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('Slot Acquisition Flow', () => { + it('should acquire slot immediately when under concurrency limit', async () => { + // 模拟 waitForConcurrencySlot 的行为 + const keyId = 'test-key-1' + const requestId = 'req-1' + const concurrencyLimit = 5 + + // 直接测试 incrConcurrency 的行为 + const count = await redis.incrConcurrency(keyId, requestId, 300) + + expect(count).toBe(1) + expect(count).toBeLessThanOrEqual(concurrencyLimit) + }) + + it('should track multiple concurrent requests correctly', async () => { + const keyId = 'test-key-2' + const concurrencyLimit = 3 + + // 模拟多个并发请求 + const results = [] + for (let i = 1; i <= 5; i++) { + const count = await redis.incrConcurrency(keyId, `req-${i}`, 300) + results.push({ requestId: `req-${i}`, count, exceeds: count > concurrencyLimit }) + } + + // 前3个应该在限制内 + expect(results[0].exceeds).toBe(false) + expect(results[1].exceeds).toBe(false) + expect(results[2].exceeds).toBe(false) + // 后2个超过限制 + expect(results[3].exceeds).toBe(true) + expect(results[4].exceeds).toBe(true) + }) + + it('should release slot and allow next request', async () => { + const keyId = 'test-key-3' + const concurrencyLimit = 1 + + // 第一个请求获取槽位 + const count1 = await redis.incrConcurrency(keyId, 'req-1', 300) + expect(count1).toBe(1) + + // 第二个请求超限 + const count2 = await redis.incrConcurrency(keyId, 'req-2', 300) + expect(count2).toBe(2) + expect(count2).toBeGreaterThan(concurrencyLimit) + + // 释放第二个请求(因为超限) + await redis.decrConcurrency(keyId, 'req-2') + + // 释放第一个请求 + await redis.decrConcurrency(keyId, 'req-1') + + // 现在第三个请求应该能获取 + const count3 = await redis.incrConcurrency(keyId, 'req-3', 300) + expect(count3).toBe(1) + }) + }) + + describe('Queue Count Management', () => { + it('should increment and decrement queue count atomically', async () => { + const keyId = 'test-key-4' + + // 增加排队计数 + const count1 = await redis.incrConcurrencyQueue(keyId, 60000) + expect(count1).toBe(1) + + const count2 = await redis.incrConcurrencyQueue(keyId, 60000) + expect(count2).toBe(2) + + // 减少排队计数 + const count3 = await redis.decrConcurrencyQueue(keyId) + expect(count3).toBe(1) + + const count4 = await redis.decrConcurrencyQueue(keyId) + expect(count4).toBe(0) + }) + + it('should not go below zero on decrement', async () => { + const keyId = 'test-key-5' + + // 直接减少(没有先增加) + const count = await redis.decrConcurrencyQueue(keyId) + expect(count).toBe(0) + }) + + it('should handle concurrent queue operations', async () => { + const keyId = 'test-key-6' + + // 并发增加 + const increments = await Promise.all([ + redis.incrConcurrencyQueue(keyId, 60000), + redis.incrConcurrencyQueue(keyId, 60000), + redis.incrConcurrencyQueue(keyId, 60000) + ]) + + // 所有增量应该是连续的 + const sortedIncrements = [...increments].sort((a, b) => a - b) + expect(sortedIncrements).toEqual([1, 2, 3]) + }) + }) + + describe('Statistics Tracking', () => { + it('should track entered/success/timeout/cancelled stats', async () => { + const keyId = 'test-key-7' + + await redis.incrConcurrencyQueueStats(keyId, 'entered') + await redis.incrConcurrencyQueueStats(keyId, 'entered') + await redis.incrConcurrencyQueueStats(keyId, 'success') + await redis.incrConcurrencyQueueStats(keyId, 'timeout') + await redis.incrConcurrencyQueueStats(keyId, 'cancelled') + + expect(mockRedis.stats[keyId]).toEqual({ + entered: 2, + success: 1, + timeout: 1, + cancelled: 1 + }) + }) + }) + + describe('Client Disconnection Handling', () => { + it('should detect client disconnection via close event', async () => { + const { req } = createMockReqRes() + + let clientDisconnected = false + + // 设置监听器 + req.once('close', () => { + clientDisconnected = true + }) + + // 模拟客户端断开 + req.emit('close') + + expect(clientDisconnected).toBe(true) + }) + + it('should detect pre-destroyed request', () => { + const { req } = createMockReqRes() + req.destroyed = true + + expect(req.destroyed).toBe(true) + }) + }) + + describe('Exponential Backoff Simulation', () => { + it('should increase poll interval with backoff', () => { + const config = { + pollIntervalMs: 200, + maxPollIntervalMs: 2000, + backoffFactor: 1.5, + jitterRatio: 0 // 禁用抖动以便测试 + } + + let interval = config.pollIntervalMs + const intervals = [interval] + + for (let i = 0; i < 5; i++) { + interval = Math.min(interval * config.backoffFactor, config.maxPollIntervalMs) + intervals.push(interval) + } + + // 验证指数增长 + expect(intervals[1]).toBe(300) // 200 * 1.5 + expect(intervals[2]).toBe(450) // 300 * 1.5 + expect(intervals[3]).toBe(675) // 450 * 1.5 + expect(intervals[4]).toBe(1012.5) // 675 * 1.5 + expect(intervals[5]).toBe(1518.75) // 1012.5 * 1.5 + }) + + it('should cap interval at maximum', () => { + const config = { + pollIntervalMs: 1000, + maxPollIntervalMs: 2000, + backoffFactor: 1.5 + } + + let interval = config.pollIntervalMs + + for (let i = 0; i < 10; i++) { + interval = Math.min(interval * config.backoffFactor, config.maxPollIntervalMs) + } + + expect(interval).toBe(2000) + }) + + it('should apply jitter within expected range', () => { + const baseInterval = 1000 + const jitterRatio = 0.2 // ±20% + const results = [] + + for (let i = 0; i < 100; i++) { + const randomValue = Math.random() + const jitter = baseInterval * jitterRatio * (randomValue * 2 - 1) + const finalInterval = baseInterval + jitter + results.push(finalInterval) + } + + const min = Math.min(...results) + const max = Math.max(...results) + + // 所有结果应该在 [800, 1200] 范围内 + expect(min).toBeGreaterThanOrEqual(800) + expect(max).toBeLessThanOrEqual(1200) + }) + }) + }) + + // ============================================ + // 第二部分:并发竞争场景测试 + // ============================================ + describe('Part 2: Concurrent Race Condition Tests', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('Race Condition: Multiple Requests Competing for Same Slot', () => { + it('should handle race condition when multiple requests try to acquire last slot', async () => { + const keyId = 'race-test-1' + const concurrencyLimit = 1 + const concurrencyState = { count: 0, holders: new Set() } + + // 模拟原子的 incrConcurrency + jest.spyOn(redis, 'incrConcurrency').mockImplementation(async (key, reqId) => { + // 模拟原子操作 + concurrencyState.count++ + concurrencyState.holders.add(reqId) + return concurrencyState.count + }) + + jest.spyOn(redis, 'decrConcurrency').mockImplementation(async (key, reqId) => { + if (concurrencyState.holders.has(reqId)) { + concurrencyState.count-- + concurrencyState.holders.delete(reqId) + } + return concurrencyState.count + }) + + // 5个请求同时竞争1个槽位 + const requests = Array.from({ length: 5 }, (_, i) => `req-${i + 1}`) + + const acquireResults = await Promise.all( + requests.map(async (reqId) => { + const count = await redis.incrConcurrency(keyId, reqId, 300) + const acquired = count <= concurrencyLimit + + if (!acquired) { + // 超限,释放 + await redis.decrConcurrency(keyId, reqId) + } + + return { reqId, count, acquired } + }) + ) + + // 只有一个请求应该成功获取槽位 + const successfulAcquires = acquireResults.filter((r) => r.acquired) + expect(successfulAcquires.length).toBe(1) + + // 最终并发计数应该是1 + expect(concurrencyState.count).toBe(1) + }) + + it('should maintain consistency under high contention', async () => { + const keyId = 'race-test-2' + const concurrencyLimit = 3 + const requestCount = 20 + const concurrencyState = { count: 0, maxSeen: 0 } + + jest.spyOn(redis, 'incrConcurrency').mockImplementation(async () => { + concurrencyState.count++ + concurrencyState.maxSeen = Math.max(concurrencyState.maxSeen, concurrencyState.count) + return concurrencyState.count + }) + + jest.spyOn(redis, 'decrConcurrency').mockImplementation(async () => { + concurrencyState.count = Math.max(0, concurrencyState.count - 1) + return concurrencyState.count + }) + + // 模拟多轮请求 + const activeRequests = [] + + for (let i = 0; i < requestCount; i++) { + const count = await redis.incrConcurrency(keyId, `req-${i}`, 300) + + if (count <= concurrencyLimit) { + activeRequests.push(`req-${i}`) + + // 模拟处理时间后释放 + setTimeout(async () => { + await redis.decrConcurrency(keyId, `req-${i}`) + }, Math.random() * 50) + } else { + await redis.decrConcurrency(keyId, `req-${i}`) + } + + // 随机延迟 + await sleep(Math.random() * 10) + } + + // 等待所有请求完成 + await sleep(100) + + // 最大并发不应超过限制 + expect(concurrencyState.maxSeen).toBeLessThanOrEqual(concurrencyLimit + requestCount) // 允许短暂超限 + }) + }) + + describe('Queue Overflow Protection', () => { + it('should reject requests when queue is full', async () => { + const keyId = 'overflow-test-1' + const maxQueueSize = 5 + const queueState = { count: 0 } + + jest.spyOn(redis, 'incrConcurrencyQueue').mockImplementation(async () => { + queueState.count++ + return queueState.count + }) + + jest.spyOn(redis, 'decrConcurrencyQueue').mockImplementation(async () => { + queueState.count = Math.max(0, queueState.count - 1) + return queueState.count + }) + + const results = [] + + // 尝试10个请求进入队列 + for (let i = 0; i < 10; i++) { + const queueCount = await redis.incrConcurrencyQueue(keyId, 60000) + + if (queueCount > maxQueueSize) { + // 队列满,释放并拒绝 + await redis.decrConcurrencyQueue(keyId) + results.push({ index: i, accepted: false }) + } else { + results.push({ index: i, accepted: true, position: queueCount }) + } + } + + const accepted = results.filter((r) => r.accepted) + const rejected = results.filter((r) => !r.accepted) + + expect(accepted.length).toBe(5) + expect(rejected.length).toBe(5) + }) + }) + }) + + // ============================================ + // 第三部分:真实 Redis 集成测试(可选) + // ============================================ + describe('Part 3: Real Redis Integration Tests', () => { + const skipRealRedis = !process.env.REDIS_TEST + + // 辅助函数:检查 Redis 连接 + async function checkRedisConnection() { + try { + const client = redis.getClient() + if (!client) { + return false + } + await client.ping() + return true + } catch { + return false + } + } + + beforeAll(async () => { + if (skipRealRedis) { + console.log('⏭️ Skipping real Redis tests (set REDIS_TEST=1 to enable)') + return + } + + const connected = await checkRedisConnection() + if (!connected) { + console.log('⚠️ Redis not connected, skipping real Redis tests') + } + }) + + // 清理测试数据 + afterEach(async () => { + if (skipRealRedis) { + return + } + + try { + const client = redis.getClient() + if (!client) { + return + } + + // 清理测试键 + const testKeys = await client.keys('concurrency:queue:test-*') + if (testKeys.length > 0) { + await client.del(...testKeys) + } + } catch { + // 忽略清理错误 + } + }) + + describe('Redis Queue Operations', () => { + const testOrSkip = skipRealRedis ? it.skip : it + + testOrSkip('should atomically increment queue count with TTL', async () => { + const keyId = 'test-redis-queue-1' + const timeoutMs = 5000 + + const count1 = await redis.incrConcurrencyQueue(keyId, timeoutMs) + expect(count1).toBe(1) + + const count2 = await redis.incrConcurrencyQueue(keyId, timeoutMs) + expect(count2).toBe(2) + + // 验证 TTL 被设置 + const client = redis.getClient() + const ttl = await client.ttl(`concurrency:queue:${keyId}`) + expect(ttl).toBeGreaterThan(0) + expect(ttl).toBeLessThanOrEqual(Math.ceil(timeoutMs / 1000) + 30) + }) + + testOrSkip('should atomically decrement and delete when zero', async () => { + const keyId = 'test-redis-queue-2' + + await redis.incrConcurrencyQueue(keyId, 60000) + const count = await redis.decrConcurrencyQueue(keyId) + + expect(count).toBe(0) + + // 验证键已删除 + const client = redis.getClient() + const exists = await client.exists(`concurrency:queue:${keyId}`) + expect(exists).toBe(0) + }) + + testOrSkip('should handle concurrent increments correctly', async () => { + const keyId = 'test-redis-queue-3' + const numRequests = 10 + + // 并发增加 + const results = await Promise.all( + Array.from({ length: numRequests }, () => redis.incrConcurrencyQueue(keyId, 60000)) + ) + + // 所有结果应该是 1 到 numRequests + const sorted = [...results].sort((a, b) => a - b) + expect(sorted).toEqual(Array.from({ length: numRequests }, (_, i) => i + 1)) + }) + }) + + describe('Redis Stats Operations', () => { + const testOrSkip = skipRealRedis ? it.skip : it + + testOrSkip('should track queue statistics correctly', async () => { + const keyId = 'test-redis-stats-1' + + await redis.incrConcurrencyQueueStats(keyId, 'entered') + await redis.incrConcurrencyQueueStats(keyId, 'entered') + await redis.incrConcurrencyQueueStats(keyId, 'success') + await redis.incrConcurrencyQueueStats(keyId, 'timeout') + + const stats = await redis.getConcurrencyQueueStats(keyId) + + expect(stats.entered).toBe(2) + expect(stats.success).toBe(1) + expect(stats.timeout).toBe(1) + expect(stats.cancelled).toBe(0) + }) + + testOrSkip('should record and retrieve wait times', async () => { + const keyId = 'test-redis-wait-1' + const waitTimes = [100, 200, 150, 300, 250] + + for (const wt of waitTimes) { + await redis.recordQueueWaitTime(keyId, wt) + } + + const recorded = await redis.getQueueWaitTimes(keyId) + + // 应该按 LIFO 顺序存储 + expect(recorded.length).toBe(5) + expect(recorded[0]).toBe(250) // 最后插入的在前面 + }) + + testOrSkip('should record global wait times', async () => { + const waitTimes = [500, 600, 700] + + for (const wt of waitTimes) { + await redis.recordGlobalQueueWaitTime(wt) + } + + const recorded = await redis.getGlobalQueueWaitTimes() + + expect(recorded.length).toBeGreaterThanOrEqual(3) + }) + }) + + describe('Redis Cleanup Operations', () => { + const testOrSkip = skipRealRedis ? it.skip : it + + testOrSkip('should clear specific queue', async () => { + const keyId = 'test-redis-clear-1' + + await redis.incrConcurrencyQueue(keyId, 60000) + await redis.incrConcurrencyQueue(keyId, 60000) + + const cleared = await redis.clearConcurrencyQueue(keyId) + expect(cleared).toBe(true) + + const count = await redis.getConcurrencyQueueCount(keyId) + expect(count).toBe(0) + }) + + testOrSkip('should clear all queues but preserve stats', async () => { + const keyId1 = 'test-redis-clearall-1' + const keyId2 = 'test-redis-clearall-2' + + // 创建队列和统计 + await redis.incrConcurrencyQueue(keyId1, 60000) + await redis.incrConcurrencyQueue(keyId2, 60000) + await redis.incrConcurrencyQueueStats(keyId1, 'entered') + + // 清理所有队列 + const cleared = await redis.clearAllConcurrencyQueues() + expect(cleared).toBeGreaterThanOrEqual(2) + + // 验证队列已清理 + const count1 = await redis.getConcurrencyQueueCount(keyId1) + const count2 = await redis.getConcurrencyQueueCount(keyId2) + expect(count1).toBe(0) + expect(count2).toBe(0) + + // 统计应该保留 + const stats = await redis.getConcurrencyQueueStats(keyId1) + expect(stats.entered).toBe(1) + }) + }) + }) + + // ============================================ + // 第四部分:配置服务集成测试 + // ============================================ + describe('Part 4: Configuration Service Integration', () => { + beforeEach(() => { + // 清除配置缓存 + claudeRelayConfigService.clearCache() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe('Queue Configuration', () => { + it('should return default queue configuration', async () => { + jest.spyOn(redis, 'getClient').mockReturnValue(null) + + const config = await claudeRelayConfigService.getConfig() + + expect(config.concurrentRequestQueueEnabled).toBe(false) + expect(config.concurrentRequestQueueMaxSize).toBe(3) + expect(config.concurrentRequestQueueMaxSizeMultiplier).toBe(0) + expect(config.concurrentRequestQueueTimeoutMs).toBe(10000) + }) + + it('should calculate max queue size correctly', async () => { + const testCases = [ + { concurrencyLimit: 5, multiplier: 2, fixedMin: 3, expected: 10 }, // 5*2=10 > 3 + { concurrencyLimit: 1, multiplier: 1, fixedMin: 5, expected: 5 }, // 1*1=1 < 5 + { concurrencyLimit: 10, multiplier: 0.5, fixedMin: 3, expected: 5 }, // 10*0.5=5 > 3 + { concurrencyLimit: 2, multiplier: 1, fixedMin: 10, expected: 10 } // 2*1=2 < 10 + ] + + for (const tc of testCases) { + const maxQueueSize = Math.max(tc.concurrencyLimit * tc.multiplier, tc.fixedMin) + expect(maxQueueSize).toBe(tc.expected) + } + }) + }) + }) + + // ============================================ + // 第五部分:端到端场景测试 + // ============================================ + describe('Part 5: End-to-End Scenario Tests', () => { + describe('Scenario: Claude Code Agent Parallel Tool Calls', () => { + it('should handle burst of parallel tool results', async () => { + // 模拟 Claude Code Agent 发送多个并行工具结果的场景 + const concurrencyLimit = 2 + const maxQueueSize = 5 + + const state = { + concurrency: 0, + queue: 0, + completed: 0, + rejected: 0 + } + + // 模拟 8 个并行工具结果请求 + const requests = Array.from({ length: 8 }, (_, i) => ({ + id: `tool-result-${i + 1}`, + startTime: Date.now() + })) + + // 模拟处理逻辑 + async function processRequest(req) { + // 尝试获取并发槽位 + state.concurrency++ + + if (state.concurrency > concurrencyLimit) { + // 超限,进入队列 + state.concurrency-- + state.queue++ + + if (state.queue > maxQueueSize) { + // 队列满,拒绝 + state.queue-- + state.rejected++ + return { ...req, status: 'rejected', reason: 'queue_full' } + } + + // 等待槽位(模拟) + await sleep(Math.random() * 100) + state.queue-- + state.concurrency++ + } + + // 处理请求 + await sleep(50) // 模拟处理时间 + state.concurrency-- + state.completed++ + + return { ...req, status: 'completed', duration: Date.now() - req.startTime } + } + + const results = await Promise.all(requests.map(processRequest)) + + const completed = results.filter((r) => r.status === 'completed') + const rejected = results.filter((r) => r.status === 'rejected') + + // 大部分请求应该完成 + expect(completed.length).toBeGreaterThan(0) + // 可能有一些被拒绝 + expect(state.rejected).toBe(rejected.length) + + console.log( + ` ✓ Completed: ${completed.length}, Rejected: ${rejected.length}, Max concurrent: ${concurrencyLimit}` + ) + }) + }) + + describe('Scenario: Graceful Degradation', () => { + it('should fallback when Redis fails', async () => { + jest + .spyOn(redis, 'incrConcurrencyQueue') + .mockRejectedValue(new Error('Redis connection lost')) + + // 模拟降级行为:Redis 失败时直接拒绝而不是崩溃 + let result + try { + await redis.incrConcurrencyQueue('fallback-test', 60000) + result = { success: true } + } catch (error) { + // 优雅降级:返回 429 而不是 500 + result = { success: false, fallback: true, error: error.message } + } + + expect(result.fallback).toBe(true) + expect(result.error).toContain('Redis') + }) + }) + + describe('Scenario: Timeout Behavior', () => { + it('should respect queue timeout', async () => { + const timeoutMs = 100 + const startTime = Date.now() + + // 模拟等待超时 + await new Promise((resolve) => setTimeout(resolve, timeoutMs)) + + const elapsed = Date.now() - startTime + expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10) // 允许 10ms 误差 + }) + + it('should track timeout statistics', async () => { + const stats = { entered: 0, success: 0, timeout: 0, cancelled: 0 } + + // 模拟多个请求,部分超时 + const requests = [ + { id: 'req-1', willTimeout: false }, + { id: 'req-2', willTimeout: true }, + { id: 'req-3', willTimeout: false }, + { id: 'req-4', willTimeout: true } + ] + + for (const req of requests) { + stats.entered++ + if (req.willTimeout) { + stats.timeout++ + } else { + stats.success++ + } + } + + expect(stats.entered).toBe(4) + expect(stats.success).toBe(2) + expect(stats.timeout).toBe(2) + + // 成功率应该是 50% + const successRate = (stats.success / stats.entered) * 100 + expect(successRate).toBe(50) + }) + }) + }) +}) diff --git a/tests/concurrencyQueue.test.js b/tests/concurrencyQueue.test.js new file mode 100644 index 0000000..ef0ff79 --- /dev/null +++ b/tests/concurrencyQueue.test.js @@ -0,0 +1,278 @@ +/** + * 并发请求排队功能测试 + * 测试排队逻辑中的核心算法:百分位数计算、等待时间统计、指数退避等 + * + * 注意:Redis 方法的测试需要集成测试环境,这里主要测试纯算法逻辑 + */ + +// Mock logger to avoid console output during tests +jest.mock('../src/utils/logger', () => ({ + api: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + database: jest.fn(), + security: jest.fn() +})) + +// 使用共享的统计工具函数(与生产代码一致) +const { getPercentile, calculateWaitTimeStats } = require('../src/utils/statsHelper') + +describe('ConcurrencyQueue', () => { + describe('Percentile Calculation (nearest-rank method)', () => { + // 直接测试共享工具函数,确保与生产代码行为一致 + it('should return 0 for empty array', () => { + expect(getPercentile([], 50)).toBe(0) + }) + + it('should return single element for single-element array', () => { + expect(getPercentile([100], 50)).toBe(100) + expect(getPercentile([100], 99)).toBe(100) + }) + + it('should return min for percentile 0', () => { + expect(getPercentile([10, 20, 30, 40, 50], 0)).toBe(10) + }) + + it('should return max for percentile 100', () => { + expect(getPercentile([10, 20, 30, 40, 50], 100)).toBe(50) + }) + + it('should calculate P50 correctly for len=10', () => { + // For [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] (len=10) + // P50: ceil(50/100 * 10) - 1 = ceil(5) - 1 = 4 → value at index 4 = 50 + const arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] + expect(getPercentile(arr, 50)).toBe(50) + }) + + it('should calculate P90 correctly for len=10', () => { + // For len=10, P90: ceil(90/100 * 10) - 1 = ceil(9) - 1 = 8 → value at index 8 = 90 + const arr = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100] + expect(getPercentile(arr, 90)).toBe(90) + }) + + it('should calculate P99 correctly for len=100', () => { + // For len=100, P99: ceil(99/100 * 100) - 1 = ceil(99) - 1 = 98 + const arr = Array.from({ length: 100 }, (_, i) => i + 1) + expect(getPercentile(arr, 99)).toBe(99) + }) + + it('should handle two-element array correctly', () => { + // For [10, 20] (len=2) + // P50: ceil(50/100 * 2) - 1 = ceil(1) - 1 = 0 → value = 10 + expect(getPercentile([10, 20], 50)).toBe(10) + }) + + it('should handle negative percentile as 0', () => { + expect(getPercentile([10, 20, 30], -10)).toBe(10) + }) + + it('should handle percentile > 100 as 100', () => { + expect(getPercentile([10, 20, 30], 150)).toBe(30) + }) + }) + + describe('Wait Time Stats Calculation', () => { + // 直接测试共享工具函数 + it('should return null for empty array', () => { + expect(calculateWaitTimeStats([])).toBeNull() + }) + + it('should return null for null input', () => { + expect(calculateWaitTimeStats(null)).toBeNull() + }) + + it('should return null for undefined input', () => { + expect(calculateWaitTimeStats(undefined)).toBeNull() + }) + + it('should calculate stats correctly for typical data', () => { + const waitTimes = [100, 200, 150, 300, 250, 180, 220, 280, 190, 210] + const stats = calculateWaitTimeStats(waitTimes) + + expect(stats.count).toBe(10) + expect(stats.min).toBe(100) + expect(stats.max).toBe(300) + // Sum: 100+150+180+190+200+210+220+250+280+300 = 2080 + expect(stats.avg).toBe(208) + expect(stats.sampleSizeWarning).toBeUndefined() + }) + + it('should add warning for small sample size (< 10)', () => { + const waitTimes = [100, 200, 300] + const stats = calculateWaitTimeStats(waitTimes) + + expect(stats.count).toBe(3) + expect(stats.sampleSizeWarning).toBe('Results may be inaccurate due to small sample size') + }) + + it('should handle single value', () => { + const stats = calculateWaitTimeStats([500]) + + expect(stats.count).toBe(1) + expect(stats.min).toBe(500) + expect(stats.max).toBe(500) + expect(stats.avg).toBe(500) + expect(stats.p50).toBe(500) + expect(stats.p90).toBe(500) + expect(stats.p99).toBe(500) + }) + + it('should sort input array before calculating', () => { + const waitTimes = [500, 100, 300, 200, 400] + const stats = calculateWaitTimeStats(waitTimes) + + expect(stats.min).toBe(100) + expect(stats.max).toBe(500) + }) + + it('should not modify original array', () => { + const waitTimes = [500, 100, 300] + calculateWaitTimeStats(waitTimes) + + expect(waitTimes).toEqual([500, 100, 300]) + }) + }) + + describe('Exponential Backoff with Jitter', () => { + /** + * 指数退避计算函数(与 auth.js 中的实现一致) + * @param {number} currentInterval - 当前轮询间隔 + * @param {number} backoffFactor - 退避系数 + * @param {number} jitterRatio - 抖动比例 + * @param {number} maxInterval - 最大间隔 + * @param {number} randomValue - 随机值 [0, 1),用于确定性测试 + */ + function calculateNextInterval( + currentInterval, + backoffFactor, + jitterRatio, + maxInterval, + randomValue + ) { + let nextInterval = currentInterval * backoffFactor + // 抖动范围:[-jitterRatio, +jitterRatio] + const jitter = nextInterval * jitterRatio * (randomValue * 2 - 1) + nextInterval = nextInterval + jitter + return Math.max(1, Math.min(nextInterval, maxInterval)) + } + + it('should apply exponential backoff without jitter (randomValue=0.5)', () => { + // randomValue = 0.5 gives jitter = 0 + const next = calculateNextInterval(100, 1.5, 0.2, 1000, 0.5) + expect(next).toBe(150) // 100 * 1.5 = 150 + }) + + it('should apply maximum positive jitter (randomValue=1.0)', () => { + // randomValue = 1.0 gives maximum positive jitter (+20%) + const next = calculateNextInterval(100, 1.5, 0.2, 1000, 1.0) + // 100 * 1.5 = 150, jitter = 150 * 0.2 * 1 = 30 + expect(next).toBe(180) // 150 + 30 + }) + + it('should apply maximum negative jitter (randomValue=0.0)', () => { + // randomValue = 0.0 gives maximum negative jitter (-20%) + const next = calculateNextInterval(100, 1.5, 0.2, 1000, 0.0) + // 100 * 1.5 = 150, jitter = 150 * 0.2 * -1 = -30 + expect(next).toBe(120) // 150 - 30 + }) + + it('should respect maximum interval', () => { + const next = calculateNextInterval(800, 1.5, 0.2, 1000, 1.0) + // 800 * 1.5 = 1200, with +20% jitter = 1440, capped at 1000 + expect(next).toBe(1000) + }) + + it('should never go below 1ms even with extreme negative jitter', () => { + const next = calculateNextInterval(1, 1.0, 0.9, 1000, 0.0) + // 1 * 1.0 = 1, jitter = 1 * 0.9 * -1 = -0.9 + // 1 - 0.9 = 0.1, but Math.max(1, ...) ensures minimum is 1 + expect(next).toBe(1) + }) + + it('should handle zero jitter ratio', () => { + const next = calculateNextInterval(100, 2.0, 0, 1000, 0.0) + expect(next).toBe(200) // Pure exponential, no jitter + }) + + it('should handle large backoff factor', () => { + const next = calculateNextInterval(100, 3.0, 0.1, 1000, 0.5) + expect(next).toBe(300) // 100 * 3.0 = 300 + }) + + describe('jitter distribution', () => { + it('should produce values in expected range', () => { + const results = [] + // Test with various random values + for (let r = 0; r <= 1; r += 0.1) { + results.push(calculateNextInterval(100, 1.5, 0.2, 1000, r)) + } + // All values should be between 120 (150 - 30) and 180 (150 + 30) + expect(Math.min(...results)).toBeGreaterThanOrEqual(120) + expect(Math.max(...results)).toBeLessThanOrEqual(180) + }) + }) + }) + + describe('Queue Size Calculation', () => { + /** + * 最大排队数计算(与 auth.js 中的实现一致) + */ + function calculateMaxQueueSize(concurrencyLimit, multiplier, fixedMin) { + return Math.max(concurrencyLimit * multiplier, fixedMin) + } + + it('should use multiplier when result is larger', () => { + // concurrencyLimit=10, multiplier=2, fixedMin=5 + // max(10*2, 5) = max(20, 5) = 20 + expect(calculateMaxQueueSize(10, 2, 5)).toBe(20) + }) + + it('should use fixed minimum when multiplier result is smaller', () => { + // concurrencyLimit=2, multiplier=1, fixedMin=5 + // max(2*1, 5) = max(2, 5) = 5 + expect(calculateMaxQueueSize(2, 1, 5)).toBe(5) + }) + + it('should handle zero multiplier', () => { + // concurrencyLimit=10, multiplier=0, fixedMin=3 + // max(10*0, 3) = max(0, 3) = 3 + expect(calculateMaxQueueSize(10, 0, 3)).toBe(3) + }) + + it('should handle fractional multiplier', () => { + // concurrencyLimit=10, multiplier=1.5, fixedMin=5 + // max(10*1.5, 5) = max(15, 5) = 15 + expect(calculateMaxQueueSize(10, 1.5, 5)).toBe(15) + }) + }) + + describe('TTL Calculation', () => { + /** + * 排队计数器 TTL 计算(与 redis.js 中的实现一致) + */ + function calculateQueueTtl(timeoutMs, bufferSeconds = 30) { + return Math.ceil(timeoutMs / 1000) + bufferSeconds + } + + it('should calculate TTL with default buffer', () => { + // 60000ms = 60s + 30s buffer = 90s + expect(calculateQueueTtl(60000)).toBe(90) + }) + + it('should round up milliseconds to seconds', () => { + // 61500ms = ceil(61.5) = 62s + 30s = 92s + expect(calculateQueueTtl(61500)).toBe(92) + }) + + it('should handle custom buffer', () => { + // 30000ms = 30s + 60s buffer = 90s + expect(calculateQueueTtl(30000, 60)).toBe(90) + }) + + it('should handle very short timeout', () => { + // 1000ms = 1s + 30s = 31s + expect(calculateQueueTtl(1000)).toBe(31) + }) + }) +}) diff --git a/tests/costCalculator.test.js b/tests/costCalculator.test.js new file mode 100644 index 0000000..cc883a9 --- /dev/null +++ b/tests/costCalculator.test.js @@ -0,0 +1,158 @@ +jest.mock('../src/services/pricingService', () => ({ + calculateCost: jest.fn(), + getModelPricing: jest.fn() +})) + +jest.mock('../src/utils/logger', () => ({ + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + success: jest.fn(), + database: jest.fn(), + api: jest.fn(), + security: jest.fn() +})) + +describe('CostCalculator', () => { + let CostCalculator + let pricingService + let logger + + beforeEach(() => { + jest.resetModules() + + pricingService = require('../src/services/pricingService') + logger = require('../src/utils/logger') + CostCalculator = require('../src/utils/costCalculator') + + jest.clearAllMocks() + pricingService.calculateCost.mockReset() + pricingService.getModelPricing.mockReset() + }) + + it('uses detailed pricing when pricingService returns a complete result', () => { + pricingService.calculateCost.mockReturnValue({ + hasPricing: true, + isLongContextRequest: false, + inputCost: 0.003, + outputCost: 0.0075, + cacheCreateCost: 0.00075, + cacheReadCost: 0.00003, + totalCost: 0.01128, + pricing: { + input: 0.000003, + output: 0.000015, + cacheCreate: 0.00000375, + cacheRead: 0.0000003 + } + }) + + const result = CostCalculator.calculateCost( + { + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 100, + cache_creation: { + ephemeral_5m_input_tokens: 200, + ephemeral_1h_input_tokens: 0 + } + }, + 'claude-sonnet-4-20250514' + ) + + expect(result.usingDynamicPricing).toBe(true) + expect(result.pricing.input).toBe(3) + expect(result.pricing.cacheWrite).toBe(3.75) + expect(result.costs.total).toBeCloseTo(0.01128, 10) + expect(result.debug.usedFallbackPricing).toBe(false) + expect(result.debug.pricingSource).toBe('dynamic') + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('falls back to unknown pricing for detailed-cache requests with missing model pricing', () => { + pricingService.calculateCost.mockReturnValue({ + hasPricing: false, + totalCost: 0, + isLongContextRequest: false + }) + pricingService.getModelPricing.mockReturnValue(null) + + const usage = { + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 100, + cache_creation: { + ephemeral_5m_input_tokens: 100, + ephemeral_1h_input_tokens: 100 + } + } + + const first = CostCalculator.calculateCost(usage, 'kimi-k2.5') + const second = CostCalculator.calculateCost(usage, 'kimi-k2.5') + + expect(first.usingDynamicPricing).toBe(false) + expect(first.pricing.input).toBe(3) + expect(first.pricing.cacheWrite).toBe(3.75) + expect(first.costs.total).toBeCloseTo(0.01128, 10) + expect(first.debug.usedFallbackPricing).toBe(true) + expect(first.debug.pricingSource).toBe('unknown-fallback') + expect(second.costs.total).toBeCloseTo(first.costs.total, 10) + expect(logger.warn).toHaveBeenCalledTimes(1) + expect(logger.warn.mock.calls[0][0]).toContain('kimi-k2.5') + }) + + it('falls back instead of throwing for unknown long-context models', () => { + pricingService.calculateCost.mockReturnValue({ + hasPricing: false, + totalCost: 0, + isLongContextRequest: false + }) + pricingService.getModelPricing.mockReturnValue(null) + + const result = CostCalculator.calculateCost( + { + input_tokens: 250000, + output_tokens: 1000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + }, + 'mystery-model[1m]' + ) + + expect(result.usingDynamicPricing).toBe(false) + expect(result.costs.total).toBeCloseTo(0.765, 10) + expect(result.debug.usedFallbackPricing).toBe(true) + expect(result.debug.isLongContextModel).toBe(true) + expect(result.debug.pricingSource).toBe('unknown-fallback') + }) + + it('keeps the legacy dynamic-pricing path for regular requests', () => { + pricingService.getModelPricing.mockReturnValue({ + input_cost_per_token: 0.000002, + output_cost_per_token: 0.000008, + cache_creation_input_token_cost: 0.0000025, + cache_read_input_token_cost: 0.0000002 + }) + + const result = CostCalculator.calculateCost( + { + input_tokens: 2000, + output_tokens: 1000, + cache_creation_input_tokens: 500, + cache_read_input_tokens: 250 + }, + 'glm-5' + ) + + expect(pricingService.calculateCost).not.toHaveBeenCalled() + expect(result.usingDynamicPricing).toBe(true) + expect(result.pricing.input).toBe(2) + expect(result.pricing.output).toBe(8) + expect(result.costs.total).toBeCloseTo(0.0133, 10) + expect(result.debug.usedFallbackPricing).toBe(false) + expect(result.debug.pricingSource).toBe('dynamic') + }) +}) diff --git a/tests/metadataUserIdHelper.integration.test.js b/tests/metadataUserIdHelper.integration.test.js new file mode 100644 index 0000000..d715657 --- /dev/null +++ b/tests/metadataUserIdHelper.integration.test.js @@ -0,0 +1,381 @@ +/** + * 集成级等价性测试 + * + * 内嵌旧代码逻辑的精确副本,与新代码做 A/B 对比, + * 证明对旧格式输入,新代码的行为与旧代码完全一致。 + * 同时验证新 JSON 格式在旧代码下的失败表现。 + */ + +const crypto = require('crypto') +const { parse, extractSessionId, build, isValid } = require('../src/utils/metadataUserIdHelper') + +// ─── 复制 requestIdentityService 中的 formatUuidFromSeed(保持不变) ─── +function formatUuidFromSeed(seed) { + const digest = crypto.createHash('sha256').update(String(seed)).digest() + const bytes = Buffer.from(digest.subarray(0, 16)) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +function normalizeAccountUuid(candidate) { + if (typeof candidate !== 'string') return null + const trimmed = candidate.trim() + return trimmed || null +} + +// ─── 旧版 rewriteUserId 精确副本 ─── +const SESSION_PREFIX = 'session_' +const ACCOUNT_MARKER = '_account_' + +function oldRewriteUserId(body, accountId, accountUuid) { + if (!body || typeof body !== 'object') return { nextBody: body, changed: false } + const { metadata } = body + if (!metadata || typeof metadata !== 'object') return { nextBody: body, changed: false } + const userId = metadata.user_id + if (typeof userId !== 'string') return { nextBody: body, changed: false } + + const pivot = userId.lastIndexOf(SESSION_PREFIX) + if (pivot === -1) return { nextBody: body, changed: false } + + const prefixBeforeSession = userId.slice(0, pivot) + const sessionTail = userId.slice(pivot + SESSION_PREFIX.length) + const seedTail = sessionTail || 'default' + const effectiveScheduler = accountId ? String(accountId) : 'unknown-scheduler' + const hashed = formatUuidFromSeed(`${effectiveScheduler}::${seedTail}`) + + let normalizedPrefix = prefixBeforeSession + if (accountUuid) { + const trimmedUuid = normalizeAccountUuid(accountUuid) + if (trimmedUuid) { + const accountIndex = normalizedPrefix.indexOf(ACCOUNT_MARKER) + if (accountIndex === -1) { + const base = normalizedPrefix.replace(/_+$/, '') + const baseWithMarker = /_account$/.test(base) ? base : `${base}_account` + normalizedPrefix = `${baseWithMarker}_${trimmedUuid}_` + } else { + const valueStart = accountIndex + ACCOUNT_MARKER.length + let separatorIndex = normalizedPrefix.indexOf('_', valueStart) + if (separatorIndex === -1) separatorIndex = normalizedPrefix.length + const head = normalizedPrefix.slice(0, valueStart) + let tail = '_' + if (separatorIndex < normalizedPrefix.length) { + tail = normalizedPrefix.slice(separatorIndex) + if (/^_+$/.test(tail)) tail = '_' + } + normalizedPrefix = `${head}${trimmedUuid}${tail}` + } + } + } + + const nextUserId = `${normalizedPrefix}${SESSION_PREFIX}${hashed}` + if (nextUserId === userId) return { nextBody: body, changed: false } + return { + nextBody: { ...body, metadata: { ...metadata, user_id: nextUserId } }, + changed: true + } +} + +// ─── 新版 rewriteUserId(从 requestIdentityService 中提取的逻辑) ─── +function newRewriteUserId(body, accountId, accountUuid) { + if (!body || typeof body !== 'object') return { nextBody: body, changed: false } + const { metadata } = body + if (!metadata || typeof metadata !== 'object') return { nextBody: body, changed: false } + const userId = metadata.user_id + if (typeof userId !== 'string') return { nextBody: body, changed: false } + + const parsed = parse(userId) + if (!parsed) return { nextBody: body, changed: false } + + const seedTail = parsed.sessionId || 'default' + const effectiveScheduler = accountId ? String(accountId) : 'unknown-scheduler' + const hashedSession = formatUuidFromSeed(`${effectiveScheduler}::${seedTail}`) + const effectiveUuid = normalizeAccountUuid(accountUuid) || parsed.accountUuid || '' + + const nextUserId = build({ + deviceId: parsed.deviceId, + accountUuid: effectiveUuid, + sessionId: hashedSession, + isJsonFormat: parsed.isJsonFormat + }) + + if (nextUserId === userId) return { nextBody: body, changed: false } + return { + nextBody: { ...body, metadata: { ...metadata, user_id: nextUserId } }, + changed: true + } +} + +// ─── 旧版 _replaceClientId 精确副本 ─── +function oldReplaceClientId(body, unifiedClientId) { + if (!body || !body.metadata || !body.metadata.user_id || !unifiedClientId) return + const userId = body.metadata.user_id + const match = userId.match(/^user_[a-f0-9]{64}(_account__session_[a-f0-9-]{36})$/) + if (match && match[1]) { + body.metadata.user_id = `user_${unifiedClientId}${match[1]}` + } +} + +// ─── 新版 _replaceClientId ─── +function newReplaceClientId(body, unifiedClientId) { + if (!body?.metadata?.user_id || !unifiedClientId) return + const parsed = parse(body.metadata.user_id) + if (!parsed) return + body.metadata.user_id = build({ ...parsed, deviceId: unifiedClientId }) +} + +// ─── 旧版 sessionHelper extractSessionId ─── +function oldExtractSessionFromMetadata(userId) { + const sessionMatch = userId.match(/session_([a-f0-9-]{36})/) + return sessionMatch && sessionMatch[1] ? sessionMatch[1] : null +} + +// ─── 旧版 claudeRelayConfigService extractOriginalSessionId ─── +function oldExtractOriginalSessionId(userId) { + const match = userId.match(/session_([a-f0-9-]{36})$/i) + return match ? match[1] : null +} + +// ─── 旧版 claudeCodeValidator 的 user_id 校验 ─── +function oldIsValidUserId(userId) { + const userIdPattern = /^user_[a-fA-F0-9]{64}_account__session_[\w-]+$/ + return userIdPattern.test(userId) +} + +// ─── 测试数据 ─── +const DEVICE_HASH = 'd98385411c93cd074b2cefd5c9831fe77f24a53e4ecdcd1f830bba586fe62cb9' +const SESSION_UUID = '17cf0fd3-d51b-4b59-977d-b899dafb3022' +const OLD_USERID = `user_${DEVICE_HASH}_account__session_${SESSION_UUID}` +const OLD_USERID_WITH_UUID = `user_${DEVICE_HASH}_account_real-uuid-123_session_${SESSION_UUID}` + +const JSON_DEVICE = 'd61f8a2c3b4e5f6071829a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70810169' +const JSON_SESSION = 'c72554f2-d198-4fd4-99c8-81e46410a1c5' +const JSON_USERID = JSON.stringify({ + device_id: JSON_DEVICE, + account_uuid: '', + session_id: JSON_SESSION +}) + +// ======================================================== +// 测试 +// ======================================================== + +describe('旧格式等价性验证', () => { + // ---- rewriteUserId ---- + describe('rewriteUserId: 旧格式 → 新旧代码产出完全一致', () => { + const cases = [ + ['空 accountUuid, 有 accountId', 'acct-1', undefined], + ['空 accountUuid, 无 accountId', undefined, undefined], + ['提供 accountUuid', 'acct-1', 'real-uuid-123'], + ['accountUuid 为空白字符串', 'acct-1', ' '], + ['accountUuid 带空格', 'acct-1', ' real-uuid '], + ['accountUuid 为 null', 'acct-1', null], + ['accountUuid 为空字符串', 'acct-1', ''] + ] + + test.each(cases)('%s', (_label, accountId, accountUuid) => { + const bodyOld = { metadata: { user_id: OLD_USERID }, model: 'claude-3' } + const bodyNew = { metadata: { user_id: OLD_USERID }, model: 'claude-3' } + + const oldResult = oldRewriteUserId(bodyOld, accountId, accountUuid) + const newResult = newRewriteUserId(bodyNew, accountId, accountUuid) + + expect(newResult.nextBody.metadata.user_id).toBe(oldResult.nextBody.metadata.user_id) + expect(newResult.changed).toBe(oldResult.changed) + }) + + it('旧格式已有 accountUuid 时覆盖新 uuid', () => { + const bodyOld = { metadata: { user_id: OLD_USERID_WITH_UUID }, model: 'claude-3' } + const bodyNew = { metadata: { user_id: OLD_USERID_WITH_UUID }, model: 'claude-3' } + + const oldResult = oldRewriteUserId(bodyOld, 'acct-2', 'new-uuid-456') + const newResult = newRewriteUserId(bodyNew, 'acct-2', 'new-uuid-456') + + expect(newResult.nextBody.metadata.user_id).toBe(oldResult.nextBody.metadata.user_id) + }) + }) + + // ---- _replaceClientId ---- + describe('_replaceClientId: 旧格式 → 新旧代码产出完全一致', () => { + it('标准替换', () => { + const unified = 'a'.repeat(64) + const bodyOld = { metadata: { user_id: OLD_USERID } } + const bodyNew = { metadata: { user_id: OLD_USERID } } + + oldReplaceClientId(bodyOld, unified) + newReplaceClientId(bodyNew, unified) + + expect(bodyNew.metadata.user_id).toBe(bodyOld.metadata.user_id) + }) + }) + + // ---- extractSessionId ---- + describe('extractSessionId: 旧格式 → 新旧代码产出完全一致', () => { + it('sessionHelper 路径', () => { + expect(extractSessionId(OLD_USERID)).toBe(oldExtractSessionFromMetadata(OLD_USERID)) + }) + + it('claudeRelayConfigService 路径', () => { + expect(extractSessionId(OLD_USERID)).toBe(oldExtractOriginalSessionId(OLD_USERID)) + }) + + it('带 accountUuid 的旧格式', () => { + expect(extractSessionId(OLD_USERID_WITH_UUID)).toBe(SESSION_UUID) + // 旧代码也能匹配(非锚定正则) + expect(oldExtractSessionFromMetadata(OLD_USERID_WITH_UUID)).toBe(SESSION_UUID) + }) + }) + + // ---- claudeCodeValidator ---- + describe('claudeCodeValidator isValid: 旧格式 → 新代码不弱于旧代码', () => { + it('标准旧格式两者都通过', () => { + expect(oldIsValidUserId(OLD_USERID)).toBe(true) + expect(isValid(OLD_USERID)).toBe(true) + }) + }) +}) + +describe('JSON 格式正确性验证', () => { + describe('旧代码在 JSON 格式下全部失败(验证 bug 存在)', () => { + it('旧 validator 拒绝 JSON', () => { + expect(oldIsValidUserId(JSON_USERID)).toBe(false) + }) + + it('旧 sessionHelper 提取失败', () => { + // 旧正则 /session_([a-f0-9-]{36})/ 在 JSON 中匹配 "session_id" 但 capture 不到 + // 因为 'i' 不在 [a-f0-9-] 中 + expect(oldExtractSessionFromMetadata(JSON_USERID)).toBeNull() + }) + + it('旧 extractOriginalSessionId 提取失败', () => { + expect(oldExtractOriginalSessionId(JSON_USERID)).toBeNull() + }) + + it('旧 rewriteUserId 产出垃圾数据', () => { + const body = { metadata: { user_id: JSON_USERID } } + const result = oldRewriteUserId(body, 'acct-1', null) + // 旧代码 lastIndexOf('session_') 匹配到 JSON key "session_id" 中的 session_ + // 切割后产出损坏的字符串 + if (result.changed) { + expect(result.nextBody.metadata.user_id).not.toContain('"session_id"') + } + }) + + it('旧 _replaceClientId 静默不生效', () => { + const body = { metadata: { user_id: JSON_USERID } } + const original = body.metadata.user_id + oldReplaceClientId(body, 'a'.repeat(64)) + expect(body.metadata.user_id).toBe(original) // 未变化 + }) + }) + + describe('新代码在 JSON 格式下全部正确', () => { + it('新 validator 接受 JSON', () => { + expect(isValid(JSON_USERID)).toBe(true) + }) + + it('新 extractSessionId 正确提取', () => { + expect(extractSessionId(JSON_USERID)).toBe(JSON_SESSION) + }) + + it('新 rewriteUserId 保留 JSON 格式并正确改写', () => { + const body = { metadata: { user_id: JSON_USERID }, model: 'claude-3' } + const result = newRewriteUserId(body, 'acct-1', 'real-uuid') + + expect(result.changed).toBe(true) + // 输出仍为合法 JSON + const output = JSON.parse(result.nextBody.metadata.user_id) + expect(output.device_id).toBe(JSON_DEVICE) // deviceId 不变 + expect(output.account_uuid).toBe('real-uuid') // 注入了真实 uuid + expect(output.session_id).not.toBe(JSON_SESSION) // session 被哈希 + // 哈希后是合法 UUID 格式 + expect(output.session_id).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/ + ) + }) + + it('新 rewriteUserId JSON 格式无 accountUuid 时保留空', () => { + const body = { metadata: { user_id: JSON_USERID }, model: 'claude-3' } + const result = newRewriteUserId(body, 'acct-1', null) + + expect(result.changed).toBe(true) + const output = JSON.parse(result.nextBody.metadata.user_id) + expect(output.account_uuid).toBe('') + }) + + it('新 _replaceClientId 正确替换 deviceId 并保留 JSON 格式', () => { + const newDeviceId = 'b'.repeat(64) + const body = { metadata: { user_id: JSON_USERID } } + newReplaceClientId(body, newDeviceId) + + const output = JSON.parse(body.metadata.user_id) + expect(output.device_id).toBe(newDeviceId) + expect(output.account_uuid).toBe('') + expect(output.session_id).toBe(JSON_SESSION) // session 不变 + }) + }) +}) + +describe('rewriteUserId 哈希 seed 一致性', () => { + it('旧格式:新旧代码使用完全相同的 seed 做哈希', () => { + // 旧代码 seed: `${effectiveScheduler}::${sessionTail}` + // sessionTail = userId.slice(pivot + 'session_'.length) + // = SESSION_UUID + // 新代码 seed: `${effectiveScheduler}::${parsed.sessionId}` + // parsed.sessionId = SESSION_UUID (regex capture) + // 两者 seed 完全相同 + const body1 = { metadata: { user_id: OLD_USERID } } + const body2 = { metadata: { user_id: OLD_USERID } } + const old = oldRewriteUserId(body1, 'acct-1', null) + const neo = newRewriteUserId(body2, 'acct-1', null) + expect(neo.nextBody.metadata.user_id).toBe(old.nextBody.metadata.user_id) + }) + + it('JSON 格式:seed 是正确的 sessionId 而非旧代码的垃圾截断', () => { + const body = { metadata: { user_id: JSON_USERID } } + const result = newRewriteUserId(body, 'acct-1', null) + const expected = formatUuidFromSeed(`acct-1::${JSON_SESSION}`) + const output = JSON.parse(result.nextBody.metadata.user_id) + expect(output.session_id).toBe(expected) + }) +}) + +describe('边界条件', () => { + it('非字符串 user_id → 新旧代码都不处理', () => { + const body = { metadata: { user_id: 12345 } } + const result = newRewriteUserId(body, 'acct-1', null) + expect(result.changed).toBe(false) + }) + + it('无 metadata → 新旧代码都不处理', () => { + const result = newRewriteUserId({ model: 'claude-3' }, 'acct-1', null) + expect(result.changed).toBe(false) + }) + + it('无法解析的字符串 → 新代码不处理', () => { + const body = { metadata: { user_id: 'random-garbage' } } + const result = newRewriteUserId(body, 'acct-1', null) + expect(result.changed).toBe(false) + }) + + it('空 body → 新代码不处理', () => { + const result = newRewriteUserId(null, 'acct-1', null) + expect(result.changed).toBe(false) + }) + + it('_replaceClientId 无 unifiedClientId → 不处理', () => { + const body = { metadata: { user_id: OLD_USERID } } + const original = body.metadata.user_id + newReplaceClientId(body, null) + expect(body.metadata.user_id).toBe(original) + }) + + it('_replaceClientId 无 user_id → 不处理', () => { + const body = { metadata: {} } + newReplaceClientId(body, 'abc') + expect(body.metadata.user_id).toBeUndefined() + }) +}) diff --git a/tests/metadataUserIdHelper.test.js b/tests/metadataUserIdHelper.test.js new file mode 100644 index 0000000..42990f8 --- /dev/null +++ b/tests/metadataUserIdHelper.test.js @@ -0,0 +1,193 @@ +const { parse, extractSessionId, build, isValid } = require('../src/utils/metadataUserIdHelper') + +const OLD_FORMAT = + 'user_d98385411c93cd074b2cefd5c9831fe77f24a53e4ecdcd1f830bba586fe62cb9_account__session_17cf0fd3-d51b-4b59-977d-b899dafb3022' + +const OLD_FORMAT_WITH_UUID = + 'user_d98385411c93cd074b2cefd5c9831fe77f24a53e4ecdcd1f830bba586fe62cb9_account_abc-123_session_17cf0fd3-d51b-4b59-977d-b899dafb3022' + +const JSON_FORMAT = JSON.stringify({ + device_id: 'd61f8a2c3b4e5f6071829a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70810169', + account_uuid: '', + session_id: 'c72554f2-d198-4fd4-99c8-81e46410a1c5' +}) + +const JSON_FORMAT_WITH_UUID = JSON.stringify({ + device_id: 'd61f8a2c3b4e5f6071829a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70810169', + account_uuid: 'some-uuid-value', + session_id: 'c72554f2-d198-4fd4-99c8-81e46410a1c5' +}) + +describe('metadataUserIdHelper', () => { + describe('parse()', () => { + it('should parse old format with empty accountUuid', () => { + const result = parse(OLD_FORMAT) + expect(result).toEqual({ + deviceId: 'd98385411c93cd074b2cefd5c9831fe77f24a53e4ecdcd1f830bba586fe62cb9', + accountUuid: '', + sessionId: '17cf0fd3-d51b-4b59-977d-b899dafb3022', + isJsonFormat: false + }) + }) + + it('should parse old format with non-empty accountUuid', () => { + const result = parse(OLD_FORMAT_WITH_UUID) + expect(result).toEqual({ + deviceId: 'd98385411c93cd074b2cefd5c9831fe77f24a53e4ecdcd1f830bba586fe62cb9', + accountUuid: 'abc-123', + sessionId: '17cf0fd3-d51b-4b59-977d-b899dafb3022', + isJsonFormat: false + }) + }) + + it('should parse JSON format', () => { + const result = parse(JSON_FORMAT) + expect(result).toEqual({ + deviceId: 'd61f8a2c3b4e5f6071829a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70810169', + accountUuid: '', + sessionId: 'c72554f2-d198-4fd4-99c8-81e46410a1c5', + isJsonFormat: true + }) + }) + + it('should parse JSON format with accountUuid', () => { + const result = parse(JSON_FORMAT_WITH_UUID) + expect(result).toEqual({ + deviceId: 'd61f8a2c3b4e5f6071829a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70810169', + accountUuid: 'some-uuid-value', + sessionId: 'c72554f2-d198-4fd4-99c8-81e46410a1c5', + isJsonFormat: true + }) + }) + + it('should tolerate extra fields in JSON format', () => { + const extended = JSON.stringify({ + device_id: 'aabbccdd' + '0'.repeat(56), + account_uuid: '', + session_id: '11111111-2222-3333-4444-555555555555', + some_future_field: 'value' + }) + const result = parse(extended) + expect(result).not.toBeNull() + expect(result.sessionId).toBe('11111111-2222-3333-4444-555555555555') + expect(result.isJsonFormat).toBe(true) + }) + + it('should return null for invalid JSON (missing device_id)', () => { + const invalid = JSON.stringify({ account_uuid: '', session_id: 'abc' }) + expect(parse(invalid)).toBeNull() + }) + + it('should return null for invalid JSON (missing session_id)', () => { + const invalid = JSON.stringify({ device_id: 'abc', account_uuid: '' }) + expect(parse(invalid)).toBeNull() + }) + + it('should return null for empty string', () => { + expect(parse('')).toBeNull() + }) + + it('should return null for random string', () => { + expect(parse('some-random-string')).toBeNull() + }) + + it('should return null for malformed JSON', () => { + expect(parse('{garbage')).toBeNull() + }) + + it('should return null for null/undefined/number', () => { + expect(parse(null)).toBeNull() + expect(parse(undefined)).toBeNull() + expect(parse(123)).toBeNull() + }) + }) + + describe('extractSessionId()', () => { + it('should extract sessionId from old format', () => { + expect(extractSessionId(OLD_FORMAT)).toBe('17cf0fd3-d51b-4b59-977d-b899dafb3022') + }) + + it('should extract sessionId from JSON format', () => { + expect(extractSessionId(JSON_FORMAT)).toBe('c72554f2-d198-4fd4-99c8-81e46410a1c5') + }) + + it('should return null for invalid input', () => { + expect(extractSessionId('invalid')).toBeNull() + expect(extractSessionId(null)).toBeNull() + }) + }) + + describe('build()', () => { + it('should build old format string', () => { + const result = build({ + deviceId: 'd98385411c93cd074b2cefd5c9831fe77f24a53e4ecdcd1f830bba586fe62cb9', + accountUuid: '', + sessionId: '17cf0fd3-d51b-4b59-977d-b899dafb3022', + isJsonFormat: false + }) + expect(result).toBe(OLD_FORMAT) + }) + + it('should build old format with accountUuid', () => { + const result = build({ + deviceId: 'd98385411c93cd074b2cefd5c9831fe77f24a53e4ecdcd1f830bba586fe62cb9', + accountUuid: 'abc-123', + sessionId: '17cf0fd3-d51b-4b59-977d-b899dafb3022', + isJsonFormat: false + }) + expect(result).toBe(OLD_FORMAT_WITH_UUID) + }) + + it('should build JSON format string', () => { + const result = build({ + deviceId: 'd61f8a2c3b4e5f6071829a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70810169', + accountUuid: '', + sessionId: 'c72554f2-d198-4fd4-99c8-81e46410a1c5', + isJsonFormat: true + }) + const parsed = JSON.parse(result) + expect(parsed.device_id).toBe( + 'd61f8a2c3b4e5f6071829a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f70810169' + ) + expect(parsed.account_uuid).toBe('') + expect(parsed.session_id).toBe('c72554f2-d198-4fd4-99c8-81e46410a1c5') + }) + }) + + describe('roundtrip consistency', () => { + it('old format parse → build should produce identical string', () => { + const parsed = parse(OLD_FORMAT) + expect(build(parsed)).toBe(OLD_FORMAT) + }) + + it('old format with uuid parse → build should produce identical string', () => { + const parsed = parse(OLD_FORMAT_WITH_UUID) + expect(build(parsed)).toBe(OLD_FORMAT_WITH_UUID) + }) + + it('JSON format parse → build should produce equivalent JSON', () => { + const parsed = parse(JSON_FORMAT) + const rebuilt = build(parsed) + expect(JSON.parse(rebuilt)).toEqual(JSON.parse(JSON_FORMAT)) + }) + }) + + describe('isValid()', () => { + it('should return true for old format', () => { + expect(isValid(OLD_FORMAT)).toBe(true) + }) + + it('should return true for JSON format', () => { + expect(isValid(JSON_FORMAT)).toBe(true) + }) + + it('should return false for invalid inputs', () => { + expect(isValid('')).toBe(false) + expect(isValid('random-string')).toBe(false) + expect(isValid('{garbage')).toBe(false) + expect(isValid(null)).toBe(false) + expect(isValid(undefined)).toBe(false) + expect(isValid(42)).toBe(false) + }) + }) +}) diff --git a/tests/modelRateLimitFamily.test.js b/tests/modelRateLimitFamily.test.js new file mode 100644 index 0000000..0f42a74 --- /dev/null +++ b/tests/modelRateLimitFamily.test.js @@ -0,0 +1,28 @@ +const { getRateLimitModelFamily, RATE_LIMITED_MODEL_FAMILIES } = require('../src/utils/modelHelper') + +describe('getRateLimitModelFamily', () => { + it('maps each Claude model to its independent rate-limit family', () => { + expect(getRateLimitModelFamily('claude-opus-4-8')).toBe('opus') + expect(getRateLimitModelFamily('claude-sonnet-4-5')).toBe('sonnet') + expect(getRateLimitModelFamily('claude-sonnet-4-6')).toBe('sonnet') + expect(getRateLimitModelFamily('claude-fable-5')).toBe('fable') + expect(getRateLimitModelFamily('claude-fable-5-mythos-5')).toBe('fable') + expect(getRateLimitModelFamily('claude-3-5-haiku-20241022')).toBe('haiku') + }) + + it('strips vendor prefixes before matching', () => { + expect(getRateLimitModelFamily('ccr,claude-sonnet-4-5')).toBe('sonnet') + }) + + it('returns null for unknown or invalid models', () => { + expect(getRateLimitModelFamily('deepseek-chat')).toBeNull() + expect(getRateLimitModelFamily('')).toBeNull() + expect(getRateLimitModelFamily(null)).toBeNull() + expect(getRateLimitModelFamily(undefined)).toBeNull() + expect(getRateLimitModelFamily(123)).toBeNull() + }) + + it('exposes the family list used for per-model rate-limit buckets', () => { + expect(RATE_LIMITED_MODEL_FAMILIES).toEqual(['opus', 'sonnet', 'haiku', 'fable']) + }) +}) diff --git a/tests/modelsConfig.test.js b/tests/modelsConfig.test.js new file mode 100644 index 0000000..0d3ce5e --- /dev/null +++ b/tests/modelsConfig.test.js @@ -0,0 +1,10 @@ +const { CLAUDE_MODELS } = require('../config/models') + +describe('models config', () => { + it('places Claude Sonnet 4.6 as the second Claude model option', () => { + expect(CLAUDE_MODELS[1]).toEqual({ + value: 'claude-sonnet-4-6', + label: 'Claude Sonnet 4.6' + }) + }) +}) diff --git a/tests/openaiResponsesPayloadToggles.test.js b/tests/openaiResponsesPayloadToggles.test.js new file mode 100644 index 0000000..e522dc8 --- /dev/null +++ b/tests/openaiResponsesPayloadToggles.test.js @@ -0,0 +1,550 @@ +const crypto = require('crypto') + +const mockRouter = { + get: jest.fn(), + post: jest.fn() +} + +jest.mock( + 'express', + () => ({ + Router: () => mockRouter + }), + { virtual: true } +) + +jest.mock( + '../config/config', + () => ({ + requestTimeout: 1000 + }), + { virtual: true } +) + +jest.mock('../src/middleware/auth', () => ({ + authenticateApiKey: jest.fn((_req, _res, next) => next()) +})) + +jest.mock('axios', () => ({ + post: jest.fn() +})) + +jest.mock('../src/services/scheduler/unifiedOpenAIScheduler', () => ({ + selectAccountForApiKey: jest.fn(), + markAccountRateLimited: jest.fn(), + isAccountRateLimited: jest.fn().mockResolvedValue(false), + removeAccountRateLimit: jest.fn(), + markAccountUnauthorized: jest.fn() +})) + +jest.mock('../src/services/account/openaiAccountService', () => ({ + getAccount: jest.fn(), + decrypt: jest.fn(), + isTokenExpired: jest.fn(() => false), + refreshAccountToken: jest.fn(), + updateCodexUsageSnapshot: jest.fn() +})) + +jest.mock('../src/services/account/openaiResponsesAccountService', () => ({ + getAccount: jest.fn() +})) + +jest.mock('../src/services/relay/openaiResponsesRelayService', () => ({ + handleRequest: jest.fn() +})) + +jest.mock('../src/services/apiKeyService', () => ({ + hasPermission: jest.fn(() => true), + recordUsage: jest.fn() +})) + +jest.mock('../src/models/redis', () => ({ + getUsageStats: jest.fn() +})) + +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + api: jest.fn(), + security: jest.fn() +})) + +jest.mock('../src/utils/proxyHelper', () => ({ + createProxyAgent: jest.fn(() => null), + getProxyDescription: jest.fn(() => 'none') +})) + +jest.mock('../src/utils/rateLimitHelper', () => ({ + updateRateLimitCounters: jest.fn() +})) + +jest.mock('../src/utils/sseParser', () => ({ + IncrementalSSEParser: jest.fn().mockImplementation(() => ({ + feed: jest.fn(() => []), + getRemaining: jest.fn(() => '') + })) +})) + +jest.mock('../src/utils/errorSanitizer', () => ({ + getSafeMessage: jest.fn((error) => error?.message || 'error') +})) + +jest.mock('../src/utils/requestDetailHelper', () => ({ + createRequestDetailMeta: jest.fn(() => null), + extractOpenAICacheReadTokens: jest.fn(() => 0) +})) + +const unifiedOpenAIScheduler = require('../src/services/scheduler/unifiedOpenAIScheduler') +const axios = require('axios') +const apiKeyService = require('../src/services/apiKeyService') +const openaiAccountService = require('../src/services/account/openaiAccountService') +const openaiResponsesAccountService = require('../src/services/account/openaiResponsesAccountService') +const openaiResponsesRelayService = require('../src/services/relay/openaiResponsesRelayService') +const openaiRoutes = require('../src/routes/openaiRoutes') + +function createHash(value) { + return crypto.createHash('sha256').update(value).digest('hex') +} + +function createReq({ + path = '/v1/responses', + body = {}, + userAgent = 'my-client/1.0', + apiKeyOverrides = {}, + fromUnifiedEndpoint = false +} = {}) { + return { + method: 'POST', + path, + originalUrl: `/openai${path}`, + headers: { + 'user-agent': userAgent + }, + body: JSON.parse(JSON.stringify(body)), + apiKey: { + id: 'key_1', + permissions: ['openai'], + enableOpenAIResponsesCodexAdaptation: true, + enableOpenAIResponsesPayloadRules: false, + openaiResponsesPayloadRules: [], + ...apiKeyOverrides + }, + _fromUnifiedEndpoint: fromUnifiedEndpoint + } +} + +function createRes() { + const res = { + statusCode: 200, + headers: {}, + destroyed: false, + writableEnded: false, + headersSent: false, + status: jest.fn((code) => { + res.statusCode = code + return res + }), + json: jest.fn((payload) => { + res.payload = payload + return res + }), + setHeader: jest.fn((key, value) => { + res.headers[key] = value + }), + set: jest.fn((key, value) => { + res.headers[key] = value + return res + }) + } + return res +} + +describe('openai responses payload toggles', () => { + beforeEach(() => { + jest.clearAllMocks() + + unifiedOpenAIScheduler.selectAccountForApiKey.mockResolvedValue({ + accountId: 'resp-1', + accountType: 'openai-responses' + }) + + openaiResponsesAccountService.getAccount.mockResolvedValue({ + id: 'resp-1', + name: 'Responses Account', + apiKey: 'sk-responses' + }) + + openaiResponsesRelayService.handleRequest.mockResolvedValue({ ok: true }) + openaiAccountService.decrypt.mockReturnValue('decrypted-token') + }) + + test('keeps standard responses payload unchanged for openai-responses when both toggles are off', async () => { + const req = createReq({ + body: { + model: 'gpt-5-2025-08-07', + temperature: 0.2, + service_tier: 'priority', + prompt_cache_key: 'session-a' + }, + apiKeyOverrides: { + enableOpenAIResponsesCodexAdaptation: false, + enableOpenAIResponsesPayloadRules: false + } + }) + + await openaiRoutes.handleResponses(req, createRes()) + + expect(req.body).toEqual({ + model: 'gpt-5-2025-08-07', + temperature: 0.2, + service_tier: 'priority', + prompt_cache_key: 'session-a' + }) + expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith( + req.apiKey, + createHash('session-a'), + 'gpt-5' + ) + }) + + test('applies Codex adaptation only when adaptation toggle is on', async () => { + const req = createReq({ + body: { + model: 'gpt-5-2025-08-07', + temperature: 0.2, + service_tier: 'priority', + prompt_cache_key: 'session-b' + } + }) + + await openaiRoutes.handleResponses(req, createRes()) + + expect(req.body.model).toBe('gpt-5') + expect(req.body.instructions).toBe(openaiRoutes.CODEX_CLI_INSTRUCTIONS) + expect(req.body.temperature).toBeUndefined() + expect(req.body.service_tier).toBeUndefined() + expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith( + req.apiKey, + createHash('session-b'), + 'gpt-5' + ) + }) + + test('applies payload rules directly on the original payload when adaptation is off', async () => { + const req = createReq({ + body: { + model: 'gpt-4.1', + temperature: 0.5, + prompt_cache_key: 'old-key', + text: { format: {} } + }, + apiKeyOverrides: { + enableOpenAIResponsesCodexAdaptation: false, + enableOpenAIResponsesPayloadRules: true, + openaiResponsesPayloadRules: [ + { path: 'model', valueType: 'string', value: 'gpt-5' }, + { path: 'prompt_cache_key', valueType: 'string', value: 'new-key' }, + { path: 'text.format.type', valueType: 'string', value: 'json_schema' } + ] + } + }) + + await openaiRoutes.handleResponses(req, createRes()) + + expect(req.body).toEqual({ + model: 'gpt-5', + temperature: 0.5, + prompt_cache_key: 'new-key', + text: { + format: { + type: 'json_schema' + } + } + }) + expect(req.body.instructions).toBeUndefined() + expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith( + req.apiKey, + createHash('new-key'), + 'gpt-5' + ) + }) + + test('applies payload rules after Codex adaptation when both toggles are on', async () => { + const req = createReq({ + body: { + model: 'gpt-5-2025-08-07', + prompt_cache_key: 'legacy-key', + temperature: 0.2, + instructions: 'raw' + }, + apiKeyOverrides: { + enableOpenAIResponsesCodexAdaptation: true, + enableOpenAIResponsesPayloadRules: true, + openaiResponsesPayloadRules: [ + { path: 'model', valueType: 'string', value: 'gpt-5.5' }, + { path: 'instructions', valueType: 'string', value: 'custom instructions' }, + { path: 'prompt_cache_key', valueType: 'string', value: 'rule-key' } + ] + } + }) + + await openaiRoutes.handleResponses(req, createRes()) + + expect(req.body.model).toBe('gpt-5.5') + expect(req.body.instructions).toBe('custom instructions') + expect(req.body.temperature).toBeUndefined() + expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith( + req.apiKey, + createHash('rule-key'), + 'gpt-5.5' + ) + }) + + test('normalizes dated gpt-5 models only for scheduling and upstream openai requests when adaptation is off', async () => { + unifiedOpenAIScheduler.selectAccountForApiKey.mockResolvedValue({ + accountId: 'openai-1', + accountType: 'openai' + }) + openaiAccountService.getAccount.mockResolvedValue({ + id: 'openai-1', + name: 'OpenAI Account', + accessToken: 'encrypted-token', + accountId: 'chatgpt-account-1' + }) + axios.post.mockResolvedValue({ + status: 200, + data: { + model: 'gpt-5', + usage: { + input_tokens: 10, + output_tokens: 4, + total_tokens: 14 + } + }, + headers: {} + }) + + const req = createReq({ + body: { + model: 'gpt-5-2025-08-07', + service_tier: 'priority', + prompt_cache_key: 'compat-key', + stream: false + }, + apiKeyOverrides: { + enableOpenAIResponsesCodexAdaptation: false, + enableOpenAIResponsesPayloadRules: false + } + }) + + await openaiRoutes.handleResponses(req, createRes()) + + expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith( + req.apiKey, + createHash('compat-key'), + 'gpt-5' + ) + expect(req.body.model).toBe('gpt-5') + expect(req.body.service_tier).toBe('priority') + expect(axios.post).toHaveBeenCalled() + expect(axios.post.mock.calls[0][1]).toMatchObject({ + model: 'gpt-5', + service_tier: 'priority', + store: false + }) + }) + + test('normalizes payload-rule gpt-5 aliases for openai scheduling without applying full Codex adaptation', async () => { + unifiedOpenAIScheduler.selectAccountForApiKey.mockResolvedValue({ + accountId: 'openai-1', + accountType: 'openai' + }) + openaiAccountService.getAccount.mockResolvedValue({ + id: 'openai-1', + name: 'OpenAI Account', + accessToken: 'encrypted-token', + accountId: 'chatgpt-account-1' + }) + axios.post.mockResolvedValue({ + status: 200, + data: { + model: 'gpt-5', + usage: { + input_tokens: 8, + output_tokens: 3, + total_tokens: 11 + } + }, + headers: {} + }) + + const req = createReq({ + body: { + model: 'gpt-4.1', + text: { format: {} }, + prompt_cache_key: 'rule-model-key', + stream: false + }, + apiKeyOverrides: { + enableOpenAIResponsesCodexAdaptation: false, + enableOpenAIResponsesPayloadRules: true, + openaiResponsesPayloadRules: [ + { path: 'model', valueType: 'string', value: 'gpt-5-2025-08-07' } + ] + } + }) + + await openaiRoutes.handleResponses(req, createRes()) + + expect(unifiedOpenAIScheduler.selectAccountForApiKey).toHaveBeenCalledWith( + req.apiKey, + createHash('rule-model-key'), + 'gpt-5' + ) + expect(req.body.model).toBe('gpt-5') + expect(req.body.text).toEqual({ format: {} }) + expect(req.body.instructions).toBeUndefined() + expect(axios.post.mock.calls[0][1]).toMatchObject({ + model: 'gpt-5', + text: { format: {} }, + store: false + }) + }) + + test('records the mutated service_tier for standard responses sent through openai accounts', async () => { + unifiedOpenAIScheduler.selectAccountForApiKey.mockResolvedValue({ + accountId: 'openai-1', + accountType: 'openai' + }) + openaiAccountService.getAccount.mockResolvedValue({ + id: 'openai-1', + name: 'OpenAI Account', + accessToken: 'encrypted-token', + accountId: 'chatgpt-account-1' + }) + axios.post.mockResolvedValue({ + status: 200, + data: { + model: 'gpt-4.1', + usage: { + input_tokens: 12, + output_tokens: 6, + total_tokens: 18 + } + }, + headers: {} + }) + + const req = createReq({ + body: { + model: 'gpt-4.1', + prompt_cache_key: 'tier-rule-key', + stream: false + }, + apiKeyOverrides: { + enableOpenAIResponsesCodexAdaptation: false, + enableOpenAIResponsesPayloadRules: true, + openaiResponsesPayloadRules: [ + { path: 'service_tier', valueType: 'string', value: 'priority' } + ] + } + }) + + await openaiRoutes.handleResponses(req, createRes()) + + expect(req._serviceTier).toBe('priority') + expect(apiKeyService.recordUsage).toHaveBeenCalled() + expect(apiKeyService.recordUsage.mock.calls[0][8]).toBe('priority') + }) + + test('records null service_tier after Codex adaptation removes it for openai accounts', async () => { + unifiedOpenAIScheduler.selectAccountForApiKey.mockResolvedValue({ + accountId: 'openai-1', + accountType: 'openai' + }) + openaiAccountService.getAccount.mockResolvedValue({ + id: 'openai-1', + name: 'OpenAI Account', + accessToken: 'encrypted-token', + accountId: 'chatgpt-account-1' + }) + axios.post.mockResolvedValue({ + status: 200, + data: { + model: 'gpt-5', + usage: { + input_tokens: 10, + output_tokens: 4, + total_tokens: 14 + } + }, + headers: {} + }) + + const req = createReq({ + body: { + model: 'gpt-5-2025-08-07', + temperature: 0.2, + service_tier: 'priority', + prompt_cache_key: 'adapt-tier-key', + stream: false + } + }) + + await openaiRoutes.handleResponses(req, createRes()) + + expect(req.body.service_tier).toBeUndefined() + expect(req._serviceTier).toBeNull() + expect(apiKeyService.recordUsage).toHaveBeenCalled() + expect(apiKeyService.recordUsage.mock.calls[0][8]).toBeNull() + }) + + test('captures the post-rule service_tier before relaying openai-responses requests', async () => { + const req = createReq({ + body: { + model: 'gpt-4.1', + prompt_cache_key: 'relay-tier-key' + }, + apiKeyOverrides: { + enableOpenAIResponsesCodexAdaptation: false, + enableOpenAIResponsesPayloadRules: true, + openaiResponsesPayloadRules: [ + { path: 'service_tier', valueType: 'string', value: 'priority' } + ] + } + }) + + await openaiRoutes.handleResponses(req, createRes()) + + expect(req._serviceTier).toBe('priority') + expect(openaiResponsesRelayService.handleRequest).toHaveBeenCalled() + expect(openaiResponsesRelayService.handleRequest.mock.calls[0][0]._serviceTier).toBe('priority') + }) + + test('does not apply the new rule flow to compact responses routes', async () => { + const req = createReq({ + path: '/v1/responses/compact', + body: { + model: 'o1-mini', + prompt_cache_key: 'compact-key', + temperature: 0.1 + }, + apiKeyOverrides: { + enableOpenAIResponsesCodexAdaptation: false, + enableOpenAIResponsesPayloadRules: true, + openaiResponsesPayloadRules: [ + { path: 'model', valueType: 'string', value: 'gpt-5' }, + { path: 'prompt_cache_key', valueType: 'string', value: 'rule-key' } + ] + } + }) + + await openaiRoutes.handleResponses(req, createRes()) + + expect(req.body.model).toBe('o1-mini') + expect(req.body.prompt_cache_key).toBe('compact-key') + expect(req.body.instructions).toBe(openaiRoutes.CODEX_CLI_INSTRUCTIONS) + }) +}) diff --git a/tests/pricingService.test.js b/tests/pricingService.test.js new file mode 100644 index 0000000..d3dd7a7 --- /dev/null +++ b/tests/pricingService.test.js @@ -0,0 +1,355 @@ +/** + * PricingService 长上下文计费测试 + * + * 根据 Anthropic 官方定价页面(https://platform.claude.com/docs/en/about-claude/pricing): + * - 所有 Claude 模型均为统一价格,无论上下文长度如何(1M token 内无额外加价) + * - Fast Mode 倍率仍适用(Opus 4.6) + * - 非 Claude 模型的 200K+ 分层计费逻辑仍保留 + */ + +// Mock logger to avoid console output during tests +jest.mock('../src/utils/logger', () => ({ + api: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + success: jest.fn(), + database: jest.fn(), + security: jest.fn() +})) + +// Mock fs to control pricing data +jest.mock('fs', () => { + const actual = jest.requireActual('fs') + return { + ...actual, + existsSync: jest.fn(), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + statSync: jest.fn(), + watchFile: jest.fn(), + unwatchFile: jest.fn() + } +}) + +describe('PricingService - Long Context Pricing', () => { + let pricingService + const fs = require('fs') + const path = require('path') + + // 使用真实的 model_pricing.json 数据(优先 data/,fallback 到 resources/) + const realFs = jest.requireActual('fs') + const primaryPath = path.join(process.cwd(), 'data', 'model_pricing.json') + const fallbackPath = path.join( + process.cwd(), + 'resources', + 'model-pricing', + 'model_prices_and_context_window.json' + ) + const pricingFilePath = realFs.existsSync(primaryPath) ? primaryPath : fallbackPath + const pricingData = JSON.parse(realFs.readFileSync(pricingFilePath, 'utf8')) + + beforeEach(() => { + // 清除缓存的模块 + jest.resetModules() + + // 配置 fs mock(防止 pricingService 初始化时的文件副作用) + fs.existsSync.mockReturnValue(true) + fs.readFileSync.mockReturnValue(JSON.stringify(pricingData)) + fs.statSync.mockReturnValue({ mtime: new Date(), mtimeMs: Date.now() }) + fs.watchFile.mockImplementation(() => {}) + fs.unwatchFile.mockImplementation(() => {}) + + // 重新加载 pricingService + pricingService = require('../src/services/pricingService') + + // 直接设置真实价格数据(绕过网络初始化) + pricingService.pricingData = pricingData + pricingService.lastUpdated = new Date() + }) + + afterEach(() => { + // 清理定时器 + if (pricingService.cleanup) { + pricingService.cleanup() + } + jest.clearAllMocks() + }) + + describe('Claude 模型平坦计费(无 200K+ 加价)', () => { + it('199999 tokens - 应使用基础价格', () => { + const usage = { + input_tokens: 199999, + output_tokens: 1000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } + + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]') + + expect(result.isLongContextRequest).toBe(false) + expect(result.pricing.input).toBe(0.000003) // 基础价格 + expect(result.pricing.output).toBe(0.000015) // 基础价格 + }) + + it('200001 tokens - Claude 模型应使用基础价格(无 200K+ 加价)', () => { + const usage = { + input_tokens: 200001, + output_tokens: 1000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } + + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]') + + // 官方定价:Claude 模型全局统一价格,超过 200K 不加价 + expect(result.isLongContextRequest).toBe(false) + expect(result.pricing.input).toBe(0.000003) // 基础价格 + expect(result.pricing.output).toBe(0.000015) // 基础价格 + }) + + it('分散在各类 token 中总计超过 200K 时 Claude 仍使用基础价格', () => { + const usage = { + input_tokens: 150000, + output_tokens: 10000, + cache_creation_input_tokens: 40000, + cache_read_input_tokens: 20000 + } + // Total: 150000 + 40000 + 20000 = 210000 > 200000 + + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]') + + expect(result.isLongContextRequest).toBe(false) + expect(result.pricing.input).toBe(0.000003) // 基础价格 + expect(result.pricing.output).toBe(0.000015) // 基础价格 + expect(result.pricing.cacheCreate).toBe(0.00000375) // 基础价格 + expect(result.pricing.cacheRead).toBeCloseTo(0.0000003, 12) // 基础价格 + }) + + it('仅 cache tokens 超过 200K 时 Claude 也使用基础价格', () => { + const usage = { + input_tokens: 50000, + output_tokens: 5000, + cache_creation_input_tokens: 100000, + cache_read_input_tokens: 60000 + } + // Total: 50000 + 100000 + 60000 = 210000 > 200000 + + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]') + + expect(result.isLongContextRequest).toBe(false) + expect(result.pricing.input).toBe(0.000003) // 基础价格 + }) + + it('claude-sonnet-4-5 超过 200K 时也使用基础价格', () => { + const usage = { + input_tokens: 300000, + output_tokens: 5000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } + + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-5[1m]') + + expect(result.isLongContextRequest).toBe(false) + expect(result.pricing.input).toBe(0.000003) // 基础价格 $3/MTok + expect(result.pricing.output).toBe(0.000015) // 基础价格 $15/MTok + }) + + it('claude-sonnet-4-6 超过 200K 时也使用基础价格', () => { + const usage = { + input_tokens: 500000, + output_tokens: 10000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } + + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-6[1m]') + + expect(result.isLongContextRequest).toBe(false) + expect(result.pricing.input).toBe(0.000003) // 基础价格 $3/MTok + expect(result.pricing.output).toBe(0.000015) // 基础价格 $15/MTok + }) + }) + + describe('详细缓存创建数据(ephemeral_5m / ephemeral_1h)', () => { + it('超过 200K 时 Claude 缓存价格仍使用基础价格', () => { + const usage = { + input_tokens: 200001, + output_tokens: 1000, + cache_creation_input_tokens: 10000, + cache_read_input_tokens: 0, + cache_creation: { + ephemeral_5m_input_tokens: 5000, + ephemeral_1h_input_tokens: 5000 + } + } + + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]') + + expect(result.isLongContextRequest).toBe(false) + // ephemeral_5m: 5000 * 0.00000375 = 0.00001875(基础 cache_creation 价格) + expect(result.ephemeral5mCost).toBeCloseTo(5000 * 0.00000375, 10) + // ephemeral_1h: 5000 * 0.000006(基础 1hr cache 价格) + expect(result.pricing.ephemeral1h).toBeCloseTo(0.000006, 10) + expect(result.ephemeral1hCost).toBeCloseTo(5000 * 0.000006, 10) + }) + }) + + describe('context-1m beta header 不影响 Claude 计费', () => { + it('无 [1m] 后缀但带 context-1m beta 且超过 200K,Claude 仍使用基础价格', () => { + const usage = { + input_tokens: 210000, + output_tokens: 1000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + request_anthropic_beta: 'context-1m-2025-08-07' + } + + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514') + + // Claude 模型统一定价,无论是否带 beta 头都不应有额外加价 + expect(result.isLongContextRequest).toBe(false) + expect(result.pricing.input).toBe(0.000003) // 基础价格 + expect(result.pricing.output).toBe(0.000015) // 基础价格 + }) + }) + + describe('Fast Mode 计费(Opus 4.6)', () => { + it('Opus 4.6 在 fast-mode beta + speed=fast 时应用 Fast Mode 6x', () => { + const usage = { + input_tokens: 100000, + output_tokens: 20000, + cache_creation_input_tokens: 10000, + cache_read_input_tokens: 5000, + request_anthropic_beta: 'fast-mode-2026-02-01', + speed: 'fast' + } + + const result = pricingService.calculateCost(usage, 'claude-opus-4-6') + + // input: 0.000005 * 6 = 0.00003 + expect(result.pricing.input).toBeCloseTo(0.00003, 12) + // output: 0.000025 * 6 = 0.00015 + expect(result.pricing.output).toBeCloseTo(0.00015, 12) + // cache create/read 由 fast 后 input 推导 + expect(result.pricing.cacheCreate).toBeCloseTo(0.0000375, 12) // 0.00003 * 1.25 + expect(result.pricing.cacheRead).toBeCloseTo(0.000003, 12) // 0.00003 * 0.1 + expect(result.pricing.ephemeral1h).toBeCloseTo(0.00006, 12) // 0.00003 * 2 + }) + + it('Opus 4.6 在 fast-mode + [1m] 且超过 200K 时不叠加长上下文加价', () => { + const usage = { + input_tokens: 210000, + output_tokens: 1000, + cache_creation_input_tokens: 10000, + cache_read_input_tokens: 10000, + request_anthropic_beta: 'fast-mode-2026-02-01,context-1m-2025-08-07', + speed: 'fast' + } + + const result = pricingService.calculateCost(usage, 'claude-opus-4-6[1m]') + + expect(result.isLongContextRequest).toBe(false) + // input: 0.000005(200K+ 维持同价)-> fast 6x => 0.00003 + expect(result.pricing.input).toBeCloseTo(0.00003, 12) + // output: 0.000025(200K+ 维持同价)-> fast 6x => 0.00015 + expect(result.pricing.output).toBeCloseTo(0.00015, 12) + }) + + it('Opus 4.6 在 [1m] 且超过 200K、未开启 fast-mode 时保持基础价格', () => { + const usage = { + input_tokens: 210000, + output_tokens: 1000, + cache_creation_input_tokens: 10000, + cache_read_input_tokens: 10000, + request_anthropic_beta: 'context-1m-2025-08-07' + } + + const result = pricingService.calculateCost(usage, 'claude-opus-4-6[1m]') + + expect(result.isLongContextRequest).toBe(false) + expect(result.pricing.input).toBeCloseTo(0.000005, 12) + expect(result.pricing.output).toBeCloseTo(0.000025, 12) + expect(result.pricing.cacheCreate).toBeCloseTo(0.00000625, 12) + expect(result.pricing.cacheRead).toBeCloseTo(0.0000005, 12) + }) + }) + + describe('兼容性测试', () => { + it('非 [1m] 模型不受影响,始终使用基础价格', () => { + const usage = { + input_tokens: 250000, + output_tokens: 1000, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } + + // 不带 [1m] 后缀 + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514') + + expect(result.isLongContextRequest).toBe(false) + expect(result.pricing.input).toBe(0.000003) // 基础价格 + expect(result.pricing.output).toBe(0.000015) // 基础价格 + expect(result.pricing.cacheCreate).toBe(0.00000375) // 基础价格 + expect(result.pricing.cacheRead).toBeCloseTo(0.0000003, 12) // 基础价格 + }) + + it('[1m] 模型未超过 200K 时使用基础价格', () => { + const usage = { + input_tokens: 100000, + output_tokens: 1000, + cache_creation_input_tokens: 50000, + cache_read_input_tokens: 49000 + } + // Total: 199000 < 200000 + + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]') + + expect(result.isLongContextRequest).toBe(false) + expect(result.pricing.input).toBe(0.000003) // 基础价格 + }) + + it('无定价数据时返回 hasPricing=false', () => { + const usage = { + input_tokens: 250000, + output_tokens: 1000 + } + + const result = pricingService.calculateCost(usage, 'unknown-model[1m]') + + expect(result.hasPricing).toBe(false) + expect(result.totalCost).toBe(0) + }) + }) + + describe('成本计算准确性(基础价格)', () => { + it('应正确以基础价格计算超过 200K 场景下的总成本', () => { + const usage = { + input_tokens: 150000, + output_tokens: 10000, + cache_creation_input_tokens: 40000, + cache_read_input_tokens: 20000 + } + // Total input: 210000 > 200000,但 Claude 使用基础价格 + + const result = pricingService.calculateCost(usage, 'claude-sonnet-4-20250514[1m]') + + // 手动计算预期成本(全部使用基础价格) + const expectedInputCost = 150000 * 0.000003 // $0.45 + const expectedOutputCost = 10000 * 0.000015 // $0.15 + const expectedCacheCreateCost = 40000 * 0.00000375 // $0.15 + const expectedCacheReadCost = 20000 * 0.0000003 // $0.006 + const expectedTotal = + expectedInputCost + expectedOutputCost + expectedCacheCreateCost + expectedCacheReadCost + + expect(result.inputCost).toBeCloseTo(expectedInputCost, 10) + expect(result.outputCost).toBeCloseTo(expectedOutputCost, 10) + expect(result.cacheCreateCost).toBeCloseTo(expectedCacheCreateCost, 10) + expect(result.cacheReadCost).toBeCloseTo(expectedCacheReadCost, 10) + expect(result.totalCost).toBeCloseTo(expectedTotal, 10) + }) + }) +}) diff --git a/tests/redisApiKeyParse.test.js b/tests/redisApiKeyParse.test.js new file mode 100644 index 0000000..54efd34 --- /dev/null +++ b/tests/redisApiKeyParse.test.js @@ -0,0 +1,45 @@ +jest.mock( + '../config/config', + () => ({ + system: { + timezoneOffset: 8 + } + }), + { virtual: true } +) + +jest.mock('../src/utils/logger', () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn(), + debug: jest.fn() +})) + +const redis = require('../src/models/redis') + +describe('redis api key parsing', () => { + test('parses openai responses toggle and rule fields for list data', () => { + const parsed = redis._parseApiKeyData({ + enableOpenAIResponsesCodexAdaptation: 'false', + enableOpenAIResponsesPayloadRules: 'true', + openaiResponsesPayloadRules: JSON.stringify([ + { path: 'model', valueType: 'string', value: 'gpt-5' } + ]) + }) + + expect(parsed.enableOpenAIResponsesCodexAdaptation).toBe(false) + expect(parsed.enableOpenAIResponsesPayloadRules).toBe(true) + expect(parsed.openaiResponsesPayloadRules).toEqual([ + { path: 'model', valueType: 'string', value: 'gpt-5' } + ]) + }) + + test('uses safe defaults for missing openai responses fields', () => { + const parsed = redis._parseApiKeyData({}) + + expect(parsed.enableOpenAIResponsesCodexAdaptation).toBe(true) + expect(parsed.enableOpenAIResponsesPayloadRules).toBe(false) + expect(parsed.openaiResponsesPayloadRules).toEqual([]) + }) +}) diff --git a/tests/requestBodyRuleService.test.js b/tests/requestBodyRuleService.test.js new file mode 100644 index 0000000..de9d022 --- /dev/null +++ b/tests/requestBodyRuleService.test.js @@ -0,0 +1,88 @@ +const requestBodyRuleService = require('../src/services/requestBodyRuleService') + +describe('requestBodyRuleService', () => { + test('applies multiple rules with nested paths and typed values', () => { + const body = { + model: 'gpt-4.1', + input: [{ role: 'user' }], + metadata: {} + } + + const result = requestBodyRuleService.applyRules(body, [ + { path: 'model', valueType: 'string', value: 'gpt-5' }, + { path: 'input.0.priority', valueType: 'number', value: '2' }, + { path: 'metadata.debug', valueType: 'boolean', value: 'true' }, + { path: 'text.format', valueType: 'json', value: '{"type":"json_schema"}' } + ]) + + expect(result).toEqual({ + model: 'gpt-5', + input: [{ role: 'user', priority: 2 }], + metadata: { debug: true }, + text: { + format: { + type: 'json_schema' + } + } + }) + expect(body).toEqual({ + model: 'gpt-4.1', + input: [{ role: 'user' }], + metadata: {} + }) + }) + + test('uses empty string when value is omitted for any value type', () => { + const result = requestBodyRuleService.applyRules({}, [ + { path: 'a', valueType: 'string', value: '' }, + { path: 'b', valueType: 'number', value: '' }, + { path: 'c', valueType: 'boolean', value: '' }, + { path: 'd', valueType: 'json', value: '' } + ]) + + expect(result).toEqual({ + a: '', + b: '', + c: '', + d: '' + }) + }) + + test('keeps the last rule when the same path appears multiple times', () => { + const result = requestBodyRuleService.applyRules({ model: 'gpt-4.1' }, [ + { path: 'model', valueType: 'string', value: 'gpt-5' }, + { path: 'model', valueType: 'string', value: 'gpt-5.5' } + ]) + + expect(result.model).toBe('gpt-5.5') + }) + + test('rejects invalid typed values', () => { + expect( + requestBodyRuleService.validateAndNormalizeRules([ + { path: 'count', valueType: 'number', value: 'abc' } + ]) + ).toEqual({ + valid: false, + error: 'Payload rule #1: Rule path "count" expects a valid number' + }) + + expect( + requestBodyRuleService.validateAndNormalizeRules([ + { path: 'flag', valueType: 'boolean', value: 'yes' } + ]) + ).toEqual({ + valid: false, + error: 'Payload rule #1: Rule path "flag" expects "true" or "false"' + }) + + expect( + requestBodyRuleService.validateAndNormalizeRules([ + { path: 'payload', valueType: 'json', value: '{broken}' } + ]) + ).toEqual({ + valid: false, + error: 'Payload rule #1: Rule path "payload" expects valid JSON' + }) + }) +}) diff --git a/tests/requestDetailHelper.test.js b/tests/requestDetailHelper.test.js new file mode 100644 index 0000000..bb959a2 --- /dev/null +++ b/tests/requestDetailHelper.test.js @@ -0,0 +1,320 @@ +const { + sanitizeRequestBodySnapshot, + extractRequestReasoningInfo, + resolveRequestDetailReasoning, + createRequestDetailMeta, + finalizeRequestDetailMeta, + extractOpenAICacheReadTokens, + isOpenAIRelatedEndpoint, + getRequestDetailCacheMetrics, + calculateCacheHitRate +} = require('../src/utils/requestDetailHelper') + +describe('requestDetailHelper', () => { + afterEach(() => { + jest.useRealTimers() + }) + + test('sanitizeRequestBodySnapshot redacts secrets and truncates long text', () => { + const snapshot = sanitizeRequestBodySnapshot({ + apiKey: 'super-secret-api-key', + messages: [ + { + role: 'user', + content: 'x'.repeat(400) + } + ] + }) + + expect(snapshot.apiKey).toContain('***') + expect(snapshot.messages[0].content).toBe(`${'x'.repeat(80)}...[320 chars]`) + }) + + test('sanitizeRequestBodySnapshot keeps all object keys while truncating long values', () => { + const payload = Object.fromEntries( + Array.from({ length: 50 }, (_, index) => [ + `key_${index}`, + `value-${index}-${'x'.repeat(100)}` + ]) + ) + + const snapshot = sanitizeRequestBodySnapshot(payload) + + expect(Object.keys(snapshot)).toHaveLength(50) + expect(snapshot.__truncatedKeys).toBeUndefined() + expect(snapshot.key_0).toBe(`value-0-${'x'.repeat(72)}...[28 chars]`) + }) + + test('sanitizeRequestBodySnapshot still wraps oversized payloads in preview metadata', () => { + const snapshot = sanitizeRequestBodySnapshot( + Object.fromEntries( + Array.from({ length: 220 }, (_, index) => [`key_${index}`, `${index}-${'x'.repeat(120)}`]) + ) + ) + + expect(snapshot.summary).toBe('request body snapshot truncated') + expect(snapshot.maxChars).toBe(12000) + expect(typeof snapshot.preview).toBe('string') + expect(snapshot.preview).toContain('...[') + }) + + test('sanitizeRequestBodySnapshot omits encrypted_content values', () => { + const snapshot = sanitizeRequestBodySnapshot({ + reasoning: { + encrypted_content: 'x'.repeat(512) + } + }) + + expect(snapshot.reasoning.encrypted_content).toBe('...[512 chars]') + }) + + test('sanitizeRequestBodySnapshot keeps only tool type and name', () => { + const snapshot = sanitizeRequestBodySnapshot({ + tools: [ + { + type: 'function', + function: { + name: 'lookup_weather', + description: 'Weather lookup', + parameters: { + type: 'object', + properties: { + city: { + type: 'string' + } + } + } + } + }, + { + name: 'claude_tool', + description: 'Anthropic style tool', + input_schema: { + type: 'object' + } + } + ] + }) + + expect(snapshot.tools).toEqual([ + { type: 'function', name: 'lookup_weather' }, + { name: 'claude_tool' } + ]) + }) + + test('extractRequestReasoningInfo supports openai, anthropic, and gemini payloads', () => { + expect( + extractRequestReasoningInfo({ + reasoning: { + effort: 'xhigh' + } + }) + ).toEqual({ + reasoningDisplay: 'xhigh', + reasoningSource: 'reasoning.effort' + }) + + expect( + extractRequestReasoningInfo({ + output_config: { + effort: 'medium' + } + }) + ).toEqual({ + reasoningDisplay: 'medium', + reasoningSource: 'output_config.effort' + }) + + expect( + extractRequestReasoningInfo({ + generationConfig: { + thinkingConfig: { + thinkingBudget: -1 + } + } + }) + ).toEqual({ + reasoningDisplay: 'dynamic', + reasoningSource: 'generationConfig.thinkingConfig.thinkingBudget' + }) + }) + + test('resolveRequestDetailReasoning falls back to stored preview text when needed', () => { + expect( + resolveRequestDetailReasoning({ + requestBodySnapshot: { + preview: + '{"model":"gpt-5.4-mini","reasoning":{"effort":"high","summary":"auto"}}...[25 chars]' + } + }) + ).toEqual({ + reasoningDisplay: 'high', + reasoningSource: 'reasoning.effort' + }) + + expect( + resolveRequestDetailReasoning({ + requestBodySnapshot: { + preview: + '{"model":"claude-opus-4-6","thinking":{"type":"enabled","budget_tokens":4096}...[60 chars]' + } + }) + ).toEqual({ + reasoningDisplay: 'enabled / budget:4096', + reasoningSource: 'thinking.type,thinking.budget_tokens' + }) + }) + + test('createRequestDetailMeta derives endpoint and duration from request', () => { + const now = Date.now() + const req = { + requestId: 'req_123', + originalUrl: '/v1/messages?stream=true', + method: 'POST', + requestStartedAt: now - 250, + body: { model: 'claude-sonnet-4-6', stream: true }, + res: { statusCode: 201 } + } + + const meta = createRequestDetailMeta(req) + + expect(meta.requestId).toBe('req_123') + expect(meta.endpoint).toBe('/v1/messages') + expect(meta.method).toBe('POST') + expect(meta.stream).toBe(true) + expect(meta.statusCode).toBe(201) + expect(meta.durationMs).toBeGreaterThanOrEqual(200) + expect(meta.requestBody).toEqual(req.body) + }) + + test('finalizeRequestDetailMeta refreshes duration from requestStartedAt', () => { + jest.useFakeTimers().setSystemTime(Date.parse('2026-04-09T05:00:00.500Z')) + + const meta = finalizeRequestDetailMeta({ + requestId: 'req_123', + requestStartedAt: '2026-04-09T05:00:00.000Z', + durationMs: 25 + }) + + expect(meta.durationMs).toBe(500) + }) + + test('identifies openai-style request detail endpoints', () => { + expect(isOpenAIRelatedEndpoint('/openai/v1/responses')).toBe(true) + expect(isOpenAIRelatedEndpoint('/openai/responses')).toBe(true) + expect(isOpenAIRelatedEndpoint('/azure/chat/completions')).toBe(true) + expect(isOpenAIRelatedEndpoint('/droid/openai/v1/responses')).toBe(true) + expect(isOpenAIRelatedEndpoint('/openai/claude/v1/messages')).toBe(false) + expect(isOpenAIRelatedEndpoint('/v1/messages')).toBe(false) + }) + + test('extractOpenAICacheReadTokens prefers input_tokens_details.cached_tokens', () => { + expect( + extractOpenAICacheReadTokens({ + input_tokens_details: { cached_tokens: 42 }, + prompt_tokens_details: { cached_tokens: 99 } + }) + ).toBe(42) + }) + + test('extractOpenAICacheReadTokens supports singular cached_token fallback fields', () => { + expect( + extractOpenAICacheReadTokens({ + input_tokens_details: { cached_token: '17' } + }) + ).toBe(17) + + expect( + extractOpenAICacheReadTokens({ + prompt_tokens_details: { cached_token: 23 } + }) + ).toBe(23) + }) + + test('extractOpenAICacheReadTokens falls back to prompt_tokens_details.cached_tokens', () => { + expect( + extractOpenAICacheReadTokens({ + prompt_tokens_details: { cached_tokens: '31' } + }) + ).toBe(31) + }) + + test('extractOpenAICacheReadTokens normalizes invalid values to zero', () => { + expect(extractOpenAICacheReadTokens()).toBe(0) + expect( + extractOpenAICacheReadTokens({ + input_tokens_details: { cached_tokens: -5 } + }) + ).toBe(0) + expect( + extractOpenAICacheReadTokens({ + input_tokens_details: { cached_tokens: 'abc' }, + prompt_tokens_details: { cached_tokens: null } + }) + ).toBe(0) + }) + + test('calculateCacheHitRate uses cacheRead / (input + cacheRead + cacheCreate)', () => { + expect(calculateCacheHitRate(2048, 0, 17206)).toBe(10.64) + expect(calculateCacheHitRate(120, 80, 100)).toBe(40) + expect(calculateCacheHitRate(0, 0)).toBe(0) + }) + + test('getRequestDetailCacheMetrics exposes formula details for the request detail page', () => { + const metrics = getRequestDetailCacheMetrics({ + endpoint: '/api/v1/messages', + accountType: 'claude-console', + inputTokens: 17206, + outputTokens: 51, + cacheReadTokens: 2048, + cacheCreateTokens: 0 + }) + + expect(metrics.numerator).toBe(2048) + expect(metrics.denominator).toBe(19254) + expect(metrics.rate).toBe(10.64) + expect(metrics.cacheHitFormula).toBe( + 'cacheReadTokens / (inputTokens + cacheReadTokens + cacheCreateTokens)' + ) + }) + + test('calculateCacheHitRate uses the same input-side denominator for /openai/ requests', () => { + expect( + calculateCacheHitRate({ + endpoint: '/openai/v1/responses', + inputTokens: 100, + cacheReadTokens: 60, + cacheCreateTokens: 0 + }) + ).toBe(37.5) + expect( + calculateCacheHitRate({ + endpoint: '/openai/v1/responses', + inputTokens: 0, + cacheReadTokens: 0 + }) + ).toBe(0) + }) + + test('calculateCacheHitRate uses one denominator for azure records and claude compatibility routes', () => { + expect( + calculateCacheHitRate({ + endpoint: '/azure/chat/completions', + accountType: 'azure-openai', + inputTokens: 100, + cacheReadTokens: 60, + cacheCreateTokens: 0 + }) + ).toBe(37.5) + + expect( + calculateCacheHitRate({ + endpoint: '/openai/claude/v1/messages', + accountType: 'claude', + inputTokens: 100, + cacheReadTokens: 30, + cacheCreateTokens: 20 + }) + ).toBe(20) + }) +}) diff --git a/tests/requestDetailService.test.js b/tests/requestDetailService.test.js new file mode 100644 index 0000000..cf58b6b --- /dev/null +++ b/tests/requestDetailService.test.js @@ -0,0 +1,1854 @@ +jest.mock('../src/models/redis', () => ({ + getClient: jest.fn(), + getApiKey: jest.fn() +})) + +jest.mock('../src/services/claudeRelayConfigService', () => ({ + getConfig: jest.fn() +})) + +jest.mock('../src/utils/logger', () => ({ + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + start: jest.fn() +})) + +jest.mock('../src/services/account/claudeAccountService', () => ({ getAccount: jest.fn() })) +jest.mock('../src/services/account/claudeConsoleAccountService', () => ({ getAccount: jest.fn() })) +jest.mock('../src/services/account/ccrAccountService', () => ({ getAccount: jest.fn() })) +jest.mock('../src/services/account/geminiAccountService', () => ({ getAccount: jest.fn() })) +jest.mock('../src/services/account/geminiApiAccountService', () => ({ getAccount: jest.fn() })) +jest.mock('../src/services/account/openaiAccountService', () => ({ getAccount: jest.fn() })) +jest.mock('../src/services/account/openaiResponsesAccountService', () => ({ + getAccount: jest.fn() +})) +jest.mock('../src/services/account/azureOpenaiAccountService', () => ({ getAccount: jest.fn() })) +jest.mock('../src/services/account/droidAccountService', () => ({ getAccount: jest.fn() })) +jest.mock('../src/services/account/bedrockAccountService', () => ({ getAccount: jest.fn() })) +jest.mock('../src/utils/costCalculator', () => ({ + calculateCost: jest.fn() +})) + +const redis = require('../src/models/redis') +const claudeRelayConfigService = require('../src/services/claudeRelayConfigService') +const claudeAccountService = require('../src/services/account/claudeAccountService') +const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService') +const openaiAccountService = require('../src/services/account/openaiAccountService') +const bedrockAccountService = require('../src/services/account/bedrockAccountService') +const CostCalculator = require('../src/utils/costCalculator') +const requestDetailService = require('../src/services/requestDetailService') + +describe('requestDetailService', () => { + beforeEach(() => { + jest.resetAllMocks() + jest.useFakeTimers().setSystemTime(Date.parse('2026-04-07T18:00:00.000Z')) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + test('captureRequestDetail stores normalized request detail records when enabled', async () => { + const exec = jest.fn().mockResolvedValue([]) + const multi = { + set: jest.fn().mockReturnThis(), + zadd: jest.fn().mockReturnThis(), + expire: jest.fn().mockReturnThis(), + exec + } + + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + redis.getClient.mockReturnValue({ multi: jest.fn(() => multi) }) + + const result = await requestDetailService.captureRequestDetail({ + requestId: 'req_capture_1', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/openai/v1/responses', + method: 'POST', + statusCode: 200, + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 10, + outputTokens: 4, + cacheReadTokens: 3, + cacheCreateTokens: 2, + cost: 0.123456, + requestBody: { + apiKey: 'super-secret', + model: 'gpt-5.4', + reasoning: { + effort: 'medium' + }, + prompt: 'hello' + } + }) + + expect(result).toEqual({ captured: true, requestId: 'req_capture_1' }) + expect(multi.set).toHaveBeenCalled() + expect(multi.set).toHaveBeenCalledWith( + 'request_detail:item:req_capture_1', + expect.any(String), + 'EX', + 21600 + ) + const storedPayload = JSON.parse(multi.set.mock.calls[0][1]) + expect(storedPayload.requestBodySnapshot.apiKey).toContain('***') + expect(storedPayload.endpoint).toBe('/openai/v1/responses') + expect(storedPayload.reasoningDisplay).toBe('medium') + expect(storedPayload.reasoningSource).toBe('reasoning.effort') + expect(multi.zadd).toHaveBeenCalled() + expect(exec).toHaveBeenCalled() + }) + + test('listRequestDetails applies openai cache display flags and openai hit-rate formula', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + const redisClient = { + zrangebyscore: jest.fn().mockResolvedValue(['req_1', '1775563200000']), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ + requestId: 'req_1', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 60, + cacheCreateTokens: 0, + totalTokens: 210, + cost: 0.5, + durationMs: 1200, + requestBodySnapshot: { model: 'gpt-5.4' } + }) + ]) + } + + redis.getClient.mockReturnValue(redisClient) + + const result = await requestDetailService.listRequestDetails({ + apiKeyId: 'key_1', + model: 'gpt-5.4', + keyword: 'primary', + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.records).toHaveLength(1) + expect(result.records[0].apiKeyName).toBe('Primary Key') + expect(result.records[0].accountName).toBe('OpenAI Main') + expect(result.records[0].requestBodySnapshot).toBeUndefined() + expect(result.records[0].isOpenAIRelated).toBe(true) + expect(result.records[0].cacheCreateNotApplicable).toBe(true) + expect(result.retentionHours).toBe(6) + expect(result.summary.totalRequests).toBe(1) + expect(result.summary.cacheCreateTokens).toBe(0) + expect(result.summary.cacheCreateNotApplicable).toBe(true) + expect(result.summary.cacheHitNumerator).toBe(60) + expect(result.summary.cacheHitDenominator).toBe(160) + expect(result.summary.cacheHitRate).toBe(37.5) + expect(result.availableFilters.models).toEqual(['gpt-5.4']) + expect(result.filters.hasCustomDateRange).toBe(true) + }) + + test('listRequestDetails aggregates mixed openai and non-openai cache metrics correctly', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockImplementation(async (keyId) => ({ name: `Key ${keyId}` })) + openaiAccountService.getAccount.mockImplementation(async (accountId) => + accountId === 'acct_1' ? { name: 'OpenAI Main' } : null + ) + claudeAccountService.getAccount.mockImplementation(async (accountId) => + accountId === 'acct_2' ? { name: 'Claude Main' } : null + ) + + redis.getClient.mockReturnValue({ + zrangebyscore: jest + .fn() + .mockResolvedValue(['req_openai', '1775563200000', 'req_claude', '1775566800000']), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ + requestId: 'req_openai', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 60, + cacheCreateTokens: 0, + totalTokens: 180, + cost: 0.3, + durationMs: 500 + }), + JSON.stringify({ + requestId: 'req_claude', + timestamp: '2026-04-07T13:00:00.000Z', + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_2', + accountId: 'acct_2', + accountType: 'claude', + model: 'claude-sonnet-4-6', + inputTokens: 90, + outputTokens: 30, + cacheReadTokens: 30, + cacheCreateTokens: 30, + totalTokens: 180, + cost: 0.2, + durationMs: 700 + }) + ]) + }) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.records).toHaveLength(2) + expect(result.summary.totalRequests).toBe(2) + expect(result.summary.cacheCreateNotApplicable).toBe(false) + expect(result.summary.cacheCreateTokens).toBe(30) + expect(result.summary.cacheHitNumerator).toBe(90) + expect(result.summary.cacheHitDenominator).toBe(310) + expect(result.summary.cacheHitFormula).toBe( + 'cacheReadTokens / (inputTokens + cacheReadTokens + cacheCreateTokens)' + ) + expect(result.summary.cacheHitRate).toBe(29.03) + }) + + test('listRequestDetails treats azure-openai cache hits as openai-style metrics', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Azure Key' }) + + redis.getClient.mockReturnValue({ + zrangebyscore: jest.fn().mockResolvedValue(['req_azure', '1775563200000']), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ + requestId: 'req_azure', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/azure/chat/completions', + method: 'POST', + apiKeyId: 'key_azure', + accountId: 'acct_azure', + accountType: 'azure-openai', + model: 'gpt-4o', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 60, + cacheCreateTokens: 0, + totalTokens: 180, + cost: 0.3, + durationMs: 500 + }) + ]) + }) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.records).toHaveLength(1) + expect(result.records[0].isOpenAIRelated).toBe(true) + expect(result.records[0].cacheCreateNotApplicable).toBe(true) + expect(result.records[0].cacheHitRate).toBe(37.5) + expect(result.summary.cacheCreateTokens).toBe(0) + expect(result.summary.cacheHitRate).toBe(37.5) + }) + + test('reads retained zero-cost unknown model records with recomputed display cost', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Mimo Key' }) + claudeConsoleAccountService.getAccount.mockResolvedValue({ name: 'Claude Console' }) + CostCalculator.calculateCost.mockReturnValue({ + costs: { + input: 0.051618, + output: 0.000765, + cacheCreate: 0, + cacheWrite: 0, + cacheRead: 0.0006144, + total: 0.0529974 + }, + debug: { + usedFallbackPricing: true, + pricingSource: 'unknown-fallback' + }, + usingDynamicPricing: false + }) + + const storedRecord = JSON.stringify({ + requestId: 'req_mimo', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/api/v1/messages', + method: 'POST', + apiKeyId: 'key_mimo', + accountId: 'acct_mimo', + accountType: 'claude-console', + model: 'mimo-v2.5-pro', + inputTokens: 17206, + outputTokens: 51, + cacheReadTokens: 2048, + cacheCreateTokens: 0, + totalTokens: 19305, + cost: 0, + realCost: 0, + realCostBreakdown: { + input: 0, + output: 0, + cacheCreate: 0, + cacheRead: 0 + }, + durationMs: 5662 + }) + const client = { + zrangebyscore: jest.fn().mockResolvedValue(['req_mimo', '1775563200000']), + mget: jest.fn().mockResolvedValue([storedRecord]), + get: jest.fn().mockResolvedValue(storedRecord), + set: jest.fn().mockResolvedValue('OK') + } + redis.getClient.mockReturnValue(client) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + const detail = await requestDetailService.getRequestDetail('req_mimo') + + expect(result.records).toHaveLength(1) + expect(result.records[0].cacheHitRate).toBe(10.64) + expect(result.records[0].cacheHitNumerator).toBe(2048) + expect(result.records[0].cacheHitDenominator).toBe(19254) + expect(result.records[0].cost).toBe(0.052997) + expect(result.records[0].realCost).toBe(0.052997) + expect(result.records[0].costRecomputed).toBe(true) + expect(result.records[0].usedFallbackPricing).toBe(true) + expect(result.records[0].pricingSource).toBe('unknown-fallback') + expect(result.records[0].realCostBreakdown).toEqual( + expect.objectContaining({ + input: 0.051618, + output: 0.000765, + cacheRead: 0.0006144, + total: 0.0529974 + }) + ) + expect(result.summary.cacheHitRate).toBe(10.64) + expect(result.summary.totalCost).toBe(0.052997) + expect(detail.record.cost).toBe(0.052997) + expect(detail.record.costRecomputed).toBe(true) + expect(client.set).not.toHaveBeenCalledWith( + 'request_detail:item:req_mimo', + expect.any(String), + expect.anything() + ) + }) + + test('listRequestDetails still exposes retained data when capture is disabled', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: false, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: false + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + redis.getClient.mockReturnValue({ + zrangebyscore: jest.fn().mockResolvedValue(['req_1', '1775563200000']), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ + requestId: 'req_1', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 60, + cacheCreateTokens: 40, + totalTokens: 250, + cost: 0.5, + durationMs: 1200 + }) + ]) + }) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.captureEnabled).toBe(false) + expect(result.retentionHours).toBe(6) + expect(result.records).toHaveLength(1) + expect(result.records[0].apiKeyName).toBe('Primary Key') + }) + + test('listRequestDetails derives reasoning from legacy preview-only records', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + redis.getClient.mockReturnValue({ + zrangebyscore: jest.fn().mockResolvedValue(['req_preview', '1775563200000']), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ + requestId: 'req_preview', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 60, + cacheCreateTokens: 0, + totalTokens: 210, + cost: 0.5, + durationMs: 1200, + requestBodySnapshot: { + summary: 'request body snapshot truncated', + originalChars: 18000, + maxChars: 12000, + preview: + '{"model":"gpt-5.4","reasoning":{"effort":"high","summary":"auto"},"input":"...[42 chars]' + } + }) + ]) + }) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.records).toHaveLength(1) + expect(result.records[0].reasoningDisplay).toBe('high') + expect(result.records[0].reasoningSource).toBe('reasoning.effort') + }) + + test('captureRequestDetail omits requestBodySnapshot when body preview is disabled', async () => { + const exec = jest.fn().mockResolvedValue([]) + const multi = { + set: jest.fn().mockReturnThis(), + zadd: jest.fn().mockReturnThis(), + expire: jest.fn().mockReturnThis(), + exec + } + + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: false + }) + redis.getClient.mockReturnValue({ multi: jest.fn(() => multi) }) + + await requestDetailService.captureRequestDetail({ + requestId: 'req_capture_no_preview', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/openai/v1/responses', + method: 'POST', + model: 'gpt-5.4', + requestBody: { + model: 'gpt-5.4', + reasoning: { + effort: 'high' + }, + input: 'hello world' + } + }) + + const storedPayload = JSON.parse(multi.set.mock.calls[0][1]) + expect(storedPayload.requestBodySnapshot).toBeUndefined() + expect(storedPayload.reasoningDisplay).toBe('high') + expect(storedPayload.reasoningSource).toBe('reasoning.effort') + }) + + test('getRequestBodyPreviewStats counts stored snapshots', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: false + }) + + redis.getClient.mockReturnValue({ + scan: jest + .fn() + .mockResolvedValueOnce([ + '0', + ['request_detail:item:req_1', 'request_detail:item:req_2', 'request_detail:item:req_3'] + ]), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ requestId: 'req_1', requestBodySnapshot: { model: 'gpt-5.4' } }), + JSON.stringify({ requestId: 'req_2', model: 'gpt-5.4' }), + JSON.stringify({ + requestId: 'req_3', + requestBodySnapshot: { + preview: '{"model":"gpt-5.4"}' + } + }) + ]) + }) + + const result = await requestDetailService.getRequestBodyPreviewStats() + + expect(result.bodyPreviewEnabled).toBe(false) + expect(result.snapshotCount).toBe(2) + expect(result.hasSnapshots).toBe(true) + }) + + test('getRequestDetail returns null for records outside retention window', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getClient.mockReturnValue({ + get: jest.fn().mockResolvedValue( + JSON.stringify({ + requestId: 'req_old', + timestamp: '2026-04-07T10:00:00.000Z', + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'claude', + model: 'claude-sonnet-4-6', + inputTokens: 100, + outputTokens: 50, + cost: 0.5, + durationMs: 1200 + }) + ) + }) + + const result = await requestDetailService.getRequestDetail('req_old') + + expect(result.record).toBeNull() + expect(result.retentionHours).toBe(6) + expect(result.captureEnabled).toBe(true) + }) + + test('getRequestDetail returns record within retention window', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + claudeAccountService.getAccount.mockResolvedValue({ name: 'Claude Main' }) + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + + redis.getClient.mockReturnValue({ + get: jest.fn().mockResolvedValue( + JSON.stringify({ + requestId: 'req_recent', + timestamp: '2026-04-07T14:00:00.000Z', + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'claude', + model: 'claude-sonnet-4-6', + inputTokens: 100, + outputTokens: 50, + cost: 0.5, + durationMs: 1200 + }) + ) + }) + + const result = await requestDetailService.getRequestDetail('req_recent') + + expect(result.record).not.toBeNull() + expect(result.record.requestId).toBe('req_recent') + expect(result.record.apiKeyName).toBe('Primary Key') + }) + + test('getRequestDetail recovers missing timestamp from retention-window day index', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getClient.mockReturnValue({ + zscore: jest.fn().mockResolvedValue('1775570400000'), + get: jest.fn().mockResolvedValue( + JSON.stringify({ + requestId: 'req_no_ts', + endpoint: '/v1/messages', + method: 'POST' + }) + ) + }) + + const result = await requestDetailService.getRequestDetail('req_no_ts') + + expect(result.record).not.toBeNull() + expect(result.record.requestId).toBe('req_no_ts') + expect(result.record.timestamp).toBe('2026-04-07T14:00:00.000Z') + }) + + test('getRequestDetail recovers unparseable timestamp from retention-window day index', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getClient.mockReturnValue({ + zscore: jest.fn().mockResolvedValue('1775570400000'), + get: jest.fn().mockResolvedValue( + JSON.stringify({ + requestId: 'req_bad_ts', + timestamp: 'invalid-date', + endpoint: '/v1/messages', + method: 'POST' + }) + ) + }) + + const result = await requestDetailService.getRequestDetail('req_bad_ts') + + expect(result.record).not.toBeNull() + expect(result.record.requestId).toBe('req_bad_ts') + expect(result.record.timestamp).toBe('2026-04-07T14:00:00.000Z') + }) + + test('getRequestDetail hides legacy records without a recoverable in-window timestamp', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getClient.mockReturnValue({ + zscore: jest.fn().mockResolvedValue('1775556000000'), + get: jest.fn().mockResolvedValue( + JSON.stringify({ + requestId: 'req_legacy_old', + endpoint: '/v1/messages', + method: 'POST' + }) + ) + }) + + const result = await requestDetailService.getRequestDetail('req_legacy_old') + + expect(result.record).toBeNull() + }) + + test('resolves bedrock account name from { success, data } wrapper', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Bedrock Key' }) + bedrockAccountService.getAccount.mockResolvedValue({ + success: true, + data: { name: 'My Bedrock Account' } + }) + + redis.getClient.mockReturnValue({ + zrangebyscore: jest.fn().mockResolvedValue(['req_bedrock', '1775563200000']), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ + requestId: 'req_bedrock', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_bedrock', + accountId: 'acct_bedrock', + accountType: 'bedrock', + model: 'anthropic.claude-sonnet-4-6', + inputTokens: 100, + outputTokens: 50, + cost: 0.5, + durationMs: 1200 + }) + ]) + }) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.records).toHaveLength(1) + expect(result.records[0].accountName).toBe('My Bedrock Account') + expect(result.records[0].accountTypeName).toBe('AWS Bedrock') + }) + + test('handles bedrock { success: false } wrapper gracefully', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Some Key' }) + claudeAccountService.getAccount.mockResolvedValue(null) + openaiAccountService.getAccount.mockResolvedValue(null) + bedrockAccountService.getAccount.mockResolvedValue({ + success: false, + error: 'Account not found' + }) + + redis.getClient.mockReturnValue({ + zrangebyscore: jest.fn().mockResolvedValue(['req_missing', '1775563200000']), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ + requestId: 'req_missing', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_gone', + accountType: 'bedrock', + model: 'anthropic.claude-sonnet-4-6', + inputTokens: 100, + outputTokens: 50, + cost: 0.5, + durationMs: 1200 + }) + ]) + }) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.records).toHaveLength(1) + expect(result.records[0].accountName).toBe('acct_gone') + expect(result.records[0].accountTypeName).toBe('AWS Bedrock') + }) + + test('listRequestDetails without keyword uses deferred enrichment for page records only', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 5; i++) { + const ts = 1775563200000 + i * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 0, + cacheCreateTokens: 0, + totalTokens: 150, + cost: 0.5, + durationMs: 1200 + }) + } + + redis.getClient.mockReturnValue({ + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)) + }) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-08T23:59:59.000Z', + pageSize: 2, + page: 1 + }) + + expect(result.records).toHaveLength(2) + expect(result.pagination.totalRecords).toBe(5) + expect(result.records[0].apiKeyName).toBe('Primary Key') + expect(result.records[0].accountName).toBe('OpenAI Main') + expect(result.availableFilters.models).toEqual(['gpt-5.4']) + expect(result.summary.totalRequests).toBe(5) + }) + + test('listRequestDetails creates a snapshot and reuses it across pages', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 4; i++) { + const ts = 1775563200000 + i * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + cost: 0.5, + durationMs: 1200 + }) + } + + const snapshots = new Map() + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn(async (key, value) => { + snapshots.set(key, value) + return 'OK' + }), + get: jest.fn(async (key) => snapshots.get(key) || null), + expire: jest.fn().mockResolvedValue(1) + } + redis.getClient.mockReturnValue(client) + + const firstPage = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-08T23:59:59.000Z', + pageSize: 2, + page: 1 + }) + + expect(firstPage.snapshotId).toBeTruthy() + expect(firstPage.records.map((record) => record.requestId)).toEqual(['req_3', 'req_2']) + expect(client.set).toHaveBeenCalledWith( + expect.stringMatching(/^request_detail:query_snapshot:/), + expect.any(String), + 'EX', + 30 + ) + + const secondPage = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-08T23:59:59.000Z', + pageSize: 2, + page: 2, + snapshotId: firstPage.snapshotId + }) + + expect(secondPage.snapshotId).toBe(firstPage.snapshotId) + expect(secondPage.records.map((record) => record.requestId)).toEqual(['req_1', 'req_0']) + expect(client.zrangebyscore).toHaveBeenCalledTimes(1) + expect(client.get).toHaveBeenCalledWith(`request_detail:query_snapshot:${firstPage.snapshotId}`) + expect(client.expire).toHaveBeenCalledWith( + `request_detail:query_snapshot:${firstPage.snapshotId}`, + 30 + ) + }) + + test('listRequestDetails rebuilds the snapshot when filters change', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockImplementation(async (keyId) => ({ name: `Key ${keyId}` })) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + const recordsByKey = { + 'request_detail:item:req_1': JSON.stringify({ + requestId: 'req_1', + timestamp: '2026-04-07T12:00:00.000Z', + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + cost: 0.1, + durationMs: 400 + }), + 'request_detail:item:req_2': JSON.stringify({ + requestId: 'req_2', + timestamp: '2026-04-07T13:00:00.000Z', + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_2', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 20, + outputTokens: 5, + totalTokens: 25, + cost: 0.2, + durationMs: 500 + }) + } + + const snapshots = new Map() + const client = { + zrangebyscore: jest + .fn() + .mockResolvedValue(['req_1', '1775563200000', 'req_2', '1775566800000']), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn(async (key, value) => { + snapshots.set(key, value) + return 'OK' + }), + get: jest.fn(async (key) => snapshots.get(key) || null), + expire: jest.fn().mockResolvedValue(1) + } + redis.getClient.mockReturnValue(client) + + const firstQuery = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z', + apiKeyId: 'key_1' + }) + + const secondQuery = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z', + apiKeyId: 'key_2', + snapshotId: firstQuery.snapshotId + }) + + expect(client.zrangebyscore).toHaveBeenCalledTimes(2) + expect(secondQuery.records).toHaveLength(1) + expect(secondQuery.records[0].apiKeyId).toBe('key_2') + expect(secondQuery.snapshotId).toBeTruthy() + expect(secondQuery.snapshotId).not.toBe(firstQuery.snapshotId) + }) + + test('listRequestDetails rejects stale snapshot when explicit date range changes', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'Acct' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 2; i++) { + const ts = Date.now() - (1 - i) * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'claude', + model: 'claude-sonnet-4-20250514', + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + cost: 0.1, + durationMs: 300 + }) + } + + const snapshots = new Map() + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn(async (key, value) => { + snapshots.set(key, value) + return 'OK' + }), + get: jest.fn(async (key) => snapshots.get(key) || null), + expire: jest.fn().mockResolvedValue(1) + } + redis.getClient.mockReturnValue(client) + + const now = Date.now() + const rangeA = { + startDate: new Date(now - 5 * 3600000).toISOString(), + endDate: new Date(now - 3 * 3600000).toISOString() + } + const rangeB = { + startDate: new Date(now - 2 * 3600000).toISOString(), + endDate: new Date(now - 1 * 3600000).toISOString() + } + + const firstQuery = await requestDetailService.listRequestDetails(rangeA) + + expect(firstQuery.snapshotId).toBeTruthy() + + // Same non-date filters, different date range, stale snapshotId — + // must NOT reuse the old snapshot. + const secondQuery = await requestDetailService.listRequestDetails({ + ...rangeB, + snapshotId: firstQuery.snapshotId + }) + + expect(client.zrangebyscore).toHaveBeenCalledTimes(2) + expect(secondQuery.snapshotId).toBeTruthy() + expect(secondQuery.snapshotId).not.toBe(firstQuery.snapshotId) + }) + + test('listRequestDetails reuses snapshot for startDate-only queries after time advances', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'Acct' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 2; i++) { + const ts = Date.now() - (1 - i) * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'claude', + model: 'claude-sonnet-4-20250514', + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + cost: 0.1, + durationMs: 300 + }) + } + + const snapshots = new Map() + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn(async (key, value) => { + snapshots.set(key, value) + return 'OK' + }), + get: jest.fn(async (key) => snapshots.get(key) || null), + expire: jest.fn().mockResolvedValue(1) + } + redis.getClient.mockReturnValue(client) + + // Only startDate, no endDate — the start is clipped to retentionStart + // and would drift without moving-boundary-aware snapshot matching. + const startOnly = { + startDate: '2020-01-01T00:00:00.000Z', + pageSize: 1 + } + + const firstQuery = await requestDetailService.listRequestDetails(startOnly) + expect(firstQuery.snapshotId).toBeTruthy() + + jest.advanceTimersByTime(10000) + + // Page 2 with the same startDate-only filter after time advances — + // the snapshot should still be reused. + const secondQuery = await requestDetailService.listRequestDetails({ + ...startOnly, + snapshotId: firstQuery.snapshotId, + page: 2 + }) + + expect(client.zrangebyscore).toHaveBeenCalledTimes(1) + expect(secondQuery.snapshotId).toBe(firstQuery.snapshotId) + }) + + test('listRequestDetails reuses snapshot for endDate-only future queries after time advances', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'Acct' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 2; i++) { + const ts = Date.now() - (1 - i) * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'claude', + model: 'claude-sonnet-4-20250514', + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + cost: 0.1, + durationMs: 300 + }) + } + + const snapshots = new Map() + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn(async (key, value) => { + snapshots.set(key, value) + return 'OK' + }), + get: jest.fn(async (key) => snapshots.get(key) || null), + expire: jest.fn().mockResolvedValue(1) + } + redis.getClient.mockReturnValue(client) + + const endOnly = { + endDate: new Date(Date.now() + 3600000).toISOString(), + pageSize: 1 + } + + const firstQuery = await requestDetailService.listRequestDetails(endOnly) + expect(firstQuery.snapshotId).toBeTruthy() + + jest.advanceTimersByTime(10000) + + const secondQuery = await requestDetailService.listRequestDetails({ + ...endOnly, + snapshotId: firstQuery.snapshotId, + page: 2 + }) + + expect(client.zrangebyscore).toHaveBeenCalledTimes(1) + expect(secondQuery.snapshotId).toBe(firstQuery.snapshotId) + }) + + test('listRequestDetails invalidates snapshot when retentionHours changes', async () => { + const makeConfig = (hours) => ({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: hours, + requestDetailBodyPreviewEnabled: true + }) + + claudeRelayConfigService.getConfig.mockResolvedValue(makeConfig(6)) + + redis.getApiKey.mockResolvedValue({ name: 'Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'Acct' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 4; i++) { + const ts = Date.now() - (3 - i) * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'claude', + model: 'claude-sonnet-4-20250514', + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + cost: 0.1, + durationMs: 300 + }) + } + + const snapshots = new Map() + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn(async (key, value) => { + snapshots.set(key, value) + return 'OK' + }), + get: jest.fn(async (key) => snapshots.get(key) || null), + expire: jest.fn().mockResolvedValue(1) + } + redis.getClient.mockReturnValue(client) + + // Page 1 with retentionHours=6 + const firstQuery = await requestDetailService.listRequestDetails({ + pageSize: 2, + page: 1 + }) + expect(firstQuery.snapshotId).toBeTruthy() + + // Admin changes retention from 6 to 2 while snapshot is alive + claudeRelayConfigService.getConfig.mockResolvedValue(makeConfig(2)) + + // Page 2 with stale snapshotId — must NOT reuse old snapshot + const secondQuery = await requestDetailService.listRequestDetails({ + pageSize: 2, + page: 2, + snapshotId: firstQuery.snapshotId + }) + + expect(client.zrangebyscore).toHaveBeenCalledTimes(2) + expect(secondQuery.snapshotId).not.toBe(firstQuery.snapshotId) + }) + + test('listRequestDetails normalizes whitespace in structured filter values', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'Acct' }) + + const ts = Date.now() - 3600000 + const pointerEntries = ['req_0', String(ts)] + const recordsByKey = { + 'request_detail:item:req_0': JSON.stringify({ + requestId: 'req_0', + timestamp: new Date(ts).toISOString(), + endpoint: '/v1/messages', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'claude', + model: 'claude-sonnet-4-20250514', + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + cost: 0.1, + durationMs: 300 + }) + } + + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn().mockResolvedValue('OK'), + get: jest.fn().mockResolvedValue(null), + expire: jest.fn().mockResolvedValue(1) + } + redis.getClient.mockReturnValue(client) + + // Filter with leading/trailing whitespace must still match records + const result = await requestDetailService.listRequestDetails({ + apiKeyId: ' key_1 ' + }) + + expect(result.pagination.totalRecords).toBe(1) + expect(result.records).toHaveLength(1) + expect(result.filters.apiKeyId).toBe('key_1') + }) + + test('listRequestDetails skips snapshot creation when result count exceeds the limit', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + const client = { + set: jest.fn() + } + redis.getClient.mockReturnValue(client) + + const buildQuerySpy = jest + .spyOn(requestDetailService, '_buildListQueryData') + .mockResolvedValue({ + hasSourceRecords: true, + matchedPointers: Array.from({ length: 25001 }, (_, index) => ({ + requestId: `req_${index}`, + timestampMs: 1775563200000 + index + })), + availableFilters: { + apiKeys: [], + accounts: [], + models: [], + endpoints: [], + dateRange: { + earliest: null, + latest: null + } + }, + summary: { + totalRequests: 25001, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreateTokens: 0, + totalCost: 0, + avgDurationMs: 0, + cacheHitRate: 0, + cacheCreateNotApplicable: false + } + }) + const buildPageSpy = jest.spyOn(requestDetailService, '_buildPageRecords').mockResolvedValue([]) + + try { + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.snapshotId).toBeNull() + expect(client.set).not.toHaveBeenCalled() + } finally { + buildQuerySpy.mockRestore() + buildPageSpy.mockRestore() + } + }) + + test('listRequestDetails skips snapshot creation when payload size exceeds the limit', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + const client = { + set: jest.fn() + } + redis.getClient.mockReturnValue(client) + + const buildQuerySpy = jest + .spyOn(requestDetailService, '_buildListQueryData') + .mockResolvedValue({ + hasSourceRecords: true, + matchedPointers: [{ requestId: 'req_1', timestampMs: 1775563200000 }], + availableFilters: { + apiKeys: [], + accounts: [], + models: [`model_${'x'.repeat(2 * 1024 * 1024)}`], + endpoints: [], + dateRange: { + earliest: null, + latest: null + } + }, + summary: { + totalRequests: 1, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreateTokens: 0, + totalCost: 0, + avgDurationMs: 0, + cacheHitRate: 0, + cacheCreateNotApplicable: false + } + }) + const buildPageSpy = jest.spyOn(requestDetailService, '_buildPageRecords').mockResolvedValue([]) + + try { + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.snapshotId).toBeNull() + expect(client.set).not.toHaveBeenCalled() + } finally { + buildQuerySpy.mockRestore() + buildPageSpy.mockRestore() + } + }) + + test('listRequestDetails degrades gracefully when snapshot write fails', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + const ts = 1775563200000 + const client = { + zrangebyscore: jest.fn().mockResolvedValue(['req_1', String(ts)]), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ + requestId: 'req_1', + timestamp: new Date(ts).toISOString(), + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + cost: 0.1, + durationMs: 400 + }) + ]), + set: jest.fn().mockRejectedValue(new Error('READONLY')) + } + redis.getClient.mockReturnValue(client) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.snapshotId).toBeNull() + expect(result.records).toHaveLength(1) + expect(result.records[0].requestId).toBe('req_1') + }) + + test('listRequestDetails falls back to full query when snapshot read fails', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 3; i++) { + const ts = 1775563200000 + i * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + cost: 0.1, + durationMs: 400 + }) + } + + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + get: jest.fn().mockRejectedValue(new Error('ETIMEDOUT')), + set: jest.fn().mockResolvedValue('OK') + } + redis.getClient.mockReturnValue(client) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-08T23:59:59.000Z', + snapshotId: 'rds_stale_id' + }) + + expect(result.records).toHaveLength(3) + expect(client.get).toHaveBeenCalledWith('request_detail:query_snapshot:rds_stale_id') + // Falls back to full query, so zrangebyscore must have been called + expect(client.zrangebyscore).toHaveBeenCalledTimes(1) + }) + + test('listRequestDetails still returns snapshot data when TTL renewal fails', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 4; i++) { + const ts = 1775563200000 + i * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + cost: 0.5, + durationMs: 1200 + }) + } + + const snapshots = new Map() + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn(async (key, value) => { + snapshots.set(key, value) + return 'OK' + }), + get: jest.fn(async (key) => snapshots.get(key) || null), + expire: jest.fn().mockRejectedValue(new Error('NOPERM')) + } + redis.getClient.mockReturnValue(client) + + const firstPage = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-08T23:59:59.000Z', + pageSize: 2, + page: 1 + }) + + expect(firstPage.snapshotId).toBeTruthy() + + const secondPage = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-08T23:59:59.000Z', + pageSize: 2, + page: 2, + snapshotId: firstPage.snapshotId + }) + + expect(secondPage.snapshotId).toBe(firstPage.snapshotId) + expect(secondPage.records.map((record) => record.requestId)).toEqual(['req_1', 'req_0']) + // Snapshot was reused despite expire failure — no full re-query + expect(client.zrangebyscore).toHaveBeenCalledTimes(1) + }) + + test('listRequestDetails reuses snapshot after time-window trimming', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 4; i++) { + const ts = 1775563200000 + i * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + cost: 0.5, + durationMs: 1200 + }) + } + + const snapshots = new Map() + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn(async (key, value) => { + snapshots.set(key, value) + return 'OK' + }), + get: jest.fn(async (key) => snapshots.get(key) || null), + expire: jest.fn().mockResolvedValue(1) + } + redis.getClient.mockReturnValue(client) + + // First request with startDate far before retention window — will be trimmed + const firstPage = await requestDetailService.listRequestDetails({ + startDate: '2020-01-01T00:00:00.000Z', + endDate: '2026-04-08T23:59:59.000Z', + pageSize: 2, + page: 1 + }) + + expect(firstPage.snapshotId).toBeTruthy() + // The response echoes back the trimmed start date + const trimmedStart = firstPage.filters.startDate + + jest.advanceTimersByTime(10000) + + // Second request uses trimmed dates (as the frontend would after syncResponseState) + const secondPage = await requestDetailService.listRequestDetails({ + startDate: trimmedStart, + endDate: firstPage.filters.endDate, + pageSize: 2, + page: 2, + snapshotId: firstPage.snapshotId + }) + + expect(secondPage.snapshotId).toBe(firstPage.snapshotId) + // Full query should run only once + expect(client.zrangebyscore).toHaveBeenCalledTimes(1) + }) + + test('listRequestDetails reuses snapshot for default time window without explicit dates', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 4; i++) { + const ts = Date.now() - (3 - i) * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 100, + outputTokens: 50, + totalTokens: 150, + cost: 0.5, + durationMs: 1200 + }) + } + + const snapshots = new Map() + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn(async (key, value) => { + snapshots.set(key, value) + return 'OK' + }), + get: jest.fn(async (key) => snapshots.get(key) || null), + expire: jest.fn().mockResolvedValue(1) + } + redis.getClient.mockReturnValue(client) + + // First page — no startDate / endDate (default rolling window) + const firstPage = await requestDetailService.listRequestDetails({ + pageSize: 2, + page: 1 + }) + + expect(firstPage.snapshotId).toBeTruthy() + + jest.advanceTimersByTime(10000) + + // Second page — still no dates, only snapshotId; the server's new Date() + // will produce different effective timestamps, but the snapshot should + // still be reused because dates are excluded from the filter signature. + const secondPage = await requestDetailService.listRequestDetails({ + pageSize: 2, + page: 2, + snapshotId: firstPage.snapshotId + }) + + expect(secondPage.snapshotId).toBe(firstPage.snapshotId) + expect(client.zrangebyscore).toHaveBeenCalledTimes(1) + }) + + test('listRequestDetails clamps out-of-range pages without rerunning the full query', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getApiKey.mockResolvedValue({ name: 'Primary Key' }) + openaiAccountService.getAccount.mockResolvedValue({ name: 'OpenAI Main' }) + + const recordsByKey = {} + const pointerEntries = [] + for (let i = 0; i < 3; i++) { + const ts = 1775563200000 + i * 3600000 + pointerEntries.push(`req_${i}`, String(ts)) + recordsByKey[`request_detail:item:req_${i}`] = JSON.stringify({ + requestId: `req_${i}`, + timestamp: new Date(ts).toISOString(), + endpoint: '/openai/v1/responses', + method: 'POST', + apiKeyId: 'key_1', + accountId: 'acct_1', + accountType: 'openai', + model: 'gpt-5.4', + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + cost: 0.1, + durationMs: 400 + }) + } + + const client = { + zrangebyscore: jest.fn().mockResolvedValue(pointerEntries), + mget: jest.fn(async (keys) => keys.map((key) => recordsByKey[key] || null)), + set: jest.fn().mockResolvedValue('OK') + } + redis.getClient.mockReturnValue(client) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-08T23:59:59.000Z', + pageSize: 2, + page: 9 + }) + + expect(result.pagination.currentPage).toBe(2) + expect(result.pagination.totalPages).toBe(2) + expect(result.records.map((record) => record.requestId)).toEqual(['req_0']) + expect(client.zrangebyscore).toHaveBeenCalledTimes(1) + }) + + test('listRequestDetails backfills invalid timestamps from day-index scores', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: true + }) + + redis.getClient.mockReturnValue({ + zrangebyscore: jest.fn().mockResolvedValue(['req_invalid_ts', '1775563200000']), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ + requestId: 'req_invalid_ts', + timestamp: 'invalid-date', + endpoint: '/v1/messages', + method: 'POST', + model: 'claude-sonnet-4-6' + }) + ]) + }) + + const result = await requestDetailService.listRequestDetails({ + startDate: '2026-04-07T00:00:00.000Z', + endDate: '2026-04-07T23:59:59.000Z' + }) + + expect(result.records).toHaveLength(1) + expect(result.records[0].timestamp).toBe('2026-04-07T12:00:00.000Z') + expect(result.availableFilters.dateRange.earliest).toBe('2026-04-07T12:00:00.000Z') + }) + + test('purgeRequestBodySnapshots removes snapshots while keeping records', async () => { + claudeRelayConfigService.getConfig.mockResolvedValue({ + requestDetailCaptureEnabled: true, + requestDetailRetentionHours: 6, + requestDetailBodyPreviewEnabled: false + }) + + const exec = jest.fn().mockResolvedValue([]) + const pipeline = { + set: jest.fn().mockReturnThis(), + exec + } + const client = { + scan: jest + .fn() + .mockResolvedValueOnce(['0', ['request_detail:item:req_1', 'request_detail:item:req_2']]), + mget: jest.fn().mockResolvedValue([ + JSON.stringify({ + requestId: 'req_1', + model: 'gpt-5.4', + requestBodySnapshot: { model: 'gpt-5.4' } + }), + JSON.stringify({ + requestId: 'req_2', + model: 'claude-sonnet-4-6' + }) + ]), + pipeline: jest.fn(() => pipeline) + } + redis.getClient.mockReturnValue(client) + + const result = await requestDetailService.purgeRequestBodySnapshots() + + expect(result.updatedRecords).toBe(1) + expect(pipeline.set).toHaveBeenCalledWith( + 'request_detail:item:req_1', + JSON.stringify({ + requestId: 'req_1', + model: 'gpt-5.4' + }), + 'KEEPTTL' + ) + expect(exec).toHaveBeenCalled() + }) +}) diff --git a/tests/requestDetailsRoute.test.js b/tests/requestDetailsRoute.test.js new file mode 100644 index 0000000..ca01f91 --- /dev/null +++ b/tests/requestDetailsRoute.test.js @@ -0,0 +1,141 @@ +const mockRouter = { + get: jest.fn(), + post: jest.fn() +} + +jest.mock( + 'express', + () => ({ + Router: () => mockRouter + }), + { virtual: true } +) + +jest.mock('../src/middleware/auth', () => ({ + authenticateAdmin: jest.fn((_req, _res, next) => next()) +})) + +jest.mock('../src/services/requestDetailService', () => ({ + listRequestDetails: jest.fn(), + getRequestDetail: jest.fn(), + getRequestBodyPreviewStats: jest.fn(), + purgeRequestBodySnapshots: jest.fn() +})) + +jest.mock('../src/utils/logger', () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + start: jest.fn() +})) + +const requestDetailService = require('../src/services/requestDetailService') +require('../src/routes/admin/requestDetails') + +function createResponse() { + const res = { + statusCode: 200, + body: null, + json: jest.fn((payload) => { + res.body = payload + return res + }), + status: jest.fn((code) => { + res.statusCode = code + return res + }) + } + return res +} + +function findGetHandler(path) { + const route = mockRouter.get.mock.calls.find((call) => call[0] === path) + return route?.[2] +} + +function findPostHandler(path) { + const route = mockRouter.post.mock.calls.find((call) => call[0] === path) + return route?.[2] +} + +describe('requestDetails admin routes', () => { + beforeEach(() => { + requestDetailService.listRequestDetails.mockReset() + requestDetailService.getRequestDetail.mockReset() + requestDetailService.getRequestBodyPreviewStats.mockReset() + requestDetailService.purgeRequestBodySnapshots.mockReset() + }) + + test('returns 400 for invalid request detail queries', async () => { + const error = new Error('Invalid date range') + error.statusCode = 400 + requestDetailService.listRequestDetails.mockRejectedValue(error) + + const handler = findGetHandler('/request-details') + const res = createResponse() + + await handler({ query: { startDate: 'bad' } }, res) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.body.success).toBe(false) + expect(res.body.message).toBe('Invalid date range') + }) + + test('returns retained detail records even when capture is disabled', async () => { + requestDetailService.getRequestDetail.mockResolvedValue({ + captureEnabled: false, + retentionHours: 6, + record: { + requestId: 'req_1', + model: 'gpt-5.4' + } + }) + + const handler = findGetHandler('/request-details/:requestId') + const res = createResponse() + + await handler({ params: { requestId: 'req_1' } }, res) + + expect(res.status).not.toHaveBeenCalled() + expect(res.body.success).toBe(true) + expect(res.body.data.captureEnabled).toBe(false) + expect(res.body.data.record.requestId).toBe('req_1') + }) + + test('returns request body preview stats', async () => { + requestDetailService.getRequestBodyPreviewStats.mockResolvedValue({ + captureEnabled: true, + retentionHours: 6, + bodyPreviewEnabled: false, + snapshotCount: 3, + hasSnapshots: true + }) + + const handler = findGetHandler('/request-details/body-preview-stats') + const res = createResponse() + + await handler({}, res) + + expect(res.status).not.toHaveBeenCalled() + expect(res.body.success).toBe(true) + expect(res.body.data.snapshotCount).toBe(3) + expect(res.body.data.hasSnapshots).toBe(true) + }) + + test('purges stored request body previews via dedicated route', async () => { + requestDetailService.purgeRequestBodySnapshots.mockResolvedValue({ + updatedRecords: 7 + }) + + const handler = findPostHandler('/request-details/body-preview-purge') + const res = createResponse() + + await handler({}, res) + + expect(res.status).not.toHaveBeenCalled() + expect(res.body.success).toBe(true) + expect(res.body.message).toBe('清理完毕') + expect(res.body.data.updatedRecords).toBe(7) + }) +}) diff --git a/tests/unifiedClaudeSchedulerDedicated.test.js b/tests/unifiedClaudeSchedulerDedicated.test.js new file mode 100644 index 0000000..1a435d6 --- /dev/null +++ b/tests/unifiedClaudeSchedulerDedicated.test.js @@ -0,0 +1,153 @@ +// Regression test: an API key bound to a dedicated Claude account must NEVER be +// silently routed to a different account. +// +// Bug: markAccountRateLimited sets schedulable='false' AND the relay writes a +// temp_unavailable key. The bound-account path checked temp_unavailable FIRST and only +// logged "falling back to pool", so the CLAUDE_DEDICATED_RATE_LIMITED throw was dead code +// and the dedicated key quietly used other accounts from the shared pool. + +const mockConfig = { claude: {} } + +jest.mock('../config/config', () => mockConfig) +jest.mock('../src/services/account/claudeAccountService', () => ({ + isAccountRateLimited: jest.fn(), + getAccountRateLimitInfo: jest.fn(), + isAccountModelRateLimited: jest.fn(), + getAccountModelRateLimitInfo: jest.fn(), + clearExpiredModelRateLimit: jest.fn() +})) +jest.mock('../src/services/account/claudeConsoleAccountService', () => ({})) +jest.mock('../src/services/account/bedrockAccountService', () => ({})) +jest.mock('../src/services/account/ccrAccountService', () => ({})) +jest.mock('../src/services/accountGroupService', () => ({})) +jest.mock('../src/models/redis', () => ({ getClaudeAccount: jest.fn() })) +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + success: jest.fn() +})) +jest.mock('../src/utils/commonHelper', () => ({ + isSchedulable: jest.fn((value) => value !== false && value !== 'false'), + sortAccountsByPriority: jest.fn((accounts) => accounts) +})) +jest.mock('../src/utils/upstreamErrorHelper', () => ({})) + +const claudeAccountService = require('../src/services/account/claudeAccountService') +const redis = require('../src/models/redis') +const scheduler = require('../src/services/scheduler/unifiedClaudeScheduler') + +const BOUND_ID = 'acct-0w' +// mirrors production: API key "river" bound to account "0w" +const apiKeyData = { id: 'key-river', name: 'river', claudeAccountId: BOUND_ID } + +const healthyAccount = { + id: BOUND_ID, + name: '0w', + isActive: 'true', + status: 'active', + schedulable: 'true' +} + +describe('dedicated (bound) Claude account never silently falls back to the shared pool', () => { + let tempSpy + + beforeEach(() => { + jest.clearAllMocks() + mockConfig.claude = {} + claudeAccountService.isAccountRateLimited.mockResolvedValue(false) + claudeAccountService.isAccountModelRateLimited.mockResolvedValue(false) + claudeAccountService.clearExpiredModelRateLimit.mockResolvedValue({ success: true }) + claudeAccountService.getAccountRateLimitInfo.mockResolvedValue({ + rateLimitEndAt: '2026-07-07T07:00:00.000Z' + }) + claudeAccountService.getAccountModelRateLimitInfo.mockResolvedValue({ resetAt: null }) + tempSpy = jest.spyOn(scheduler, 'isAccountTemporarilyUnavailable').mockResolvedValue(false) + }) + + afterEach(() => { + tempSpy.mockRestore() + }) + + it('uses the bound account when it is healthy', async () => { + redis.getClaudeAccount.mockResolvedValue(healthyAccount) + + await expect( + scheduler.selectAccountForApiKey(apiKeyData, null, 'claude-opus-4-8') + ).resolves.toEqual({ accountId: BOUND_ID, accountType: 'claude-official' }) + }) + + it('throws CLAUDE_DEDICATED_RATE_LIMITED when rate limited AND temp-unavailable (the production bug)', async () => { + // markAccountRateLimited sets schedulable=false + rateLimitAutoStopped, + // and the relay also writes temp_unavailable — the exact state of "0w". + redis.getClaudeAccount.mockResolvedValue({ + ...healthyAccount, + schedulable: 'false', + rateLimitStatus: 'limited', + rateLimitAutoStopped: 'true' + }) + claudeAccountService.isAccountRateLimited.mockResolvedValue(true) + tempSpy.mockResolvedValue(true) + + await expect( + scheduler.selectAccountForApiKey(apiKeyData, null, 'claude-opus-4-8') + ).rejects.toMatchObject({ code: 'CLAUDE_DEDICATED_RATE_LIMITED', accountId: BOUND_ID }) + }) + + it('throws CLAUDE_DEDICATED_RATE_LIMITED when only schedulable=false via rate-limit auto-stop', async () => { + redis.getClaudeAccount.mockResolvedValue({ + ...healthyAccount, + schedulable: 'false', + rateLimitAutoStopped: 'true' + }) + + await expect( + scheduler.selectAccountForApiKey(apiKeyData, null, 'claude-opus-4-8') + ).rejects.toMatchObject({ code: 'CLAUDE_DEDICATED_RATE_LIMITED' }) + }) + + it('throws CLAUDE_DEDICATED_RATE_LIMITED when the requested model family is limited', async () => { + redis.getClaudeAccount.mockResolvedValue(healthyAccount) + claudeAccountService.isAccountModelRateLimited.mockResolvedValue(true) + claudeAccountService.getAccountModelRateLimitInfo.mockResolvedValue({ resetAt: 'soon' }) + + await expect( + scheduler.selectAccountForApiKey(apiKeyData, null, 'claude-sonnet-4-5') + ).rejects.toMatchObject({ code: 'CLAUDE_DEDICATED_RATE_LIMITED', modelFamily: 'sonnet' }) + }) + + it('throws CLAUDE_DEDICATED_UNAVAILABLE when only temporarily unavailable', async () => { + redis.getClaudeAccount.mockResolvedValue(healthyAccount) + tempSpy.mockResolvedValue(true) + + await expect( + scheduler.selectAccountForApiKey(apiKeyData, null, 'claude-opus-4-8') + ).rejects.toMatchObject({ + code: 'CLAUDE_DEDICATED_UNAVAILABLE', + reason: 'temporarily_unavailable' + }) + }) + + it('throws CLAUDE_DEDICATED_UNAVAILABLE when the bound account is inactive', async () => { + redis.getClaudeAccount.mockResolvedValue({ ...healthyAccount, isActive: 'false' }) + + await expect( + scheduler.selectAccountForApiKey(apiKeyData, null, 'claude-opus-4-8') + ).rejects.toMatchObject({ code: 'CLAUDE_DEDICATED_UNAVAILABLE', reason: 'inactive_or_error' }) + }) + + it('falls back to the shared pool ONLY when dedicatedAccountFallback is enabled', async () => { + mockConfig.claude = { dedicatedAccountFallback: true } + redis.getClaudeAccount.mockResolvedValue(healthyAccount) + tempSpy.mockResolvedValue(true) + const poolSpy = jest.spyOn(scheduler, '_getAllAvailableAccounts').mockResolvedValue([]) + + await expect( + scheduler.selectAccountForApiKey(apiKeyData, null, 'claude-opus-4-8') + ).rejects.toThrow(/No available Claude accounts/) + expect(poolSpy).toHaveBeenCalled() + + poolSpy.mockRestore() + }) +}) diff --git a/tests/unifiedOpenAIScheduler.test.js b/tests/unifiedOpenAIScheduler.test.js new file mode 100644 index 0000000..0457da2 --- /dev/null +++ b/tests/unifiedOpenAIScheduler.test.js @@ -0,0 +1,75 @@ +jest.mock('../src/services/account/openaiAccountService', () => ({ + setAccountRateLimited: jest.fn() +})) + +jest.mock('../src/services/account/openaiResponsesAccountService', () => ({ + getAccount: jest.fn(), + markAccountRateLimited: jest.fn(), + updateAccount: jest.fn() +})) + +jest.mock('../src/services/accountGroupService', () => ({})) +jest.mock('../src/models/redis', () => ({})) +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + warn: jest.fn() +})) +jest.mock('../src/utils/commonHelper', () => ({ + isSchedulable: jest.fn((value) => value !== false && value !== 'false'), + sortAccountsByPriority: jest.fn((accounts) => accounts) +})) +jest.mock('../src/utils/upstreamErrorHelper', () => ({})) + +const openaiResponsesAccountService = require('../src/services/account/openaiResponsesAccountService') +const unifiedOpenAIScheduler = require('../src/services/scheduler/unifiedOpenAIScheduler') + +describe('UnifiedOpenAIScheduler', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('markAccountRateLimited', () => { + it('does not disable scheduling again when OpenAI-Responses auto protection is disabled', async () => { + openaiResponsesAccountService.getAccount.mockResolvedValue({ + id: 'account-1', + disableAutoProtection: 'true' + }) + + await unifiedOpenAIScheduler.markAccountRateLimited( + 'account-1', + 'openai-responses', + null, + 120 + ) + + expect(openaiResponsesAccountService.markAccountRateLimited).toHaveBeenCalledWith( + 'account-1', + 2 + ) + expect(openaiResponsesAccountService.updateAccount).not.toHaveBeenCalled() + }) + + it('keeps disabling scheduling for protected OpenAI-Responses accounts', async () => { + openaiResponsesAccountService.getAccount.mockResolvedValue({ + id: 'account-1', + disableAutoProtection: 'false' + }) + + await unifiedOpenAIScheduler.markAccountRateLimited( + 'account-1', + 'openai-responses', + null, + 120 + ) + + expect(openaiResponsesAccountService.updateAccount).toHaveBeenCalledWith( + 'account-1', + expect.objectContaining({ + schedulable: 'false' + }) + ) + }) + }) +}) diff --git a/tests/upstreamErrorTtlCap.test.js b/tests/upstreamErrorTtlCap.test.js new file mode 100644 index 0000000..9fc3631 --- /dev/null +++ b/tests/upstreamErrorTtlCap.test.js @@ -0,0 +1,59 @@ +// Regression test: temp-unavailable is a *transient* cooldown. A weekly-scale +// upstream retry-after (observed: 443300s ≈ 5.1 days) must never sideline an account +// for days — that key is a separate TTL'd Redis key and the account hash looks clean, +// which made it nearly impossible to diagnose in production. + +const mockSetex = jest.fn(async () => 'OK') +const mockDel = jest.fn(async () => 1) +const mockHgetall = jest.fn(async () => ({})) + +jest.mock('../src/models/redis', () => ({ + getClientSafe: () => ({ + setex: mockSetex, + del: mockDel, + hgetall: mockHgetall, + zadd: jest.fn(async () => 1), + expire: jest.fn(async () => 1), + zremrangebyrank: jest.fn(async () => 1) + }) +})) +jest.mock('../src/utils/logger', () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn() +})) +jest.mock('../config/config', () => ({ upstreamError: {} })) + +const upstreamErrorHelper = require('../src/utils/upstreamErrorHelper') + +const ACCOUNT = 'acct-1' +const TYPE = 'claude-official' +const ttlPassedToSetex = () => mockSetex.mock.calls[0][1] + +describe('markTempUnavailable clamps upstream retry-after', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('clamps a weekly-scale retry-after (5.1 days) to the 30 minute cap', async () => { + await upstreamErrorHelper.markTempUnavailable(ACCOUNT, TYPE, 429, 443300) + expect(mockSetex).toHaveBeenCalledTimes(1) + expect(ttlPassedToSetex()).toBe(1800) + }) + + it('clamps the other observed value (4.2 days) too', async () => { + await upstreamErrorHelper.markTempUnavailable(ACCOUNT, TYPE, 429, 360117) + expect(ttlPassedToSetex()).toBe(1800) + }) + + it('leaves a short, sane retry-after untouched', async () => { + await upstreamErrorHelper.markTempUnavailable(ACCOUNT, TYPE, 429, 600) + expect(ttlPassedToSetex()).toBe(600) + }) + + it('falls back to the per-error-type default when no retry-after is given', async () => { + await upstreamErrorHelper.markTempUnavailable(ACCOUNT, TYPE, 429, null) + expect(ttlPassedToSetex()).toBe(300) // DEFAULT_TTL.rate_limit + }) +}) diff --git a/tests/userMessageQueue.test.js b/tests/userMessageQueue.test.js new file mode 100644 index 0000000..4fd7adb --- /dev/null +++ b/tests/userMessageQueue.test.js @@ -0,0 +1,434 @@ +/** + * 用户消息队列服务测试 + * 测试消息类型检测、队列串行行为、延迟间隔、超时处理和功能开关 + */ + +const redis = require('../src/models/redis') +const userMessageQueueService = require('../src/services/userMessageQueueService') + +describe('UserMessageQueueService', () => { + describe('isUserMessageRequest', () => { + it('should return true when last message role is user', () => { + const requestBody = { + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' }, + { role: 'user', content: 'How are you?' } + ] + } + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true) + }) + + it('should return false when last message role is assistant', () => { + const requestBody = { + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' } + ] + } + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false) + }) + + it('should return false when last message contains tool_result', () => { + const requestBody = { + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Let me check that' }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'test-id', + content: 'Tool result' + } + ] + } + ] + } + // tool_result 消息虽然 role 是 user,但不是真正的用户消息 + // 应该返回 false,不进入用户消息队列 + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false) + }) + + it('should return false when last message contains multiple tool_results', () => { + const requestBody = { + messages: [ + { role: 'user', content: 'Run multiple tools' }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'Result 1' + }, + { + type: 'tool_result', + tool_use_id: 'tool-2', + content: 'Result 2' + } + ] + } + ] + } + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false) + }) + + it('should return true when user message has array content with text type', () => { + const requestBody = { + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Hello, this is a user message' + } + ] + } + ] + } + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true) + }) + + it('should return true when user message has mixed text and image content', () => { + const requestBody = { + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'What is in this image?' + }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: '...' } + } + ] + } + ] + } + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true) + }) + + it('should return false when messages is empty', () => { + const requestBody = { messages: [] } + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false) + }) + + it('should return false when messages is not an array', () => { + const requestBody = { messages: 'not an array' } + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false) + }) + + it('should return false when messages is undefined', () => { + const requestBody = {} + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false) + }) + + it('should return false when requestBody is null', () => { + expect(userMessageQueueService.isUserMessageRequest(null)).toBe(false) + }) + + it('should return false when requestBody is undefined', () => { + expect(userMessageQueueService.isUserMessageRequest(undefined)).toBe(false) + }) + + it('should return false when last message has no role', () => { + const requestBody = { + messages: [{ content: 'Hello' }] + } + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false) + }) + + it('should handle single user message', () => { + const requestBody = { + messages: [{ role: 'user', content: 'Hello' }] + } + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true) + }) + + it('should handle single assistant message', () => { + const requestBody = { + messages: [{ role: 'assistant', content: 'Hello' }] + } + expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false) + }) + }) + + describe('getConfig', () => { + it('should return config with expected properties', async () => { + const config = await userMessageQueueService.getConfig() + expect(config).toHaveProperty('enabled') + expect(config).toHaveProperty('delayMs') + expect(config).toHaveProperty('timeoutMs') + expect(config).toHaveProperty('lockTtlMs') + expect(typeof config.enabled).toBe('boolean') + expect(typeof config.delayMs).toBe('number') + expect(typeof config.timeoutMs).toBe('number') + expect(typeof config.lockTtlMs).toBe('number') + }) + }) + + describe('isEnabled', () => { + it('should return boolean', async () => { + const enabled = await userMessageQueueService.isEnabled() + expect(typeof enabled).toBe('boolean') + }) + }) + + describe('acquireQueueLock', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should acquire lock immediately when no lock exists', async () => { + jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({ + enabled: true, + delayMs: 200, + timeoutMs: 30000, + lockTtlMs: 120000 + }) + jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({ + acquired: true, + waitMs: 0 + }) + + const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1') + + expect(result.acquired).toBe(true) + expect(result.requestId).toBe('req-1') + expect(result.error).toBeUndefined() + }) + + it('should skip lock acquisition when queue disabled', async () => { + jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({ + enabled: false, + delayMs: 200, + timeoutMs: 30000, + lockTtlMs: 120000 + }) + const acquireSpy = jest.spyOn(redis, 'acquireUserMessageLock') + + const result = await userMessageQueueService.acquireQueueLock('acct-1') + + expect(result.acquired).toBe(true) + expect(result.skipped).toBe(true) + expect(acquireSpy).not.toHaveBeenCalled() + }) + + it('should generate requestId when not provided', async () => { + jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({ + enabled: true, + delayMs: 200, + timeoutMs: 30000, + lockTtlMs: 120000 + }) + jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({ + acquired: true, + waitMs: 0 + }) + + const result = await userMessageQueueService.acquireQueueLock('acct-1') + + expect(result.acquired).toBe(true) + expect(result.requestId).toBeDefined() + expect(result.requestId.length).toBeGreaterThan(0) + }) + + it('should wait and retry when lock is held by another request', async () => { + jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({ + enabled: true, + delayMs: 200, + timeoutMs: 1000, + lockTtlMs: 120000 + }) + + let callCount = 0 + jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => { + callCount++ + if (callCount < 3) { + return { acquired: false, waitMs: -1 } // lock held + } + return { acquired: true, waitMs: 0 } + }) + + // Mock sleep to speed up test + jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined) + + const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1') + + expect(result.acquired).toBe(true) + expect(callCount).toBe(3) + }) + + it('should respect delay when previous request just completed', async () => { + jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({ + enabled: true, + delayMs: 200, + timeoutMs: 1000, + lockTtlMs: 120000 + }) + + let callCount = 0 + jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => { + callCount++ + if (callCount === 1) { + return { acquired: false, waitMs: 150 } // need to wait 150ms for delay + } + return { acquired: true, waitMs: 0 } + }) + + const sleepSpy = jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined) + + const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1') + + expect(result.acquired).toBe(true) + expect(sleepSpy).toHaveBeenCalledWith(150) // Should wait for delay + }) + + it('should timeout and return error when wait exceeds timeout', async () => { + jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({ + enabled: true, + delayMs: 200, + timeoutMs: 100, // very short timeout + lockTtlMs: 120000 + }) + + jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({ + acquired: false, + waitMs: -1 // always held + }) + + // Use real timers for timeout test but mock sleep to be instant + jest.spyOn(userMessageQueueService, '_sleep').mockImplementation(async () => { + // Simulate time passing + await new Promise((resolve) => setTimeout(resolve, 60)) + }) + + const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1', 100) + + expect(result.acquired).toBe(false) + expect(result.error).toBe('queue_timeout') + }) + }) + + describe('releaseQueueLock', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should release lock successfully when holding the lock', async () => { + jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(true) + + const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1') + + expect(result).toBe(true) + expect(redis.releaseUserMessageLock).toHaveBeenCalledWith('acct-1', 'req-1') + }) + + it('should return false when not holding the lock', async () => { + jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(false) + + const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1') + + expect(result).toBe(false) + }) + + it('should return false when accountId is missing', async () => { + const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock') + + const result = await userMessageQueueService.releaseQueueLock(null, 'req-1') + + expect(result).toBe(false) + expect(releaseSpy).not.toHaveBeenCalled() + }) + + it('should return false when requestId is missing', async () => { + const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock') + + const result = await userMessageQueueService.releaseQueueLock('acct-1', null) + + expect(result).toBe(false) + expect(releaseSpy).not.toHaveBeenCalled() + }) + }) + + describe('queue serialization behavior', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('should allow different accounts to acquire locks simultaneously', async () => { + jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({ + enabled: true, + delayMs: 200, + timeoutMs: 30000, + lockTtlMs: 120000 + }) + jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({ + acquired: true, + waitMs: 0 + }) + + const [result1, result2] = await Promise.all([ + userMessageQueueService.acquireQueueLock('acct-1', 'req-1'), + userMessageQueueService.acquireQueueLock('acct-2', 'req-2') + ]) + + expect(result1.acquired).toBe(true) + expect(result2.acquired).toBe(true) + }) + + it('should serialize requests for same account', async () => { + jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({ + enabled: true, + delayMs: 50, + timeoutMs: 5000, + lockTtlMs: 120000 + }) + + const lockState = { held: false, holderId: null } + + jest + .spyOn(redis, 'acquireUserMessageLock') + .mockImplementation(async (accountId, requestId) => { + if (!lockState.held) { + lockState.held = true + lockState.holderId = requestId + return { acquired: true, waitMs: 0 } + } + return { acquired: false, waitMs: -1 } + }) + + jest + .spyOn(redis, 'releaseUserMessageLock') + .mockImplementation(async (accountId, requestId) => { + if (lockState.holderId === requestId) { + lockState.held = false + lockState.holderId = null + return true + } + return false + }) + + jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined) + + // First request acquires lock + const result1 = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1') + expect(result1.acquired).toBe(true) + + // Second request should fail to acquire (lock held) + const acquirePromise = userMessageQueueService.acquireQueueLock('acct-1', 'req-2', 200) + + // Release first lock + await userMessageQueueService.releaseQueueLock('acct-1', 'req-1') + + // Now second request should acquire + const result2 = await acquirePromise + expect(result2.acquired).toBe(true) + }) + }) +}) diff --git a/web/admin-spa/.env.example b/web/admin-spa/.env.example new file mode 100644 index 0000000..d0bd7a9 --- /dev/null +++ b/web/admin-spa/.env.example @@ -0,0 +1,37 @@ +# ========== 基础配置 ========== + +# 应用基础路径 +# 用于配置路由和资源的基础路径 +# 开发环境默认:/admin/ +# 生产环境默认:/admin-next/ +# 如果使用默认值,可以注释掉此行 +VITE_APP_BASE_URL=/admin-next/ + +# 应用标题 +# 显示在浏览器标签页和页面头部 +VITE_APP_TITLE=Claude Relay Service - 管理后台 + +# ========== 开发环境配置 ========== + +# API 代理目标地址 +# 开发环境下,所有 /webapi 前缀的请求会被代理到这个地址 +# 默认值:http://localhost:3000 +# VITE_API_TARGET=http://localhost:3000 + +# HTTP 代理配置(可选) +# 如果需要通过代理访问后端服务器,请取消注释并配置 +# 格式:http://proxy-host:port +#VITE_HTTP_PROXY=http://127.0.0.1:7890 + +# ========== 教程页面配置 ========== + +# API 基础前缀(可选) +# 用于教程页面显示的自定义 API 前缀 +# 如果不配置,则使用当前浏览器访问地址 +# 示例:https://api.example.com 或 https://relay.mysite.com +# VITE_API_BASE_PREFIX=https://api.example.com + +# ========== 使用说明 ========== +# 1. 复制此文件为 .env.local 进行本地配置 +# 2. .env.local 文件不会被提交到版本控制 +# 3. 详细说明请查看 ENV_CONFIG.md \ No newline at end of file diff --git a/web/admin-spa/.env.production b/web/admin-spa/.env.production new file mode 100644 index 0000000..8b3e201 --- /dev/null +++ b/web/admin-spa/.env.production @@ -0,0 +1,4 @@ +# 生产环境配置 +# 应用基础路径(用于路由配置) +VITE_APP_BASE_URL=/admin-next/ +VITE_APP_TITLE=Claude Relay Service - 管理后台 \ No newline at end of file diff --git a/web/admin-spa/.eslintrc.cjs b/web/admin-spa/.eslintrc.cjs new file mode 100644 index 0000000..2a62505 --- /dev/null +++ b/web/admin-spa/.eslintrc.cjs @@ -0,0 +1,44 @@ +module.exports = { + root: true, + env: { + node: true, + browser: true, + es2021: true + }, + extends: [ + 'plugin:vue/vue3-strongly-recommended', + 'eslint:recommended', + 'plugin:prettier/recommended' + ], + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest' + }, + plugins: ['prettier'], + rules: { + 'vue/multi-word-component-names': 'off', + 'vue/no-v-html': 'off', + 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'prettier/prettier': 'error', + 'vue/attributes-order': [ + 'error', + { + order: [ + 'DEFINITION', + 'LIST_RENDERING', + 'CONDITIONALS', + 'RENDER_MODIFIERS', + 'GLOBAL', + 'UNIQUE', + 'TWO_WAY_BINDING', + 'OTHER_DIRECTIVES', + 'OTHER_ATTR', + 'EVENTS', + 'CONTENT' + ], + alphabetical: true + } + ] + } +} diff --git a/web/admin-spa/.gitignore b/web/admin-spa/.gitignore new file mode 100644 index 0000000..355deb2 --- /dev/null +++ b/web/admin-spa/.gitignore @@ -0,0 +1,35 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies +node_modules + +# Production build files +dist +dist-ssr + +# Local env files +!.env.production +*.local +.env.local +.env.*.local +.env +vite.config.js.timestamp-*.mjs +web/admin/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file diff --git a/web/admin-spa/.prettierrc b/web/admin-spa/.prettierrc new file mode 100644 index 0000000..b31a3d5 --- /dev/null +++ b/web/admin-spa/.prettierrc @@ -0,0 +1,17 @@ +{ + "printWidth": 100, + "semi": false, + "singleQuote": true, + "trailingComma": "none", + "endOfLine": "auto", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindConfig": "./tailwind.config.js", + + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "arrowParens": "always", + "quoteProps": "as-needed", + "bracketSameLine": false, + "proseWrap": "preserve" +} diff --git a/web/admin-spa/README.md b/web/admin-spa/README.md new file mode 100644 index 0000000..c667442 --- /dev/null +++ b/web/admin-spa/README.md @@ -0,0 +1,147 @@ +# Claude Relay Service 管理后台 SPA + +这是 Claude Relay Service 管理后台的 Vue3 SPA 重构版本。 + +## 开发环境要求 + +- Node.js >= 16 +- npm >= 7 + +## 安装和运行 + +### 1. 安装依赖 + +```bash +cd web/admin-spa +npm install +``` + +### 2. 开发模式运行 + +```bash +npm run dev +``` + +**重要提示:** +- 开发服务器启动后,会自动在浏览器中打开 +- 必须访问完整路径:http://localhost:3001/web/admin/ +- 不要访问 http://localhost:3001/ (会显示404) +- 首次访问会自动跳转到登录页面 + +### 3. 生产构建 + +```bash +npm run build +``` + +构建产物将输出到 `dist` 目录。 + +### 4. 预览生产构建 + +```bash +npm run preview +``` + +## 项目结构 + +``` +web/admin-spa/ +├── public/ # 静态资源 +├── src/ +│ ├── api/ # API 接口封装 +│ ├── assets/ # 资源文件 +│ ├── components/ # 组件 +│ ├── composables/ # 组合式函数 +│ ├── router/ # 路由配置 +│ ├── stores/ # Pinia 状态管理 +│ ├── utils/ # 工具函数 +│ ├── views/ # 页面视图 +│ ├── App.vue # 根组件 +│ └── main.js # 入口文件 +├── package.json +└── vite.config.js +``` + +## 功能模块 + +- ✅ 登录认证 +- ✅ 仪表板(系统统计、使用趋势、模型分布) +- 🚧 API Keys 管理 +- 🚧 账户管理(Claude/Gemini) +- 🚧 使用教程 +- 🚧 系统设置 + +## 技术栈 + +- Vue 3.3.4 +- Vue Router 4 +- Pinia(状态管理) +- Element Plus 2.4.4 +- Tailwind CSS +- Chart.js 4.4.0 +- Vite 5 + +## 开发注意事项 + +1. 所有 API 请求都通过 `/api` 目录下的模块进行封装 +2. 状态管理使用 Pinia,存放在 `/stores` 目录 +3. 组件按功能模块组织在 `/components` 目录下 +4. 保持与原版页面的功能和样式一致性 + +## 代理配置 + +如果你的后端服务器需要通过代理访问(例如服务器在国外),可以配置 HTTP 代理: + +### 方法一:使用环境变量文件(推荐) + +创建 `.env.development.local` 文件: + +```bash +# 后端服务器地址 +VITE_API_TARGET=http://74.48.134.98:3000 + +# HTTP 代理配置 +VITE_HTTP_PROXY=http://127.0.0.1:7890 +``` + +### 方法二:使用系统环境变量 + +```bash +# Linux/Mac +export VITE_HTTP_PROXY=http://127.0.0.1:7890 +npm run dev + +# Windows +set VITE_HTTP_PROXY=http://127.0.0.1:7890 +npm run dev +``` + +注意:`.env.development.local` 文件不会被提交到版本控制,适合存放本地特定的配置。 + +## 部署 + +构建后的文件需要部署到 Claude Relay Service 的 `web/admin/` 路径下。 + +## 常见问题 + +### Q: 访问 localhost:3001 显示 404? +A: 这是正常的。应用配置在 `/web/admin/` 路径下,必须访问完整路径:http://localhost:3001/web/admin/ + +### Q: 登录时 API 请求失败(500错误)? +A: +1. **确保主服务运行**:Claude Relay Service 必须运行在 http://localhost:3000 +2. **检查代理配置**:Vite 会自动代理 `/admin` 和 `/api` 请求到 3000 端口 +3. **重启开发服务器**:如果修改了配置,需要重启 `npm run dev` +4. **测试代理**:运行 `node test-proxy.js` 检查代理是否正常工作 + +### Q: 如何处理开发和生产环境的 API 配置? +A: +- **开发环境**:使用 Vite 代理,自动转发请求到 localhost:3000 +- **生产环境**:直接使用相对路径 `/admin`,无需配置 +- 两种环境都使用相同的 API 路径,通过环境变量自动切换 + +### Q: 如何部署到生产环境? +A: +1. 运行 `npm run build` 构建项目 +2. 将 `dist` 目录内容复制到服务器的 `/web/admin/` 路径 +3. 确保服务器配置了 SPA 路由回退规则 \ No newline at end of file diff --git a/web/admin-spa/components/.gitkeep b/web/admin-spa/components/.gitkeep new file mode 100644 index 0000000..0661b78 --- /dev/null +++ b/web/admin-spa/components/.gitkeep @@ -0,0 +1 @@ +# This file keeps the empty directory in git \ No newline at end of file diff --git a/web/admin-spa/index.html b/web/admin-spa/index.html new file mode 100644 index 0000000..53328eb --- /dev/null +++ b/web/admin-spa/index.html @@ -0,0 +1,15 @@ + + + + + + + Claude Relay Service - 管理后台 + + + +
+ + + + \ No newline at end of file diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json new file mode 100644 index 0000000..7aa4b2e --- /dev/null +++ b/web/admin-spa/package-lock.json @@ -0,0 +1,5485 @@ +{ + "name": "claude-relay-admin-spa", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claude-relay-admin-spa", + "version": "1.0.0", + "dependencies": { + "@fortawesome/fontawesome-free": "^6.5.1", + "axios": "^1.6.2", + "chart.js": "^4.4.0", + "dayjs": "^1.11.9", + "element-plus": "^2.4.4", + "pinia": "^2.1.7", + "vue": "^3.3.4", + "vue-router": "^4.2.5", + "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@vitejs/plugin-vue": "^4.5.2", + "@vue/eslint-config-prettier": "^10.2.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-vue": "^9.19.2", + "playwright": "^1.55.0", + "postcss": "^8.4.32", + "prettier": "^3.1.1", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^3.3.6", + "unplugin-auto-import": "^0.17.2", + "unplugin-element-plus": "^0.8.0", + "unplugin-vue-components": "^0.26.0", + "vite": "^5.0.8", + "vite-plugin-checker": "^0.10.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@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/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmmirror.com/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@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.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.1.tgz", + "integrity": "sha512-XxVUZv48RZAd87ucGS48jPf6pKu0yV5UCg9f4FFwtrYxXOwWuVJo6wOvSLKEoMQKjv8GsX/mhP6UsC1lRwbUWg==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "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.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "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.npmmirror.com/@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/js": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/@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/@floating-ui/core": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "6.7.2", + "resolved": "https://registry.npmmirror.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.7.2.tgz", + "integrity": "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmmirror.com/@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/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/@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.1.0", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "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.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.12", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "dev": true, + "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.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", + "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.29", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", + "dev": true, + "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.npmmirror.com/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@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.npmmirror.com/@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.npmmirror.com/@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/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz", + "integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.7", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz", + "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.2.0.tgz", + "integrity": "sha512-qWJ2ZTbmumwiLFomfzTyt5Kng4hwPi9rwCYN4SHb6eaRU1KNO4ccxINHr/VhH4GgPlt1XfSTLX2LBTme8ne4Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.1.tgz", + "integrity": "sha512-oENme6QxtLCqjChRUUo3S6X8hjCXnWmJWnedD7VbGML5GUtaOtAyx+fEEXnBXVf0CBZApMQU0Idwi0FmyxzQhw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.1.tgz", + "integrity": "sha512-OikvNT3qYTl9+4qQ9Bpn6+XHM+ogtFadRLuT2EXiFQMiNkXFLQfNVppi5o28wvYdHL2s3fM0D/MZJ8UkNFZWsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.1.tgz", + "integrity": "sha512-EFYNNGij2WllnzljQDQnlFTXzSJw87cpAs4TVBAWLdkvic5Uh5tISrIL6NRcxoh/b2EFBG/TK8hgRrGx94zD4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.1.tgz", + "integrity": "sha512-ZaNH06O1KeTug9WI2+GRBE5Ujt9kZw4a1+OIwnBHal92I8PxSsl5KpsrPvthRynkhMck4XPdvY0z26Cym/b7oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.1.tgz", + "integrity": "sha512-n4SLVebZP8uUlJ2r04+g2U/xFeiQlw09Me5UFqny8HGbARl503LNH5CqFTb5U5jNxTouhRjai6qPT0CR5c/Iig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.1.tgz", + "integrity": "sha512-8vu9c02F16heTqpvo3yeiu7Vi1REDEC/yES/dIfq3tSXe6mLndiwvYr3AAvd1tMNUqE9yeGYa5w7PRbI5QUV+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.1.tgz", + "integrity": "sha512-K4ncpWl7sQuyp6rWiGUvb6Q18ba8mzM0rjWJ5JgYKlIXAau1db7hZnR0ldJvqKWWJDxqzSLwGUhA4jp+KqgDtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.1.tgz", + "integrity": "sha512-YykPnXsjUjmXE6j6k2QBBGAn1YsJUix7pYaPLK3RVE0bQL2jfdbfykPxfF8AgBlqtYbfEnYHmLXNa6QETjdOjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.1.tgz", + "integrity": "sha512-kKvqBGbZ8i9pCGW3a1FH3HNIVg49dXXTsChGFsHGXQaVJPLA4f/O+XmTxfklhccxdF5FefUn2hvkoGJH0ScWOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.1.tgz", + "integrity": "sha512-zzX5nTw1N1plmqC9RGC9vZHFuiM7ZP7oSWQGqpbmfjK7p947D518cVK1/MQudsBdcD84t6k70WNczJOct6+hdg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.1.tgz", + "integrity": "sha512-O8CwgSBo6ewPpktFfSDgB6SJN9XDcPSvuwxfejiddbIC/hn9Tg6Ai0f0eYDf3XvB/+PIWzOQL+7+TZoB8p9Yuw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.1.tgz", + "integrity": "sha512-JnCfFVEKeq6G3h3z8e60kAp8Rd7QVnWCtPm7cxx+5OtP80g/3nmPtfdCXbVl063e3KsRnGSKDHUQMydmzc/wBA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.1.tgz", + "integrity": "sha512-dVxuDqS237eQXkbYzQQfdf/njgeNw6LZuVyEdUaWwRpKHhsLI+y4H/NJV8xJGU19vnOJCVwaBFgr936FHOnJsQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.1.tgz", + "integrity": "sha512-CvvgNl2hrZrTR9jXK1ye0Go0HQRT6ohQdDfWR47/KFKiLd5oN5T14jRdUVGF4tnsN8y9oSfMOqH6RuHh+ck8+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.1.tgz", + "integrity": "sha512-x7ANt2VOg2565oGHJ6rIuuAon+A8sfe1IeUx25IKqi49OjSr/K3awoNqr9gCwGEJo9OuXlOn+H2p1VJKx1psxA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.1.tgz", + "integrity": "sha512-9OADZYryz/7E8/qt0vnaHQgmia2Y0wrjSSn1V/uL+zw/i7NUhxbX4cHXdEQ7dnJgzYDS81d8+tf6nbIdRFZQoQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.1.tgz", + "integrity": "sha512-NuvSCbXEKY+NGWHyivzbjSVJi68Xfq1VnIvGmsuXs6TCtveeoDRKutI5vf2ntmNnVq64Q4zInet0UDQ+yMB6tA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.1.tgz", + "integrity": "sha512-mWz+6FSRb82xuUMMV1X3NGiaPFqbLN9aIueHleTZCc46cJvwTlvIh7reQLk4p97dv0nddyewBhwzryBHH7wtPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.1.tgz", + "integrity": "sha512-7Thzy9TMXDw9AU4f4vsLNBxh7/VOKuXi73VH3d/kHGr0tZ3x/ewgL9uC7ojUKmH1/zvmZe2tLapYcZllk3SO8Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz", + "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz", + "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.18.tgz", + "integrity": "sha512-3slwjQrrV1TO8MoXgy3aynDQ7lslj5UqDxuHnrzHtpON5CBinhWjJETciPngpin/T3OuW3tXUf86tEurusnztw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@vue/shared": "3.5.18", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.18.tgz", + "integrity": "sha512-RMbU6NTU70++B1JyVJbNbeFkK+A+Q7y9XKE2EM4NLGm2WFR8x9MbAtWxPPLdm0wUkuZv9trpwfSlL6tjdIa1+A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.18", + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.18.tgz", + "integrity": "sha512-5aBjvGqsWs+MoxswZPoTB9nSDb3dhd1x30xrrltKujlCxo48j8HGDNj3QPhF4VIS0VQDUrA1xUfp2hEa+FNyXA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.0", + "@vue/compiler-core": "3.5.18", + "@vue/compiler-dom": "3.5.18", + "@vue/compiler-ssr": "3.5.18", + "@vue/shared": "3.5.18", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.17", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.18.tgz", + "integrity": "sha512-xM16Ak7rSWHkM3m22NlmcdIM+K4BMyFARAfV9hYFl+SFuRzrZ3uGMNW05kA5pmeMa0X9X963Kgou7ufdbpOP9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.18", + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/eslint-config-prettier": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz", + "integrity": "sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2" + }, + "peerDependencies": { + "eslint": ">= 8.21.0", + "prettier": ">= 3.0.0" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.18.tgz", + "integrity": "sha512-x0vPO5Imw+3sChLM5Y+B6G1zPjwdOri9e8V21NnTnlEvkxatHEH5B5KEAJcjuzQ7BsjGrKtfzuQ5eQwXh8HXBg==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.18.tgz", + "integrity": "sha512-DUpHa1HpeOQEt6+3nheUfqVXRog2kivkXHUhoqJiKR33SO4x+a5uNOMkV487WPerQkL0vUuRvq/7JhRgLW3S+w==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.18", + "@vue/shared": "3.5.18" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.18.tgz", + "integrity": "sha512-YwDj71iV05j4RnzZnZtGaXwPoUWeRsqinblgVJwR8XTXYZ9D5PbahHQgsbmzUvCWNF6x7siQ89HgnX5eWkr3mw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.18", + "@vue/runtime-core": "3.5.18", + "@vue/shared": "3.5.18", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.18.tgz", + "integrity": "sha512-PvIHLUoWgSbDG7zLHqSqaCoZvHi6NNmfVFOqO+OnwvqMz/tqQr3FuGWS8ufluNddk7ZLBJYMrjcw1c6XzR12mA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.18", + "@vue/shared": "3.5.18" + }, + "peerDependencies": { + "vue": "3.5.18" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.18", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.18.tgz", + "integrity": "sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-9.13.0.tgz", + "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-9.13.0.tgz", + "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-9.13.0.tgz", + "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/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/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "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/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "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.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "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.11.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/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.25.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "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": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/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.30001727", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/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.0", + "resolved": "https://registry.npmmirror.com/chart.js/-/chart.js-4.5.0.tgz", + "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/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.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/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": "4.1.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "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.npmmirror.com/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.npmmirror.com/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/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/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/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.191", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.191.tgz", + "integrity": "sha512-xcwe9ELcuxYLUFqZZxL19Z6HVKcvNkIwhbHUz7L3us6u12yR+7uY89dSl570f/IqNthx8dAw3tojG7i4Ni4tDA==", + "dev": true, + "license": "ISC" + }, + "node_modules/element-plus": { + "version": "2.10.4", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.10.4.tgz", + "integrity": "sha512-UD4elWHrCnp1xlPhbXmVcaKFLCRaRAY6WWRwemGfGW3ceIjXm9fSYc9RNH3AiOEA6Ds1p9ZvhCs76CR9J8Vd+A==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.1", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.14.182", + "@types/lodash-es": "^4.17.6", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.13", + "escape-html": "^1.0.3", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.2", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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/esbuild/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/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-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", + "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.7" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "9.33.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmmirror.com/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.6.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "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.npmmirror.com/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.npmmirror.com/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": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/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/exit-on-epipe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz", + "integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.3.11.tgz", + "integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/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/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/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-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.3.3", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "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.npmmirror.com/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.4", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "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": "4.3.7", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "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/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "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.npmmirror.com/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.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/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-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/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.17.21", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mlly": { + "version": "1.7.4", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.7.4.tgz", + "integrity": "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "pathe": "^2.0.1", + "pkg-types": "^1.3.0", + "ufo": "^1.5.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "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.npmmirror.com/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/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/playwright": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz", + "integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.55.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.55.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz", + "integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.npmmirror.com/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.0.1", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmmirror.com/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-import-sort": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-style-order": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/printj": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz", + "integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==", + "license": "Apache-2.0", + "bin": { + "printj": "bin/printj.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "0.2.10", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.10.tgz", + "integrity": "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/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/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "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.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/rollup": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.46.1.tgz", + "integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==", + "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.46.1", + "@rollup/rollup-android-arm64": "4.46.1", + "@rollup/rollup-darwin-arm64": "4.46.1", + "@rollup/rollup-darwin-x64": "4.46.1", + "@rollup/rollup-freebsd-arm64": "4.46.1", + "@rollup/rollup-freebsd-x64": "4.46.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.1", + "@rollup/rollup-linux-arm-musleabihf": "4.46.1", + "@rollup/rollup-linux-arm64-gnu": "4.46.1", + "@rollup/rollup-linux-arm64-musl": "4.46.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.1", + "@rollup/rollup-linux-ppc64-gnu": "4.46.1", + "@rollup/rollup-linux-riscv64-gnu": "4.46.1", + "@rollup/rollup-linux-riscv64-musl": "4.46.1", + "@rollup/rollup-linux-s390x-gnu": "4.46.1", + "@rollup/rollup-linux-x64-gnu": "4.46.1", + "@rollup/rollup-linux-x64-musl": "4.46.1", + "@rollup/rollup-win32-arm64-msvc": "4.46.1", + "@rollup/rollup-win32-ia32-msvc": "4.46.1", + "@rollup/rollup-win32-x64-msvc": "4.46.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.46.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.1.tgz", + "integrity": "sha512-7GVB4luhFmGUNXXJhH2jJwZCFB3pIOixv2E3s17GQHBFUOQaISlt7aGcQgqvCaDSxTZJUzlK/QJ1FN8S94MrzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/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/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/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/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.1.0", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "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.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/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-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "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/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "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/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "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.6", + "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", + "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/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/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/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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/ufo": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.1.tgz", + "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "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/unimport": { + "version": "3.14.6", + "resolved": "https://registry.npmmirror.com/unimport/-/unimport-3.14.6.tgz", + "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "acorn": "^8.14.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "fast-glob": "^3.3.3", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "pathe": "^2.0.1", + "picomatch": "^4.0.2", + "pkg-types": "^1.3.0", + "scule": "^1.3.0", + "strip-literal": "^2.1.1", + "unplugin": "^1.16.1" + } + }, + "node_modules/unimport/node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/local-pkg": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.1.tgz", + "integrity": "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.0.1", + "quansync": "^0.2.8" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.2.0.tgz", + "integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/unimport/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "0.17.8", + "resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-0.17.8.tgz", + "integrity": "sha512-CHryj6HzJ+n4ASjzwHruD8arhbdl+UXvhuAIlHDs15Y/IMecG3wrf7FVg4pVH/DIysbq/n0phIjNHAjl7TG7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.0", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.10", + "minimatch": "^9.0.4", + "unimport": "^3.7.2", + "unplugin": "^1.11.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-auto-import/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/unplugin-auto-import/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/unplugin-element-plus": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/unplugin-element-plus/-/unplugin-element-plus-0.8.0.tgz", + "integrity": "sha512-jByUGY3FG2B8RJKFryqxx4eNtSTj+Hjlo8edcOdJymewndDQjThZ1pRUQHRjQsbKhTV2jEctJV7t7RJ405UL4g==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.2", + "es-module-lexer": "^1.3.0", + "magic-string": "^0.30.1", + "unplugin": "^1.3.2" + }, + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/unplugin-vue-components": { + "version": "0.26.0", + "resolved": "https://registry.npmmirror.com/unplugin-vue-components/-/unplugin-vue-components-0.26.0.tgz", + "integrity": "sha512-s7IdPDlnOvPamjunVxw8kNgKNK8A5KM1YpK5j/p97jEKTjlPNrA0nZBiSfAKKlK1gWZuyWXlKL5dk3EDw874LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.6", + "@rollup/pluginutils": "^5.0.4", + "chokidar": "^3.5.3", + "debug": "^4.3.4", + "fast-glob": "^3.3.1", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.3", + "minimatch": "^9.0.3", + "resolve": "^1.22.4", + "unplugin": "^1.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@babel/parser": "^7.15.8", + "@nuxt/kit": "^3.2.2", + "vue": "2 || 3" + }, + "peerDependenciesMeta": { + "@babel/parser": { + "optional": true + }, + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/unplugin-vue-components/node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unplugin-vue-components/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "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.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "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-plugin-checker": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.10.2.tgz", + "integrity": "sha512-FX9U8TnIS6AGOlqmC6O2YmkJzcZJRrjA03UF7FOhcUJ7it3HmCoxcIPMcoHliBP6EFOuNzle9K4c0JL4suRPow==", + "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.3", + "strip-ansi": "^7.1.0", + "tiny-invariant": "^1.3.3", + "tinyglobby": "^0.2.14", + "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 || ^3.0.0" + }, + "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.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "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.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "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.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "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.18", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz", + "integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.18", + "@vue/compiler-sfc": "3.5.18", + "@vue/runtime-dom": "3.5.18", + "@vue/server-renderer": "3.5.18", + "@vue/shared": "3.5.18" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/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-eslint-parser": { + "version": "9.4.3", + "resolved": "https://registry.npmmirror.com/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-router": { + "version": "4.5.1", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.5.1.tgz", + "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/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/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.1.0", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "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.1", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "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.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "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/xlsx-js-style": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz", + "integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.2.0", + "cfb": "^1.1.4", + "codepage": "~1.14.0", + "commander": "~2.17.1", + "crc-32": "~1.2.0", + "exit-on-epipe": "~1.0.1", + "fflate": "^0.3.8", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/adler-32": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.2.0.tgz", + "integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==", + "license": "Apache-2.0", + "dependencies": { + "exit-on-epipe": "~1.0.1", + "printj": "~1.1.0" + }, + "bin": { + "adler32": "bin/adler32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/codepage": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.14.0.tgz", + "integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==", + "license": "Apache-2.0", + "dependencies": { + "commander": "~2.14.1", + "exit-on-epipe": "~1.0.1" + }, + "bin": { + "codepage": "bin/codepage.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx-js-style/node_modules/codepage/node_modules/commander": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.14.1.tgz", + "integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==", + "license": "MIT" + }, + "node_modules/xlsx-js-style/node_modules/commander": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz", + "integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==", + "license": "MIT" + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/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/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/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/web/admin-spa/package.json b/web/admin-spa/package.json new file mode 100644 index 0000000..af353d8 --- /dev/null +++ b/web/admin-spa/package.json @@ -0,0 +1,44 @@ +{ + "name": "claude-relay-admin-spa", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore", + "format": "prettier --write src/" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^6.5.1", + "axios": "^1.6.2", + "chart.js": "^4.4.0", + "dayjs": "^1.11.9", + "element-plus": "^2.4.4", + "pinia": "^2.1.7", + "vue": "^3.3.4", + "vue-router": "^4.2.5", + "xlsx": "^0.18.5", + "xlsx-js-style": "^1.2.0" + }, + "devDependencies": { + "@playwright/test": "^1.55.0", + "@vitejs/plugin-vue": "^4.5.2", + "@vue/eslint-config-prettier": "^10.2.0", + "autoprefixer": "^10.4.16", + "eslint": "^8.55.0", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-vue": "^9.19.2", + "playwright": "^1.55.0", + "postcss": "^8.4.32", + "prettier": "^3.1.1", + "prettier-plugin-tailwindcss": "^0.6.14", + "tailwindcss": "^3.3.6", + "unplugin-auto-import": "^0.17.2", + "unplugin-element-plus": "^0.8.0", + "unplugin-vue-components": "^0.26.0", + "vite": "^5.0.8", + "vite-plugin-checker": "^0.10.2" + } +} diff --git a/web/admin-spa/postcss.config.js b/web/admin-spa/postcss.config.js new file mode 100644 index 0000000..2b75bd8 --- /dev/null +++ b/web/admin-spa/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/web/admin-spa/src/App.vue b/web/admin-spa/src/App.vue new file mode 100644 index 0000000..feb8605 --- /dev/null +++ b/web/admin-spa/src/App.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2 b/web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b9e3cb3b1fde34010b001d0a069b6a5ce2a08d0f GIT binary patch literal 114840 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 z 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

LO{JFkRaobgc*GOUNWuFO7PN}S?aWqZ(|*W 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 NH~uRS8S9q;{U4!vH7x)D literal 0 HcmV?d00001 diff --git a/web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2 b/web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f3e012a456aaa0ae6b7efb6b42034fae929dc202 GIT binary patch literal 112592 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

;;L2P^t-YY0OcVwzIJy^XKC$aMjr80 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 literal 0 HcmV?d00001 diff --git a/web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2 b/web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fdfdcc699fc1e19eb1943c2896e8d66e17b538ff GIT binary patch literal 114348 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

8xHZHepPN#0KgU{FP@5lMu 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 Q|3zqFp7#i%;`h*l0SMlYXs3emQm!N_Vh) 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 Pl?f(p%*Rm#2=M;_zqO9b literal 0 HcmV?d00001 diff --git a/web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2 b/web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..2bcd222ecfae996d035ff72bf70672305cc29261 GIT binary patch literal 111268 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 YlP_pm ztM9Q_f^Ju1U)kTCx1hKLRy|sob}2&j2&ldb*lu^!byk+hq!~}+_;u0>%whduh(~N+;TSmC{wFV7$q?xsq7HHtMXK!Bh!IC?>zVs z4&UzGhlt&#PF~ytjIoG2{EVzwA9Ayn;0-nF>|uE@byk1q(c@|?JQr1hR#*>F-g@|3 z?d-u%RhtFtnGy!$p9|0OCFNRm^z%%F8(4ACq<8yCY_Hc|D8qZYSm*17Mn{Q9zjQ7{ zWfdcpJ30>$V|E`dKo#O~);}W*c`NIB=ixeXAWr4{M&@5$vHbWzt2~(8LaUJGqAPVJ)q4w>fZ@NJ>GjaD5Za+z3T(KUb6?k6#p55W& zzdE(3wqIfzb5xdWH)5pQl}g&^b7iso(49RpBYsl+z&Ctcv$65{CqiX_R~PMr-M*qk zMelENM32kx+LqlCdk3_bIp9<3eCw7gFi=ub6zCs=GQ3KL9W)6`Ru|96ft0=62$k8| zVOV(-+H(-=L$ddL>u0g3Jh$w-J3c;QaKf~Qd8czNBf7Gtu6nwvZPy`U^C3&S1G(q1 zr$34x5bWzPFd;1p#28_0z$r$+P1q9wJL13yW(6F|N~Me%`>)4TzsrGQFYie+@f8cJCrV-T8JxXD=5 zJrdSonp)91%bh=&Jz=jb$AhL5qzRPPEoM!%zEg%Kmt)s!=7$I^i}Dw`EWjbBL`$Y& zk}*nY;`rIChMh(Yow>6GBp+SYeW}>tp|!=!bB2q%Z(B3b*gui-0a^NY>l{__6Ya62 z|71g`ABa3sAy;yce)@2JibjfE+luE(DnDt`T(ig~O7}|uBO2@wCOa5tDaE8xe?iZE zt*pzaU9d^Q#vuY#Fso`TJdBjVVM`O0B}y8JDJ3sq03iLV_Cq=+3<|t~4a7Kk^7_}M zNH1LGzJt|m%v3vaW3xW38?;gCe1DPq#a+l$Qf($O*d_&#;zzJb8F%kd4 zDm2=jD7%Ui)nEr}CGT*xE9D?Qe9XWs&QympaHq}Xm6%HuY?yKrlB9-#hh`B0F<~8|0QHjtaY~aZX{1O<$ID_nw3iwV4}3~!{1*h z#VcpV3ah`-sxSN*|IWk;5<3FpWmT36p}{ zN_fRy`tLdWq)NUmPTqv$i!{@<^C%1Q5fW#+0e~-Qe3UT7t$|rg0u`OtsXsEBvxhWS zE7|kU_1*MtPEU9)`p2tm)MQ!Iupaeq*!y==LwwzSuC(eJo=E9lU?9YVujY_ik7wBL z>uSFMtJbM;yuQ)g)uzol9JNzU&LKDMQyh`YBqq@dSYk;Fn*r-fb-UVd+oWboS)6RI zN2|l~sj}br`}EUPX6D@7{fErZ{Qohkhw&fq7yDmSZp3$HueoH@=YQ$*Lvn;hQ2X>$ zcbzb2G^_jOuIW2hZ1hJP{J8UDaeecZPiFakc<1av@zkwVXooy%qiXxkXjWg3$`WM7 z0n_HVx11ns0NDwuqwC2AhFV8Qzo$uWXHOJNH2)mKMzM9HUoEAxekuC}&whSue;Cdq z@UwHPG3oEs->kut2X3FEob+#Fcj*Y!&Yfp6coz;Y_lx;sfB8PvFSHl$J^jQ_t(kY! zfyvDbe-{Au-kw>)S1XmVQET`c7FZ7JOEUN!m5Nsdt;|OyylC0Ik$+f)W+k_l)ViLB z4(@E`kX%u~|3$T`@7)c~e|j{nZJWXO^pV1}(oi0(v95cK8=LF5CVe}m;N@2>1pGff C!H4<) literal 0 HcmV?d00001 diff --git a/web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2 b/web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fbae113d2855e22c06376495bd2dfe5f02411272 GIT binary patch literal 114812 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

    eUvlpY^BQ;sc~Q~=U*%-q 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 a!Kymz`|Y2zy;Kxy`;^}w^N6qj0RIQ3n!o!1 literal 0 HcmV?d00001 diff --git a/web/admin-spa/src/assets/fonts/inter/inter.css b/web/admin-spa/src/assets/fonts/inter/inter.css new file mode 100644 index 0000000..4d5d03b --- /dev/null +++ b/web/admin-spa/src/assets/fonts/inter/inter.css @@ -0,0 +1,46 @@ +/* Inter 字体本地化 - 仅包含项目使用的字重 (300, 400, 500, 600, 700) */ + +/* Inter Light - 300 */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url('./Inter-Light.woff2') format('woff2'); +} + +/* Inter Regular - 400 */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('./Inter-Regular.woff2') format('woff2'); +} + +/* Inter Medium - 500 */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('./Inter-Medium.woff2') format('woff2'); +} + +/* Inter SemiBold - 600 */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('./Inter-SemiBold.woff2') format('woff2'); +} + +/* Inter Bold - 700 */ +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('./Inter-Bold.woff2') format('woff2'); +} diff --git a/web/admin-spa/src/assets/styles/components.css b/web/admin-spa/src/assets/styles/components.css new file mode 100644 index 0000000..86d5be5 --- /dev/null +++ b/web/admin-spa/src/assets/styles/components.css @@ -0,0 +1,488 @@ +/* Glass效果 - 优化版 */ +.glass { + background: var(--glass-color); + /* 降低模糊强度 */ + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--border-color); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.glass-strong { + background: var(--surface-color); + /* 降低模糊强度 */ + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +/* 标签按钮 */ +.tab-btn { + position: relative; + overflow: hidden; + border-radius: 12px; + font-weight: 500; + letter-spacing: 0.025em; +} + +.tab-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.tab-btn:hover::before { + left: 100%; +} + +.tab-btn.active { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(var(--primary-rgb), 0.3), + 0 4px 6px -2px rgba(var(--primary-rgb), 0.05); + transform: translateY(-1px); +} + +/* 卡片 */ +.card { + background: var(--surface-color); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + overflow: hidden; + position: relative; +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent); +} + +/* 统计卡片 */ +.stat-card { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.8) 100%); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 24px; + position: relative; + overflow: hidden; + transition: all 0.3s ease; +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.stat-icon { + width: 56px; + height: 56px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: white; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +/* 按钮 */ +.btn { + font-weight: 500; + border-radius: 12px; + border: none; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + letter-spacing: 0.025em; +} + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: + width 0.3s ease, + height 0.3s ease; +} + +.btn:active::before { + width: 300px; + height: 300px; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(var(--primary-rgb), 0.3), + 0 4px 6px -2px rgba(var(--primary-rgb), 0.05); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(var(--primary-rgb), 0.3), + 0 10px 10px -5px rgba(var(--primary-rgb), 0.1); +} + +.btn-success { + background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(16, 185, 129, 0.3), + 0 4px 6px -2px rgba(16, 185, 129, 0.05); +} + +.btn-success:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(16, 185, 129, 0.3), + 0 10px 10px -5px rgba(16, 185, 129, 0.1); +} + +.btn-danger { + background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(239, 68, 68, 0.3), + 0 4px 6px -2px rgba(239, 68, 68, 0.05); +} + +.btn-danger:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(239, 68, 68, 0.3), + 0 10px 10px -5px rgba(239, 68, 68, 0.1); +} + +.btn-secondary { + background: linear-gradient(135deg, var(--text-secondary) 0%, var(--bg-gradient-end) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(107, 114, 128, 0.3), + 0 4px 6px -2px rgba(107, 114, 128, 0.05); +} + +.btn-secondary:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(107, 114, 128, 0.3), + 0 10px 10px -5px rgba(107, 114, 128, 0.1); +} + +/* 表单输入 */ +.form-input { + background: rgba(255, 255, 255, 0.95); + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 12px; + padding: 8px 12px; + font-size: 14px; + transition: all 0.2s ease; + /* 移除模糊效果,使用纯色背景 */ +} + +.form-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: + 0 0 0 3px rgba(var(--primary-rgb), 0.1), + 0 10px 15px -3px rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.95); +} + +/* 表格容器 */ +.table-container { + background: rgba(255, 255, 255, 0.95); + border-radius: 16px; + overflow: hidden; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.table-row { + transition: all 0.2s ease; +} + +.table-row:hover { + background: rgba(var(--primary-rgb), 0.05); + transform: scale(1.005); +} + +/* 模态框 */ +.modal { + /* 移除模糊,使用半透明背景 */ + background: rgba(0, 0, 0, 0.6); +} + +.dark .modal { + background: rgba(0, 0, 0, 0.75); +} + +.modal-content { + background: rgba(255, 255, 255, 0.98); + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.3); + box-shadow: + 0 10px 25px -5px rgba(0, 0, 0, 0.15), + 0 0 0 1px rgba(255, 255, 255, 0.05); + /* 移除模糊效果 */ +} + +.dark .modal-content { + background: var(--glass-strong-color); + border: 1px solid var(--border-color); + box-shadow: + 0 10px 25px -5px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba(255, 255, 255, 0.05); +} + +/* 弹窗滚动内容样式 */ +.modal-scroll-content { + max-height: calc(90vh - 160px); + overflow-y: auto; + padding-right: 8px; +} + +/* 标题渐变 */ +.header-title { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: 700; + letter-spacing: -0.025em; +} + +/* 加载动画 */ +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top: 2px solid white; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Toast通知 */ +.toast { + position: fixed; + top: 80px; + right: 20px; + z-index: 1000; + min-width: 320px; + max-width: 500px; + transform: translateX(100%); + transition: transform 0.3s ease-in-out; +} + +/* 响应式设计 - 移动端优化 */ +@media (max-width: 640px) { + /* 玻璃态容器 */ + .glass, + .glass-strong { + margin: 12px; + border-radius: 16px; + padding: 16px; + } + + /* 统计卡片 */ + .stat-card { + padding: 12px; + border-radius: 12px; + } + + .stat-icon { + width: 40px; + height: 40px; + font-size: 16px; + } + + /* 标签按钮 */ + .tab-btn { + font-size: 12px; + padding: 10px 6px; + } + + /* 模态框 */ + .modal-content { + margin: 8px; + max-width: calc(100vw - 24px); + padding: 16px; + } + + .modal-scroll-content { + max-height: calc(90vh - 100px); + } + + /* 卡片 */ + .card { + border-radius: 12px; + } + + /* 表单元素 */ + .form-input, + .form-select, + .form-textarea { + font-size: 14px; + padding: 8px 12px; + } + + /* 按钮 */ + .btn { + font-size: 14px; + padding: 8px 16px; + } + + /* 表格 */ + .table-container table { + font-size: 12px; + } + + .table-container th, + .table-container td { + padding: 8px 12px; + } + + /* Toast通知 */ + .toast { + min-width: 280px; + max-width: calc(100vw - 40px); + right: 12px; + top: 60px; + } + + /* 加载动画 */ + .loading-spinner { + width: 16px; + height: 16px; + } +} + +@media (max-width: 768px) { + /* 玻璃态容器 */ + .glass, + .glass-strong { + margin: 0; + border-radius: 16px; + } + + /* 统计卡片 */ + .stat-card { + padding: 16px; + } + + /* 标签按钮 */ + .tab-btn { + font-size: 14px; + padding: 12px 8px; + } + + /* 模态框滚动内容 */ + .modal-scroll-content { + max-height: calc(85vh - 120px); + } +} + +.toast.show { + transform: translateX(0); +} + +.toast-success { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.toast-error { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: white; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.toast-info { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.toast-warning { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + border: 1px solid rgba(245, 158, 11, 0.3); +} + +/* 版本更新提醒动画 */ +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.8; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.animate-pulse { + animation: pulse 2s infinite; +} + +/* 用户菜单下拉框优化 */ +.user-menu-dropdown { + min-width: 240px; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.fa-openai { + width: 16px; + height: 16px; + background: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNjQwIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIHY3LjAuMCBieSBAZm9udGF3ZXNvbWUgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbSBMaWNlbnNlIC0gaHR0cHM6Ly9mb250YXdlc29tZS5jb20vbGljZW5zZS9mcmVlIENvcHlyaWdodCAyMDI1IEZvbnRpY29ucywgSW5jLi0tPjxwYXRoIGQ9Ik0yNjAuNCAyNDkuOHYtNDguNmMwLTQuMSAxLjUtNy4yIDUuMS05LjJsOTcuOC01Ni4zYzEzLjMtNy43IDI5LjItMTEuMyA0NS42LTExLjMgNjEuNCAwIDEwMC40IDQ3LjYgMTAwLjQgOTguMyAwIDMuNiAwIDcuNy0uNSAxMS44bC0xMDEuNS01OS40Yy02LjEtMy42LTEyLjMtMy42LTE4LjQgMGwtMTI4LjUgNzQuN3ptMjI4LjMgMTg5LjRWMzIzYzAtNy4yLTMuMS0xMi4zLTkuMi0xNS45TDM1MSAyMzIuNGw0Mi0yNC4xYzMuNi0yIDYuNy0yIDEwLjIgMGw5Ny44IDU2LjRjMjguMiAxNi40IDQ3LjEgNTEuMiA0Ny4xIDg1IDAgMzguOS0yMyA3NC44LTU5LjQgODkuNnpNMjMwLjIgMzM2LjhsLTQyLTI0LjZjLTMuNi0yLTUuMS01LjEtNS4xLTkuMlYxOTAuNGMwLTU0LjggNDItOTYuMyA5OC44LTk2LjMgMjEuNSAwIDQxLjUgNy4yIDU4LjQgMjBsLTEwMC45IDU4LjRjLTYuMSAzLjYtOS4yIDguNy05LjIgMTUuOXYxNDguNXptOTAuNCA1Mi4ybC02MC4yLTMzLjh2LTcxLjdsNjAuMi0zMy44IDYwLjIgMzMuOHY3MS43TDMyMC42IDM4OXptMzguNyAxNTUuN2MtMjEuNSAwLTQxLjUtNy4yLTU4LjQtMjBsMTAwLjktNTguNGM2LjEtMy42IDkuMi04LjcgOS4yLTE1LjlWMzAxLjlsNDIuNSAyNC42YzMuNiAyIDUuMSA1LjEgNS4xIDkuMnYxMTIuNmMwIDU0LjgtNDIuNSA5Ni4zLTk5LjMgOTYuM3pNMjM3LjggNDMwLjVsLTk3LjctNTYuM0MxMTEuOSAzNTcuOCA5MyAzMjMgOTMgMjg5LjJjMC0zOS40IDIzLjYtNzQuOCA1OS45LTg5LjZ2MTE2LjdjMCA3LjIgMy4xIDEyLjMgOS4yIDE1LjlsMTI4IDc0LjItNDIgMjQuMWMtMy42IDItNi43IDItMTAuMiAwem0tNS42IDg0Yy01Ny45IDAtMTAwLjQtNDMuNS0xMDAuNC05Ny4zIDAtNC4xLjUtOC4yIDEtMTIuM2wxMDAuOSA1OC40YzYuMSAzLjYgMTIuMyAzLjYgMTguNCAwbDEyOC41LTc0LjJ2NDguNmMwIDQuMS0xLjUgNy4yLTUuMSA5LjJsLTk3LjggNTYuM2MtMTMuMyA3LjctMjkuMiAxMS4zLTQ1LjYgMTEuM3ptMTI3IDYwLjljNjIgMCAxMTMuNy00NCAxMjUuNC0xMDIuNCA1Ny4zLTE0LjkgOTQuMi02OC42IDk0LjItMTIzLjQgMC0zNS44LTE1LjQtNzAuNy00My05NS43IDIuNi0xMC44IDQuMS0yMS41IDQuMS0zMi4zIDAtNzMuMi01OS40LTEyOC0xMjgtMTI4LTEzLjggMC0yNy4xIDItNDAuNCA2LjctMjMtMjIuNS01NC44LTM2LjktODkuNi0zNi45LTYyIDAtMTEzLjcgNDQtMTI1LjQgMTAyLjQtNTcuMyAxNC44LTk0LjIgNjguNi05NC4yIDEyMy40IDAgMzUuOCAxNS40IDcwLjcgNDMgOTUuNy0yLjYgMTAuOC00LjEgMjEuNS00LjEgMzIuMyAwIDczLjIgNTkuNCAxMjggMTI4IDEyOCAxMy44IDAgMjcuMS0yIDQwLjQtNi43IDIzIDIyLjUgNTQuOCAzNi45IDg5LjYgMzYuOXoiLz48L3N2Zz4=) + no-repeat center/100%; +} diff --git a/web/admin-spa/src/assets/styles/global.css b/web/admin-spa/src/assets/styles/global.css new file mode 100644 index 0000000..376a422 --- /dev/null +++ b/web/admin-spa/src/assets/styles/global.css @@ -0,0 +1,830 @@ +/* 从原始 style.css 复制的全局样式 */ +:root { + /* 亮色模式 */ + --primary-color: #667eea; + --secondary-color: #764ba2; + --accent-color: #f093fb; + --success-color: #10b981; + --warning-color: #f59e0b; + --error-color: #ef4444; + --surface-color: rgba(255, 255, 255, 0.95); + --glass-color: rgba(255, 255, 255, 0.1); + --glass-strong-color: rgba(255, 255, 255, 0.95); + --text-primary: #1f2937; + --text-secondary: #6b7280; + --border-color: rgba(255, 255, 255, 0.2); + --bg-gradient-start: #667eea; + --bg-gradient-mid: #764ba2; + --bg-gradient-end: #f093fb; + --input-bg: rgba(255, 255, 255, 0.9); + --input-border: rgba(209, 213, 219, 0.8); + --modal-bg: rgba(0, 0, 0, 0.4); + --table-bg: rgba(255, 255, 255, 0.95); + --table-hover: rgba(102, 126, 234, 0.05); +} + +.dark { + /* 暗黑模式 */ + --primary-color: #818cf8; + --secondary-color: #a78bfa; + --accent-color: #c084fc; + --success-color: #10b981; + --warning-color: #f59e0b; + --error-color: #ef4444; + --surface-color: rgba(31, 41, 55, 0.95); + --glass-color: rgba(0, 0, 0, 0.2); + --glass-strong-color: rgba(31, 41, 55, 0.95); + --text-primary: #f3f4f6; + --text-secondary: #9ca3af; + --border-color: rgba(75, 85, 99, 0.3); + --bg-gradient-start: #1f2937; + --bg-gradient-mid: #374151; + --bg-gradient-end: #4b5563; + --input-bg: rgba(31, 41, 55, 0.9); + --input-border: rgba(75, 85, 99, 0.5); + --modal-bg: rgba(0, 0, 0, 0.6); + --table-bg: rgba(31, 41, 55, 0.95); + --table-hover: rgba(129, 140, 248, 0.1); +} + +/* 覆盖 Tailwind v3 的暗黑模式背景色使用主题色 */ +.dark .bg-gray-800, +.dark\:bg-gray-800:is(.dark *) { + background-color: var(--glass-strong-color) !important; +} + +.dark .bg-gray-700, +.dark\:bg-gray-700:is(.dark *) { + background-color: var(--bg-gradient-mid) !important; +} + +.dark .bg-gray-900, +.dark\:bg-gray-900:is(.dark *) { + background-color: var(--bg-gradient-start) !important; +} + +/* 覆盖带透明度的背景色 */ +.dark\:bg-gray-800\/40:is(.dark *) { + background-color: color-mix(in srgb, var(--glass-strong-color) 40%, transparent) !important; +} + +.dark\:bg-gray-700\/30:is(.dark *) { + background-color: color-mix(in srgb, var(--bg-gradient-mid) 30%, transparent) !important; +} + +/* 覆盖 Tailwind v3 的暗黑模式渐变色 */ +.dark\:from-gray-700:is(.dark *) { + --tw-gradient-from: var(--bg-gradient-mid) !important; + --tw-gradient-to: var(--bg-gradient-mid) !important; + --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to) !important; +} + +.dark\:to-gray-800\/90:is(.dark *) { + --tw-gradient-to: var(--glass-strong-color) !important; +} + +/* 覆盖 Tailwind v3 的暗黑模式悬停背景色 */ +.dark\:hover\:bg-gray-600:is(.dark *):hover { + background-color: var(--bg-gradient-end) !important; +} + +.dark\:hover\:bg-gray-700:is(.dark *):hover { + background-color: var(--bg-gradient-mid) !important; +} + +.dark\:hover\:bg-gray-500:is(.dark *):hover { + background-color: var(--text-secondary) !important; +} + +.dark .border-gray-700, +.dark\:border-gray-700:is(.dark *) { + border-color: var(--border-color) !important; +} + +.dark .border-gray-600, +.dark\:border-gray-600:is(.dark *) { + border-color: var(--border-color) !important; +} + +/* 覆盖悬停边框色 */ +.dark\:hover\:border-gray-500:is(.dark *):hover { + border-color: var(--border-color) !important; +} + +/* 优化后的transition - 避免布局跳动 */ +button, +input, +select, +textarea { + transition: + background-color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease, + transform 0.2s ease; +} + +/* 颜色和背景过渡 */ +.transition-colors { + transition: + color 0.3s ease, + background-color 0.3s ease, + border-color 0.3s ease; +} + +body { + font-family: + 'Inter', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; + background: linear-gradient( + 135deg, + var(--bg-gradient-start) 0%, + var(--bg-gradient-mid) 50%, + var(--bg-gradient-end) 100% + ); + background-attachment: fixed; + min-height: 100vh; + margin: 0; + overflow-x: hidden; + color: var(--text-primary); + transition: + background 0.3s ease, + color 0.3s ease; +} + +body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 80%, rgba(var(--accent-rgb), 0.2) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(var(--primary-rgb), 0.2) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%); + pointer-events: none; + z-index: -1; +} + +.glass { + background: var(--glass-color); + /* 降低模糊强度 */ + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border: 1px solid var(--border-color); + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transition: + background-color 0.3s ease, + border-color 0.3s ease; +} + +.dark .glass { + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.25), + 0 10px 10px -5px rgba(0, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.glass-strong { + background: var(--glass-strong-color); + /* 降低模糊强度 */ + /* 移除模糊效果 */ + border: 1px solid var(--border-color); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + transition: + background-color 0.3s ease, + border-color 0.3s ease; +} + +.dark .glass-strong { + box-shadow: + 0 25px 50px -12px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(255, 255, 255, 0.02), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.tab-btn { + position: relative; + overflow: hidden; + border-radius: 12px; + font-weight: 500; + letter-spacing: 0.025em; +} + +.tab-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.tab-btn:hover::before { + left: 100%; +} + +.tab-btn.active { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(var(--primary-rgb), 0.3), + 0 4px 6px -2px rgba(var(--primary-rgb), 0.05); + transform: translateY(-1px); +} + +/* 按钮样式 */ +.btn { + font-weight: 500; + border-radius: 12px; + border: none; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + letter-spacing: 0.025em; +} + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: + width 0.3s ease, + height 0.3s ease; +} + +.btn:active::before { + width: 300px; + height: 300px; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(var(--primary-rgb), 0.3), + 0 4px 6px -2px rgba(var(--primary-rgb), 0.05); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(var(--primary-rgb), 0.3), + 0 10px 10px -5px rgba(var(--primary-rgb), 0.1); +} + +.btn-success { + background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(16, 185, 129, 0.3), + 0 4px 6px -2px rgba(16, 185, 129, 0.05); +} + +.btn-success:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(16, 185, 129, 0.3), + 0 10px 10px -5px rgba(16, 185, 129, 0.1); +} + +.btn-danger { + background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(239, 68, 68, 0.3), + 0 4px 6px -2px rgba(239, 68, 68, 0.05); +} + +.btn-danger:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(239, 68, 68, 0.3), + 0 10px 10px -5px rgba(239, 68, 68, 0.1); +} + +/* 表单输入框样式 */ +.form-input { + background: var(--input-bg); + border: 2px solid var(--input-border); + border-radius: 12px; + padding: 16px; + font-size: 16px; + color: var(--text-primary); + transition: + background-color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; + /* 移除模糊效果 */ +} + +.form-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: + 0 0 0 3px rgba(var(--primary-rgb), 0.1), + 0 10px 15px -3px rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.95); + color: #1f2937; +} + +.dark .form-input:focus { + box-shadow: + 0 0 0 3px rgba(var(--primary-rgb), 0.2), + 0 10px 15px -3px rgba(0, 0, 0, 0.2); + background: var(--glass-strong-color); + color: #f3f4f6; +} + +.card { + background: var(--surface-color); + border-radius: 16px; + border: 1px solid var(--border-color); + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + overflow: hidden; + position: relative; + transition: + background-color 0.3s ease, + border-color 0.3s ease; +} + +.dark .card { + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.25), + 0 4px 6px -2px rgba(0, 0, 0, 0.1); +} + +.card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5), transparent); +} + +.stat-card { + background: linear-gradient(135deg, var(--surface-color) 0%, var(--glass-strong-color) 100%); + border-radius: 20px; + border: 1px solid var(--border-color); + padding: 24px; + position: relative; + overflow: hidden; + transition: + transform 0.3s ease, + box-shadow 0.3s ease; +} + +.dark .stat-card { + background: linear-gradient(135deg, var(--glass-strong-color) 0%, var(--bg-gradient-start) 100%); +} + +.stat-card:hover { + transform: translateY(-4px); + box-shadow: + 0 20px 25px -5px rgba(0, 0, 0, 0.1), + 0 10px 10px -5px rgba(0, 0, 0, 0.04); +} + +.stat-icon { + width: 56px; + height: 56px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + color: white; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +.btn { + font-weight: 500; + border-radius: 12px; + border: none; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + letter-spacing: 0.025em; +} + +.btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: + width 0.3s ease, + height 0.3s ease; +} + +.btn:active::before { + width: 300px; + height: 300px; +} + +.btn-primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(var(--primary-rgb), 0.3), + 0 4px 6px -2px rgba(var(--primary-rgb), 0.05); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(var(--primary-rgb), 0.3), + 0 10px 10px -5px rgba(var(--primary-rgb), 0.1); +} + +.btn-success { + background: linear-gradient(135deg, var(--success-color) 0%, #059669 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(16, 185, 129, 0.3), + 0 4px 6px -2px rgba(16, 185, 129, 0.05); +} + +.btn-success:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(16, 185, 129, 0.3), + 0 10px 10px -5px rgba(16, 185, 129, 0.1); +} + +.btn-danger { + background: linear-gradient(135deg, var(--error-color) 0%, #dc2626 100%); + color: white; + box-shadow: + 0 10px 15px -3px rgba(239, 68, 68, 0.3), + 0 4px 6px -2px rgba(239, 68, 68, 0.05); +} + +.btn-danger:hover { + transform: translateY(-1px); + box-shadow: + 0 20px 25px -5px rgba(239, 68, 68, 0.3), + 0 10px 10px -5px rgba(239, 68, 68, 0.1); +} + +.form-input { + background: var(--input-bg); + border: 2px solid var(--input-border); + border-radius: 12px; + padding: 16px; + font-size: 16px; + color: var(--text-primary); + transition: + background-color 0.3s ease, + border-color 0.3s ease, + box-shadow 0.3s ease; + /* 移除模糊效果 */ +} + +.form-input:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: + 0 0 0 3px rgba(var(--primary-rgb), 0.1), + 0 10px 15px -3px rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.95); + color: #1f2937; +} + +.dark .form-input:focus { + box-shadow: + 0 0 0 3px rgba(var(--primary-rgb), 0.2), + 0 10px 15px -3px rgba(0, 0, 0, 0.2); + background: var(--glass-strong-color); + color: #f3f4f6; +} + +.table-container { + background: var(--table-bg); + border-radius: 16px; + overflow: hidden; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); + transition: background-color 0.3s ease; +} + +.dark .table-container { + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.25), + 0 4px 6px -2px rgba(0, 0, 0, 0.1); +} + +.table-row { + transition: + background-color 0.2s ease, + transform 0.2s ease; +} + +.table-row:hover { + background: var(--table-hover); + transform: scale(1.005); +} + +.modal { + /* 移除模糊效果 */ + background: var(--modal-bg); + transition: background-color 0.3s ease; +} + +.modal-content { + background: white; + border-radius: 24px; + border: 1px solid rgba(229, 231, 235, 0.8); + box-shadow: + 0 10px 25px -5px rgba(0, 0, 0, 0.15), + 0 0 0 1px rgba(255, 255, 255, 0.05); + /* 移除模糊效果 */ + transition: + background-color 0.3s ease, + border-color 0.3s ease; +} + +.dark .modal-content { + background: var(--bg-gradient-start); + border: 1px solid var(--border-color); + box-shadow: + 0 25px 50px -12px rgba(0, 0, 0, 0.6), + 0 0 0 1px rgba(255, 255, 255, 0.02); +} + +.header-title { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + font-weight: 700; + letter-spacing: -0.025em; +} + +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top: 2px solid white; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.3s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.slide-up-enter-active, +.slide-up-leave-active { + transition: all 0.3s ease; +} + +.slide-up-enter-from { + opacity: 0; + transform: translateY(30px); +} + +.slide-up-leave-to { + opacity: 0; + transform: translateY(-30px); +} + +.toast { + position: fixed; + top: 80px; + right: 20px; + z-index: 1000; + min-width: 320px; + max-width: 500px; + transform: translateX(100%); + transition: transform 0.3s ease-in-out; +} + +.toast.show { + transform: translateX(0); +} + +.toast-success { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + border: 1px solid rgba(16, 185, 129, 0.3); +} + +.toast-error { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: white; + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.toast-info { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.toast-warning { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + border: 1px solid rgba(245, 158, 11, 0.3); +} + +[v-cloak] { + display: none; +} + +/* 自定义滚动条样式 */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05); +} + +.dark .custom-scrollbar { + scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05); +} + +.custom-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: rgba(var(--primary-rgb), 0.05); + border-radius: 10px; +} + +.dark .custom-scrollbar::-webkit-scrollbar-track { + background: rgba(var(--primary-rgb), 0.05); +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.4) 0%, + rgba(var(--secondary-rgb), 0.4) 100% + ); + border-radius: 10px; + transition: background 0.3s ease; +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.4) 0%, + rgba(var(--secondary-rgb), 0.4) 100% + ); +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.6) 0%, + rgba(var(--secondary-rgb), 0.6) 100% + ); +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.6) 0%, + rgba(var(--secondary-rgb), 0.6) 100% + ); +} + +.custom-scrollbar::-webkit-scrollbar-thumb:active { + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.8) 0%, + rgba(var(--secondary-rgb), 0.8) 100% + ); +} + +.dark .custom-scrollbar::-webkit-scrollbar-thumb:active { + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.8) 0%, + rgba(var(--secondary-rgb), 0.8) 100% + ); +} + +/* 弹窗滚动内容样式 */ +.modal-scroll-content { + max-height: calc(90vh - 160px); + overflow-y: auto; + padding-right: 8px; +} + +@media (max-width: 768px) { + .glass, + .glass-strong { + margin: 0; + border-radius: 16px; + } + + .stat-card { + padding: 16px; + } + + .tab-btn { + font-size: 14px; + padding: 12px 8px; + } + + .modal-scroll-content { + max-height: calc(85vh - 120px); + } +} + +/* 版本更新提醒动画 */ +@keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.1); + opacity: 0.8; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.animate-pulse { + /* 移除无限脉冲动画,改为 hover 效果 */ + transition: transform 0.2s ease; +} + +.animate-pulse:hover { + animation: pulse 0.3s ease; +} + +/* 用户菜单下拉框优化 */ +.user-menu-dropdown { + min-width: 240px; + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -2px rgba(0, 0, 0, 0.05); +} + +/* Tab 内容区域样式 */ +.tab-content { + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/web/admin-spa/src/assets/styles/main.css b/web/admin-spa/src/assets/styles/main.css new file mode 100644 index 0000000..c75ceb0 --- /dev/null +++ b/web/admin-spa/src/assets/styles/main.css @@ -0,0 +1,168 @@ +@import 'tailwindcss/base'; +@import 'tailwindcss/components'; +@import 'tailwindcss/utilities'; +@import './variables.css'; +@import './components.css'; + +/* Font Awesome 图标 */ +@import '@fortawesome/fontawesome-free/css/all.css'; + +/* 全局样式 */ +body { + font-family: + 'Inter', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + Roboto, + sans-serif; + background: linear-gradient( + 135deg, + var(--primary-color) 0%, + var(--secondary-color) 50%, + var(--accent-color) 100% + ); + background-attachment: fixed; + min-height: 100vh; + margin: 0; + overflow-x: hidden; +} + +body::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 80%, rgba(var(--accent-rgb), 0.2) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(var(--primary-rgb), 0.2) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(var(--secondary-rgb), 0.1) 0%, transparent 50%); + pointer-events: none; + z-index: -1; +} + +/* 通用transition - 仅应用于特定元素 */ +body, +div, +button, +input, +select, +textarea, +table, +tr, +td, +th, +span, +p, +h1, +h2, +h3, +h4, +h5, +h6 { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Element Plus 主题覆盖 */ +.el-button--primary { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + border-color: transparent; +} + +.el-button--primary:hover, +.el-button--primary:focus { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); + opacity: 0.9; +} + +/* 自定义滚动条样式 */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: rgba(var(--primary-rgb), 0.3) rgba(var(--primary-rgb), 0.05); +} + +.custom-scrollbar::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: rgba(var(--primary-rgb), 0.05); + border-radius: 10px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.4) 0%, + rgba(var(--secondary-rgb), 0.4) 100% + ); + border-radius: 10px; + transition: background 0.3s ease; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.6) 0%, + rgba(var(--secondary-rgb), 0.6) 100% + ); +} + +.custom-scrollbar::-webkit-scrollbar-thumb:active { + background: linear-gradient( + 135deg, + rgba(var(--primary-rgb), 0.8) 0%, + rgba(var(--secondary-rgb), 0.8) 100% + ); +} + +/* Vue过渡动画 */ +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.3s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} + +.slide-up-enter-active, +.slide-up-leave-active { + transition: all 0.3s ease; +} + +.slide-up-enter-from { + opacity: 0; + transform: translateY(30px); +} + +.slide-up-leave-to { + opacity: 0; + transform: translateY(-30px); +} + +/* 响应式调整 */ +@media (max-width: 768px) { + .glass, + .glass-strong { + margin: 0; + border-radius: 16px; + } + + .stat-card { + padding: 16px; + } + + .tab-btn { + font-size: 14px; + padding: 12px 8px; + } + + .modal-scroll-content { + max-height: calc(85vh - 120px); + } +} diff --git a/web/admin-spa/src/assets/styles/variables.css b/web/admin-spa/src/assets/styles/variables.css new file mode 100644 index 0000000..1e73d52 --- /dev/null +++ b/web/admin-spa/src/assets/styles/variables.css @@ -0,0 +1,13 @@ +:root { + --primary-color: #667eea; + --secondary-color: #764ba2; + --accent-color: #f093fb; + --success-color: #10b981; + --warning-color: #f59e0b; + --error-color: #ef4444; + --surface-color: rgba(255, 255, 255, 0.95); + --glass-color: rgba(255, 255, 255, 0.1); + --text-primary: #1f2937; + --text-secondary: #6b7280; + --border-color: rgba(255, 255, 255, 0.2); +} diff --git a/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue new file mode 100644 index 0000000..d3f85aa --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountBalanceScriptModal.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/web/admin-spa/src/components/accounts/AccountErrorHistoryModal.vue b/web/admin-spa/src/components/accounts/AccountErrorHistoryModal.vue new file mode 100644 index 0000000..d410d42 --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountErrorHistoryModal.vue @@ -0,0 +1,234 @@ + + + diff --git a/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue new file mode 100644 index 0000000..09673fa --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountExpiryEditModal.vue @@ -0,0 +1,434 @@ + + + + + diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue new file mode 100644 index 0000000..b2f80fc --- /dev/null +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -0,0 +1,6753 @@ + + + diff --git a/web/admin-spa/src/components/user/UserApiKeysManager.vue b/web/admin-spa/src/components/user/UserApiKeysManager.vue new file mode 100644 index 0000000..6343a6b --- /dev/null +++ b/web/admin-spa/src/components/user/UserApiKeysManager.vue @@ -0,0 +1,330 @@ + + + diff --git a/web/admin-spa/src/components/user/UserUsageStats.vue b/web/admin-spa/src/components/user/UserUsageStats.vue new file mode 100644 index 0000000..3e1309d --- /dev/null +++ b/web/admin-spa/src/components/user/UserUsageStats.vue @@ -0,0 +1,384 @@ + + + diff --git a/web/admin-spa/src/components/user/ViewApiKeyModal.vue b/web/admin-spa/src/components/user/ViewApiKeyModal.vue new file mode 100644 index 0000000..345cff7 --- /dev/null +++ b/web/admin-spa/src/components/user/ViewApiKeyModal.vue @@ -0,0 +1,226 @@ + + + diff --git a/web/admin-spa/src/main.js b/web/admin-spa/src/main.js new file mode 100644 index 0000000..b3e44a9 --- /dev/null +++ b/web/admin-spa/src/main.js @@ -0,0 +1,34 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import 'element-plus/dist/index.css' +import 'element-plus/theme-chalk/dark/css-vars.css' +import './assets/fonts/inter/inter.css' +import App from './App.vue' +import router from './router' +import { useUserStore } from './stores/user' +import './assets/styles/main.css' +import './assets/styles/global.css' + +// 创建Vue应用 +const app = createApp(App) + +// 使用Pinia状态管理 +const pinia = createPinia() +app.use(pinia) + +// 使用路由 +app.use(router) + +// 使用Element Plus +app.use(ElementPlus, { + locale: zhCn +}) + +// 设置axios拦截器 +const userStore = useUserStore() +userStore.setupAxiosInterceptors() + +// 挂载应用 +app.mount('#app') diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js new file mode 100644 index 0000000..b89ad70 --- /dev/null +++ b/web/admin-spa/src/router/index.js @@ -0,0 +1,246 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' +import { useUserStore } from '@/stores/user' +import { APP_CONFIG, showToast } from '@/utils/tools' + +// 路由懒加载 +const LoginView = () => import('@/views/LoginView.vue') +const UserLoginView = () => import('@/views/UserLoginView.vue') +const UserDashboardView = () => import('@/views/UserDashboardView.vue') +const UserManagementView = () => import('@/views/UserManagementView.vue') +const MainLayout = () => import('@/components/layout/MainLayout.vue') +const DashboardView = () => import('@/views/DashboardView.vue') +const ApiKeysView = () => import('@/views/ApiKeysView.vue') +const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue') +const AccountsView = () => import('@/views/AccountsView.vue') +const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue') +const SettingsView = () => import('@/views/SettingsView.vue') +const ApiStatsView = () => import('@/views/ApiStatsView.vue') +const QuotaCardsView = () => import('@/views/QuotaCardsView.vue') +const RequestDetailsView = () => import('@/views/RequestDetailsView.vue') + +const routes = [ + { + path: '/', + redirect: () => { + // 智能重定向:避免循环 + const currentPath = window.location.pathname + const basePath = APP_CONFIG.basePath.replace(/\/$/, '') // 移除末尾斜杠 + + // 如果当前路径已经是 basePath 或 basePath/,重定向到 api-stats + if (currentPath === basePath || currentPath === basePath + '/') { + return '/api-stats' + } + + // 否则保持默认重定向 + return '/api-stats' + } + }, + { + path: '/login', + name: 'Login', + component: LoginView, + meta: { requiresAuth: false } + }, + { + path: '/admin-login', + redirect: '/login' + }, + { + path: '/user-login', + name: 'UserLogin', + component: UserLoginView, + meta: { requiresAuth: false, userAuth: true } + }, + { + path: '/user-dashboard', + name: 'UserDashboard', + component: UserDashboardView, + meta: { requiresUserAuth: true } + }, + { + path: '/api-stats', + name: 'ApiStats', + component: ApiStatsView, + meta: { requiresAuth: false } + }, + { + path: '/dashboard', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Dashboard', + component: DashboardView + } + ] + }, + { + path: '/api-keys', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'ApiKeys', + component: ApiKeysView + } + ] + }, + { + path: '/api-keys/:keyId/usage-records', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'ApiKeyUsageRecords', + component: ApiKeyUsageRecordsView + } + ] + }, + { + path: '/accounts', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Accounts', + component: AccountsView + } + ] + }, + { + path: '/accounts/:accountId/usage-records', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'AccountUsageRecords', + component: AccountUsageRecordsView + } + ] + }, + { + path: '/settings', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'Settings', + component: SettingsView + } + ] + }, + { + path: '/user-management', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'UserManagement', + component: UserManagementView + } + ] + }, + { + path: '/quota-cards', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'QuotaCards', + component: QuotaCardsView + } + ] + }, + { + path: '/request-details', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'RequestDetails', + component: RequestDetailsView + } + ] + }, + // 捕获所有未匹配的路由 + { + path: '/:pathMatch(.*)*', + redirect: '/api-stats' + } +] + +const router = createRouter({ + history: createWebHistory(APP_CONFIG.basePath), + routes +}) + +// 路由守卫 +router.beforeEach(async (to, from, next) => { + const authStore = useAuthStore() + const userStore = useUserStore() + + console.log('路由导航:', { + to: to.path, + from: from.path, + fullPath: to.fullPath, + requiresAuth: to.meta.requiresAuth, + requiresUserAuth: to.meta.requiresUserAuth, + isAuthenticated: authStore.isAuthenticated, + isUserAuthenticated: userStore.isAuthenticated + }) + + // 防止重定向循环:如果已经在目标路径,直接放行 + if (to.path === from.path && to.fullPath === from.fullPath) { + return next() + } + + // 检查用户认证状态 + if (to.meta.requiresUserAuth) { + if (!userStore.isAuthenticated) { + // 尝试检查本地存储的认证信息 + try { + const isUserLoggedIn = await userStore.checkAuth() + if (!isUserLoggedIn) { + return next('/user-login') + } + } catch (error) { + // If the error is about disabled account, redirect to login with error + if (error.message && error.message.includes('disabled')) { + showToast(error.message, 'error') + } + return next('/user-login') + } + } + return next() + } + + // API Stats 页面不需要认证,直接放行 + if (to.path === '/api-stats' || to.path.startsWith('/api-stats')) { + next() + } else if (to.path === '/user-login') { + // 如果已经是用户登录状态,重定向到用户仪表板 + if (userStore.isAuthenticated) { + next('/user-dashboard') + } else { + next() + } + } else if (to.meta.requiresAuth && !authStore.isAuthenticated) { + next('/login') + } else if (to.path === '/login' && authStore.isAuthenticated) { + next('/dashboard') + } else { + next() + } +}) + +export default router diff --git a/web/admin-spa/src/stores/accounts.js b/web/admin-spa/src/stores/accounts.js new file mode 100644 index 0000000..aa4d950 --- /dev/null +++ b/web/admin-spa/src/stores/accounts.js @@ -0,0 +1,338 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import * as httpApis from '@/utils/http_apis' + +// 平台配置映射 +const PLATFORM_CONFIG = { + claude: { endpoint: 'claude-accounts', stateKey: 'claudeAccounts' }, + 'claude-console': { endpoint: 'claude-console-accounts', stateKey: 'claudeConsoleAccounts' }, + bedrock: { endpoint: 'bedrock-accounts', stateKey: 'bedrockAccounts' }, + gemini: { endpoint: 'gemini-accounts', stateKey: 'geminiAccounts' }, + openai: { endpoint: 'openai-accounts', stateKey: 'openaiAccounts' }, + azure_openai: { endpoint: 'azure-openai-accounts', stateKey: 'azureOpenaiAccounts' }, + 'openai-responses': { + endpoint: 'openai-responses-accounts', + stateKey: 'openaiResponsesAccounts' + }, + droid: { endpoint: 'droid-accounts', stateKey: 'droidAccounts' } +} + +export const useAccountsStore = defineStore('accounts', () => { + const claudeAccounts = ref([]) + const claudeConsoleAccounts = ref([]) + const bedrockAccounts = ref([]) + const geminiAccounts = ref([]) + const openaiAccounts = ref([]) + const azureOpenaiAccounts = ref([]) + const openaiResponsesAccounts = ref([]) + const droidAccounts = ref([]) + const loading = ref(false) + const error = ref(null) + const sortBy = ref('') + const sortOrder = ref('asc') + + // 状态映射 + const stateMap = { + claudeAccounts, + claudeConsoleAccounts, + bedrockAccounts, + geminiAccounts, + openaiAccounts, + azureOpenaiAccounts, + openaiResponsesAccounts, + droidAccounts + } + + // 通用获取账户 + const fetchAccounts = async (apiFunc, stateRef) => { + loading.value = true + const res = await apiFunc() + if (res.success) stateRef.value = res.data || [] + else error.value = res.message + loading.value = false + } + + // 通用创建/更新账户 + const mutateAccount = async (apiFunc, fetchFunc, ...args) => { + loading.value = true + const res = await apiFunc(...args) + if (res.success) await fetchFunc() + else error.value = res.message + loading.value = false + return res + } + + // 获取各平台账户 + const fetchClaudeAccounts = () => fetchAccounts(httpApis.getClaudeAccountsApi, claudeAccounts) + const fetchClaudeConsoleAccounts = () => + fetchAccounts(httpApis.getClaudeConsoleAccountsApi, claudeConsoleAccounts) + const fetchBedrockAccounts = () => fetchAccounts(httpApis.getBedrockAccountsApi, bedrockAccounts) + const fetchGeminiAccounts = () => fetchAccounts(httpApis.getGeminiAccountsApi, geminiAccounts) + const fetchOpenAIAccounts = () => fetchAccounts(httpApis.getOpenAIAccountsApi, openaiAccounts) + const fetchAzureOpenAIAccounts = () => + fetchAccounts(httpApis.getAzureOpenAIAccountsApi, azureOpenaiAccounts) + const fetchOpenAIResponsesAccounts = () => + fetchAccounts(httpApis.getOpenAIResponsesAccountsApi, openaiResponsesAccounts) + const fetchDroidAccounts = () => fetchAccounts(httpApis.getDroidAccountsApi, droidAccounts) + + const fetchAllAccounts = async () => { + loading.value = true + await Promise.all([ + fetchClaudeAccounts(), + fetchClaudeConsoleAccounts(), + fetchBedrockAccounts(), + fetchGeminiAccounts(), + fetchOpenAIAccounts(), + fetchAzureOpenAIAccounts(), + fetchOpenAIResponsesAccounts(), + fetchDroidAccounts() + ]) + loading.value = false + } + + // 创建账户 + const createClaudeAccount = (data) => + mutateAccount(httpApis.createClaudeAccountApi, fetchClaudeAccounts, data) + const createClaudeConsoleAccount = (data) => + mutateAccount(httpApis.createClaudeConsoleAccountApi, fetchClaudeConsoleAccounts, data) + const createBedrockAccount = (data) => + mutateAccount(httpApis.createBedrockAccountApi, fetchBedrockAccounts, data) + const createGeminiAccount = (data) => + mutateAccount(httpApis.createGeminiAccountApi, fetchGeminiAccounts, data) + const createOpenAIAccount = (data) => + mutateAccount(httpApis.createOpenAIAccountApi, fetchOpenAIAccounts, data) + const createDroidAccount = (data) => + mutateAccount(httpApis.createDroidAccountApi, fetchDroidAccounts, data) + const createAzureOpenAIAccount = (data) => + mutateAccount(httpApis.createAzureOpenAIAccountApi, fetchAzureOpenAIAccounts, data) + const createOpenAIResponsesAccount = (data) => + mutateAccount(httpApis.createOpenAIResponsesAccountApi, fetchOpenAIResponsesAccounts, data) + const createGeminiApiAccount = (data) => + mutateAccount(httpApis.createGeminiApiAccountApi, fetchGeminiAccounts, data) + + // 更新账户 + const updateClaudeAccount = (id, data) => + mutateAccount(httpApis.updateClaudeAccountApi, fetchClaudeAccounts, id, data) + const updateClaudeConsoleAccount = (id, data) => + mutateAccount(httpApis.updateClaudeConsoleAccountApi, fetchClaudeConsoleAccounts, id, data) + const updateBedrockAccount = (id, data) => + mutateAccount(httpApis.updateBedrockAccountApi, fetchBedrockAccounts, id, data) + const updateGeminiAccount = (id, data) => + mutateAccount(httpApis.updateGeminiAccountApi, fetchGeminiAccounts, id, data) + const updateOpenAIAccount = (id, data) => + mutateAccount(httpApis.updateOpenAIAccountApi, fetchOpenAIAccounts, id, data) + const updateAzureOpenAIAccount = (id, data) => + mutateAccount(httpApis.updateAzureOpenAIAccountApi, fetchAzureOpenAIAccounts, id, data) + const updateOpenAIResponsesAccount = (id, data) => + mutateAccount(httpApis.updateOpenAIResponsesAccountApi, fetchOpenAIResponsesAccounts, id, data) + const updateGeminiApiAccount = (id, data) => + mutateAccount(httpApis.updateGeminiApiAccountApi, fetchGeminiAccounts, id, data) + const updateDroidAccount = (id, data) => + mutateAccount(httpApis.updateDroidAccountApi, fetchDroidAccounts, id, data) + + // 切换账户状态 + const toggleAccount = async (platform, id) => { + const config = PLATFORM_CONFIG[platform] + if (!config) return { success: false, message: '未知平台' } + loading.value = true + const res = await httpApis.toggleAccountStatusApi(`/admin/${config.endpoint}/${id}/toggle`) + if (res.success) + await fetchAccounts( + httpApis[ + `get${config.stateKey.charAt(0).toUpperCase() + config.stateKey.slice(1).replace('Accounts', '')}AccountsApi` + ], + stateMap[config.stateKey] + ) + else error.value = res.message + loading.value = false + return res + } + + // 删除账户 + const deleteAccount = async (platform, id) => { + const config = PLATFORM_CONFIG[platform] + if (!config) return { success: false, message: '未知平台' } + loading.value = true + const res = await httpApis.deleteAccountByEndpointApi(`/admin/${config.endpoint}/${id}`) + if (res.success) { + const fetchMap = { + claude: fetchClaudeAccounts, + 'claude-console': fetchClaudeConsoleAccounts, + bedrock: fetchBedrockAccounts, + gemini: fetchGeminiAccounts, + openai: fetchOpenAIAccounts, + azure_openai: fetchAzureOpenAIAccounts, + 'openai-responses': fetchOpenAIResponsesAccounts, + droid: fetchDroidAccounts + } + await fetchMap[platform]() + } else { + error.value = res.message + } + loading.value = false + return res + } + + // 刷新Claude Token + const refreshClaudeToken = async (id) => { + loading.value = true + const res = await httpApis.refreshClaudeAccountApi(id) + if (res.success) await fetchClaudeAccounts() + else error.value = res.message + loading.value = false + return res + } + + // OAuth 相关 + const generateClaudeAuthUrl = async (proxyConfig) => { + const res = await httpApis.generateClaudeAuthUrlApi(proxyConfig) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const exchangeClaudeCode = async (data) => { + const res = await httpApis.exchangeClaudeCodeApi(data) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const generateClaudeSetupTokenUrl = async (proxyConfig) => { + const res = await httpApis.generateClaudeSetupTokenUrlApi(proxyConfig) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const exchangeClaudeSetupTokenCode = async (data) => { + const res = await httpApis.exchangeClaudeSetupTokenApi(data) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const oauthWithCookie = async (payload) => { + const res = await httpApis.claudeOAuthWithCookieApi(payload) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const oauthSetupTokenWithCookie = async (payload) => { + const res = await httpApis.claudeSetupTokenWithCookieApi(payload) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const generateGeminiAuthUrl = async (proxyConfig) => { + const res = await httpApis.generateGeminiAuthUrlApi(proxyConfig) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const exchangeGeminiCode = async (data) => { + const res = await httpApis.exchangeGeminiCodeApi(data) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const generateOpenAIAuthUrl = async (proxyConfig) => { + const res = await httpApis.generateOpenAIAuthUrlApi(proxyConfig) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const exchangeOpenAICode = async (data) => { + const res = await httpApis.exchangeOpenAICodeApi(data) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const generateDroidAuthUrl = async (proxyConfig) => { + const res = await httpApis.generateDroidAuthUrlApi(proxyConfig) + if (!res.success) error.value = res.message + return res.success ? res.data : null + } + + const exchangeDroidCode = (data) => httpApis.exchangeDroidCodeApi(data) + + const sortAccounts = (field) => { + if (sortBy.value === field) { + sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc' + } else { + sortBy.value = field + sortOrder.value = 'asc' + } + } + + const reset = () => { + claudeAccounts.value = [] + claudeConsoleAccounts.value = [] + bedrockAccounts.value = [] + geminiAccounts.value = [] + openaiAccounts.value = [] + azureOpenaiAccounts.value = [] + openaiResponsesAccounts.value = [] + droidAccounts.value = [] + loading.value = false + error.value = null + sortBy.value = '' + sortOrder.value = 'asc' + } + + return { + claudeAccounts, + claudeConsoleAccounts, + bedrockAccounts, + geminiAccounts, + openaiAccounts, + azureOpenaiAccounts, + openaiResponsesAccounts, + droidAccounts, + loading, + error, + sortBy, + sortOrder, + fetchClaudeAccounts, + fetchClaudeConsoleAccounts, + fetchBedrockAccounts, + fetchGeminiAccounts, + fetchOpenAIAccounts, + fetchAzureOpenAIAccounts, + fetchOpenAIResponsesAccounts, + fetchDroidAccounts, + fetchAllAccounts, + createClaudeAccount, + createClaudeConsoleAccount, + createBedrockAccount, + createGeminiAccount, + createOpenAIAccount, + createDroidAccount, + updateDroidAccount, + createAzureOpenAIAccount, + createOpenAIResponsesAccount, + createGeminiApiAccount, + updateClaudeAccount, + updateClaudeConsoleAccount, + updateBedrockAccount, + updateGeminiAccount, + updateOpenAIAccount, + updateAzureOpenAIAccount, + updateOpenAIResponsesAccount, + updateGeminiApiAccount, + toggleAccount, + deleteAccount, + refreshClaudeToken, + generateClaudeAuthUrl, + exchangeClaudeCode, + generateClaudeSetupTokenUrl, + exchangeClaudeSetupTokenCode, + oauthWithCookie, + oauthSetupTokenWithCookie, + generateGeminiAuthUrl, + exchangeGeminiCode, + generateOpenAIAuthUrl, + exchangeOpenAICode, + generateDroidAuthUrl, + exchangeDroidCode, + sortAccounts, + reset + } +}) diff --git a/web/admin-spa/src/stores/apiKeys.js b/web/admin-spa/src/stores/apiKeys.js new file mode 100644 index 0000000..96f3ac8 --- /dev/null +++ b/web/admin-spa/src/stores/apiKeys.js @@ -0,0 +1,106 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import * as httpApis from '@/utils/http_apis' + +export const useApiKeysStore = defineStore('apiKeys', () => { + const apiKeys = ref([]) + const loading = ref(false) + const error = ref(null) + const statsTimeRange = ref('all') + const sortBy = ref('') + const sortOrder = ref('asc') + + const fetchApiKeys = async () => { + loading.value = true + const res = await httpApis.getApiKeysApi() + if (res.success) apiKeys.value = res.data || [] + else error.value = res.message + loading.value = false + } + + const createApiKey = async (data) => { + loading.value = true + const res = await httpApis.createApiKeyApi(data) + if (res.success) await fetchApiKeys() + else error.value = res.message + loading.value = false + return res + } + + const updateApiKey = async (id, data) => { + loading.value = true + const res = await httpApis.updateApiKeyApi(id, data) + if (res.success) await fetchApiKeys() + else error.value = res.message + loading.value = false + return res + } + + const toggleApiKey = async (id) => { + loading.value = true + const res = await httpApis.toggleApiKeyApi(id) + if (res.success) await fetchApiKeys() + else error.value = res.message + loading.value = false + return res + } + + const renewApiKey = (id, data) => updateApiKey(id, data) + + const deleteApiKey = async (id) => { + loading.value = true + const res = await httpApis.deleteApiKeyApi(id) + if (res.success) await fetchApiKeys() + else error.value = res.message + loading.value = false + return res + } + + const fetchApiKeyStats = async (id, timeRange = 'all') => { + const res = await httpApis.getApiKeyStatsApi(id, { timeRange }) + return res.success ? res.stats : null + } + + const fetchTags = async () => { + const res = await httpApis.getApiKeyTagsApi() + return res.success ? res.data || [] : [] + } + + const sortApiKeys = (field) => { + if (sortBy.value === field) { + sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc' + } else { + sortBy.value = field + sortOrder.value = 'asc' + } + } + + const reset = () => { + apiKeys.value = [] + loading.value = false + error.value = null + statsTimeRange.value = 'all' + sortBy.value = '' + sortOrder.value = 'asc' + } + + return { + apiKeys, + loading, + error, + statsTimeRange, + sortBy, + sortOrder, + fetchApiKeys, + createApiKey, + updateApiKey, + toggleApiKey, + renewApiKey, + deleteApiKey, + fetchApiKeyStats, + fetchTags, + sortApiKeys, + reset + } +}) diff --git a/web/admin-spa/src/stores/apistats.js b/web/admin-spa/src/stores/apistats.js new file mode 100644 index 0000000..377fbb4 --- /dev/null +++ b/web/admin-spa/src/stores/apistats.js @@ -0,0 +1,625 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +import * as httpApis from '@/utils/http_apis' + +export const useApiStatsStore = defineStore('apistats', () => { + // 状态 + const apiKey = ref('') + const apiId = ref(null) + const loading = ref(false) + const modelStatsLoading = ref(false) + const oemLoading = ref(true) + const error = ref('') + const statsPeriod = ref('daily') + const statsData = ref(null) + const modelStats = ref([]) + const dailyModelStats = ref([]) + const monthlyModelStats = ref([]) + const alltimeModelStats = ref([]) + const dailyStats = ref(null) + const monthlyStats = ref(null) + const alltimeStats = ref(null) + const oemSettings = ref({ + siteName: '', + siteIcon: '', + siteIconData: '' + }) + + // 多 Key 模式相关状态 + const multiKeyMode = ref(false) + const apiKeys = ref([]) // 多个 API Key 数组 + const apiIds = ref([]) // 对应的 ID 数组 + const aggregatedStats = ref(null) // 聚合后的统计数据 + const individualStats = ref([]) // 各个 Key 的独立数据 + const invalidKeys = ref([]) // 无效的 Keys 列表 + + // 服务倍率配置 + const serviceRates = ref(null) + + // Key 级别的服务倍率 + const keyServiceRates = ref({}) + + // 计算属性 + const currentPeriodData = computed(() => { + const defaultData = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + } + + // 聚合模式下使用聚合数据 + if (multiKeyMode.value && aggregatedStats.value) { + if (statsPeriod.value === 'daily') { + return aggregatedStats.value.dailyUsage || defaultData + } else if (statsPeriod.value === 'monthly') { + return aggregatedStats.value.monthlyUsage || defaultData + } else { + return aggregatedStats.value.alltimeUsage || defaultData + } + } + + // 单个 Key 模式下使用原有逻辑 + if (statsPeriod.value === 'daily') { + return dailyStats.value || defaultData + } else if (statsPeriod.value === 'monthly') { + return monthlyStats.value || defaultData + } else { + return alltimeStats.value || defaultData + } + }) + + const usagePercentages = computed(() => { + if (!statsData.value || !currentPeriodData.value) { + return { + tokenUsage: 0, + costUsage: 0, + requestUsage: 0 + } + } + + const current = currentPeriodData.value + const limits = statsData.value.limits + + return { + tokenUsage: + limits.tokenLimit > 0 ? Math.min((current.allTokens / limits.tokenLimit) * 100, 100) : 0, + costUsage: + limits.dailyCostLimit > 0 ? Math.min((current.cost / limits.dailyCostLimit) * 100, 100) : 0, + requestUsage: + limits.rateLimitRequests > 0 + ? Math.min((current.requests / limits.rateLimitRequests) * 100, 100) + : 0 + } + }) + + // Actions + + // 查询统计数据 + async function queryStats() { + // 多 Key 模式处理 + if (multiKeyMode.value) { + return queryBatchStats() + } + + const trimmedKey = apiKey.value.trim() + + if (!trimmedKey) { + error.value = '请输入 API Key' + return + } + + // 验证 API Key 格式:长度应在 10-512 之间 + if (trimmedKey.length < 10 || trimmedKey.length > 512) { + error.value = 'API Key 格式无效:长度应在 10-512 个字符之间' + return + } + + loading.value = true + error.value = '' + statsData.value = null + modelStats.value = [] + apiId.value = null + + try { + // 获取 API Key ID + const idResult = await httpApis.getKeyIdApi(trimmedKey) + + if (idResult.success) { + apiId.value = idResult.data.id + + // 使用 apiId 查询统计数据 + const statsResult = await httpApis.getUserStatsApi(apiId.value) + + if (statsResult.success) { + statsData.value = statsResult.data + + // 保存 Key 级别的服务倍率 + keyServiceRates.value = statsResult.data.serviceRates || {} + + // 同时加载今日和本月的统计数据 + await loadAllPeriodStats() + + // 清除错误信息 + error.value = '' + + // 更新 URL + updateURL() + + // 保存 API Key 到 localStorage + saveApiKeyToStorage() + } else { + throw new Error(statsResult.message || '查询失败') + } + } else { + throw new Error(idResult.message || '获取 API Key ID 失败') + } + } catch (err) { + console.error('Query stats error:', err) + error.value = err.message || '查询统计数据失败,请检查您的 API Key 是否正确' + statsData.value = null + modelStats.value = [] + apiId.value = null + } finally { + loading.value = false + } + } + + // 加载所有时间段的统计数据 + async function loadAllPeriodStats() { + if (!apiId.value) return + + // 并行加载今日和本月的数据 + await Promise.all([loadPeriodStats('daily'), loadPeriodStats('monthly')]) + + // 并行加载三个时间段的模型统计 + await loadAllModelStats() + } + + // 加载所有时间段的模型统计 + async function loadAllModelStats() { + if (!apiId.value) return + + modelStatsLoading.value = true + + try { + const [dailyResult, monthlyResult, alltimeResult] = await Promise.all([ + httpApis.getUserModelStatsApi(apiId.value, 'daily'), + httpApis.getUserModelStatsApi(apiId.value, 'monthly'), + httpApis.getUserModelStatsApi(apiId.value, 'alltime') + ]) + + dailyModelStats.value = dailyResult.success ? dailyResult.data || [] : [] + monthlyModelStats.value = monthlyResult.success ? monthlyResult.data || [] : [] + alltimeModelStats.value = alltimeResult.success ? alltimeResult.data || [] : [] + + // 保持 modelStats 兼容性(用于现有组件) + modelStats.value = dailyModelStats.value + } catch (err) { + console.error('Load all model stats error:', err) + dailyModelStats.value = [] + monthlyModelStats.value = [] + alltimeModelStats.value = [] + modelStats.value = [] + } finally { + modelStatsLoading.value = false + } + } + + // 加载指定时间段的统计数据 + async function loadPeriodStats(period) { + try { + const result = await httpApis.getUserModelStatsApi(apiId.value, period) + + if (result.success) { + // 计算汇总数据 + const modelData = result.data || [] + const summary = { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0, + cost: 0, + formattedCost: '$0.000000' + } + + modelData.forEach((model) => { + summary.requests += model.requests || 0 + summary.inputTokens += model.inputTokens || 0 + summary.outputTokens += model.outputTokens || 0 + summary.cacheCreateTokens += model.cacheCreateTokens || 0 + summary.cacheReadTokens += model.cacheReadTokens || 0 + summary.allTokens += model.allTokens || 0 + summary.cost += model.costs?.total || 0 + }) + + summary.formattedCost = formatCost(summary.cost) + + // 存储到对应的时间段数据 + if (period === 'daily') { + dailyStats.value = summary + } else if (period === 'monthly') { + monthlyStats.value = summary + } else if (period === 'alltime') { + alltimeStats.value = summary + } + } else { + console.warn(`Failed to load ${period} stats:`, result.message) + } + } catch (err) { + console.error(`Load ${period} stats error:`, err) + } + } + + // 加载模型统计数据 + async function loadModelStats(period = 'daily') { + if (!apiId.value) return + + modelStatsLoading.value = true + + try { + const result = await httpApis.getUserModelStatsApi(apiId.value, period) + + if (result.success) { + modelStats.value = result.data || [] + } else { + throw new Error(result.message || '加载模型统计失败') + } + } catch (err) { + console.error('Load model stats error:', err) + modelStats.value = [] + } finally { + modelStatsLoading.value = false + } + } + + // 切换时间范围 + async function switchPeriod(period) { + if (statsPeriod.value === period || modelStatsLoading.value) { + return + } + + statsPeriod.value = period + + // 多 Key 模式下加载批量模型统计 + if (multiKeyMode.value && apiIds.value.length > 0) { + await loadBatchModelStats(period) + return + } + + // 如果对应时间段的数据还没有加载,则加载它 + if ( + (period === 'daily' && !dailyStats.value) || + (period === 'monthly' && !monthlyStats.value) || + (period === 'alltime' && !alltimeStats.value) + ) { + await loadPeriodStats(period) + } + + // 加载对应的模型统计 + await loadModelStats(period) + } + + // 使用 apiId 直接加载数据 + async function loadStatsWithApiId() { + if (!apiId.value) return + + loading.value = true + error.value = '' + statsData.value = null + modelStats.value = [] + + try { + const result = await httpApis.getUserStatsApi(apiId.value) + + if (result.success) { + statsData.value = result.data + + // 保存 Key 级别的服务倍率 + keyServiceRates.value = result.data.serviceRates || {} + + // 调试:打印返回的限制数据 + console.log('API Stats - Full response:', result.data) + console.log('API Stats - limits data:', result.data.limits) + console.log('API Stats - weeklyOpusCostLimit:', result.data.limits?.weeklyOpusCostLimit) + console.log('API Stats - weeklyOpusCost:', result.data.limits?.weeklyOpusCost) + + // 同时加载今日和本月的统计数据 + await loadAllPeriodStats() + + // 清除错误信息 + error.value = '' + } else { + throw new Error(result.message || '查询失败') + } + } catch (err) { + console.error('Load stats with apiId error:', err) + error.value = err.message || '查询统计数据失败' + statsData.value = null + modelStats.value = [] + } finally { + loading.value = false + } + } + + // 加载 OEM 设置 + async function loadOemSettings() { + oemLoading.value = true + try { + const result = await httpApis.getOemSettingsApi() + if (result && result.success && result.data) { + oemSettings.value = { ...oemSettings.value, ...result.data } + } + } catch (err) { + console.error('Error loading OEM settings:', err) + // 失败时使用默认值 + oemSettings.value = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '' + } + } finally { + oemLoading.value = false + } + } + + // 加载服务倍率配置 + async function loadServiceRates() { + try { + const result = await httpApis.getServiceRatesApi() + if (result && result.success && result.data) { + serviceRates.value = result.data + } + } catch (err) { + console.error('Error loading service rates:', err) + serviceRates.value = null + } + } + + // 工具函数 + + // 格式化费用 + function formatCost(cost) { + if (typeof cost !== 'number' || cost === 0) { + return '$0.000000' + } + + // 根据数值大小选择精度 + if (cost >= 1) { + return '$' + cost.toFixed(2) + } else if (cost >= 0.01) { + return '$' + cost.toFixed(4) + } else { + return '$' + cost.toFixed(6) + } + } + + // 更新 URL + function updateURL() { + if (apiId.value) { + const url = new URL(window.location) + url.searchParams.set('apiId', apiId.value) + window.history.pushState({}, '', url) + } + } + + // 保存 API Key 到 localStorage + function saveApiKeyToStorage() { + if (apiKey.value) { + localStorage.setItem('lastApiKey', apiKey.value) + } + } + + // 从 localStorage 加载 API Key + function loadApiKeyFromStorage() { + return localStorage.getItem('lastApiKey') + } + + // 批量查询统计数据 + async function queryBatchStats() { + const keys = parseApiKeys() + if (keys.length === 0) { + error.value = '请输入至少一个有效的 API Key' + return + } + + loading.value = true + error.value = '' + aggregatedStats.value = null + individualStats.value = [] + invalidKeys.value = [] + modelStats.value = [] + apiKeys.value = keys + apiIds.value = [] + keyServiceRates.value = {} // 多 Key 模式清理 Key 倍率 + + try { + // 批量获取 API Key IDs + const idResults = await Promise.allSettled(keys.map((key) => httpApis.getKeyIdApi(key))) + + const validIds = [] + const validKeys = [] + + idResults.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value.success) { + validIds.push(result.value.data.id) + validKeys.push(keys[index]) + } else { + invalidKeys.value.push(keys[index]) + } + }) + + if (validIds.length === 0) { + throw new Error('所有 API Key 都无效') + } + + apiIds.value = validIds + apiKeys.value = validKeys + + // 批量查询统计数据 + const batchResult = await httpApis.getBatchStatsApi(validIds) + + if (batchResult.success) { + aggregatedStats.value = batchResult.data.aggregated + individualStats.value = batchResult.data.individual + statsData.value = batchResult.data.aggregated // 兼容现有组件 + + // 设置聚合模式下的日期统计数据,以保证现有组件的兼容性 + dailyStats.value = batchResult.data.aggregated.dailyUsage || null + monthlyStats.value = batchResult.data.aggregated.monthlyUsage || null + + // 加载聚合的模型统计 + await loadBatchModelStats(statsPeriod.value) + + // 更新 URL + updateBatchURL() + } else { + throw new Error(batchResult.message || '批量查询失败') + } + } catch (err) { + console.error('Batch query error:', err) + error.value = err.message || '批量查询统计数据失败' + aggregatedStats.value = null + individualStats.value = [] + } finally { + loading.value = false + } + } + + // 加载批量模型统计 + async function loadBatchModelStats(period = 'daily') { + if (apiIds.value.length === 0) return + + modelStatsLoading.value = true + + try { + const result = await httpApis.getBatchModelStatsApi(apiIds.value, period) + + if (result.success) { + modelStats.value = result.data || [] + } else { + throw new Error(result.message || '加载批量模型统计失败') + } + } catch (err) { + console.error('Load batch model stats error:', err) + modelStats.value = [] + } finally { + modelStatsLoading.value = false + } + } + + // 解析 API Keys + function parseApiKeys() { + if (!apiKey.value) return [] + + const keys = apiKey.value + .split(/[,\n]+/) + .map((key) => key.trim()) + .filter((key) => key.length >= 10 && key.length <= 512) // 验证 API Key 格式 + + // 去重并限制最多30个 + const uniqueKeys = [...new Set(keys)] + return uniqueKeys.slice(0, 30) + } + + // 更新批量查询 URL + function updateBatchURL() { + if (apiIds.value.length > 0) { + const url = new URL(window.location) + url.searchParams.set('apiIds', apiIds.value.join(',')) + url.searchParams.set('batch', 'true') + window.history.pushState({}, '', url) + } + } + + // 清空输入 + function clearInput() { + apiKey.value = '' + } + + // 清除数据 + function clearData() { + statsData.value = null + modelStats.value = [] + dailyModelStats.value = [] + monthlyModelStats.value = [] + alltimeModelStats.value = [] + dailyStats.value = null + monthlyStats.value = null + alltimeStats.value = null + error.value = '' + statsPeriod.value = 'daily' + apiId.value = null + keyServiceRates.value = {} + // 清除多 Key 模式数据 + apiKeys.value = [] + apiIds.value = [] + aggregatedStats.value = null + individualStats.value = [] + invalidKeys.value = [] + } + + // 重置 + function reset() { + apiKey.value = '' + multiKeyMode.value = false + clearData() + } + + return { + // State + apiKey, + apiId, + loading, + modelStatsLoading, + oemLoading, + error, + statsPeriod, + statsData, + modelStats, + dailyModelStats, + monthlyModelStats, + alltimeModelStats, + dailyStats, + monthlyStats, + alltimeStats, + oemSettings, + // 多 Key 模式状态 + multiKeyMode, + apiKeys, + apiIds, + aggregatedStats, + individualStats, + invalidKeys, + serviceRates, + keyServiceRates, + + // Computed + currentPeriodData, + usagePercentages, + + // Actions + queryStats, + queryBatchStats, + loadAllPeriodStats, + loadAllModelStats, + loadPeriodStats, + loadModelStats, + loadBatchModelStats, + switchPeriod, + loadStatsWithApiId, + loadOemSettings, + loadServiceRates, + loadApiKeyFromStorage, + clearData, + clearInput, + reset + } +}) diff --git a/web/admin-spa/src/stores/auth.js b/web/admin-spa/src/stores/auth.js new file mode 100644 index 0000000..57b500f --- /dev/null +++ b/web/admin-spa/src/stores/auth.js @@ -0,0 +1,128 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import router from '@/router' + +import { loginApi, getAuthUserApi, getOemSettingsApi } from '@/utils/http_apis' + +export const useAuthStore = defineStore('auth', () => { + // 状态 + const isLoggedIn = ref(false) + const authToken = ref(localStorage.getItem('authToken') || '') + const username = ref('') + const loginError = ref('') + const loginLoading = ref(false) + const oemSettings = ref({ + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', + faviconData: '' + }) + const oemLoading = ref(true) + + // 计算属性 + const isAuthenticated = computed(() => !!authToken.value && isLoggedIn.value) + const token = computed(() => authToken.value) + const user = computed(() => ({ username: username.value })) + + // 方法 + async function login(credentials) { + loginLoading.value = true + loginError.value = '' + + try { + const result = await loginApi(credentials) + + if (result.success) { + authToken.value = result.token + username.value = result.username || credentials.username + isLoggedIn.value = true + localStorage.setItem('authToken', result.token) + + await router.push('/dashboard') + } else { + loginError.value = result.message || '登录失败' + } + } catch (error) { + loginError.value = error.message || '登录失败,请检查用户名和密码' + } finally { + loginLoading.value = false + } + } + + function logout() { + isLoggedIn.value = false + authToken.value = '' + username.value = '' + localStorage.removeItem('authToken') + router.push('/login') + } + + function checkAuth() { + if (authToken.value) { + isLoggedIn.value = true + // 验证token有效性 + verifyToken() + } + } + + async function verifyToken() { + try { + const userResult = await getAuthUserApi() + if (!userResult.success || !userResult.user) { + logout() + return + } + username.value = userResult.user.username + } catch (error) { + logout() + } + } + + async function loadOemSettings() { + oemLoading.value = true + try { + const result = await getOemSettingsApi() + if (result.success && result.data) { + oemSettings.value = { ...oemSettings.value, ...result.data } + + if (result.data.siteIconData || result.data.siteIcon) { + const link = document.querySelector("link[rel*='icon']") || document.createElement('link') + link.type = 'image/x-icon' + link.rel = 'shortcut icon' + link.href = result.data.siteIconData || result.data.siteIcon + document.getElementsByTagName('head')[0].appendChild(link) + } + + if (result.data.siteName) { + document.title = `${result.data.siteName} - 管理后台` + } + } + } catch (error) { + console.error('加载OEM设置失败:', error) + } finally { + oemLoading.value = false + } + } + + return { + // 状态 + isLoggedIn, + authToken, + username, + loginError, + loginLoading, + oemSettings, + oemLoading, + + // 计算属性 + isAuthenticated, + token, + user, + + // 方法 + login, + logout, + checkAuth, + loadOemSettings + } +}) diff --git a/web/admin-spa/src/stores/clients.js b/web/admin-spa/src/stores/clients.js new file mode 100644 index 0000000..fa8cabf --- /dev/null +++ b/web/admin-spa/src/stores/clients.js @@ -0,0 +1,23 @@ +import { defineStore } from 'pinia' +import { getSupportedClientsApi } from '@/utils/http_apis' + +export const useClientsStore = defineStore('clients', { + state: () => ({ + supportedClients: [], + loading: false, + error: null + }), + + actions: { + async loadSupportedClients() { + if (this.supportedClients.length > 0) return this.supportedClients + + this.loading = true + const res = await getSupportedClientsApi() + if (res.success) this.supportedClients = res.data || [] + else this.error = res.message + this.loading = false + return this.supportedClients + } + } +}) diff --git a/web/admin-spa/src/stores/dashboard.js b/web/admin-spa/src/stores/dashboard.js new file mode 100644 index 0000000..003ff42 --- /dev/null +++ b/web/admin-spa/src/stores/dashboard.js @@ -0,0 +1,767 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +import { getDashboardApi, getUsageCostsApi, getUsageStatsApi } from '@/utils/http_apis' +import { showToast } from '@/utils/tools' + +export const useDashboardStore = defineStore('dashboard', () => { + // 状态 + const loading = ref(false) + const dashboardData = ref({ + totalApiKeys: 0, + activeApiKeys: 0, + totalAccounts: 0, + normalAccounts: 0, + abnormalAccounts: 0, + pausedAccounts: 0, + activeAccounts: 0, // 保留兼容 + rateLimitedAccounts: 0, + accountsByPlatform: { + claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + 'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + azure_openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 } + }, + todayRequests: 0, + totalRequests: 0, + todayTokens: 0, + todayInputTokens: 0, + todayOutputTokens: 0, + totalTokens: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreateTokens: 0, + totalCacheReadTokens: 0, + todayCacheCreateTokens: 0, + todayCacheReadTokens: 0, + systemRPM: 0, + systemTPM: 0, + realtimeRPM: 0, + realtimeTPM: 0, + metricsWindow: 5, + isHistoricalMetrics: false, + systemStatus: '正常', + uptime: 0, + systemTimezone: 8 // 默认 UTC+8 + }) + + const costsData = ref({ + todayCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } }, + totalCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } } + }) + + const trendData = ref([]) + const dashboardModelStats = ref([]) + const apiKeysTrendData = ref({ + data: [], + topApiKeys: [], + totalApiKeys: 0 + }) + const accountUsageTrendData = ref({ + data: [], + topAccounts: [], + totalAccounts: 0, + group: 'claude', + groupLabel: 'Claude账户' + }) + + // 本地偏好 + const STORAGE_KEYS = { + preset: 'dashboard:date:preset', + granularity: 'dashboard:trend:granularity' + } + const defaultPreset = 'today' + const defaultGranularity = 'day' + + const getPresetOptions = (granularity) => + granularity === 'hour' + ? [ + { value: 'last24h', label: '近24小时', hours: 24 }, + { value: 'yesterday', label: '昨天', hours: 24 }, + { value: 'dayBefore', label: '前天', hours: 24 } + ] + : [ + { value: 'today', label: '今日', days: 1 }, + { value: '7days', label: '7天', days: 7 }, + { value: '30days', label: '30天', days: 30 } + ] + + const readFromStorage = (key, fallback) => { + try { + const value = localStorage.getItem(key) + return value || fallback + } catch (error) { + return fallback + } + } + + const saveToStorage = (key, value) => { + try { + localStorage.setItem(key, value) + } catch (error) { + // 忽略存储错误,避免影响渲染 + } + } + + const normalizePresetForGranularity = (preset, granularity) => { + const options = getPresetOptions(granularity) + const hasPreset = options.some((opt) => opt.value === preset) + if (hasPreset) return preset + return granularity === 'hour' ? 'last24h' : defaultPreset + } + + const storedGranularity = readFromStorage(STORAGE_KEYS.granularity, defaultGranularity) + const initialGranularity = ['day', 'hour'].includes(storedGranularity) + ? storedGranularity + : defaultGranularity + const initialPreset = normalizePresetForGranularity( + readFromStorage(STORAGE_KEYS.preset, defaultPreset), + initialGranularity + ) + + // 日期筛选 + const dateFilter = ref({ + type: 'preset', // preset 或 custom + preset: initialPreset, // today, 7days, 30days + customStart: '', + customEnd: '', + customRange: null, + presetOptions: getPresetOptions(initialGranularity) + }) + + // 趋势图粒度 + const trendGranularity = ref(initialGranularity) // 'day' 或 'hour' + const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens' + const accountUsageGroup = ref('claude') // claude | openai | gemini + + // 计算属性 + const formattedUptime = computed(() => { + const seconds = dashboardData.value.uptime + const days = Math.floor(seconds / 86400) + const hours = Math.floor((seconds % 86400) / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + + if (days > 0) { + return `${days}天 ${hours}小时` + } else if (hours > 0) { + return `${hours}小时 ${minutes}分钟` + } else { + return `${minutes}分钟` + } + }) + + // 辅助函数:获取系统时区某一天的起止UTC时间 + // 输入:一个时间点(Date 对象,通常由当前时刻推算而来) + // 输出:该时间点在系统时区(UTC+8)所属日期的0点/23:59对应的UTC时间 + function getSystemTimezoneDay(localDate, startOfDay = true) { + // 固定使用UTC+8,因为后端系统时区是UTC+8 + // 通过时间戳偏移取 UTC+8 的日历日期,不能用 getFullYear/getDate(那是浏览器本地时区的日期, + // 浏览器时区 ≠ UTC+8 时会在系统时区已跨日、本地未跨日的窗口内漏掉整天数据) + const shifted = new Date(localDate.getTime() + 8 * 60 * 60 * 1000) + const year = shifted.getUTCFullYear() + const month = shifted.getUTCMonth() + const day = shifted.getUTCDate() + + if (startOfDay) { + // 系统时区(UTC+8)的 YYYY-MM-DD 00:00:00 + // 对应的UTC时间是前一天的16:00 + return new Date(Date.UTC(year, month, day - 1, 16, 0, 0, 0)) + } else { + // 系统时区(UTC+8)的 YYYY-MM-DD 23:59:59 + // 对应的UTC时间是当天的15:59:59 + return new Date(Date.UTC(year, month, day, 15, 59, 59, 999)) + } + } + + // 辅助函数:把日期选择器的系统时区(UTC+8)墙钟字符串转换为 ISO 字符串 + // 不能把无时区字符串原样发给后端——后端会按服务器本地时区解析,产生偏移 + function systemTimeStringToISO(timeStr) { + const [datePart, timePart = '00:00:00'] = timeStr.split(' ') + const [year, month, day] = datePart.split('-').map(Number) + const [hours, minutes, seconds] = timePart.split(':').map(Number) + return new Date(Date.UTC(year, month - 1, day, hours - 8, minutes, seconds)).toISOString() + } + + // 公共函数:根据预设计算时间范围 + function getPresetTimeRange(preset) { + const now = new Date() + switch (preset) { + case 'today': { + return { start: getSystemTimezoneDay(now, true), end: getSystemTimezoneDay(now, false) } + } + case 'last24h': { + return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end: new Date(now) } + } + case 'yesterday': { + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + return { + start: getSystemTimezoneDay(yesterday, true), + end: getSystemTimezoneDay(yesterday, false) + } + } + case 'dayBefore': { + const dayBefore = new Date() + dayBefore.setDate(dayBefore.getDate() - 2) + return { + start: getSystemTimezoneDay(dayBefore, true), + end: getSystemTimezoneDay(dayBefore, false) + } + } + default: { + return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end: new Date(now) } + } + } + } + + const persistDatePreferences = ( + preset = dateFilter.value.preset, + granularity = trendGranularity.value + ) => { + saveToStorage(STORAGE_KEYS.preset, preset) + saveToStorage(STORAGE_KEYS.granularity, granularity) + } + + const getEffectiveGranularity = () => + dateFilter.value.type === 'preset' && + dateFilter.value.preset === 'today' && + trendGranularity.value === 'day' + ? 'hour' + : trendGranularity.value + + // 方法 + async function loadDashboardData(timeRange = null) { + loading.value = true + try { + // 根据timeRange动态设置costs查询参数 + let costsParams = { today: 'today', all: 'all' } + + if (timeRange) { + const periodMapping = { + today: { today: 'today', all: 'today' }, + '7days': { today: '7days', all: '7days' }, + monthly: { today: 'monthly', all: 'monthly' }, + all: { today: 'today', all: 'all' } + } + costsParams = periodMapping[timeRange] || costsParams + } + + const [dashboardResponse, todayCostsResponse, totalCostsResponse] = await Promise.all([ + getDashboardApi(), + getUsageCostsApi(costsParams.today), + getUsageCostsApi(costsParams.all) + ]) + + if (dashboardResponse.success) { + const overview = dashboardResponse.data.overview || {} + const recentActivity = dashboardResponse.data.recentActivity || {} + const systemAverages = dashboardResponse.data.systemAverages || {} + const realtimeMetrics = dashboardResponse.data.realtimeMetrics || {} + const systemHealth = dashboardResponse.data.systemHealth || {} + + dashboardData.value = { + totalApiKeys: overview.totalApiKeys || 0, + activeApiKeys: overview.activeApiKeys || 0, + // 使用新的统一统计字段 + totalAccounts: overview.totalAccounts || overview.totalClaudeAccounts || 0, + normalAccounts: overview.normalAccounts || 0, + abnormalAccounts: overview.abnormalAccounts || 0, + pausedAccounts: overview.pausedAccounts || 0, + activeAccounts: overview.activeAccounts || overview.activeClaudeAccounts || 0, // 兼容 + rateLimitedAccounts: + overview.rateLimitedAccounts || overview.rateLimitedClaudeAccounts || 0, + // 各平台详细统计 + accountsByPlatform: overview.accountsByPlatform || { + claude: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + 'claude-console': { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + gemini: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + azure_openai: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 }, + bedrock: { total: 0, normal: 0, abnormal: 0, paused: 0, rateLimited: 0 } + }, + todayRequests: recentActivity.requestsToday || 0, + totalRequests: overview.totalRequestsUsed || 0, + todayTokens: recentActivity.tokensToday || 0, + todayInputTokens: recentActivity.inputTokensToday || 0, + todayOutputTokens: recentActivity.outputTokensToday || 0, + totalTokens: overview.totalTokensUsed || 0, + totalInputTokens: overview.totalInputTokensUsed || 0, + totalOutputTokens: overview.totalOutputTokensUsed || 0, + totalCacheCreateTokens: overview.totalCacheCreateTokensUsed || 0, + totalCacheReadTokens: overview.totalCacheReadTokensUsed || 0, + todayCacheCreateTokens: recentActivity.cacheCreateTokensToday || 0, + todayCacheReadTokens: recentActivity.cacheReadTokensToday || 0, + systemRPM: systemAverages.rpm || 0, + systemTPM: systemAverages.tpm || 0, + realtimeRPM: realtimeMetrics.rpm || 0, + realtimeTPM: realtimeMetrics.tpm || 0, + metricsWindow: realtimeMetrics.windowMinutes || 5, + isHistoricalMetrics: realtimeMetrics.isHistorical || false, + systemStatus: systemHealth.redisConnected ? '正常' : '异常', + uptime: systemHealth.uptime || 0, + systemTimezone: dashboardResponse.data.systemTimezone || 8 + } + } + + // 更新费用数据 + if (todayCostsResponse.success && totalCostsResponse.success) { + costsData.value = { + todayCosts: todayCostsResponse.data.totalCosts || { + totalCost: 0, + formatted: { totalCost: '$0.000000' } + }, + totalCosts: totalCostsResponse.data.totalCosts || { + totalCost: 0, + formatted: { totalCost: '$0.000000' } + } + } + } + } catch (error) { + console.error('加载仪表板数据失败:', error) + } finally { + loading.value = false + } + } + + async function loadUsageTrend(days = 7, granularity = getEffectiveGranularity()) { + try { + let url = '/admin/usage-trend?' + + if (granularity === 'hour') { + url += `granularity=hour` + + // 预设模式优先于 customRange:customRange 里存的是用于展示的系统时区字符串, + // 预设范围必须由 getPresetTimeRange 重新计算并以 ISO 发送 + if (dateFilter.value.type === 'preset') { + const { start, end } = getPresetTimeRange(dateFilter.value.preset) + url += `&startDate=${encodeURIComponent(start.toISOString())}` + url += `&endDate=${encodeURIComponent(end.toISOString())}` + } else if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) { + url += `&startDate=${encodeURIComponent(systemTimeStringToISO(dateFilter.value.customRange[0]))}` + url += `&endDate=${encodeURIComponent(systemTimeStringToISO(dateFilter.value.customRange[1]))}` + } else { + const now = new Date() + url += `&startDate=${encodeURIComponent(new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString())}` + url += `&endDate=${encodeURIComponent(now.toISOString())}` + } + } else { + url += `granularity=day&days=${days}` + } + + const response = await getUsageStatsApi(url) + if (response.success) { + trendData.value = response.data + } + } catch (error) { + console.error('加载使用趋势失败:', error) + } + } + + async function loadModelStats(period = 'daily', granularity = null) { + const currentGranularity = granularity || getEffectiveGranularity() + try { + let url = `/admin/model-stats?period=${period}` + + if (currentGranularity === 'hour' && dateFilter.value.type === 'preset') { + // 预设模式优先于 customRange:customRange 里存的是用于展示的系统时区字符串 + const { start, end } = getPresetTimeRange(dateFilter.value.preset) + url += `&startDate=${encodeURIComponent(start.toISOString())}` + url += `&endDate=${encodeURIComponent(end.toISOString())}` + } else if ( + dateFilter.value.type === 'custom' && + dateFilter.value.customRange && + dateFilter.value.customRange.length === 2 + ) { + url += `&startDate=${encodeURIComponent(systemTimeStringToISO(dateFilter.value.customRange[0]))}` + url += `&endDate=${encodeURIComponent(systemTimeStringToISO(dateFilter.value.customRange[1]))}` + } else if (dateFilter.value.type === 'preset' && currentGranularity === 'day') { + const now = new Date() + const option = dateFilter.value.presetOptions.find( + (opt) => opt.value === dateFilter.value.preset + ) + if (option) { + let startDate, endDate + if (dateFilter.value.preset === 'today') { + startDate = getSystemTimezoneDay(now, true) + endDate = getSystemTimezoneDay(now, false) + } else { + const daysAgo = new Date() + daysAgo.setDate(daysAgo.getDate() - (option.days - 1)) + startDate = getSystemTimezoneDay(daysAgo, true) + endDate = getSystemTimezoneDay(now, false) + } + url += `&startDate=${encodeURIComponent(startDate.toISOString())}` + url += `&endDate=${encodeURIComponent(endDate.toISOString())}` + } + } + + const response = await getUsageStatsApi(url) + if (response.success) { + dashboardModelStats.value = response.data + } + } catch (error) { + console.error('加载模型统计失败:', error) + } + } + + async function loadApiKeysTrend(metric = 'requests', granularity = null) { + const currentGranularity = granularity || getEffectiveGranularity() + try { + let url = '/admin/api-keys-usage-trend?' + let days = 7 + + if (currentGranularity === 'hour') { + url += `granularity=hour` + + // 预设模式优先于 customRange:customRange 里存的是用于展示的系统时区字符串 + if (dateFilter.value.type === 'preset') { + const { start, end } = getPresetTimeRange(dateFilter.value.preset) + url += `&startDate=${encodeURIComponent(start.toISOString())}` + url += `&endDate=${encodeURIComponent(end.toISOString())}` + } else if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) { + url += `&startDate=${encodeURIComponent(systemTimeStringToISO(dateFilter.value.customRange[0]))}` + url += `&endDate=${encodeURIComponent(systemTimeStringToISO(dateFilter.value.customRange[1]))}` + } else { + const now = new Date() + url += `&startDate=${encodeURIComponent(new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString())}` + url += `&endDate=${encodeURIComponent(now.toISOString())}` + } + } else { + days = + dateFilter.value.type === 'preset' + ? dateFilter.value.preset === 'today' + ? 1 + : dateFilter.value.preset === '7days' + ? 7 + : 30 + : calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd) + url += `granularity=day&days=${days}` + } + + url += `&metric=${metric}` + + const response = await getUsageStatsApi(url) + if (response.success) { + apiKeysTrendData.value = { + data: response.data || [], + topApiKeys: response.topApiKeys || [], + totalApiKeys: response.totalApiKeys || 0 + } + } + } catch (error) { + console.error('加载API Keys趋势失败:', error) + } + } + + async function loadAccountUsageTrend(group = accountUsageGroup.value, granularity = null) { + const currentGranularity = granularity || getEffectiveGranularity() + try { + let url = '/admin/account-usage-trend?' + let days = 7 + + if (currentGranularity === 'hour') { + url += `granularity=hour` + + // 预设模式优先于 customRange:customRange 里存的是用于展示的系统时区字符串 + if (dateFilter.value.type === 'preset') { + const { start, end } = getPresetTimeRange(dateFilter.value.preset) + url += `&startDate=${encodeURIComponent(start.toISOString())}` + url += `&endDate=${encodeURIComponent(end.toISOString())}` + } else if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) { + url += `&startDate=${encodeURIComponent(systemTimeStringToISO(dateFilter.value.customRange[0]))}` + url += `&endDate=${encodeURIComponent(systemTimeStringToISO(dateFilter.value.customRange[1]))}` + } else { + const now = new Date() + url += `&startDate=${encodeURIComponent(new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString())}` + url += `&endDate=${encodeURIComponent(now.toISOString())}` + } + } else { + days = + dateFilter.value.type === 'preset' + ? dateFilter.value.preset === 'today' + ? 1 + : dateFilter.value.preset === '7days' + ? 7 + : 30 + : calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd) + url += `granularity=day&days=${days}` + } + + url += `&group=${group}` + + const response = await getUsageStatsApi(url) + if (response.success) { + accountUsageTrendData.value = { + data: response.data || [], + topAccounts: response.topAccounts || [], + totalAccounts: response.totalAccounts || 0, + group: response.group || group, + groupLabel: response.groupLabel || '' + } + } + } catch (error) { + console.error('加载账号使用趋势失败:', error) + } + } + + // 日期筛选相关方法 + function setDateFilterPreset(preset, options = {}) { + const { silent = false, skipSave = false } = options + const normalizedPreset = normalizePresetForGranularity(preset, trendGranularity.value) + + dateFilter.value.type = 'preset' + dateFilter.value.preset = normalizedPreset + + const option = dateFilter.value.presetOptions.find((opt) => opt.value === normalizedPreset) + const now = new Date() + let startDate, endDate + + if (trendGranularity.value === 'hour') { + const range = getPresetTimeRange(normalizedPreset) + startDate = range.start + endDate = range.end + } else { + // 日界线按系统时区(UTC+8)计算,不能用浏览器本地的 setHours + if (normalizedPreset === 'today') { + startDate = getSystemTimezoneDay(now, true) + endDate = getSystemTimezoneDay(now, false) + } else if (option?.days) { + const daysAgo = new Date(now) + daysAgo.setDate(daysAgo.getDate() - (option.days - 1)) + startDate = getSystemTimezoneDay(daysAgo, true) + endDate = getSystemTimezoneDay(now, false) + } else { + startDate = new Date(now) + endDate = new Date(now) + } + } + + const formatDateForDisplay = (date) => { + // 按系统时区(UTC+8)格式化显示,与查询范围的日期归属保持一致, + // 也保证 customRange 里的字符串能按系统时区语义回转(systemTimeStringToISO) + const systemTime = new Date(date.getTime() + 8 * 60 * 60 * 1000) + const year = systemTime.getUTCFullYear() + const month = String(systemTime.getUTCMonth() + 1).padStart(2, '0') + const day = String(systemTime.getUTCDate()).padStart(2, '0') + const hours = String(systemTime.getUTCHours()).padStart(2, '0') + const minutes = String(systemTime.getUTCMinutes()).padStart(2, '0') + const seconds = String(systemTime.getUTCSeconds()).padStart(2, '0') + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}` + } + + dateFilter.value.customStart = startDate ? formatDateForDisplay(startDate).split(' ')[0] : '' + dateFilter.value.customEnd = endDate ? formatDateForDisplay(endDate).split(' ')[0] : '' + dateFilter.value.customRange = + startDate && endDate ? [formatDateForDisplay(startDate), formatDateForDisplay(endDate)] : null + + if (!skipSave) { + persistDatePreferences(dateFilter.value.preset, trendGranularity.value) + } + + if (!silent) { + refreshChartsData() + } + } + + function onCustomDateRangeChange(value) { + if (value && value.length === 2) { + dateFilter.value.type = 'custom' + dateFilter.value.preset = '' // 清除预设选择 + dateFilter.value.customRange = value + dateFilter.value.customStart = value[0].split(' ')[0] + dateFilter.value.customEnd = value[1].split(' ')[0] + + // 检查日期范围限制 - value中的时间已经是系统时区时间 + // const systemTz = dashboardData.value.systemTimezone || 8 + + // 解析系统时区时间 + const parseSystemTime = (timeStr) => { + const [datePart, timePart] = timeStr.split(' ') + const [year, month, day] = datePart.split('-').map(Number) + const [hours, minutes, seconds] = timePart.split(':').map(Number) + return new Date(year, month - 1, day, hours, minutes, seconds) + } + + const start = parseSystemTime(value[0]) + const end = parseSystemTime(value[1]) + + if (trendGranularity.value === 'hour') { + // 小时粒度:限制 24 小时 + const hoursDiff = (end - start) / (1000 * 60 * 60) + if (hoursDiff > 24) { + showToast('小时粒度下日期范围不能超过24小时', 'warning') + return + } + } else { + // 天粒度:限制 31 天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 31) { + showToast('日期范围不能超过 31 天', 'warning') + return + } + } + + // 触发数据刷新 + refreshChartsData() + } else if (value === null) { + // 清空时恢复默认 + setDateFilterPreset(trendGranularity.value === 'hour' ? 'last24h' : defaultPreset) + } + } + + function setTrendGranularity(granularity, options = {}) { + const { silent = false, skipSave = false, presetOverride } = options + trendGranularity.value = granularity + + // 根据粒度更新预设选项 + if (granularity === 'hour') { + dateFilter.value.presetOptions = getPresetOptions('hour') + + // 检查当前自定义日期范围是否超过24小时 + if ( + dateFilter.value.type === 'custom' && + dateFilter.value.customRange && + dateFilter.value.customRange.length === 2 + ) { + const start = new Date(dateFilter.value.customRange[0]) + const end = new Date(dateFilter.value.customRange[1]) + const hoursDiff = (end - start) / (1000 * 60 * 60) + if (hoursDiff > 24) { + showToast('小时粒度下日期范围不能超过24小时,已切换到近24小时', 'warning') + setDateFilterPreset('last24h', { silent, skipSave }) + return + } + } + } else { + // 天粒度 + dateFilter.value.presetOptions = getPresetOptions('day') + } + + if (dateFilter.value.type === 'custom') { + if (!skipSave) { + persistDatePreferences(dateFilter.value.preset || defaultPreset, trendGranularity.value) + } + + if (!silent) { + refreshChartsData() + } + return + } + + const nextPreset = + presetOverride || + normalizePresetForGranularity(dateFilter.value.preset, trendGranularity.value) + + setDateFilterPreset(nextPreset, { silent: true, skipSave: true }) + + if (!skipSave) { + persistDatePreferences(dateFilter.value.preset, trendGranularity.value) + } + + if (!silent) { + refreshChartsData() + } + } + + async function refreshChartsData() { + // 根据当前筛选条件刷新数据 + let days + let modelPeriod = 'monthly' + const effectiveGranularity = getEffectiveGranularity() + + if (dateFilter.value.type === 'preset') { + const option = dateFilter.value.presetOptions.find( + (opt) => opt.value === dateFilter.value.preset + ) + + if (effectiveGranularity === 'hour') { + // 小时粒度 + days = 1 // 小时粒度默认查看1天的数据 + modelPeriod = 'daily' // 小时粒度使用日统计 + } else { + // 天粒度 + days = option ? option.days : 7 + // 设置模型统计期间 + if (dateFilter.value.preset === 'today') { + modelPeriod = 'daily' + } else { + modelPeriod = 'monthly' + } + } + } else { + // 自定义日期范围 + if (effectiveGranularity === 'hour') { + // 小时粒度下的自定义范围,计算小时数 + const start = new Date(dateFilter.value.customRange[0]) + const end = new Date(dateFilter.value.customRange[1]) + const hoursDiff = Math.ceil((end - start) / (1000 * 60 * 60)) + days = Math.ceil(hoursDiff / 24) || 1 + } else { + days = calculateDaysBetween(dateFilter.value.customStart, dateFilter.value.customEnd) + } + modelPeriod = 'daily' // 自定义范围使用日统计 + } + + await Promise.all([ + loadUsageTrend(days, effectiveGranularity), + loadModelStats(modelPeriod, effectiveGranularity), + loadApiKeysTrend(apiKeysTrendMetric.value, effectiveGranularity), + loadAccountUsageTrend(accountUsageGroup.value, effectiveGranularity) + ]) + } + + function setAccountUsageGroup(group) { + accountUsageGroup.value = group + return loadAccountUsageTrend(group, getEffectiveGranularity()) + } + + function calculateDaysBetween(start, end) { + if (!start || !end) return 7 + const startDate = new Date(start) + const endDate = new Date(end) + const diffTime = Math.abs(endDate - startDate) + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + return diffDays || 7 + } + + function disabledDate(date) { + return date > new Date() + } + + // 初始化日期筛选:同步本地偏好并填充范围 + setDateFilterPreset(dateFilter.value.preset, { silent: true, skipSave: true }) + persistDatePreferences(dateFilter.value.preset, trendGranularity.value) + + return { + // 状态 + loading, + dashboardData, + costsData, + trendData, + dashboardModelStats, + apiKeysTrendData, + accountUsageTrendData, + dateFilter, + trendGranularity, + apiKeysTrendMetric, + accountUsageGroup, + + // 计算属性 + formattedUptime, + + // 方法 + loadDashboardData, + loadUsageTrend, + loadModelStats, + loadApiKeysTrend, + loadAccountUsageTrend, + setDateFilterPreset, + onCustomDateRangeChange, + setTrendGranularity, + refreshChartsData, + setAccountUsageGroup, + disabledDate + } +}) diff --git a/web/admin-spa/src/stores/settings.js b/web/admin-spa/src/stores/settings.js new file mode 100644 index 0000000..b8b4f44 --- /dev/null +++ b/web/admin-spa/src/stores/settings.js @@ -0,0 +1,134 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +import { getOemSettingsApi, updateOemSettingsApi } from '@/utils/http_apis' + +export const useSettingsStore = defineStore('settings', () => { + // 状态 + const oemSettings = ref({ + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', + showAdminButton: true, + apiStatsNotice: { enabled: false, title: '', content: '' }, + updatedAt: null + }) + + const loading = ref(false) + const saving = ref(false) + + // Actions + const loadOemSettings = async () => { + loading.value = true + const res = await getOemSettingsApi() + if (res.success) { + oemSettings.value = { ...oemSettings.value, ...res.data } + applyOemSettings() + } + loading.value = false + return res + } + + const saveOemSettings = async (settings) => { + saving.value = true + const res = await updateOemSettingsApi(settings) + if (res.success) { + oemSettings.value = { ...oemSettings.value, ...res.data } + applyOemSettings() + } + saving.value = false + return res + } + + const resetOemSettings = async () => { + const defaultSettings = { + siteName: 'Claude Relay Service', + siteIcon: '', + siteIconData: '', + showAdminButton: true, + apiStatsNotice: { enabled: false, title: '', content: '' }, + updatedAt: null + } + + oemSettings.value = { ...defaultSettings } + return await saveOemSettings(defaultSettings) + } + + // 应用OEM设置到页面 + const applyOemSettings = () => { + // 更新页面标题 + if (oemSettings.value.siteName) { + document.title = `${oemSettings.value.siteName} - 管理后台` + } + + // 更新favicon + if (oemSettings.value.siteIconData || oemSettings.value.siteIcon) { + const favicon = document.querySelector('link[rel="icon"]') || document.createElement('link') + favicon.rel = 'icon' + favicon.href = oemSettings.value.siteIconData || oemSettings.value.siteIcon + if (!document.querySelector('link[rel="icon"]')) { + document.head.appendChild(favicon) + } + } + } + + // 格式化日期时间 + const formatDateTime = (dateString) => { + if (!dateString) return '' + return new Date(dateString).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } + + // 验证文件上传 + const validateIconFile = (file) => { + const errors = [] + + // 检查文件大小 (350KB) + if (file.size > 350 * 1024) { + errors.push('图标文件大小不能超过 350KB') + } + + // 检查文件类型 + const allowedTypes = ['image/x-icon', 'image/png', 'image/jpeg', 'image/jpg', 'image/svg+xml'] + if (!allowedTypes.includes(file.type)) { + errors.push('不支持的文件类型,请选择 .ico, .png, .jpg 或 .svg 文件') + } + + return { + isValid: errors.length === 0, + errors + } + } + + // 将文件转换为Base64 + const fileToBase64 = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = (e) => resolve(e.target.result) + reader.onerror = reject + reader.readAsDataURL(file) + }) + } + + return { + // State + oemSettings, + loading, + saving, + + // Actions + loadOemSettings, + saveOemSettings, + resetOemSettings, + applyOemSettings, + formatDateTime, + validateIconFile, + fileToBase64 + } +}) diff --git a/web/admin-spa/src/stores/theme.js b/web/admin-spa/src/stores/theme.js new file mode 100644 index 0000000..e389378 --- /dev/null +++ b/web/admin-spa/src/stores/theme.js @@ -0,0 +1,399 @@ +import { defineStore } from 'pinia' +import { ref, computed, watch } from 'vue' + +// 主题模式枚举 +export const ThemeMode = { + LIGHT: 'light', + DARK: 'dark', + AUTO: 'auto' +} + +// 中国传统色系预设 +export const ColorSchemes = { + purple: { + name: '默认紫', + nameEn: 'Purple', + primary: '#667eea', + secondary: '#764ba2', + accent: '#f093fb', + gradientStart: '#667eea', + gradientMid: '#764ba2', + gradientEnd: '#f093fb', + // 玻璃态背景色(亮色模式) + glassStrong: 'rgba(255, 255, 255, 0.95)', + glass: 'rgba(255, 255, 255, 0.1)', + // 暗黑模式 + darkPrimary: '#818cf8', + darkSecondary: '#a78bfa', + darkAccent: '#c084fc', + darkGradientStart: '#1f2937', + darkGradientMid: '#374151', + darkGradientEnd: '#4b5563', + darkGlassStrong: 'rgba(31, 41, 55, 0.95)', + darkGlass: 'rgba(0, 0, 0, 0.2)' + }, + celadon: { + name: '青瓷', + nameEn: 'Celadon', + primary: '#7faaaf', + secondary: '#5d8a8e', + accent: '#a8d8dc', + gradientStart: '#7faaaf', + gradientMid: '#5d8a8e', + gradientEnd: '#3d6a6e', + glassStrong: 'rgba(248, 253, 253, 0.95)', + glass: 'rgba(168, 216, 220, 0.1)', + darkPrimary: '#9fcacd', + darkSecondary: '#7daaae', + darkAccent: '#c8f8fc', + darkGradientStart: '#1a2a2b', + darkGradientMid: '#2a3a3b', + darkGradientEnd: '#3a4a4b', + darkGlassStrong: 'rgba(26, 42, 43, 0.95)', + darkGlass: 'rgba(0, 20, 20, 0.2)' + }, + cinnabar: { + name: '朱砂', + nameEn: 'Cinnabar', + primary: '#c45a5a', + secondary: '#8b3a3a', + accent: '#e8a0a0', + gradientStart: '#c45a5a', + gradientMid: '#8b3a3a', + gradientEnd: '#5c2a2a', + glassStrong: 'rgba(255, 252, 252, 0.95)', + glass: 'rgba(232, 160, 160, 0.1)', + darkPrimary: '#e47a7a', + darkSecondary: '#ab5a5a', + darkAccent: '#f8c0c0', + darkGradientStart: '#2a1a1a', + darkGradientMid: '#3a2a2a', + darkGradientEnd: '#4a3a3a', + darkGlassStrong: 'rgba(42, 26, 26, 0.95)', + darkGlass: 'rgba(20, 0, 0, 0.2)' + }, + jade: { + name: '墨玉', + nameEn: 'Jade', + primary: '#4a7c59', + secondary: '#2d5a3d', + accent: '#7eb08c', + gradientStart: '#4a7c59', + gradientMid: '#2d5a3d', + gradientEnd: '#1a3d28', + glassStrong: 'rgba(250, 255, 252, 0.95)', + glass: 'rgba(126, 176, 140, 0.1)', + darkPrimary: '#6a9c79', + darkSecondary: '#4d7a5d', + darkAccent: '#9ed0ac', + darkGradientStart: '#1a2a1e', + darkGradientMid: '#2a3a2e', + darkGradientEnd: '#3a4a3e', + darkGlassStrong: 'rgba(26, 42, 30, 0.95)', + darkGlass: 'rgba(0, 20, 10, 0.2)' + }, + indigo: { + name: '藏蓝', + nameEn: 'Indigo', + primary: '#3a5a8c', + secondary: '#2a4066', + accent: '#6a8ab8', + gradientStart: '#3a5a8c', + gradientMid: '#2a4066', + gradientEnd: '#1a2a44', + glassStrong: 'rgba(250, 252, 255, 0.95)', + glass: 'rgba(106, 138, 184, 0.1)', + darkPrimary: '#5a7aac', + darkSecondary: '#4a6086', + darkAccent: '#8aaad8', + darkGradientStart: '#1a1a2a', + darkGradientMid: '#2a2a3a', + darkGradientEnd: '#3a3a4a', + darkGlassStrong: 'rgba(26, 26, 42, 0.95)', + darkGlass: 'rgba(0, 0, 20, 0.2)' + }, + amber: { + name: '琥珀', + nameEn: 'Amber', + primary: '#c49a3a', + secondary: '#8b6914', + accent: '#e8c86a', + gradientStart: '#c49a3a', + gradientMid: '#8b6914', + gradientEnd: '#5c4a0a', + glassStrong: 'rgba(255, 253, 248, 0.95)', + glass: 'rgba(232, 200, 106, 0.1)', + darkPrimary: '#e4ba5a', + darkSecondary: '#ab8934', + darkAccent: '#f8e88a', + darkGradientStart: '#2a2a1a', + darkGradientMid: '#3a3a2a', + darkGradientEnd: '#4a4a3a', + darkGlassStrong: 'rgba(42, 42, 26, 0.95)', + darkGlass: 'rgba(20, 20, 0, 0.2)' + }, + rouge: { + name: '胭脂', + nameEn: 'Rouge', + primary: '#b85a6a', + secondary: '#8a3a4a', + accent: '#e8a0b0', + gradientStart: '#b85a6a', + gradientMid: '#8a3a4a', + gradientEnd: '#5c2a3a', + glassStrong: 'rgba(255, 250, 252, 0.95)', + glass: 'rgba(232, 160, 176, 0.1)', + darkPrimary: '#d87a8a', + darkSecondary: '#aa5a6a', + darkAccent: '#f8c0d0', + darkGradientStart: '#2a1a1e', + darkGradientMid: '#3a2a2e', + darkGradientEnd: '#4a3a3e', + darkGlassStrong: 'rgba(42, 26, 30, 0.95)', + darkGlass: 'rgba(20, 0, 10, 0.2)' + } +} + +export const useThemeStore = defineStore('theme', () => { + // 状态 - 支持三种模式:light, dark, auto + const themeMode = ref(ThemeMode.AUTO) + const systemPrefersDark = ref(false) + // 色系状态 + const colorScheme = ref('purple') + + // 计算属性 - 实际的暗黑模式状态 + const isDarkMode = computed(() => { + if (themeMode.value === ThemeMode.DARK) { + return true + } else if (themeMode.value === ThemeMode.LIGHT) { + return false + } else { + // auto 模式,跟随系统 + return systemPrefersDark.value + } + }) + + // 计算属性 - 当前实际使用的主题 + const currentTheme = computed(() => { + return isDarkMode.value ? ThemeMode.DARK : ThemeMode.LIGHT + }) + + // 计算属性 - 当前色系配置 + const currentColorScheme = computed(() => { + return ColorSchemes[colorScheme.value] || ColorSchemes.purple + }) + + // 初始化主题 + const initTheme = () => { + // 检测系统主题偏好 + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + systemPrefersDark.value = mediaQuery.matches + + // 从 localStorage 读取保存的主题模式 + const savedMode = localStorage.getItem('themeMode') + + if (savedMode && Object.values(ThemeMode).includes(savedMode)) { + themeMode.value = savedMode + } else { + // 默认使用 auto 模式 + themeMode.value = ThemeMode.AUTO + } + + // 从 localStorage 读取保存的色系 + const savedColorScheme = localStorage.getItem('colorScheme') + if (savedColorScheme && ColorSchemes[savedColorScheme]) { + colorScheme.value = savedColorScheme + } + + // 应用主题 + applyTheme() + + // 开始监听系统主题变化 + watchSystemTheme() + } + + // 应用主题到 DOM + const applyTheme = () => { + const root = document.documentElement + + if (isDarkMode.value) { + root.classList.add('dark') + } else { + root.classList.remove('dark') + } + + // 应用色系 CSS 变量 + applyColorScheme() + } + + // 应用色系 CSS 变量 + const applyColorScheme = () => { + const root = document.documentElement + const scheme = currentColorScheme.value + const dark = isDarkMode.value + + const primary = dark ? scheme.darkPrimary : scheme.primary + const secondary = dark ? scheme.darkSecondary : scheme.secondary + const accent = dark ? scheme.darkAccent : scheme.accent + + // 设置主题色 + root.style.setProperty('--primary-color', primary) + root.style.setProperty('--secondary-color', secondary) + root.style.setProperty('--accent-color', accent) + + // 设置背景渐变 + root.style.setProperty( + '--bg-gradient-start', + dark ? scheme.darkGradientStart : scheme.gradientStart + ) + root.style.setProperty('--bg-gradient-mid', dark ? scheme.darkGradientMid : scheme.gradientMid) + root.style.setProperty('--bg-gradient-end', dark ? scheme.darkGradientEnd : scheme.gradientEnd) + + // 设置玻璃态背景色 + root.style.setProperty( + '--glass-strong-color', + dark ? scheme.darkGlassStrong : scheme.glassStrong + ) + root.style.setProperty('--glass-color', dark ? scheme.darkGlass : scheme.glass) + + // 设置表面颜色(卡片背景等) + root.style.setProperty('--surface-color', dark ? scheme.darkGlassStrong : scheme.glassStrong) + root.style.setProperty('--table-bg', dark ? scheme.darkGlassStrong : scheme.glassStrong) + root.style.setProperty('--input-bg', dark ? scheme.darkGlassStrong : scheme.glassStrong) + + // 解析颜色为 RGB 值用于 rgba() + const hexToRgb = (hex) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + return result + ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}` + : '102, 126, 234' + } + + const primaryRgb = hexToRgb(primary) + const secondaryRgb = hexToRgb(secondary) + const accentRgb = hexToRgb(accent) + + // 设置 RGB 变量用于 rgba() + root.style.setProperty('--primary-rgb', primaryRgb) + root.style.setProperty('--secondary-rgb', secondaryRgb) + root.style.setProperty('--accent-rgb', accentRgb) + + // 设置表格 hover 颜色(暗黑模式透明度更高) + root.style.setProperty('--table-hover', `rgba(${primaryRgb}, ${dark ? 0.1 : 0.05})`) + + // 设置边框颜色(基于主题色) + root.style.setProperty('--border-color', `rgba(${primaryRgb}, ${dark ? 0.25 : 0.2})`) + + // 设置输入框边框 + root.style.setProperty('--input-border', `rgba(${primaryRgb}, ${dark ? 0.3 : 0.25})`) + } + + // 设置主题模式 + const setThemeMode = (mode) => { + if (Object.values(ThemeMode).includes(mode)) { + themeMode.value = mode + } + } + + // 循环切换主题模式 + const cycleThemeMode = () => { + const modes = [ThemeMode.LIGHT, ThemeMode.DARK, ThemeMode.AUTO] + const currentIndex = modes.indexOf(themeMode.value) + const nextIndex = (currentIndex + 1) % modes.length + themeMode.value = modes[nextIndex] + } + + // 设置色系 + const setColorScheme = (scheme) => { + if (ColorSchemes[scheme]) { + colorScheme.value = scheme + } + } + + // 循环切换色系 + const cycleColorScheme = () => { + const schemes = Object.keys(ColorSchemes) + const currentIndex = schemes.indexOf(colorScheme.value) + const nextIndex = (currentIndex + 1) % schemes.length + colorScheme.value = schemes[nextIndex] + } + + // 监听主题模式变化,自动保存到 localStorage 并应用 + watch(themeMode, (newMode) => { + localStorage.setItem('themeMode', newMode) + applyTheme() + }) + + // 监听色系变化,自动保存到 localStorage 并应用 + watch(colorScheme, (newScheme) => { + localStorage.setItem('colorScheme', newScheme) + applyColorScheme() + }) + + // 监听系统主题偏好变化 + watch(systemPrefersDark, () => { + // 只有在 auto 模式下才需要重新应用主题 + if (themeMode.value === ThemeMode.AUTO) { + applyTheme() + } + }) + + // 监听系统主题变化 + const watchSystemTheme = () => { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + + const handleChange = (e) => { + systemPrefersDark.value = e.matches + } + + // 初始检测 + systemPrefersDark.value = mediaQuery.matches + + // 添加监听器 + mediaQuery.addEventListener('change', handleChange) + + // 返回清理函数 + return () => { + mediaQuery.removeEventListener('change', handleChange) + } + } + + // 兼容旧版 API + const toggleTheme = () => { + cycleThemeMode() + } + + const setTheme = (theme) => { + if (theme === 'dark') { + setThemeMode(ThemeMode.DARK) + } else if (theme === 'light') { + setThemeMode(ThemeMode.LIGHT) + } + } + + return { + // State + themeMode, + isDarkMode, + currentTheme, + systemPrefersDark, + colorScheme, + currentColorScheme, + + // Constants + ThemeMode, + ColorSchemes, + + // Actions + initTheme, + setThemeMode, + cycleThemeMode, + watchSystemTheme, + setColorScheme, + cycleColorScheme, + + // 兼容旧版 API + toggleTheme, + setTheme + } +}) diff --git a/web/admin-spa/src/stores/user.js b/web/admin-spa/src/stores/user.js new file mode 100644 index 0000000..0b4dbb4 --- /dev/null +++ b/web/admin-spa/src/stores/user.js @@ -0,0 +1,218 @@ +import { defineStore } from 'pinia' +import axios from 'axios' +import { showToast } from '@/utils/tools' +import { APP_CONFIG } from '@/utils/tools' + +const API_BASE = `${APP_CONFIG.apiPrefix}/users` + +export const useUserStore = defineStore('user', { + state: () => ({ + user: null, + isAuthenticated: false, + sessionToken: null, + loading: false, + config: null + }), + + getters: { + isLoggedIn: (state) => state.isAuthenticated && state.user, + userName: (state) => state.user?.displayName || state.user?.username, + userRole: (state) => state.user?.role + }, + + actions: { + // 🔐 用户登录 + async login(credentials) { + this.loading = true + try { + const response = await axios.post(`${API_BASE}/login`, credentials) + + if (response.data.success) { + this.user = response.data.user + this.sessionToken = response.data.sessionToken + this.isAuthenticated = true + + // 保存到 localStorage + localStorage.setItem('userToken', this.sessionToken) + localStorage.setItem('userData', JSON.stringify(this.user)) + + // 设置 axios 默认头部 + this.setAuthHeader() + + return response.data + } else { + throw new Error(response.data.message || 'Login failed') + } + } catch (error) { + this.clearAuth() + throw error + } finally { + this.loading = false + } + }, + + // 🚪 用户登出 + async logout() { + try { + if (this.sessionToken) { + await axios.post( + `${API_BASE}/logout`, + {}, + { + headers: { 'x-user-token': this.sessionToken } + } + ) + } + } catch (error) { + console.error('Logout request failed:', error) + } finally { + this.clearAuth() + } + }, + + // 🔄 检查认证状态 + async checkAuth() { + const token = localStorage.getItem('userToken') + const userData = localStorage.getItem('userData') + const userConfig = localStorage.getItem('userConfig') + + if (!token || !userData) { + this.clearAuth() + return false + } + + try { + this.sessionToken = token + this.user = JSON.parse(userData) + this.config = userConfig ? JSON.parse(userConfig) : null + this.isAuthenticated = true + this.setAuthHeader() + + // 验证 token 是否仍然有效 + await this.getUserProfile() + return true + } catch (error) { + console.error('Auth check failed:', error) + this.clearAuth() + return false + } + }, + + // 👤 获取用户资料 + async getUserProfile() { + try { + const response = await axios.get(`${API_BASE}/profile`) + + if (response.data.success) { + this.user = response.data.user + this.config = response.data.config + localStorage.setItem('userData', JSON.stringify(this.user)) + localStorage.setItem('userConfig', JSON.stringify(this.config)) + return response.data.user + } + } catch (error) { + if (error.response?.status === 401 || error.response?.status === 403) { + // 401: Invalid/expired session, 403: Account disabled + this.clearAuth() + // If it's a disabled account error, throw a specific error + if (error.response?.status === 403) { + throw new Error(error.response.data?.message || 'Your account has been disabled') + } + } + throw error + } + }, + + // 🔑 获取用户API Keys + async getUserApiKeys(includeDeleted = false) { + try { + const params = {} + if (includeDeleted) { + params.includeDeleted = 'true' + } + const response = await axios.get(`${API_BASE}/api-keys`, { params }) + return response.data.success ? response.data.apiKeys : [] + } catch (error) { + console.error('Failed to fetch API keys:', error) + throw error + } + }, + + // 🔑 创建API Key + async createApiKey(keyData) { + try { + const response = await axios.post(`${API_BASE}/api-keys`, keyData) + return response.data + } catch (error) { + console.error('Failed to create API key:', error) + throw error + } + }, + + // 🗑️ 删除API Key + async deleteApiKey(keyId) { + try { + const response = await axios.delete(`${API_BASE}/api-keys/${keyId}`) + return response.data + } catch (error) { + console.error('Failed to delete API key:', error) + throw error + } + }, + + // 📊 获取使用统计 + async getUserUsageStats(params = {}) { + try { + const response = await axios.get(`${API_BASE}/usage-stats`, { params }) + return response.data.success ? response.data.stats : null + } catch (error) { + console.error('Failed to fetch usage stats:', error) + throw error + } + }, + + // 🧹 清除认证信息 + clearAuth() { + this.user = null + this.sessionToken = null + this.isAuthenticated = false + this.config = null + + localStorage.removeItem('userToken') + localStorage.removeItem('userData') + localStorage.removeItem('userConfig') + + // 清除 axios 默认头部 + delete axios.defaults.headers.common['x-user-token'] + }, + + // 🔧 设置认证头部 + setAuthHeader() { + if (this.sessionToken) { + axios.defaults.headers.common['x-user-token'] = this.sessionToken + } + }, + + // 🔧 设置axios拦截器 + setupAxiosInterceptors() { + // Response interceptor to handle disabled user responses globally + axios.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 403) { + const message = error.response.data?.message + if (message && (message.includes('disabled') || message.includes('Account disabled'))) { + this.clearAuth() + showToast(message, 'error') + // Redirect to login page + if (window.location.pathname !== '/user-login') { + window.location.href = '/user-login' + } + } + } + return Promise.reject(error) + } + ) + } + } +}) diff --git a/web/admin-spa/src/utils/http_apis.js b/web/admin-spa/src/utils/http_apis.js new file mode 100644 index 0000000..a93959a --- /dev/null +++ b/web/admin-spa/src/utils/http_apis.js @@ -0,0 +1,359 @@ +import request from '@/utils/request' + +// 模型 +export const getModelsApi = () => request({ url: '/apiStats/models', method: 'GET' }) + +// 模型价格管理 +export const getModelPricingApi = () => request({ url: '/admin/models/pricing', method: 'GET' }) +export const getModelPricingStatusApi = () => + request({ url: '/admin/models/pricing/status', method: 'GET' }) +export const refreshModelPricingApi = () => + request({ url: '/admin/models/pricing/refresh', method: 'POST' }) + +// API Stats +export const getKeyIdApi = (apiKey) => + request({ url: '/apiStats/api/get-key-id', method: 'POST', data: { apiKey } }) +export const getUserStatsApi = (apiId) => + request({ url: '/apiStats/api/user-stats', method: 'POST', data: { apiId } }) +export const getUserModelStatsApi = (apiId, period = 'daily') => + request({ url: '/apiStats/api/user-model-stats', method: 'POST', data: { apiId, period } }) +export const getBatchStatsApi = (apiIds) => + request({ url: '/apiStats/api/batch-stats', method: 'POST', data: { apiIds } }) +export const getBatchModelStatsApi = (apiIds, period = 'daily') => + request({ url: '/apiStats/api/batch-model-stats', method: 'POST', data: { apiIds, period } }) + +// 认证 +export const loginApi = (data) => request({ url: '/web/auth/login', method: 'POST', data }) +export const getAuthUserApi = () => request({ url: '/web/auth/user', method: 'GET' }) +export const changePasswordApi = (data) => + request({ url: '/web/auth/change-password', method: 'POST', data }) + +// OEM 设置 +export const getOemSettingsApi = () => request({ url: '/admin/oem-settings', method: 'GET' }) +export const updateOemSettingsApi = (data) => + request({ url: '/admin/oem-settings', method: 'PUT', data }) + +// 服务倍率配置(公开接口) +export const getServiceRatesApi = () => request({ url: '/apiStats/service-rates', method: 'GET' }) + +// 额度卡兑换(公开接口) +export const redeemCardByApiIdApi = (data) => + request({ url: '/apiStats/api/redeem-card', method: 'POST', data }) +export const getRedemptionHistoryByApiIdApi = (apiId, params = {}) => + request({ url: '/apiStats/api/redemption-history', method: 'GET', params: { apiId, ...params } }) + +// 仪表板 +export const getDashboardApi = () => request({ url: '/admin/dashboard', method: 'GET' }) +export const getTempUnavailableApi = () => + request({ url: '/admin/temp-unavailable', method: 'GET' }) +export const getUsageCostsApi = (period) => + request({ url: `/admin/usage-costs?period=${period}`, method: 'GET' }) +export const getUsageStatsApi = (url) => request({ url, method: 'GET' }) +export const getRequestDetailsApi = (params) => + request({ url: '/admin/request-details', method: 'GET', params }) +export const getRequestDetailBodyPreviewStatsApi = (config) => + request({ url: '/admin/request-details/body-preview-stats', method: 'GET', ...config }) +export const purgeRequestDetailBodyPreviewApi = (config) => + request({ url: '/admin/request-details/body-preview-purge', method: 'POST', ...config }) +export const getRequestDetailApi = (requestId) => + request({ url: `/admin/request-details/${requestId}`, method: 'GET' }) + +// 客户端 +export const getSupportedClientsApi = () => + request({ url: '/admin/supported-clients', method: 'GET' }) + +// API Keys +export const getApiKeysApi = () => request({ url: '/admin/api-keys', method: 'GET' }) +export const getApiKeysWithParamsApi = (params) => + request({ url: `/admin/api-keys?${params}`, method: 'GET' }) +export const createApiKeyApi = (data) => request({ url: '/admin/api-keys', method: 'POST', data }) +export const updateApiKeyApi = (id, data) => + request({ url: `/admin/api-keys/${id}`, method: 'PUT', data }) +export const toggleApiKeyApi = (id) => + request({ url: `/admin/api-keys/${id}/toggle`, method: 'PUT' }) +export const deleteApiKeyApi = (id) => request({ url: `/admin/api-keys/${id}`, method: 'DELETE' }) +export const getApiKeyStatsApi = (id, params) => + request({ url: `/admin/api-keys/${id}/stats`, method: 'GET', params }) +export const getApiKeyModelStatsApi = (id, params) => + request({ url: `/admin/api-keys/${id}/model-stats`, method: 'GET', params }) +export const getApiKeyTagsApi = () => request({ url: '/admin/api-keys/tags', method: 'GET' }) +export const getApiKeyTagsDetailsApi = () => + request({ url: '/admin/api-keys/tags/details', method: 'GET' }) +export const createApiKeyTagApi = (name) => + request({ url: '/admin/api-keys/tags', method: 'POST', data: { name } }) +export const deleteApiKeyTagApi = (tagName) => + request({ url: `/admin/api-keys/tags/${encodeURIComponent(tagName)}`, method: 'DELETE' }) +export const renameApiKeyTagApi = (tagName, newName) => + request({ + url: `/admin/api-keys/tags/${encodeURIComponent(tagName)}`, + method: 'PUT', + data: { newName } + }) +export const getApiKeyUsedModelsApi = () => + request({ url: '/admin/api-keys/used-models', method: 'GET' }) +export const getApiKeysBatchStatsApi = (data) => + request({ url: '/admin/api-keys/batch-stats', method: 'POST', data }) +export const getApiKeysBatchLastUsageApi = (data) => + request({ url: '/admin/api-keys/batch-last-usage', method: 'POST', data }) +export const getDeletedApiKeysApi = () => request({ url: '/admin/api-keys/deleted', method: 'GET' }) +export const getApiKeysCostSortStatusApi = () => + request({ url: '/admin/api-keys/cost-sort-status', method: 'GET' }) +export const restoreApiKeyApi = (id) => + request({ url: `/admin/api-keys/${id}/restore`, method: 'POST' }) +export const permanentDeleteApiKeyApi = (id) => + request({ url: `/admin/api-keys/${id}/permanent`, method: 'DELETE' }) +export const clearAllDeletedApiKeysApi = () => + request({ url: '/admin/api-keys/deleted/clear-all', method: 'DELETE' }) +export const batchDeleteApiKeysApi = (data) => + request({ url: '/admin/api-keys/batch', method: 'DELETE', data }) +export const updateApiKeyExpirationApi = (id, data) => + request({ url: `/admin/api-keys/${id}/expiration`, method: 'PATCH', data }) +export const batchCreateApiKeysApi = (data) => + request({ url: '/admin/api-keys/batch', method: 'POST', data }) +export const batchUpdateApiKeysApi = (data) => + request({ url: '/admin/api-keys/batch', method: 'PUT', data }) +export const getApiKeyUsageRecordsApi = (id, params) => + request({ url: `/admin/api-keys/${id}/usage-records`, method: 'GET', params }) + +// Claude 账户 +export const getClaudeAccountsApi = () => request({ url: '/admin/claude-accounts', method: 'GET' }) +export const createClaudeAccountApi = (data) => + request({ url: '/admin/claude-accounts', method: 'POST', data }) +export const updateClaudeAccountApi = (id, data) => + request({ url: `/admin/claude-accounts/${id}`, method: 'PUT', data }) +export const refreshClaudeAccountApi = (id) => + request({ url: `/admin/claude-accounts/${id}/refresh`, method: 'POST' }) +export const generateClaudeAuthUrlApi = (data) => + request({ url: '/admin/claude-accounts/generate-auth-url', method: 'POST', data }) +export const exchangeClaudeCodeApi = (data) => + request({ url: '/admin/claude-accounts/exchange-code', method: 'POST', data }) +export const generateClaudeSetupTokenUrlApi = (data) => + request({ url: '/admin/claude-accounts/generate-setup-token-url', method: 'POST', data }) +export const exchangeClaudeSetupTokenApi = (data) => + request({ url: '/admin/claude-accounts/exchange-setup-token-code', method: 'POST', data }) +export const claudeOAuthWithCookieApi = (data) => + request({ url: '/admin/claude-accounts/oauth-with-cookie', method: 'POST', data }) +export const claudeSetupTokenWithCookieApi = (data) => + request({ url: '/admin/claude-accounts/setup-token-with-cookie', method: 'POST', data }) + +// Claude Console 账户 +export const getClaudeConsoleAccountsApi = () => + request({ url: '/admin/claude-console-accounts', method: 'GET' }) +export const createClaudeConsoleAccountApi = (data) => + request({ url: '/admin/claude-console-accounts', method: 'POST', data }) +export const updateClaudeConsoleAccountApi = (id, data) => + request({ url: `/admin/claude-console-accounts/${id}`, method: 'PUT', data }) + +// Bedrock 账户 +export const getBedrockAccountsApi = () => + request({ url: '/admin/bedrock-accounts', method: 'GET' }) +export const createBedrockAccountApi = (data) => + request({ url: '/admin/bedrock-accounts', method: 'POST', data }) +export const updateBedrockAccountApi = (id, data) => + request({ url: `/admin/bedrock-accounts/${id}`, method: 'PUT', data }) + +// Gemini 账户 +export const getGeminiAccountsApi = () => request({ url: '/admin/gemini-accounts', method: 'GET' }) +export const createGeminiAccountApi = (data) => + request({ url: '/admin/gemini-accounts', method: 'POST', data }) +export const updateGeminiAccountApi = (id, data) => + request({ url: `/admin/gemini-accounts/${id}`, method: 'PUT', data }) +export const generateGeminiAuthUrlApi = (data) => + request({ url: '/admin/gemini-accounts/generate-auth-url', method: 'POST', data }) +export const exchangeGeminiCodeApi = (data) => + request({ url: '/admin/gemini-accounts/exchange-code', method: 'POST', data }) + +// Gemini API 账户 +export const getGeminiApiAccountsApi = () => + request({ url: '/admin/gemini-api-accounts', method: 'GET' }) +export const createGeminiApiAccountApi = (data) => + request({ url: '/admin/gemini-api-accounts', method: 'POST', data }) +export const updateGeminiApiAccountApi = (id, data) => + request({ url: `/admin/gemini-api-accounts/${id}`, method: 'PUT', data }) + +// OpenAI 账户 +export const getOpenAIAccountsApi = () => request({ url: '/admin/openai-accounts', method: 'GET' }) +export const createOpenAIAccountApi = (data) => + request({ url: '/admin/openai-accounts', method: 'POST', data }) +export const updateOpenAIAccountApi = (id, data) => + request({ url: `/admin/openai-accounts/${id}`, method: 'PUT', data }) +export const generateOpenAIAuthUrlApi = (data) => + request({ url: '/admin/openai-accounts/generate-auth-url', method: 'POST', data }) +export const exchangeOpenAICodeApi = (data) => + request({ url: '/admin/openai-accounts/exchange-code', method: 'POST', data }) + +// OpenAI Responses 账户 +export const getOpenAIResponsesAccountsApi = () => + request({ url: '/admin/openai-responses-accounts', method: 'GET' }) +export const createOpenAIResponsesAccountApi = (data) => + request({ url: '/admin/openai-responses-accounts', method: 'POST', data }) +export const updateOpenAIResponsesAccountApi = (id, data) => + request({ url: `/admin/openai-responses-accounts/${id}`, method: 'PUT', data }) + +// Azure OpenAI 账户 +export const getAzureOpenAIAccountsApi = () => + request({ url: '/admin/azure-openai-accounts', method: 'GET' }) +export const createAzureOpenAIAccountApi = (data) => + request({ url: '/admin/azure-openai-accounts', method: 'POST', data }) +export const updateAzureOpenAIAccountApi = (id, data) => + request({ url: `/admin/azure-openai-accounts/${id}`, method: 'PUT', data }) + +// Droid 账户 +export const getDroidAccountsApi = () => request({ url: '/admin/droid-accounts', method: 'GET' }) +export const createDroidAccountApi = (data) => + request({ url: '/admin/droid-accounts', method: 'POST', data }) +export const updateDroidAccountApi = (id, data) => + request({ url: `/admin/droid-accounts/${id}`, method: 'PUT', data }) +export const generateDroidAuthUrlApi = (data) => + request({ url: '/admin/droid-accounts/generate-auth-url', method: 'POST', data }) +export const exchangeDroidCodeApi = (data) => + request({ url: '/admin/droid-accounts/exchange-code', method: 'POST', data }) +export const getDroidAccountByIdApi = (id) => + request({ url: `/admin/droid-accounts/${id}`, method: 'GET' }) + +// CCR 账户 +export const getCcrAccountsApi = () => request({ url: '/admin/ccr-accounts', method: 'GET' }) +export const createCcrAccountApi = (data) => + request({ url: '/admin/ccr-accounts', method: 'POST', data }) +export const updateCcrAccountApi = (id, data) => + request({ url: `/admin/ccr-accounts/${id}`, method: 'PUT', data }) + +// 账户通用操作 +export const toggleAccountStatusApi = (endpoint) => request({ url: endpoint, method: 'PUT' }) +export const deleteAccountByEndpointApi = (endpoint) => request({ url: endpoint, method: 'DELETE' }) +export const testAccountByEndpointApi = (endpoint) => request({ url: endpoint, method: 'POST' }) +export const updateAccountByEndpointApi = (endpoint, data) => + request({ url: endpoint, method: 'PUT', data }) + +// 账户使用统计 +export const getClaudeAccountsUsageApi = () => + request({ url: '/admin/claude-accounts/usage', method: 'GET' }) +export const getAccountsBindingCountsApi = () => + request({ url: '/admin/accounts/binding-counts', method: 'GET' }) +export const getAccountUsageHistoryApi = (id, platform, days = 30) => + request({ + url: `/admin/accounts/${id}/usage-history?platform=${platform}&days=${days}`, + method: 'GET' + }) +export const getClaudeConsoleAccountUsageApi = (id) => + request({ url: `/admin/claude-console-accounts/${id}/usage`, method: 'GET' }) +export const getAccountUsageRecordsByIdApi = (id, params) => + request({ url: `/admin/accounts/${id}/usage-records`, method: 'GET', params }) + +// 账户组 +export const getAccountGroupsApi = () => request({ url: '/admin/account-groups', method: 'GET' }) +export const createAccountGroupApi = (data) => + request({ url: '/admin/account-groups', method: 'POST', data }) +export const updateAccountGroupApi = (id, data) => + request({ url: `/admin/account-groups/${id}`, method: 'PUT', data }) +export const deleteAccountGroupApi = (id) => + request({ url: `/admin/account-groups/${id}`, method: 'DELETE' }) +export const getAccountGroupMembersApi = (id) => + request({ url: `/admin/account-groups/${id}/members`, method: 'GET' }) + +// 用户管理(管理员) +export const getUsersApi = () => request({ url: '/admin/users', method: 'GET' }) + +// 配额卡片 +export const createQuotaCardApi = (data) => + request({ url: '/admin/quota-cards', method: 'POST', data }) +export const deleteQuotaCardApi = (id) => + request({ url: `/admin/quota-cards/${id}`, method: 'DELETE' }) +export const getQuotaCardsWithParamsApi = (params) => + request({ url: '/admin/quota-cards', method: 'GET', params }) +export const getQuotaCardsStatsApi = () => + request({ url: '/admin/quota-cards/stats', method: 'GET' }) +export const getRedemptionsApi = () => request({ url: '/admin/redemptions', method: 'GET' }) +export const revokeRedemptionApi = (id, data) => + request({ url: `/admin/redemptions/${id}/revoke`, method: 'POST', data }) +export const getQuotaCardLimitsApi = () => + request({ url: '/admin/quota-cards/limits', method: 'GET' }) +export const updateQuotaCardLimitsApi = (data) => + request({ url: '/admin/quota-cards/limits', method: 'PUT', data }) + +// 账户余额 +export const getAccountBalanceApi = (id, params) => + request({ url: `/admin/accounts/${id}/balance`, method: 'GET', params }) + +// 账户错误历史 +export const getAccountErrorHistoryApi = (accountType, accountId, params) => + request({ url: `/admin/accounts/${accountType}/${accountId}/error-history`, params }) +export const clearAccountErrorHistoryApi = (accountType, accountId) => + request({ url: `/admin/accounts/${accountType}/${accountId}/error-history`, method: 'DELETE' }) +export const refreshAccountBalanceApi = (id, data) => + request({ url: `/admin/accounts/${id}/balance/refresh`, method: 'POST', data }) +export const getBalanceSummaryApi = () => + request({ url: '/admin/accounts/balance/summary', method: 'GET' }) +export const getBalanceByPlatformApi = (platform, params) => + request({ url: `/admin/accounts/balance/platform/${platform}`, method: 'GET', params }) + +// 账户余额脚本 +export const getAccountBalanceScriptApi = (id, platform) => + request({ url: `/admin/accounts/${id}/balance/script?platform=${platform}`, method: 'GET' }) +export const updateAccountBalanceScriptApi = (id, platform, data) => + request({ url: `/admin/accounts/${id}/balance/script?platform=${platform}`, method: 'PUT', data }) +export const testAccountBalanceScriptApi = (id, platform, data) => + request({ + url: `/admin/accounts/${id}/balance/script/test?platform=${platform}`, + method: 'POST', + data + }) + +// 默认余额脚本 +export const getDefaultBalanceScriptApi = () => + request({ url: '/admin/balance-scripts/default', method: 'GET' }) +export const updateDefaultBalanceScriptApi = (data) => + request({ url: '/admin/balance-scripts/default', method: 'PUT', data }) +export const testDefaultBalanceScriptApi = (data) => + request({ url: '/admin/balance-scripts/default/test', method: 'POST', data }) + +// 前台用户管理 +export const getFrontUsersApi = (params) => request({ url: '/users', method: 'GET', params }) +export const getFrontUsersStatsOverviewApi = () => + request({ url: '/users/stats/overview', method: 'GET' }) +export const getFrontUserByIdApi = (id) => request({ url: `/users/${id}`, method: 'GET' }) +export const updateFrontUserStatusApi = (id, data) => + request({ url: `/users/${id}/status`, method: 'PATCH', data }) +export const disableFrontUserKeysApi = (id) => + request({ url: `/users/${id}/disable-keys`, method: 'POST' }) +export const getFrontUserUsageStatsApi = (id, params) => + request({ url: `/users/${id}/usage-stats`, method: 'GET', params }) +export const updateFrontUserRoleApi = (id, data) => + request({ url: `/users/${id}/role`, method: 'PATCH', data }) + +// Webhook 配置 +export const getWebhookConfigApi = (config) => + request({ url: '/admin/webhook/config', method: 'GET', ...config }) +export const updateWebhookConfigApi = (data, config) => + request({ url: '/admin/webhook/config', method: 'POST', data, ...config }) +export const createWebhookPlatformApi = (data, config) => + request({ url: '/admin/webhook/platforms', method: 'POST', data, ...config }) +export const deleteWebhookPlatformApi = (id, config) => + request({ url: `/admin/webhook/platforms/${id}`, method: 'DELETE', ...config }) +export const updateWebhookPlatformApi = (id, data, config) => + request({ url: `/admin/webhook/platforms/${id}`, method: 'PUT', data, ...config }) +export const toggleWebhookPlatformApi = (id, config) => + request({ url: `/admin/webhook/platforms/${id}/toggle`, method: 'POST', ...config }) +export const testWebhookApi = (data, config) => + request({ url: '/admin/webhook/test', method: 'POST', data, ...config }) +export const testWebhookNotificationApi = (config) => + request({ url: '/admin/webhook/test-notification', method: 'POST', ...config }) + +// Claude Relay 配置 +export const getClaudeRelayConfigApi = (config) => + request({ url: '/admin/claude-relay-config', method: 'GET', ...config }) +export const updateClaudeRelayConfigApi = (data, config) => + request({ url: '/admin/claude-relay-config', method: 'PUT', data, ...config }) + +// 服务倍率配置(管理端) +export const getAdminServiceRatesApi = (config) => + request({ url: '/admin/service-rates', method: 'GET', ...config }) +export const updateAdminServiceRatesApi = (data, config) => + request({ url: '/admin/service-rates', method: 'PUT', data, ...config }) + +// 系统 +export const checkUpdatesApi = () => request({ url: '/admin/check-updates', method: 'GET' }) +export const getClaudeCodeVersionApi = () => + request({ url: '/admin/claude-code-version', method: 'GET' }) +export const clearClaudeCodeVersionApi = () => + request({ url: '/admin/claude-code-version/clear', method: 'POST' }) diff --git a/web/admin-spa/src/utils/request.js b/web/admin-spa/src/utils/request.js new file mode 100644 index 0000000..2f88372 --- /dev/null +++ b/web/admin-spa/src/utils/request.js @@ -0,0 +1,57 @@ +import axios from 'axios' + +import { APP_CONFIG, getLoginUrl } from '@/utils/tools' + +const axiosInstance = axios.create({ + baseURL: APP_CONFIG.apiPrefix, + timeout: 30000, + headers: { 'Content-Type': 'application/json' } +}) + +axiosInstance.interceptors.request.use((config) => { + const token = localStorage.getItem('authToken') + if (token) config.headers['Authorization'] = `Bearer ${token}` + return config +}) + +axiosInstance.interceptors.response.use( + (response) => response.data, + (error) => { + if (error.response?.status === 401) { + const path = window.location.pathname + window.location.hash + // api-stats 和 user-login 是公开页面,401 是业务错误不是认证错误 + const isPublicPage = path.includes('/api-stats') || path.includes('/user-login') + if (!path.includes('/login') && !path.endsWith('/') && !isPublicPage) { + localStorage.removeItem('authToken') + window.location.href = getLoginUrl() + } + } + return Promise.reject(error) + } +) + +// 通用请求函数 - 只会 resolve,调用方无需 try-catch +const request = async (config) => { + try { + return await axiosInstance(config) + } catch (error) { + console.error('Request failed:', error) + const data = error.response?.data + // 如果后端返回了数据,直接返回(可能是 { success, message } 或 { error, message } 格式) + if (data) { + if (typeof data.success !== 'undefined') return data + // 处理 { error, message } 格式的响应 + if (data.error || data.message) return { success: false, message: data.message || data.error } + } + const status = error.response?.status + const messages = { + 401: '未授权,请重新登录', + 403: '无权限访问', + 404: '请求的资源不存在', + 500: '服务器内部错误' + } + return { success: false, message: messages[status] || error.message || '请求失败' } + } +} + +export default request diff --git a/web/admin-spa/src/utils/tools.js b/web/admin-spa/src/utils/tools.js new file mode 100644 index 0000000..6de7da5 --- /dev/null +++ b/web/admin-spa/src/utils/tools.js @@ -0,0 +1,164 @@ +// App 配置 +export const APP_CONFIG = { + basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'), + apiPrefix: import.meta.env.DEV ? '/webapi' : '' +} + +export const getAppUrl = (path = '') => { + if (path && !path.startsWith('/')) path = '/' + path + return APP_CONFIG.basePath + (path.startsWith('#') ? path : '#' + path) +} + +export const getLoginUrl = () => getAppUrl('/login') + +// Toast 通知管理 +let toastContainer = null +let toastId = 0 + +export const showToast = (message, type = 'info', title = '', duration = 3000) => { + // 创建容器 + if (!toastContainer) { + toastContainer = document.createElement('div') + toastContainer.id = 'toast-container' + toastContainer.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 10000;' + document.body.appendChild(toastContainer) + } + + const id = ++toastId + const toast = document.createElement('div') + toast.className = `toast rounded-2xl p-4 shadow-2xl backdrop-blur-sm toast-${type}` + toast.style.cssText = ` + position: relative; + min-width: 320px; + max-width: 500px; + margin-bottom: 16px; + transform: translateX(100%); + transition: transform 0.3s ease-in-out; + ` + + const iconMap = { + success: 'fas fa-check-circle', + error: 'fas fa-times-circle', + warning: 'fas fa-exclamation-triangle', + info: 'fas fa-info-circle' + } + + toast.innerHTML = ` +

    +
    + +
    +
    + ${title ? `

    ${title}

    ` : ''} +

    ${message.replace(/\n/g, '
    ')}

    +
    + +
    + ` + + toastContainer.appendChild(toast) + setTimeout(() => (toast.style.transform = 'translateX(0)'), 10) + + if (duration > 0) { + setTimeout(() => { + toast.style.transform = 'translateX(100%)' + setTimeout(() => toast.remove(), 300) + }, duration) + } + + return id +} + +// 复制文本到剪贴板 +export const copyText = async (text, successMsg = '已复制') => { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text) + } else { + const textarea = document.createElement('textarea') + textarea.value = text + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + } + showToast(successMsg, 'success') + return true + } catch (error) { + console.error('Failed to copy:', error) + showToast('复制失败', 'error') + return false + } +} + +// 数字格式化 +export const formatNumber = (num) => { + if (num === null || num === undefined) return '0' + const absNum = Math.abs(num) + if (absNum >= 1e9) return (num / 1e9).toFixed(2) + 'B' + if (absNum >= 1e6) return (num / 1e6).toFixed(2) + 'M' + if (absNum >= 1e3) return (num / 1e3).toFixed(1) + 'K' + return num.toLocaleString() +} + +// 日期格式化 +export const formatDate = (date, format = 'YYYY-MM-DD HH:mm:ss') => { + if (!date) return '' + const d = new Date(date) + const pad = (n) => String(n).padStart(2, '0') + return format + .replace('YYYY', d.getFullYear()) + .replace('MM', pad(d.getMonth() + 1)) + .replace('DD', pad(d.getDate())) + .replace('HH', pad(d.getHours())) + .replace('mm', pad(d.getMinutes())) + .replace('ss', pad(d.getSeconds())) +} + +// 相对时间格式化 +export const formatRelativeTime = (date) => { + if (!date) return '' + const d = new Date(date) + const diffMs = new Date() - d + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMins / 60) + const diffDays = Math.floor(diffHours / 24) + if (diffDays >= 7) return d.toLocaleDateString('zh-CN') + if (diffDays > 0) return `${diffDays}天前` + if (diffHours > 0) return `${diffHours}小时前` + if (diffMins > 0) return `${diffMins}分钟前` + return '刚刚' +} + +// 字节格式化 +export const formatBytes = (bytes, decimals = 2) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals < 0 ? 0 : decimals)) + ' ' + sizes[i] +} + +// 日期时间格式化 (简化版) +export const formatDateTime = (date) => { + if (!date) return '' + return new Date(date).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) +} + +// 金额格式化 +export const formatCost = (value) => { + const num = Number(value || 0) + if (num === 0) return '$0.00' + if (num < 0.01) return `$${num.toFixed(6)}` + return `$${num.toFixed(2)}` +} diff --git a/web/admin-spa/src/utils/useChartConfig.js b/web/admin-spa/src/utils/useChartConfig.js new file mode 100644 index 0000000..c12ba39 --- /dev/null +++ b/web/admin-spa/src/utils/useChartConfig.js @@ -0,0 +1,120 @@ +import { Chart } from 'chart.js/auto' +import { useThemeStore } from '@/stores/theme' + +export function useChartConfig() { + const themeStore = useThemeStore() + + // 设置Chart.js默认配置 + Chart.defaults.font.family = + "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif" + Chart.defaults.color = '#6b7280' + Chart.defaults.plugins.tooltip.backgroundColor = 'rgba(0, 0, 0, 0.8)' + Chart.defaults.plugins.tooltip.padding = 12 + Chart.defaults.plugins.tooltip.cornerRadius = 8 + Chart.defaults.plugins.tooltip.titleFont.size = 14 + Chart.defaults.plugins.tooltip.bodyFont.size = 12 + + // 创建渐变色 + const getGradient = (ctx, color, opacity = 0.2) => { + const gradient = ctx.createLinearGradient(0, 0, 0, 300) + gradient.addColorStop( + 0, + `${color}${Math.round(opacity * 255) + .toString(16) + .padStart(2, '0')}` + ) + gradient.addColorStop(1, `${color}00`) + return gradient + } + + // 获取当前主题的颜色方案 + const getThemeColors = () => { + const scheme = themeStore.currentColorScheme + return [scheme.primary, scheme.secondary, scheme.accent, '#4facfe', '#00f2fe'] + } + + // 通用图表选项 + const commonOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'top', + labels: { + usePointStyle: true, + padding: 20, + font: { + size: 12, + weight: '500' + } + } + }, + tooltip: { + mode: 'index', + intersect: false, + callbacks: { + label: function (context) { + let label = context.dataset.label || '' + if (label) { + label += ': ' + } + if (context.parsed.y !== null) { + label += new Intl.NumberFormat('zh-CN').format(context.parsed.y) + } + return label + } + } + } + }, + scales: { + x: { + grid: { + display: false + }, + ticks: { + font: { + size: 11 + } + } + }, + y: { + grid: { + color: 'rgba(0, 0, 0, 0.05)', + drawBorder: false + }, + ticks: { + font: { + size: 11 + }, + callback: function (value) { + if (value >= 1000000) { + return (value / 1000000).toFixed(1) + 'M' + } else if (value >= 1000) { + return (value / 1000).toFixed(1) + 'K' + } + return value + } + } + } + } + } + + // 颜色方案 - 动态获取主题色 + const colorSchemes = { + get primary() { + const scheme = themeStore.currentColorScheme + return [scheme.primary, scheme.secondary, scheme.accent, '#4facfe', '#00f2fe'] + }, + success: ['#10b981', '#059669', '#34d399', '#6ee7b7', '#a7f3d0'], + warning: ['#f59e0b', '#d97706', '#fbbf24', '#fcd34d', '#fde68a'], + danger: ['#ef4444', '#dc2626', '#f87171', '#fca5a5', '#fecaca'] + } + + return { + getGradient, + getThemeColors, + commonOptions, + colorSchemes + } +} diff --git a/web/admin-spa/src/utils/useTestState.js b/web/admin-spa/src/utils/useTestState.js new file mode 100644 index 0000000..01d087a --- /dev/null +++ b/web/admin-spa/src/utils/useTestState.js @@ -0,0 +1,208 @@ +import { ref, computed, onUnmounted } from 'vue' + +export const useTestState = () => { + // ========== 状态 ========== + const testStatus = ref('idle') // idle, testing, success, error + const responseText = ref('') + const errorMessage = ref('') + const testDuration = ref(0) + const testStartTime = ref(null) + const abortController = ref(null) + + // ========== 状态样式计算属性 ========== + const statusStyleMap = { + idle: { + title: '准备就绪', + card: 'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50', + iconBg: 'bg-gray-200 dark:bg-gray-700', + icon: 'fa-hourglass-start', + iconColor: 'text-gray-500 dark:text-gray-400', + text: 'text-gray-700 dark:text-gray-300' + }, + testing: { + title: '正在测试...', + card: 'border-blue-200 bg-blue-50 dark:border-blue-500/30 dark:bg-blue-900/20', + iconBg: 'bg-blue-100 dark:bg-blue-500/30', + icon: 'fa-spinner fa-spin', + iconColor: 'text-blue-500 dark:text-blue-400', + text: 'text-blue-700 dark:text-blue-300' + }, + success: { + title: '测试成功', + card: 'border-green-200 bg-green-50 dark:border-green-500/30 dark:bg-green-900/20', + iconBg: 'bg-green-100 dark:bg-green-500/30', + icon: 'fa-check-circle', + iconColor: 'text-green-500 dark:text-green-400', + text: 'text-green-700 dark:text-green-300' + }, + error: { + title: '测试失败', + card: 'border-red-200 bg-red-50 dark:border-red-500/30 dark:bg-red-900/20', + iconBg: 'bg-red-100 dark:bg-red-500/30', + icon: 'fa-exclamation-circle', + iconColor: 'text-red-500 dark:text-red-400', + text: 'text-red-700 dark:text-red-300' + } + } + + const currentStyle = computed(() => statusStyleMap[testStatus.value] || statusStyleMap.idle) + const statusTitle = computed(() => currentStyle.value.title) + const statusCardClass = computed(() => currentStyle.value.card) + const statusIconBgClass = computed(() => currentStyle.value.iconBg) + const statusIcon = computed(() => currentStyle.value.icon) + const statusIconClass = computed(() => currentStyle.value.iconColor) + const statusTextClass = computed(() => currentStyle.value.text) + + // ========== SSE 事件处理 ========== + const handleSSEEvent = (data) => { + switch (data.type) { + case 'test_start': + break + case 'content': + responseText.value += data.text + break + case 'message_stop': + break + case 'test_complete': + testDuration.value = Date.now() - testStartTime.value + if (data.success) { + testStatus.value = 'success' + } else { + testStatus.value = 'error' + errorMessage.value = data.error || '测试失败' + } + break + case 'error': + testStatus.value = 'error' + errorMessage.value = data.error || '未知错误' + testDuration.value = Date.now() - testStartTime.value + break + } + } + + // ========== SSE 流读取 ========== + const readSSEStream = async (response) => { + const reader = response.body.getReader() + const decoder = new TextDecoder() + let streamDone = false + let buffer = '' + + while (!streamDone) { + const { done, value } = await reader.read() + if (done) { + streamDone = true + // 处理缓冲区中剩余的数据 + if (buffer.trim()) { + processSSELine(buffer) + } + continue + } + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + // 最后一行可能不完整,保留在缓冲区 + buffer = lines.pop() || '' + + for (const line of lines) { + processSSELine(line) + } + } + } + + const processSSELine = (line) => { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.substring(6)) + handleSSEEvent(data) + } catch { + // 忽略解析错误 + } + } + } + + // ========== 通用测试请求 ========== + const sendTestRequest = async (endpoint, payload, options = {}) => { + const { useSSE = true, headers = {} } = options + + // 重置状态 + testStatus.value = 'testing' + responseText.value = '' + errorMessage.value = '' + testDuration.value = 0 + testStartTime.value = Date.now() + + // 取消之前的请求 + if (abortController.value) { + abortController.value.abort() + } + abortController.value = new AbortController() + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(payload), + signal: abortController.value.signal + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.message || errorData.error || `HTTP ${response.status}`) + } + + if (useSSE) { + await readSSEStream(response) + } else { + // JSON 响应 + const data = await response.json() + testDuration.value = Date.now() - testStartTime.value + if (data.success) { + testStatus.value = 'success' + responseText.value = data.data?.responseText || 'Test passed' + } else { + testStatus.value = 'error' + errorMessage.value = data.message || 'Test failed' + } + } + } catch (err) { + if (err.name === 'AbortError') return + testStatus.value = 'error' + errorMessage.value = err.message || '连接失败' + testDuration.value = Date.now() - testStartTime.value + } + } + + // ========== 重置 + 清理 ========== + const resetState = () => { + testStatus.value = 'idle' + responseText.value = '' + errorMessage.value = '' + testDuration.value = 0 + testStartTime.value = null + } + + const cleanup = () => { + if (abortController.value) { + abortController.value.abort() + abortController.value = null + } + } + + onUnmounted(cleanup) + + return { + testStatus, + responseText, + errorMessage, + testDuration, + statusTitle, + statusCardClass, + statusIconBgClass, + statusIcon, + statusIconClass, + statusTextClass, + sendTestRequest, + resetState, + cleanup + } +} diff --git a/web/admin-spa/src/utils/useTutorialUrls.js b/web/admin-spa/src/utils/useTutorialUrls.js new file mode 100644 index 0000000..a0f7d09 --- /dev/null +++ b/web/admin-spa/src/utils/useTutorialUrls.js @@ -0,0 +1,52 @@ +import { computed } from 'vue' + +export function useTutorialUrls() { + const getBaseUrlPrefix = () => { + const customPrefix = import.meta.env.VITE_API_BASE_PREFIX + if (customPrefix) { + return customPrefix.replace(/\/$/, '') + } + + let origin = '' + if (window.location.origin) { + origin = window.location.origin + } else { + const protocol = window.location.protocol + const hostname = window.location.hostname + const port = window.location.port + origin = protocol + '//' + hostname + if ( + port && + ((protocol === 'http:' && port !== '80') || (protocol === 'https:' && port !== '443')) + ) { + origin += ':' + port + } + } + + if (!origin) { + const currentUrl = window.location.href + const pathStart = currentUrl.indexOf('/', 8) + if (pathStart !== -1) { + origin = currentUrl.substring(0, pathStart) + } else { + return '' + } + } + + return origin + } + + const currentBaseUrl = computed(() => getBaseUrlPrefix() + '/api') + const geminiBaseUrl = computed(() => getBaseUrlPrefix() + '/gemini') + const openaiBaseUrl = computed(() => getBaseUrlPrefix() + '/openai') + const droidClaudeBaseUrl = computed(() => getBaseUrlPrefix() + '/droid/claude') + const droidOpenaiBaseUrl = computed(() => getBaseUrlPrefix() + '/droid/openai') + + return { + currentBaseUrl, + geminiBaseUrl, + openaiBaseUrl, + droidClaudeBaseUrl, + droidOpenaiBaseUrl + } +} diff --git a/web/admin-spa/src/views/AccountUsageRecordsView.vue b/web/admin-spa/src/views/AccountUsageRecordsView.vue new file mode 100644 index 0000000..6835b0f --- /dev/null +++ b/web/admin-spa/src/views/AccountUsageRecordsView.vue @@ -0,0 +1,567 @@ + + + diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue new file mode 100644 index 0000000..e8247d5 --- /dev/null +++ b/web/admin-spa/src/views/AccountsView.vue @@ -0,0 +1,5425 @@ + + + + + diff --git a/web/admin-spa/src/views/ApiKeyUsageRecordsView.vue b/web/admin-spa/src/views/ApiKeyUsageRecordsView.vue new file mode 100644 index 0000000..9f7fd3e --- /dev/null +++ b/web/admin-spa/src/views/ApiKeyUsageRecordsView.vue @@ -0,0 +1,542 @@ + + + diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue new file mode 100644 index 0000000..32b500a --- /dev/null +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -0,0 +1,5030 @@ + + + + + diff --git a/web/admin-spa/src/views/ApiStatsView.vue b/web/admin-spa/src/views/ApiStatsView.vue new file mode 100644 index 0000000..a44b027 --- /dev/null +++ b/web/admin-spa/src/views/ApiStatsView.vue @@ -0,0 +1,1240 @@ + + + + + diff --git a/web/admin-spa/src/views/BalanceScriptsView.vue b/web/admin-spa/src/views/BalanceScriptsView.vue new file mode 100644 index 0000000..fac5c69 --- /dev/null +++ b/web/admin-spa/src/views/BalanceScriptsView.vue @@ -0,0 +1,302 @@ + + + + + diff --git a/web/admin-spa/src/views/DashboardView.vue b/web/admin-spa/src/views/DashboardView.vue new file mode 100644 index 0000000..39caf0c --- /dev/null +++ b/web/admin-spa/src/views/DashboardView.vue @@ -0,0 +1,1820 @@ + + + + + diff --git a/web/admin-spa/src/views/LoginView.vue b/web/admin-spa/src/views/LoginView.vue new file mode 100644 index 0000000..3aeb08e --- /dev/null +++ b/web/admin-spa/src/views/LoginView.vue @@ -0,0 +1,123 @@ + + + diff --git a/web/admin-spa/src/views/QuotaCardsView.vue b/web/admin-spa/src/views/QuotaCardsView.vue new file mode 100644 index 0000000..d9eb5bd --- /dev/null +++ b/web/admin-spa/src/views/QuotaCardsView.vue @@ -0,0 +1,1071 @@ + + + diff --git a/web/admin-spa/src/views/RequestDetailsView.vue b/web/admin-spa/src/views/RequestDetailsView.vue new file mode 100644 index 0000000..622ef69 --- /dev/null +++ b/web/admin-spa/src/views/RequestDetailsView.vue @@ -0,0 +1,1294 @@ + + + + + diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue new file mode 100644 index 0000000..3dbcceb --- /dev/null +++ b/web/admin-spa/src/views/SettingsView.vue @@ -0,0 +1,3266 @@ + + + + + diff --git a/web/admin-spa/src/views/TutorialView.vue b/web/admin-spa/src/views/TutorialView.vue new file mode 100644 index 0000000..d6f462a --- /dev/null +++ b/web/admin-spa/src/views/TutorialView.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/web/admin-spa/src/views/UserDashboardView.vue b/web/admin-spa/src/views/UserDashboardView.vue new file mode 100644 index 0000000..55bfa17 --- /dev/null +++ b/web/admin-spa/src/views/UserDashboardView.vue @@ -0,0 +1,412 @@ + + + diff --git a/web/admin-spa/src/views/UserLoginView.vue b/web/admin-spa/src/views/UserLoginView.vue new file mode 100644 index 0000000..4c9d506 --- /dev/null +++ b/web/admin-spa/src/views/UserLoginView.vue @@ -0,0 +1,197 @@ + + + diff --git a/web/admin-spa/src/views/UserManagementView.vue b/web/admin-spa/src/views/UserManagementView.vue new file mode 100644 index 0000000..72cc90d --- /dev/null +++ b/web/admin-spa/src/views/UserManagementView.vue @@ -0,0 +1,652 @@ + + + diff --git a/web/admin-spa/tailwind.config.js b/web/admin-spa/tailwind.config.js new file mode 100644 index 0000000..64172a2 --- /dev/null +++ b/web/admin-spa/tailwind.config.js @@ -0,0 +1,62 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], + darkMode: 'class', + theme: { + extend: { + colors: { + // 主题色 - 使用 CSS 变量 + primary: { + DEFAULT: 'var(--primary-color)', + rgb: 'rgb(var(--primary-rgb))' + }, + secondary: { + DEFAULT: 'var(--secondary-color)', + rgb: 'rgb(var(--secondary-rgb))' + }, + accent: { + DEFAULT: 'var(--accent-color)', + rgb: 'rgb(var(--accent-rgb))' + }, + // 表面颜色 + surface: 'var(--surface-color)', + 'glass-strong': 'var(--glass-strong-color)', + glass: 'var(--glass-color)' + }, + backgroundColor: { + 'theme-surface': 'var(--surface-color)', + 'theme-glass': 'var(--glass-strong-color)' + }, + borderColor: { + 'theme-border': 'var(--border-color)' + }, + animation: { + gradient: 'gradient 8s ease infinite', + float: 'float 6s ease-in-out infinite', + 'float-delayed': 'float 6s ease-in-out infinite 2s', + 'pulse-glow': 'pulse-glow 2s ease-in-out infinite' + }, + keyframes: { + gradient: { + '0%, 100%': { + 'background-size': '200% 200%', + 'background-position': 'left center' + }, + '50%': { + 'background-size': '200% 200%', + 'background-position': 'right center' + } + }, + float: { + '0%, 100%': { transform: 'translateY(0px)' }, + '50%': { transform: 'translateY(-10px)' } + }, + 'pulse-glow': { + '0%, 100%': { opacity: 1 }, + '50%': { opacity: 0.8 } + } + } + } + }, + plugins: [] +} diff --git a/web/admin-spa/vite.config.js b/web/admin-spa/vite.config.js new file mode 100644 index 0000000..7b67743 --- /dev/null +++ b/web/admin-spa/vite.config.js @@ -0,0 +1,127 @@ +import { defineConfig, loadEnv } from 'vite' +import vue from '@vitejs/plugin-vue' +import checker from 'vite-plugin-checker' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig(({ mode }) => { + // 加载环境变量 + const env = loadEnv(mode, process.cwd(), '') + const apiTarget = env.VITE_API_TARGET || 'http://localhost:3000' + const httpProxy = env.VITE_HTTP_PROXY || env.HTTP_PROXY || env.http_proxy + // 使用环境变量配置基础路径,如果未设置则使用默认值 + const basePath = env.VITE_APP_BASE_URL || (mode === 'development' ? '/admin/' : '/admin-next/') + + // 创建代理配置 + const proxyConfig = { + target: apiTarget, + changeOrigin: true, + secure: false + } + + // 如果设置了代理,动态导入并配置 agent(仅在开发模式下) + if (httpProxy && mode === 'development') { + console.log(`Using HTTP proxy: ${httpProxy}`) + // Vite 的 proxy 使用 http-proxy,它支持通过环境变量自动使用代理 + // 设置环境变量让 http-proxy 使用代理 + process.env.HTTP_PROXY = httpProxy + process.env.HTTPS_PROXY = httpProxy + } + + console.log( + `${mode === 'development' ? 'Starting dev server' : 'Building'} with base path: ${basePath}` + ) + + return { + base: basePath, + plugins: [ + vue(), + checker({ + eslint: { + lintCommand: 'eslint "./src/**/*.{js,vue}" --cache=false', + dev: { + logLevel: ['error', 'warning'] + } + } + }), + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: ['vue', 'vue-router', 'pinia'] + }), + Components({ + resolvers: [ElementPlusResolver()] + }) + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + port: 3001, + host: true, + open: true, + proxy: { + // 统一的 API 代理规则 - 开发环境所有 API 请求都加 /webapi 前缀 + '/webapi': { + ...proxyConfig, + rewrite: (path) => path.replace(/^\/webapi/, ''), // 转发时去掉 /webapi 前缀 + configure: (proxy, options) => { + proxy.on('proxyReq', (proxyReq, req) => { + console.log( + 'Proxying:', + req.method, + req.url, + '->', + options.target + req.url.replace(/^\/webapi/, '') + ) + }) + proxy.on('error', (err) => { + console.log('Proxy error:', err) + }) + } + }, + // API Stats 专用代理规则 + '/apiStats': { + ...proxyConfig, + configure: (proxy, options) => { + proxy.on('proxyReq', (proxyReq, req) => { + console.log( + 'API Stats Proxying:', + req.method, + req.url, + '->', + options.target + req.url + ) + }) + } + } + } + }, + build: { + outDir: 'dist', + assetsDir: 'assets', + rollupOptions: { + output: { + manualChunks(id) { + // 将 vue 相关的库打包到一起 + if (id.includes('node_modules')) { + if (id.includes('element-plus')) { + return 'element-plus' + } + if (id.includes('chart.js')) { + return 'chart' + } + if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) { + return 'vue-vendor' + } + return 'vendor' + } + } + } + } + } + } +})