From fb44c8bf0327e53824b7357cd2018af847396aab Mon Sep 17 00:00:00 2001 From: nosqli Date: Thu, 21 Aug 2025 23:33:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AE=8C=E6=95=B4=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 包含Go API项目的所有源代码、配置文件、Docker配置、文档和前端资源 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .claude/settings.local.json | 15 + .dockerignore | 7 + .env.example | 75 + .github/FUNDING.yml | 12 + .github/ISSUE_TEMPLATE/bug_report.md | 26 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.md | 21 + .../pull_request_template.md | 19 + .github/workflows/docker-image-alpha.yml | 62 + .github/workflows/docker-image-arm64.yml | 56 + .github/workflows/linux-release.yml | 59 + .github/workflows/macos-release.yml | 51 + .github/workflows/pr-target-branch-check.yml | 21 + .github/workflows/windows-release.yml | 53 + .gitignore | 13 + Dockerfile | 35 + LICENSE | 201 ++ README.en.md | 216 ++ VERSION | 0 bin/migration_v0.2-v0.3.sql | 6 + bin/migration_v0.3-v0.4.sql | 17 + bin/time_test.sh | 40 + common/api_type.go | 73 + common/constants.go | 201 ++ common/crypto.go | 31 + common/custom-event.go | 82 + common/database.go | 15 + common/email-outlook-auth.go | 40 + common/email.go | 90 + common/embed-file-system.go | 32 + common/endpoint_type.go | 41 + common/env.go | 38 + common/gin.go | 111 + common/go-channel.go | 53 + common/gopool.go | 24 + common/hash.go | 34 + common/http.go | 57 + common/init.go | 120 + common/json.go | 22 + common/limiter/limiter.go | 89 + common/limiter/lua/rate_limit.lua | 44 + common/logger.go | 123 + common/model.go | 42 + common/page_info.go | 82 + common/pprof.go | 44 + common/rate-limit.go | 70 + common/redis.go | 327 +++ common/str.go | 97 + common/topup-ratio.go | 33 + common/utils.go | 304 +++ common/validate.go | 9 + common/verification.go | 77 + constant/README.md | 26 + constant/api_type.go | 35 + constant/azure.go | 5 + constant/cache_key.go | 14 + constant/channel.go | 109 + constant/context_key.go | 44 + constant/endpoint_type.go | 16 + constant/env.go | 15 + constant/finish_reason.go | 9 + constant/midjourney.go | 48 + constant/multi_key_mode.go | 8 + constant/setup.go | 3 + constant/task.go | 23 + controller/billing.go | 92 + controller/channel-billing.go | 492 ++++ controller/channel-test.go | 464 ++++ controller/channel.go | 916 +++++++ controller/console_migrate.go | 103 + controller/github.go | 239 ++ controller/group.go | 50 + controller/image.go | 9 + controller/linuxdo.go | 259 ++ controller/log.go | 168 ++ controller/midjourney.go | 263 ++ controller/misc.go | 302 +++ controller/model.go | 216 ++ controller/oidc.go | 228 ++ controller/option.go | 171 ++ controller/playground.go | 84 + controller/pricing.go | 71 + controller/ratio_config.go | 24 + controller/ratio_sync.go | 474 ++++ controller/redemption.go | 193 ++ controller/relay.go | 476 ++++ controller/setup.go | 181 ++ controller/swag_video.go | 116 + controller/task.go | 273 ++ controller/task_video.go | 138 + controller/telegram.go | 124 + controller/token.go | 236 ++ controller/topup.go | 265 ++ controller/topup_stripe.go | 275 ++ controller/uptime_kuma.go | 154 ++ controller/usedata.go | 52 + controller/user.go | 956 +++++++ controller/wechat.go | 168 ++ docker-compose.yml | 52 + docs/api/api_auth.md | 53 + docs/api/web_api.md | 197 ++ docs/channel/other_setting.md | 33 + docs/images/aliyun.png | Bin 0 -> 5102 bytes docs/images/cherry-studio.png | Bin 0 -> 11339 bytes docs/images/io-net.png | Bin 0 -> 2016 bytes docs/images/pku.png | Bin 0 -> 12247 bytes docs/images/ucloud.png | Bin 0 -> 11630 bytes docs/installation/BT.md | 3 + docs/models/Midjourney.md | 82 + docs/models/Rerank.md | 62 + docs/models/Suno.md | 44 + dto/audio.go | 34 + dto/channel_settings.go | 7 + dto/claude.go | 337 +++ dto/dalle.go | 29 + dto/embedding.go | 57 + dto/error.go | 57 + dto/file_data.go | 8 + dto/midjourney.go | 107 + dto/notify.go | 25 + dto/openai_request.go | 655 +++++ dto/openai_response.go | 278 +++ dto/playground.go | 6 + dto/pricing.go | 11 + dto/ratio_sync.go | 38 + dto/realtime.go | 88 + dto/rerank.go | 33 + dto/sensitive.go | 6 + dto/suno.go | 129 + dto/task.go | 10 + dto/user_settings.go | 16 + dto/video.go | 47 + go.mod | 98 + go.sum | 293 +++ i18n/zh-cn.json | 1041 ++++++++ main.go | 210 ++ makefile | 14 + middleware/auth.go | 286 +++ middleware/cache.go | 16 + middleware/cors.go | 15 + middleware/distributor.go | 331 +++ middleware/gzip.go | 38 + middleware/kling_adapter.go | 47 + middleware/logger.go | 25 + middleware/model-rate-limit.go | 199 ++ middleware/rate-limit.go | 113 + middleware/recover.go | 28 + middleware/request-id.go | 18 + middleware/stats.go | 41 + middleware/turnstile-check.go | 80 + middleware/utils.go | 29 + model/ability.go | 320 +++ model/channel.go | 909 +++++++ model/channel_cache.go | 262 ++ model/log.go | 411 +++ model/main.go | 363 +++ model/midjourney.go | 207 ++ model/option.go | 442 ++++ model/pricing.go | 127 + model/redemption.go | 195 ++ model/setup.go | 16 + model/task.go | 365 +++ model/token.go | 363 +++ model/token_cache.go | 64 + model/topup.go | 100 + model/usedata.go | 133 + model/user.go | 830 +++++++ model/user_cache.go | 218 ++ model/utils.go | 111 + one-api.service | 18 + relay/audio_handler.go | 134 + relay/channel/adapter.go | 50 + relay/channel/ai360/constants.go | 14 + relay/channel/ali/adaptor.go | 127 + relay/channel/ali/constants.go | 14 + relay/channel/ali/dto.go | 126 + relay/channel/ali/image.go | 171 ++ relay/channel/ali/rerank.go | 74 + relay/channel/ali/text.go | 206 ++ relay/channel/api_request.go | 277 +++ relay/channel/aws/adaptor.go | 107 + relay/channel/aws/constants.go | 65 + relay/channel/aws/dto.go | 36 + relay/channel/aws/relay-aws.go | 196 ++ relay/channel/baidu/adaptor.go | 164 ++ relay/channel/baidu/constants.go | 22 + relay/channel/baidu/dto.go | 78 + relay/channel/baidu/relay-baidu.go | 245 ++ relay/channel/baidu_v2/adaptor.go | 111 + relay/channel/baidu_v2/constants.go | 29 + relay/channel/claude/adaptor.go | 113 + relay/channel/claude/constants.go | 22 + relay/channel/claude/dto.go | 95 + relay/channel/claude/relay-claude.go | 813 ++++++ relay/channel/cloudflare/adaptor.go | 122 + relay/channel/cloudflare/constant.go | 39 + relay/channel/cloudflare/dto.go | 21 + relay/channel/cloudflare/relay_cloudflare.go | 150 ++ relay/channel/cohere/adaptor.go | 94 + relay/channel/cohere/constant.go | 12 + relay/channel/cohere/dto.go | 60 + relay/channel/cohere/relay-cohere.go | 248 ++ relay/channel/coze/adaptor.go | 133 + relay/channel/coze/constants.go | 30 + relay/channel/coze/dto.go | 78 + relay/channel/coze/relay-coze.go | 296 +++ relay/channel/deepseek/adaptor.go | 100 + relay/channel/deepseek/constants.go | 7 + relay/channel/dify/adaptor.go | 115 + relay/channel/dify/constants.go | 5 + relay/channel/dify/dto.go | 45 + relay/channel/dify/relay-dify.go | 289 +++ relay/channel/gemini/adaptor.go | 271 ++ relay/channel/gemini/constant.go | 37 + relay/channel/gemini/dto.go | 222 ++ relay/channel/gemini/relay-gemini-native.go | 138 + relay/channel/gemini/relay-gemini.go | 958 +++++++ relay/channel/jimeng/adaptor.go | 136 + relay/channel/jimeng/constants.go | 9 + relay/channel/jimeng/image.go | 89 + relay/channel/jimeng/sign.go | 176 ++ relay/channel/jina/adaptor.go | 92 + relay/channel/jina/constant.go | 9 + relay/channel/jina/relay-jina.go | 1 + relay/channel/lingyiwanwu/constrants.go | 9 + relay/channel/minimax/constants.go | 13 + relay/channel/minimax/relay-minimax.go | 10 + relay/channel/mistral/adaptor.go | 88 + relay/channel/mistral/constants.go | 12 + relay/channel/mistral/text.go | 78 + relay/channel/mokaai/adaptor.go | 106 + relay/channel/mokaai/constants.go | 9 + relay/channel/mokaai/relay-mokaai.go | 82 + relay/channel/moonshot/constants.go | 9 + relay/channel/ollama/adaptor.go | 97 + relay/channel/ollama/constants.go | 7 + relay/channel/ollama/dto.go | 45 + relay/channel/ollama/relay-ollama.go | 132 + relay/channel/openai/adaptor.go | 491 ++++ relay/channel/openai/constant.go | 35 + relay/channel/openai/helper.go | 196 ++ relay/channel/openai/relay-openai.go | 587 +++++ relay/channel/openai/relay_responses.go | 97 + relay/channel/openrouter/constant.go | 5 + relay/channel/openrouter/dto.go | 9 + relay/channel/palm/adaptor.go | 91 + relay/channel/palm/constants.go | 7 + relay/channel/palm/dto.go | 38 + relay/channel/palm/relay-palm.go | 162 ++ relay/channel/perplexity/adaptor.go | 92 + relay/channel/perplexity/constants.go | 7 + relay/channel/perplexity/relay-perplexity.go | 21 + relay/channel/siliconflow/adaptor.go | 104 + relay/channel/siliconflow/constant.go | 51 + relay/channel/siliconflow/dto.go | 17 + .../channel/siliconflow/relay-siliconflow.go | 44 + relay/channel/task/jimeng/adaptor.go | 380 +++ relay/channel/task/kling/adaptor.go | 346 +++ relay/channel/task/suno/adaptor.go | 176 ++ relay/channel/task/suno/models.go | 7 + relay/channel/tencent/adaptor.go | 113 + relay/channel/tencent/constants.go | 10 + relay/channel/tencent/dto.go | 75 + relay/channel/tencent/relay-tencent.go | 233 ++ relay/channel/vertex/adaptor.go | 262 ++ relay/channel/vertex/constants.go | 15 + relay/channel/vertex/dto.go | 37 + relay/channel/vertex/relay-vertex.go | 19 + relay/channel/vertex/service_account.go | 134 + relay/channel/volcengine/adaptor.go | 251 ++ relay/channel/volcengine/constants.go | 13 + relay/channel/xai/adaptor.go | 128 + relay/channel/xai/constants.go | 20 + relay/channel/xai/dto.go | 27 + relay/channel/xai/text.go | 107 + relay/channel/xinference/constant.go | 8 + relay/channel/xinference/dto.go | 11 + relay/channel/xunfei/adaptor.go | 99 + relay/channel/xunfei/constants.go | 12 + relay/channel/xunfei/dto.go | 59 + relay/channel/xunfei/relay-xunfei.go | 287 +++ relay/channel/zhipu/adaptor.go | 96 + relay/channel/zhipu/constants.go | 7 + relay/channel/zhipu/dto.go | 46 + relay/channel/zhipu/relay-zhipu.go | 245 ++ relay/channel/zhipu_4v/adaptor.go | 99 + relay/channel/zhipu_4v/constants.go | 7 + relay/channel/zhipu_4v/dto.go | 59 + relay/channel/zhipu_4v/relay-zhipu_v4.go | 113 + relay/claude_handler.go | 162 ++ relay/common/relay_info.go | 344 +++ relay/common/relay_utils.go | 34 + relay/common_handler/rerank.go | 73 + relay/constant/relay_mode.go | 167 ++ relay/embedding_handler.go | 116 + relay/gemini_handler.go | 234 ++ relay/helper/common.go | 167 ++ relay/helper/model_mapped.go | 92 + relay/helper/price.go | 161 ++ relay/helper/stream_scanner.go | 259 ++ relay/image_handler.go | 247 ++ relay/relay-mj.go | 668 +++++ relay/relay-text.go | 571 +++++ relay/relay_adaptor.go | 115 + relay/relay_task.go | 289 +++ relay/rerank_handler.go | 110 + relay/responses_handler.go | 169 ++ relay/websocket.go | 76 + router/api-router.go | 179 ++ router/dashboard.go | 22 + router/main.go | 31 + router/relay-router.go | 115 + router/video-router.go | 24 + router/web-router.go | 28 + service/audio.go | 48 + service/cf_worker.go | 57 + service/channel.go | 101 + service/convert.go | 440 ++++ service/epay.go | 12 + service/error.go | 155 ++ service/file_decoder.go | 135 + service/http_client.go | 81 + service/image.go | 172 ++ service/log_info_generate.go | 83 + service/midjourney.go | 258 ++ service/notify-limit.go | 117 + service/quota.go | 510 ++++ service/sensitive.go | 94 + service/str.go | 101 + service/task.go | 10 + service/token_counter.go | 474 ++++ service/usage_helpr.go | 30 + service/user_notify.go | 66 + service/webhook.go | 118 + setting/auto_group.go | 31 + setting/chat.go | 41 + setting/config/config.go | 259 ++ setting/console_setting/config.go | 39 + setting/console_setting/validation.go | 304 +++ setting/midjourney.go | 7 + setting/model_setting/claude.go | 65 + setting/model_setting/gemini.go | 70 + setting/model_setting/global.go | 26 + setting/operation_setting/general_setting.go | 25 + .../operation_setting/operation_setting.go | 32 + setting/operation_setting/tools.go | 90 + setting/payment.go | 46 + setting/payment_stripe.go | 7 + setting/rate_limit.go | 64 + setting/ratio_setting/cache_ratio.go | 122 + setting/ratio_setting/expose_ratio.go | 17 + setting/ratio_setting/exposed_cache.go | 55 + setting/ratio_setting/group_ratio.go | 122 + setting/ratio_setting/model_ratio.go | 665 +++++ setting/sensitive.go | 43 + setting/system_setting.go | 10 + setting/system_setting/oidc.go | 25 + setting/user_usable_group.go | 76 + types/channel_error.go | 21 + types/error.go | 210 ++ types/set.go | 42 + web/.gitignore | 26 + web/.prettierrc.mjs | 1 + web/bun.lock | 2010 +++++++++++++++ web/index.html | 19 + web/package.json | 85 + web/postcss.config.js | 6 + web/public/azure_model_name.png | Bin 0 -> 256912 bytes web/public/favicon.ico | Bin 0 -> 15406 bytes web/public/logo.png | Bin 0 -> 9597 bytes web/public/ratio.png | Bin 0 -> 143438 bytes web/public/robots.txt | 3 + web/src/App.js | 297 +++ web/src/components/auth/LoginForm.js | 547 ++++ web/src/components/auth/OAuth2Callback.js | 70 + .../components/auth/PasswordResetConfirm.js | 173 ++ web/src/components/auth/PasswordResetForm.js | 149 ++ web/src/components/auth/RegisterForm.js | 564 +++++ web/src/components/common/Loading.js | 16 + web/src/components/common/logo/LinuxDoIcon.js | 37 + web/src/components/common/logo/OIDCIcon.js | 38 + web/src/components/common/logo/WeChatIcon.js | 36 + .../common/markdown/MarkdownRenderer.js | 513 ++++ .../components/common/markdown/markdown.css | 444 ++++ web/src/components/layout/Footer.js | 112 + web/src/components/layout/HeaderBar.js | 646 +++++ web/src/components/layout/NoticeModal.js | 184 ++ web/src/components/layout/PageLayout.js | 165 ++ web/src/components/layout/SetupCheck.js | 18 + web/src/components/layout/SiderBar.js | 434 ++++ web/src/components/playground/ChatArea.js | 113 + web/src/components/playground/CodeViewer.js | 313 +++ .../components/playground/ConfigManager.js | 260 ++ .../playground/CustomInputRender.js | 58 + .../playground/CustomRequestEditor.js | 190 ++ web/src/components/playground/DebugPanel.js | 193 ++ .../components/playground/FloatingButtons.js | 71 + .../components/playground/ImageUrlInput.js | 113 + .../components/playground/MessageActions.js | 121 + .../components/playground/MessageContent.js | 313 +++ .../playground/OptimizedComponents.js | 60 + .../components/playground/ParameterControl.js | 241 ++ .../components/playground/SettingsPanel.js | 234 ++ .../components/playground/ThinkingContent.js | 125 + .../components/playground/configStorage.js | 203 ++ web/src/components/playground/index.js | 20 + .../settings/ChannelSelectorModal.js | 236 ++ web/src/components/settings/ChatsSetting.js | 63 + .../components/settings/DashboardSetting.js | 148 ++ web/src/components/settings/DrawingSetting.js | 65 + web/src/components/settings/ModelSetting.js | 95 + .../components/settings/OperationSetting.js | 113 + web/src/components/settings/OtherSetting.js | 416 ++++ web/src/components/settings/PaymentSetting.js | 100 + .../components/settings/PersonalSetting.js | 1566 ++++++++++++ .../components/settings/RateLimitSetting.js | 70 + web/src/components/settings/RatioSetting.js | 122 + web/src/components/settings/SystemSetting.js | 1039 ++++++++ web/src/components/table/ChannelsTable.js | 2212 +++++++++++++++++ web/src/components/table/LogsTable.js | 1464 +++++++++++ web/src/components/table/MjLogsTable.js | 982 ++++++++ web/src/components/table/ModelPricing.js | 671 +++++ web/src/components/table/RedemptionsTable.js | 629 +++++ web/src/components/table/TaskLogsTable.js | 813 ++++++ web/src/components/table/TokensTable.js | 924 +++++++ web/src/components/table/UsersTable.js | 686 +++++ web/src/constants/channel.constants.js | 140 ++ web/src/constants/common.constant.js | 23 + web/src/constants/index.js | 5 + web/src/constants/playground.constants.js | 95 + web/src/constants/toast.constants.js | 7 + web/src/constants/user.constants.js | 19 + web/src/context/Status/index.js | 19 + web/src/context/Status/reducer.js | 20 + web/src/context/Theme/index.js | 36 + web/src/context/User/index.js | 19 + web/src/context/User/reducer.js | 21 + web/src/helpers/api.js | 253 ++ web/src/helpers/auth.js | 33 + web/src/helpers/boolean.js | 10 + web/src/helpers/data.js | 41 + web/src/helpers/history.js | 3 + web/src/helpers/index.js | 9 + web/src/helpers/log.js | 7 + web/src/helpers/render.js | 1698 +++++++++++++ web/src/helpers/token.js | 44 + web/src/helpers/utils.js | 540 ++++ web/src/hooks/useApiRequest.js | 410 +++ web/src/hooks/useDataLoader.js | 69 + web/src/hooks/useIsMobile.js | 16 + web/src/hooks/useMessageActions.js | 223 ++ web/src/hooks/useMessageEdit.js | 109 + web/src/hooks/usePlaygroundState.js | 225 ++ web/src/hooks/useSidebarCollapsed.js | 22 + web/src/hooks/useSyncMessageAndCustomBody.js | 111 + web/src/hooks/useTableCompactMode.js | 34 + web/src/hooks/useTokenKeys.js | 30 + web/src/i18n/i18n.js | 26 + web/src/i18n/locales/en.json | 1785 +++++++++++++ web/src/i18n/locales/zh.json | 13 + web/src/index.css | 618 +++++ web/src/index.js | 41 + web/src/pages/About/index.js | 139 ++ web/src/pages/Channel/EditChannel.js | 1469 +++++++++++ web/src/pages/Channel/EditTagModal.js | 444 ++++ web/src/pages/Channel/index.js | 12 + web/src/pages/Chat/index.js | 60 + web/src/pages/Chat2Link/index.js | 26 + web/src/pages/Detail/index.js | 1700 +++++++++++++ web/src/pages/Home/index.js | 286 +++ web/src/pages/Log/index.js | 10 + web/src/pages/Midjourney/index.js | 10 + web/src/pages/NotFound/index.js | 19 + web/src/pages/Playground/index.js | 478 ++++ web/src/pages/Pricing/index.js | 10 + web/src/pages/Redemption/EditRedemption.js | 305 +++ web/src/pages/Redemption/index.js | 12 + web/src/pages/Setting/Chat/SettingsChats.js | 140 ++ .../Setting/Dashboard/SettingsAPIInfo.js | 506 ++++ .../Dashboard/SettingsAnnouncements.js | 580 +++++ .../Dashboard/SettingsDataDashboard.js | 151 ++ .../pages/Setting/Dashboard/SettingsFAQ.js | 455 ++++ .../Setting/Dashboard/SettingsUptimeKuma.js | 478 ++++ .../pages/Setting/Drawing/SettingsDrawing.js | 191 ++ .../pages/Setting/Model/SettingClaudeModel.js | 209 ++ .../pages/Setting/Model/SettingGeminiModel.js | 235 ++ .../pages/Setting/Model/SettingGlobalModel.js | 135 + .../Setting/Operation/SettingsCreditLimit.js | 161 ++ .../Setting/Operation/SettingsGeneral.js | 238 ++ .../pages/Setting/Operation/SettingsLog.js | 150 ++ .../Setting/Operation/SettingsMonitoring.js | 178 ++ .../Operation/SettingsSensitiveWords.js | 138 + .../Setting/Payment/SettingsGeneralPayment.js | 75 + .../Setting/Payment/SettingsPaymentGateway.js | 218 ++ .../Payment/SettingsPaymentGatewayStripe.js | 195 ++ .../RateLimit/SettingsRequestRateLimit.js | 204 ++ .../pages/Setting/Ratio/GroupRatioSettings.js | 228 ++ .../pages/Setting/Ratio/ModelRatioSettings.js | 235 ++ .../Setting/Ratio/ModelRationNotSetEditor.js | 607 +++++ .../Ratio/ModelSettingsVisualEditor.js | 750 ++++++ .../pages/Setting/Ratio/UpstreamRatioSync.js | 762 ++++++ web/src/pages/Setting/index.js | 174 ++ web/src/pages/Setup/index.js | 578 +++++ web/src/pages/Task/index.js | 10 + web/src/pages/Token/EditToken.js | 525 ++++ web/src/pages/Token/index.js | 12 + web/src/pages/TopUp/index.js | 1311 ++++++++++ web/src/pages/User/AddUser.js | 167 ++ web/src/pages/User/EditUser.js | 351 +++ web/src/pages/User/index.js | 12 + web/tailwind.config.js | 121 + web/vercel.json | 5 + web/vite.config.js | 79 + 513 files changed, 90387 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE/pull_request_template.md create mode 100644 .github/workflows/docker-image-alpha.yml create mode 100644 .github/workflows/docker-image-arm64.yml create mode 100644 .github/workflows/linux-release.yml create mode 100644 .github/workflows/macos-release.yml create mode 100644 .github/workflows/pr-target-branch-check.yml create mode 100644 .github/workflows/windows-release.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.en.md create mode 100644 VERSION create mode 100644 bin/migration_v0.2-v0.3.sql create mode 100644 bin/migration_v0.3-v0.4.sql create mode 100644 bin/time_test.sh create mode 100644 common/api_type.go create mode 100644 common/constants.go create mode 100644 common/crypto.go create mode 100644 common/custom-event.go create mode 100644 common/database.go create mode 100644 common/email-outlook-auth.go create mode 100644 common/email.go create mode 100644 common/embed-file-system.go create mode 100644 common/endpoint_type.go create mode 100644 common/env.go create mode 100644 common/gin.go create mode 100644 common/go-channel.go create mode 100644 common/gopool.go create mode 100644 common/hash.go create mode 100644 common/http.go create mode 100644 common/init.go create mode 100644 common/json.go create mode 100644 common/limiter/limiter.go create mode 100644 common/limiter/lua/rate_limit.lua create mode 100644 common/logger.go create mode 100644 common/model.go create mode 100644 common/page_info.go create mode 100644 common/pprof.go create mode 100644 common/rate-limit.go create mode 100644 common/redis.go create mode 100644 common/str.go create mode 100644 common/topup-ratio.go create mode 100644 common/utils.go create mode 100644 common/validate.go create mode 100644 common/verification.go create mode 100644 constant/README.md create mode 100644 constant/api_type.go create mode 100644 constant/azure.go create mode 100644 constant/cache_key.go create mode 100644 constant/channel.go create mode 100644 constant/context_key.go create mode 100644 constant/endpoint_type.go create mode 100644 constant/env.go create mode 100644 constant/finish_reason.go create mode 100644 constant/midjourney.go create mode 100644 constant/multi_key_mode.go create mode 100644 constant/setup.go create mode 100644 constant/task.go create mode 100644 controller/billing.go create mode 100644 controller/channel-billing.go create mode 100644 controller/channel-test.go create mode 100644 controller/channel.go create mode 100644 controller/console_migrate.go create mode 100644 controller/github.go create mode 100644 controller/group.go create mode 100644 controller/image.go create mode 100644 controller/linuxdo.go create mode 100644 controller/log.go create mode 100644 controller/midjourney.go create mode 100644 controller/misc.go create mode 100644 controller/model.go create mode 100644 controller/oidc.go create mode 100644 controller/option.go create mode 100644 controller/playground.go create mode 100644 controller/pricing.go create mode 100644 controller/ratio_config.go create mode 100644 controller/ratio_sync.go create mode 100644 controller/redemption.go create mode 100644 controller/relay.go create mode 100644 controller/setup.go create mode 100644 controller/swag_video.go create mode 100644 controller/task.go create mode 100644 controller/task_video.go create mode 100644 controller/telegram.go create mode 100644 controller/token.go create mode 100644 controller/topup.go create mode 100644 controller/topup_stripe.go create mode 100644 controller/uptime_kuma.go create mode 100644 controller/usedata.go create mode 100644 controller/user.go create mode 100644 controller/wechat.go create mode 100644 docker-compose.yml create mode 100644 docs/api/api_auth.md create mode 100644 docs/api/web_api.md create mode 100644 docs/channel/other_setting.md create mode 100644 docs/images/aliyun.png create mode 100644 docs/images/cherry-studio.png create mode 100644 docs/images/io-net.png create mode 100644 docs/images/pku.png create mode 100644 docs/images/ucloud.png create mode 100644 docs/installation/BT.md create mode 100644 docs/models/Midjourney.md create mode 100644 docs/models/Rerank.md create mode 100644 docs/models/Suno.md create mode 100644 dto/audio.go create mode 100644 dto/channel_settings.go create mode 100644 dto/claude.go create mode 100644 dto/dalle.go create mode 100644 dto/embedding.go create mode 100644 dto/error.go create mode 100644 dto/file_data.go create mode 100644 dto/midjourney.go create mode 100644 dto/notify.go create mode 100644 dto/openai_request.go create mode 100644 dto/openai_response.go create mode 100644 dto/playground.go create mode 100644 dto/pricing.go create mode 100644 dto/ratio_sync.go create mode 100644 dto/realtime.go create mode 100644 dto/rerank.go create mode 100644 dto/sensitive.go create mode 100644 dto/suno.go create mode 100644 dto/task.go create mode 100644 dto/user_settings.go create mode 100644 dto/video.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 i18n/zh-cn.json create mode 100644 main.go create mode 100644 makefile create mode 100644 middleware/auth.go create mode 100644 middleware/cache.go create mode 100644 middleware/cors.go create mode 100644 middleware/distributor.go create mode 100644 middleware/gzip.go create mode 100644 middleware/kling_adapter.go create mode 100644 middleware/logger.go create mode 100644 middleware/model-rate-limit.go create mode 100644 middleware/rate-limit.go create mode 100644 middleware/recover.go create mode 100644 middleware/request-id.go create mode 100644 middleware/stats.go create mode 100644 middleware/turnstile-check.go create mode 100644 middleware/utils.go create mode 100644 model/ability.go create mode 100644 model/channel.go create mode 100644 model/channel_cache.go create mode 100644 model/log.go create mode 100644 model/main.go create mode 100644 model/midjourney.go create mode 100644 model/option.go create mode 100644 model/pricing.go create mode 100644 model/redemption.go create mode 100644 model/setup.go create mode 100644 model/task.go create mode 100644 model/token.go create mode 100644 model/token_cache.go create mode 100644 model/topup.go create mode 100644 model/usedata.go create mode 100644 model/user.go create mode 100644 model/user_cache.go create mode 100644 model/utils.go create mode 100644 one-api.service create mode 100644 relay/audio_handler.go create mode 100644 relay/channel/adapter.go create mode 100644 relay/channel/ai360/constants.go create mode 100644 relay/channel/ali/adaptor.go create mode 100644 relay/channel/ali/constants.go create mode 100644 relay/channel/ali/dto.go create mode 100644 relay/channel/ali/image.go create mode 100644 relay/channel/ali/rerank.go create mode 100644 relay/channel/ali/text.go create mode 100644 relay/channel/api_request.go create mode 100644 relay/channel/aws/adaptor.go create mode 100644 relay/channel/aws/constants.go create mode 100644 relay/channel/aws/dto.go create mode 100644 relay/channel/aws/relay-aws.go create mode 100644 relay/channel/baidu/adaptor.go create mode 100644 relay/channel/baidu/constants.go create mode 100644 relay/channel/baidu/dto.go create mode 100644 relay/channel/baidu/relay-baidu.go create mode 100644 relay/channel/baidu_v2/adaptor.go create mode 100644 relay/channel/baidu_v2/constants.go create mode 100644 relay/channel/claude/adaptor.go create mode 100644 relay/channel/claude/constants.go create mode 100644 relay/channel/claude/dto.go create mode 100644 relay/channel/claude/relay-claude.go create mode 100644 relay/channel/cloudflare/adaptor.go create mode 100644 relay/channel/cloudflare/constant.go create mode 100644 relay/channel/cloudflare/dto.go create mode 100644 relay/channel/cloudflare/relay_cloudflare.go create mode 100644 relay/channel/cohere/adaptor.go create mode 100644 relay/channel/cohere/constant.go create mode 100644 relay/channel/cohere/dto.go create mode 100644 relay/channel/cohere/relay-cohere.go create mode 100644 relay/channel/coze/adaptor.go create mode 100644 relay/channel/coze/constants.go create mode 100644 relay/channel/coze/dto.go create mode 100644 relay/channel/coze/relay-coze.go create mode 100644 relay/channel/deepseek/adaptor.go create mode 100644 relay/channel/deepseek/constants.go create mode 100644 relay/channel/dify/adaptor.go create mode 100644 relay/channel/dify/constants.go create mode 100644 relay/channel/dify/dto.go create mode 100644 relay/channel/dify/relay-dify.go create mode 100644 relay/channel/gemini/adaptor.go create mode 100644 relay/channel/gemini/constant.go create mode 100644 relay/channel/gemini/dto.go create mode 100644 relay/channel/gemini/relay-gemini-native.go create mode 100644 relay/channel/gemini/relay-gemini.go create mode 100644 relay/channel/jimeng/adaptor.go create mode 100644 relay/channel/jimeng/constants.go create mode 100644 relay/channel/jimeng/image.go create mode 100644 relay/channel/jimeng/sign.go create mode 100644 relay/channel/jina/adaptor.go create mode 100644 relay/channel/jina/constant.go create mode 100644 relay/channel/jina/relay-jina.go create mode 100644 relay/channel/lingyiwanwu/constrants.go create mode 100644 relay/channel/minimax/constants.go create mode 100644 relay/channel/minimax/relay-minimax.go create mode 100644 relay/channel/mistral/adaptor.go create mode 100644 relay/channel/mistral/constants.go create mode 100644 relay/channel/mistral/text.go create mode 100644 relay/channel/mokaai/adaptor.go create mode 100644 relay/channel/mokaai/constants.go create mode 100644 relay/channel/mokaai/relay-mokaai.go create mode 100644 relay/channel/moonshot/constants.go create mode 100644 relay/channel/ollama/adaptor.go create mode 100644 relay/channel/ollama/constants.go create mode 100644 relay/channel/ollama/dto.go create mode 100644 relay/channel/ollama/relay-ollama.go create mode 100644 relay/channel/openai/adaptor.go create mode 100644 relay/channel/openai/constant.go create mode 100644 relay/channel/openai/helper.go create mode 100644 relay/channel/openai/relay-openai.go create mode 100644 relay/channel/openai/relay_responses.go create mode 100644 relay/channel/openrouter/constant.go create mode 100644 relay/channel/openrouter/dto.go create mode 100644 relay/channel/palm/adaptor.go create mode 100644 relay/channel/palm/constants.go create mode 100644 relay/channel/palm/dto.go create mode 100644 relay/channel/palm/relay-palm.go create mode 100644 relay/channel/perplexity/adaptor.go create mode 100644 relay/channel/perplexity/constants.go create mode 100644 relay/channel/perplexity/relay-perplexity.go create mode 100644 relay/channel/siliconflow/adaptor.go create mode 100644 relay/channel/siliconflow/constant.go create mode 100644 relay/channel/siliconflow/dto.go create mode 100644 relay/channel/siliconflow/relay-siliconflow.go create mode 100644 relay/channel/task/jimeng/adaptor.go create mode 100644 relay/channel/task/kling/adaptor.go create mode 100644 relay/channel/task/suno/adaptor.go create mode 100644 relay/channel/task/suno/models.go create mode 100644 relay/channel/tencent/adaptor.go create mode 100644 relay/channel/tencent/constants.go create mode 100644 relay/channel/tencent/dto.go create mode 100644 relay/channel/tencent/relay-tencent.go create mode 100644 relay/channel/vertex/adaptor.go create mode 100644 relay/channel/vertex/constants.go create mode 100644 relay/channel/vertex/dto.go create mode 100644 relay/channel/vertex/relay-vertex.go create mode 100644 relay/channel/vertex/service_account.go create mode 100644 relay/channel/volcengine/adaptor.go create mode 100644 relay/channel/volcengine/constants.go create mode 100644 relay/channel/xai/adaptor.go create mode 100644 relay/channel/xai/constants.go create mode 100644 relay/channel/xai/dto.go create mode 100644 relay/channel/xai/text.go create mode 100644 relay/channel/xinference/constant.go create mode 100644 relay/channel/xinference/dto.go create mode 100644 relay/channel/xunfei/adaptor.go create mode 100644 relay/channel/xunfei/constants.go create mode 100644 relay/channel/xunfei/dto.go create mode 100644 relay/channel/xunfei/relay-xunfei.go create mode 100644 relay/channel/zhipu/adaptor.go create mode 100644 relay/channel/zhipu/constants.go create mode 100644 relay/channel/zhipu/dto.go create mode 100644 relay/channel/zhipu/relay-zhipu.go create mode 100644 relay/channel/zhipu_4v/adaptor.go create mode 100644 relay/channel/zhipu_4v/constants.go create mode 100644 relay/channel/zhipu_4v/dto.go create mode 100644 relay/channel/zhipu_4v/relay-zhipu_v4.go create mode 100644 relay/claude_handler.go create mode 100644 relay/common/relay_info.go create mode 100644 relay/common/relay_utils.go create mode 100644 relay/common_handler/rerank.go create mode 100644 relay/constant/relay_mode.go create mode 100644 relay/embedding_handler.go create mode 100644 relay/gemini_handler.go create mode 100644 relay/helper/common.go create mode 100644 relay/helper/model_mapped.go create mode 100644 relay/helper/price.go create mode 100644 relay/helper/stream_scanner.go create mode 100644 relay/image_handler.go create mode 100644 relay/relay-mj.go create mode 100644 relay/relay-text.go create mode 100644 relay/relay_adaptor.go create mode 100644 relay/relay_task.go create mode 100644 relay/rerank_handler.go create mode 100644 relay/responses_handler.go create mode 100644 relay/websocket.go create mode 100644 router/api-router.go create mode 100644 router/dashboard.go create mode 100644 router/main.go create mode 100644 router/relay-router.go create mode 100644 router/video-router.go create mode 100644 router/web-router.go create mode 100644 service/audio.go create mode 100644 service/cf_worker.go create mode 100644 service/channel.go create mode 100644 service/convert.go create mode 100644 service/epay.go create mode 100644 service/error.go create mode 100644 service/file_decoder.go create mode 100644 service/http_client.go create mode 100644 service/image.go create mode 100644 service/log_info_generate.go create mode 100644 service/midjourney.go create mode 100644 service/notify-limit.go create mode 100644 service/quota.go create mode 100644 service/sensitive.go create mode 100644 service/str.go create mode 100644 service/task.go create mode 100644 service/token_counter.go create mode 100644 service/usage_helpr.go create mode 100644 service/user_notify.go create mode 100644 service/webhook.go create mode 100644 setting/auto_group.go create mode 100644 setting/chat.go create mode 100644 setting/config/config.go create mode 100644 setting/console_setting/config.go create mode 100644 setting/console_setting/validation.go create mode 100644 setting/midjourney.go create mode 100644 setting/model_setting/claude.go create mode 100644 setting/model_setting/gemini.go create mode 100644 setting/model_setting/global.go create mode 100644 setting/operation_setting/general_setting.go create mode 100644 setting/operation_setting/operation_setting.go create mode 100644 setting/operation_setting/tools.go create mode 100644 setting/payment.go create mode 100644 setting/payment_stripe.go create mode 100644 setting/rate_limit.go create mode 100644 setting/ratio_setting/cache_ratio.go create mode 100644 setting/ratio_setting/expose_ratio.go create mode 100644 setting/ratio_setting/exposed_cache.go create mode 100644 setting/ratio_setting/group_ratio.go create mode 100644 setting/ratio_setting/model_ratio.go create mode 100644 setting/sensitive.go create mode 100644 setting/system_setting.go create mode 100644 setting/system_setting/oidc.go create mode 100644 setting/user_usable_group.go create mode 100644 types/channel_error.go create mode 100644 types/error.go create mode 100644 types/set.go create mode 100644 web/.gitignore create mode 100644 web/.prettierrc.mjs create mode 100644 web/bun.lock create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/public/azure_model_name.png create mode 100644 web/public/favicon.ico create mode 100644 web/public/logo.png create mode 100644 web/public/ratio.png create mode 100644 web/public/robots.txt create mode 100644 web/src/App.js create mode 100644 web/src/components/auth/LoginForm.js create mode 100644 web/src/components/auth/OAuth2Callback.js create mode 100644 web/src/components/auth/PasswordResetConfirm.js create mode 100644 web/src/components/auth/PasswordResetForm.js create mode 100644 web/src/components/auth/RegisterForm.js create mode 100644 web/src/components/common/Loading.js create mode 100644 web/src/components/common/logo/LinuxDoIcon.js create mode 100644 web/src/components/common/logo/OIDCIcon.js create mode 100644 web/src/components/common/logo/WeChatIcon.js create mode 100644 web/src/components/common/markdown/MarkdownRenderer.js create mode 100644 web/src/components/common/markdown/markdown.css create mode 100644 web/src/components/layout/Footer.js create mode 100644 web/src/components/layout/HeaderBar.js create mode 100644 web/src/components/layout/NoticeModal.js create mode 100644 web/src/components/layout/PageLayout.js create mode 100644 web/src/components/layout/SetupCheck.js create mode 100644 web/src/components/layout/SiderBar.js create mode 100644 web/src/components/playground/ChatArea.js create mode 100644 web/src/components/playground/CodeViewer.js create mode 100644 web/src/components/playground/ConfigManager.js create mode 100644 web/src/components/playground/CustomInputRender.js create mode 100644 web/src/components/playground/CustomRequestEditor.js create mode 100644 web/src/components/playground/DebugPanel.js create mode 100644 web/src/components/playground/FloatingButtons.js create mode 100644 web/src/components/playground/ImageUrlInput.js create mode 100644 web/src/components/playground/MessageActions.js create mode 100644 web/src/components/playground/MessageContent.js create mode 100644 web/src/components/playground/OptimizedComponents.js create mode 100644 web/src/components/playground/ParameterControl.js create mode 100644 web/src/components/playground/SettingsPanel.js create mode 100644 web/src/components/playground/ThinkingContent.js create mode 100644 web/src/components/playground/configStorage.js create mode 100644 web/src/components/playground/index.js create mode 100644 web/src/components/settings/ChannelSelectorModal.js create mode 100644 web/src/components/settings/ChatsSetting.js create mode 100644 web/src/components/settings/DashboardSetting.js create mode 100644 web/src/components/settings/DrawingSetting.js create mode 100644 web/src/components/settings/ModelSetting.js create mode 100644 web/src/components/settings/OperationSetting.js create mode 100644 web/src/components/settings/OtherSetting.js create mode 100644 web/src/components/settings/PaymentSetting.js create mode 100644 web/src/components/settings/PersonalSetting.js create mode 100644 web/src/components/settings/RateLimitSetting.js create mode 100644 web/src/components/settings/RatioSetting.js create mode 100644 web/src/components/settings/SystemSetting.js create mode 100644 web/src/components/table/ChannelsTable.js create mode 100644 web/src/components/table/LogsTable.js create mode 100644 web/src/components/table/MjLogsTable.js create mode 100644 web/src/components/table/ModelPricing.js create mode 100644 web/src/components/table/RedemptionsTable.js create mode 100644 web/src/components/table/TaskLogsTable.js create mode 100644 web/src/components/table/TokensTable.js create mode 100644 web/src/components/table/UsersTable.js create mode 100644 web/src/constants/channel.constants.js create mode 100644 web/src/constants/common.constant.js create mode 100644 web/src/constants/index.js create mode 100644 web/src/constants/playground.constants.js create mode 100644 web/src/constants/toast.constants.js create mode 100644 web/src/constants/user.constants.js create mode 100644 web/src/context/Status/index.js create mode 100644 web/src/context/Status/reducer.js create mode 100644 web/src/context/Theme/index.js create mode 100644 web/src/context/User/index.js create mode 100644 web/src/context/User/reducer.js create mode 100644 web/src/helpers/api.js create mode 100644 web/src/helpers/auth.js create mode 100644 web/src/helpers/boolean.js create mode 100644 web/src/helpers/data.js create mode 100644 web/src/helpers/history.js create mode 100644 web/src/helpers/index.js create mode 100644 web/src/helpers/log.js create mode 100644 web/src/helpers/render.js create mode 100644 web/src/helpers/token.js create mode 100644 web/src/helpers/utils.js create mode 100644 web/src/hooks/useApiRequest.js create mode 100644 web/src/hooks/useDataLoader.js create mode 100644 web/src/hooks/useIsMobile.js create mode 100644 web/src/hooks/useMessageActions.js create mode 100644 web/src/hooks/useMessageEdit.js create mode 100644 web/src/hooks/usePlaygroundState.js create mode 100644 web/src/hooks/useSidebarCollapsed.js create mode 100644 web/src/hooks/useSyncMessageAndCustomBody.js create mode 100644 web/src/hooks/useTableCompactMode.js create mode 100644 web/src/hooks/useTokenKeys.js create mode 100644 web/src/i18n/i18n.js create mode 100644 web/src/i18n/locales/en.json create mode 100644 web/src/i18n/locales/zh.json create mode 100644 web/src/index.css create mode 100644 web/src/index.js create mode 100644 web/src/pages/About/index.js create mode 100644 web/src/pages/Channel/EditChannel.js create mode 100644 web/src/pages/Channel/EditTagModal.js create mode 100644 web/src/pages/Channel/index.js create mode 100644 web/src/pages/Chat/index.js create mode 100644 web/src/pages/Chat2Link/index.js create mode 100644 web/src/pages/Detail/index.js create mode 100644 web/src/pages/Home/index.js create mode 100644 web/src/pages/Log/index.js create mode 100644 web/src/pages/Midjourney/index.js create mode 100644 web/src/pages/NotFound/index.js create mode 100644 web/src/pages/Playground/index.js create mode 100644 web/src/pages/Pricing/index.js create mode 100644 web/src/pages/Redemption/EditRedemption.js create mode 100644 web/src/pages/Redemption/index.js create mode 100644 web/src/pages/Setting/Chat/SettingsChats.js create mode 100644 web/src/pages/Setting/Dashboard/SettingsAPIInfo.js create mode 100644 web/src/pages/Setting/Dashboard/SettingsAnnouncements.js create mode 100644 web/src/pages/Setting/Dashboard/SettingsDataDashboard.js create mode 100644 web/src/pages/Setting/Dashboard/SettingsFAQ.js create mode 100644 web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js create mode 100644 web/src/pages/Setting/Drawing/SettingsDrawing.js create mode 100644 web/src/pages/Setting/Model/SettingClaudeModel.js create mode 100644 web/src/pages/Setting/Model/SettingGeminiModel.js create mode 100644 web/src/pages/Setting/Model/SettingGlobalModel.js create mode 100644 web/src/pages/Setting/Operation/SettingsCreditLimit.js create mode 100644 web/src/pages/Setting/Operation/SettingsGeneral.js create mode 100644 web/src/pages/Setting/Operation/SettingsLog.js create mode 100644 web/src/pages/Setting/Operation/SettingsMonitoring.js create mode 100644 web/src/pages/Setting/Operation/SettingsSensitiveWords.js create mode 100644 web/src/pages/Setting/Payment/SettingsGeneralPayment.js create mode 100644 web/src/pages/Setting/Payment/SettingsPaymentGateway.js create mode 100644 web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js create mode 100644 web/src/pages/Setting/RateLimit/SettingsRequestRateLimit.js create mode 100644 web/src/pages/Setting/Ratio/GroupRatioSettings.js create mode 100644 web/src/pages/Setting/Ratio/ModelRatioSettings.js create mode 100644 web/src/pages/Setting/Ratio/ModelRationNotSetEditor.js create mode 100644 web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.js create mode 100644 web/src/pages/Setting/Ratio/UpstreamRatioSync.js create mode 100644 web/src/pages/Setting/index.js create mode 100644 web/src/pages/Setup/index.js create mode 100644 web/src/pages/Task/index.js create mode 100644 web/src/pages/Token/EditToken.js create mode 100644 web/src/pages/Token/index.js create mode 100644 web/src/pages/TopUp/index.js create mode 100644 web/src/pages/User/AddUser.js create mode 100644 web/src/pages/User/EditUser.js create mode 100644 web/src/pages/User/index.js create mode 100644 web/tailwind.config.js create mode 100644 web/vercel.json create mode 100644 web/vite.config.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..010182e3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(git init:*)", + "Bash(touch:*)", + "Bash(git checkout:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git remote add:*)", + "Bash(git push:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..e4e8e72e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.github +.git +*.md +.vscode +.gitignore +Makefile +docs \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..ea246427 --- /dev/null +++ b/.env.example @@ -0,0 +1,75 @@ +# 端口号 +# PORT=3000 +# 前端基础URL +# FRONTEND_BASE_URL=https://your-frontend-url.com + + +# 调试相关配置 +# 启用pprof +# ENABLE_PPROF=true +# 启用调试模式 +# DEBUG=true + +# 数据库相关配置 +# 数据库连接字符串 +# SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true +# 日志数据库连接字符串 +# LOG_SQL_DSN=user:password@tcp(127.0.0.1:3306)/logdb?parseTime=true +# SQLite数据库路径 +# SQLITE_PATH=/path/to/sqlite.db +# 数据库最大空闲连接数 +# SQL_MAX_IDLE_CONNS=100 +# 数据库最大打开连接数 +# SQL_MAX_OPEN_CONNS=1000 +# 数据库连接最大生命周期(秒) +# SQL_MAX_LIFETIME=60 + + +# 缓存相关配置 +# Redis连接字符串 +# REDIS_CONN_STRING=redis://user:password@localhost:6379/0 +# 同步频率(单位:秒) +# SYNC_FREQUENCY=60 +# 内存缓存启用 +# MEMORY_CACHE_ENABLED=true +# 渠道更新频率(单位:秒) +# CHANNEL_UPDATE_FREQUENCY=30 +# 批量更新启用 +# BATCH_UPDATE_ENABLED=true +# 批量更新间隔(单位:秒) +# BATCH_UPDATE_INTERVAL=5 + +# 任务和功能配置 +# 更新任务启用 +# UPDATE_TASK=true + +# 对话超时设置 +# 所有请求超时时间,单位秒,默认为0,表示不限制 +# RELAY_TIMEOUT=0 +# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值 +# STREAMING_TIMEOUT=120 + +# Gemini 识别图片 最大图片数量 +# GEMINI_VISION_MAX_IMAGE_NUM=16 + +# 会话密钥 +# SESSION_SECRET=random_string + +# 其他配置 +# 渠道测试频率(单位:秒) +# CHANNEL_TEST_FREQUENCY=10 +# 生成默认token +# GENERATE_DEFAULT_TOKEN=false +# Cohere 安全设置 +# COHERE_SAFETY_SETTING=NONE +# 是否统计图片token +# GET_MEDIA_TOKEN=true +# 是否在非流(stream=false)情况下统计图片token +# GET_MEDIA_TOKEN_NOT_STREAM=true +# 设置 Dify 渠道是否输出工作流和节点信息到客户端 +# DIFY_DEBUG=true + + +# 节点类型 +# 如果是主节点则为master +# NODE_TYPE=master diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..87747788 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: ['https://afdian.com/a/new-api'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..dd688493 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: 报告问题 +about: 使用简练详细的语言描述你遇到的问题 +title: '' +labels: bug +assignees: '' + +--- + +**例行检查** + +[//]: # (方框内删除已有的空格,填 x 号) ++ [ ] 我已确认目前没有类似 issue ++ [ ] 我已确认我已升级到最新版本 ++ [ ] 我已完整查看过项目 README,尤其是常见问题部分 ++ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈 ++ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭** + +**问题描述** + +**复现步骤** + +**预期结果** + +**相关截图** +如果没有的话,请删除此节。 \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..5b8ee14f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 项目群聊 + url: https://private-user-images.githubusercontent.com/61247483/283011625-de536a8a-0161-47a7-a0a2-66ef6de81266.jpeg?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTEiLCJleHAiOjE3MDIyMjQzOTAsIm5iZiI6MTcwMjIyNDA5MCwicGF0aCI6Ii82MTI0NzQ4My8yODMwMTE2MjUtZGU1MzZhOGEtMDE2MS00N2E3LWEwYTItNjZlZjZkZTgxMjY2LmpwZWc_WC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BS0lBSVdOSllBWDRDU1ZFSDUzQSUyRjIwMjMxMjEwJTJGdXMtZWFzdC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDIzMTIxMFQxNjAxMzBaJlgtQW16LUV4cGlyZXM9MzAwJlgtQW16LVNpZ25hdHVyZT02MGIxYmM3ZDQyYzBkOTA2ZTYyYmVmMzQ1NjY4NjM1YjY0NTUzNTM5NjE1NDZkYTIzODdhYTk4ZjZjODJmYzY2JlgtQW16LVNpZ25lZEhlYWRlcnM9aG9zdCZhY3Rvcl9pZD0wJmtleV9pZD0wJnJlcG9faWQ9MCJ9.TJ8CTfOSwR0-CHS1KLfomqgL0e4YH1luy8lSLrkv5Zg + about: QQ 群:629454374 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..049d89c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: 功能请求 +about: 使用简练详细的语言描述希望加入的新功能 +title: '' +labels: enhancement +assignees: '' + +--- + +**例行检查** + +[//]: # (方框内删除已有的空格,填 x 号) ++ [ ] 我已确认目前没有类似 issue ++ [ ] 我已确认我已升级到最新版本 ++ [ ] 我已完整查看过项目 README,已确定现有版本无法满足需求 ++ [ ] 我理解并愿意跟进此 issue,协助测试和提供反馈 ++ [ ] 我理解并认可上述内容,并理解项目维护者精力有限,**不遵循规则的 issue 可能会被无视或直接关闭** + +**功能描述** + +**应用场景** diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 00000000..4f6e41ac --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,19 @@ +### PR 类型 + +- [ ] Bug 修复 +- [ ] 新功能 +- [ ] 文档更新 +- [ ] 其他 + +### PR 是否包含破坏性更新? + +- [ ] 是 +- [ ] 否 + +### PR 描述 + +**请在下方详细描述您的 PR,包括目的、实现细节等。** + +### **重要提示** + +**所有 PR 都必须提交到 `alpha` 分支。请确保您的 PR 目标分支是 `alpha`。** diff --git a/.github/workflows/docker-image-alpha.yml b/.github/workflows/docker-image-alpha.yml new file mode 100644 index 00000000..c02bd409 --- /dev/null +++ b/.github/workflows/docker-image-alpha.yml @@ -0,0 +1,62 @@ +name: Publish Docker image (alpha) + +on: + push: + branches: + - alpha + workflow_dispatch: + inputs: + name: + description: "reason" + required: false + +jobs: + push_to_registries: + name: Push Docker image to multiple registries + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Save version info + run: | + echo "alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)" > VERSION + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: | + calciumion/new-api + ghcr.io/${{ github.repository }} + tags: | + type=raw,value=alpha + type=raw,value=alpha-{{date 'YYYYMMDD'}}-{{sha}} + + - name: Build and push Docker images + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml new file mode 100644 index 00000000..8e4656aa --- /dev/null +++ b/.github/workflows/docker-image-arm64.yml @@ -0,0 +1,56 @@ +name: Publish Docker image (Multi Registries) + +on: + push: + tags: + - '*' +jobs: + push_to_registries: + name: Push Docker image to multiple registries + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Check out the repo + uses: actions/checkout@v4 + + - name: Save version info + run: | + git describe --tags > VERSION + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: | + calciumion/new-api + ghcr.io/${{ github.repository }} + + - name: Build and push Docker images + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/linux-release.yml b/.github/workflows/linux-release.yml new file mode 100644 index 00000000..c87fcfce --- /dev/null +++ b/.github/workflows/linux-release.yml @@ -0,0 +1,59 @@ +name: Linux Release +permissions: + contents: write + +on: + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false + push: + tags: + - '*' + - '!*-alpha*' +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Build Frontend + env: + CI: "" + run: | + cd web + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build + cd .. + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '>=1.18.0' + - name: Build Backend (amd64) + run: | + go mod download + go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api + + - name: Build Backend (arm64) + run: | + sudo apt-get update + DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu + CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)' -extldflags '-static'" -o one-api-arm64 + + - name: Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + files: | + one-api + one-api-arm64 + draft: true + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml new file mode 100644 index 00000000..1bc786ac --- /dev/null +++ b/.github/workflows/macos-release.yml @@ -0,0 +1,51 @@ +name: macOS Release +permissions: + contents: write + +on: + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false + push: + tags: + - '*' + - '!*-alpha*' +jobs: + release: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Build Frontend + env: + CI: "" + NODE_OPTIONS: "--max-old-space-size=4096" + run: | + cd web + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build + cd .. + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '>=1.18.0' + - name: Build Backend + run: | + go mod download + go build -ldflags "-X 'one-api/common.Version=$(git describe --tags)'" -o one-api-macos + - name: Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + files: one-api-macos + draft: true + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-target-branch-check.yml b/.github/workflows/pr-target-branch-check.yml new file mode 100644 index 00000000..e7bd4c81 --- /dev/null +++ b/.github/workflows/pr-target-branch-check.yml @@ -0,0 +1,21 @@ +name: Check PR Branching Strategy +on: + pull_request: + types: [opened, synchronize, reopened, edited] + +jobs: + check-branching-strategy: + runs-on: ubuntu-latest + steps: + - name: Enforce branching strategy + run: | + if [[ "${{ github.base_ref }}" == "main" ]]; then + if [[ "${{ github.head_ref }}" != "alpha" ]]; then + echo "Error: Pull requests to 'main' are only allowed from the 'alpha' branch." + exit 1 + fi + elif [[ "${{ github.base_ref }}" != "alpha" ]]; then + echo "Error: Pull requests must be targeted to the 'alpha' or 'main' branch." + exit 1 + fi + echo "Branching strategy check passed." \ No newline at end of file diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml new file mode 100644 index 00000000..de3d83d5 --- /dev/null +++ b/.github/workflows/windows-release.yml @@ -0,0 +1,53 @@ +name: Windows Release +permissions: + contents: write + +on: + workflow_dispatch: + inputs: + name: + description: 'reason' + required: false + push: + tags: + - '*' + - '!*-alpha*' +jobs: + release: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - name: Build Frontend + env: + CI: "" + run: | + cd web + bun install + DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(git describe --tags) bun run build + cd .. + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: '>=1.18.0' + - name: Build Backend + run: | + go mod download + go build -ldflags "-s -w -X 'one-api/common.Version=$(git describe --tags)'" -o one-api.exe + - name: Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + files: one-api.exe + draft: true + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..6a23f89e --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.idea +.vscode +upload +*.exe +*.db +build +*.db-journal +logs +web/dist +.env +one-api +.DS_Store +tiktoken_cache \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..08cc86f7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +FROM oven/bun:latest AS builder + +WORKDIR /build +COPY web/package.json . +COPY web/bun.lock . +RUN bun install +COPY ./web . +COPY ./VERSION . +RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build + +FROM golang:alpine AS builder2 + +ENV GO111MODULE=on \ + CGO_ENABLED=0 \ + GOOS=linux + +WORKDIR /build + +ADD go.mod go.sum ./ +RUN go mod download + +COPY . . +COPY --from=builder /build/dist ./web/dist +RUN go build -ldflags "-s -w -X 'one-api/common.Version=$(cat VERSION)'" -o one-api + +FROM alpine + +RUN apk upgrade --no-cache \ + && apk add --no-cache ca-certificates tzdata ffmpeg \ + && update-ca-certificates + +COPY --from=builder2 /build/one-api / +EXPOSE 3000 +WORKDIR /data +ENTRYPOINT ["/one-api"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.en.md b/README.en.md new file mode 100644 index 00000000..69fd32f8 --- /dev/null +++ b/README.en.md @@ -0,0 +1,216 @@ +

+ 中文 | English +

+
+ +![new-api](/web/public/logo.png) + +# New API + +🍥 Next-Generation Large Model Gateway and AI Asset Management System + +Calcium-Ion%2Fnew-api | Trendshift + +

+ + license + + + release + + + docker + + + docker + + + GoReportCard + +

+
+ +## 📝 Project Description + +> [!NOTE] +> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api) + +> [!IMPORTANT] +> - This project is for personal learning purposes only, with no guarantee of stability or technical support. +> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes. +> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China. + +

🤝 Trusted Partners

+

 

+

No particular order

+

+ Cherry Studio + Peking University + UCloud + Alibaba Cloud + IO.NET +

+

 

+ +## 📚 Documentation + +For detailed documentation, please visit our official Wiki: [https://docs.newapi.pro/](https://docs.newapi.pro/) + +You can also access the AI-generated DeepWiki: +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + +## ✨ Key Features + +New API offers a wide range of features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) for details: + +1. 🎨 Brand new UI interface +2. 🌍 Multi-language support +3. 💰 Online recharge functionality (YiPay) +4. 🔍 Support for querying usage quotas with keys (works with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) +5. 🔄 Compatible with the original One API database +6. 💵 Support for pay-per-use model pricing +7. ⚖️ Support for weighted random channel selection +8. 📈 Data dashboard (console) +9. 🔒 Token grouping and model restrictions +10. 🤖 Support for more authorization login methods (LinuxDO, Telegram, OIDC) +11. 🔄 Support for Rerank models (Cohere and Jina), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank) +12. ⚡ Support for OpenAI Realtime API (including Azure channels), [API Documentation](https://docs.newapi.pro/api/openai-realtime) +13. ⚡ Support for Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat) +14. Support for entering chat interface via /chat2link route +15. 🧠 Support for setting reasoning effort through model name suffixes: + 1. OpenAI o-series models + - Add `-high` suffix for high reasoning effort (e.g.: `o3-mini-high`) + - Add `-medium` suffix for medium reasoning effort (e.g.: `o3-mini-medium`) + - Add `-low` suffix for low reasoning effort (e.g.: `o3-mini-low`) + 2. Claude thinking models + - Add `-thinking` suffix to enable thinking mode (e.g.: `claude-3-7-sonnet-20250219-thinking`) +16. 🔄 Thinking-to-content functionality +17. 🔄 Model rate limiting for users +18. 💰 Cache billing support, which allows billing at a set ratio when cache is hit: + 1. Set the `Prompt Cache Ratio` option in `System Settings-Operation Settings` + 2. Set `Prompt Cache Ratio` in the channel, range 0-1, e.g., setting to 0.5 means billing at 50% when cache is hit + 3. Supported channels: + - [x] OpenAI + - [x] Azure + - [x] DeepSeek + - [x] Claude + +## Model Support + +This version supports multiple models, please refer to [API Documentation-Relay Interface](https://docs.newapi.pro/api) for details: + +1. Third-party models **gpts** (gpt-4-gizmo-*) +2. Third-party channel [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) interface, [API Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) +3. Third-party channel [Suno API](https://github.com/Suno-API/Suno-API) interface, [API Documentation](https://docs.newapi.pro/api/suno-music) +4. Custom channels, supporting full call address input +5. Rerank models ([Cohere](https://cohere.ai/) and [Jina](https://jina.ai/)), [API Documentation](https://docs.newapi.pro/api/jinaai-rerank) +6. Claude Messages format, [API Documentation](https://docs.newapi.pro/api/anthropic-chat) +7. Dify, currently only supports chatflow + +## Environment Variable Configuration + +For detailed configuration instructions, please refer to [Installation Guide-Environment Variables Configuration](https://docs.newapi.pro/installation/environment-variables): + +- `GENERATE_DEFAULT_TOKEN`: Whether to generate initial tokens for newly registered users, default is `false` +- `STREAMING_TIMEOUT`: Streaming response timeout, default is 300 seconds +- `DIFY_DEBUG`: Whether to output workflow and node information for Dify channels, default is `true` +- `FORCE_STREAM_OPTION`: Whether to override client stream_options parameter, default is `true` +- `GET_MEDIA_TOKEN`: Whether to count image tokens, default is `true` +- `GET_MEDIA_TOKEN_NOT_STREAM`: Whether to count image tokens in non-streaming cases, default is `true` +- `UPDATE_TASK`: Whether to update asynchronous tasks (Midjourney, Suno), default is `true` +- `COHERE_SAFETY_SETTING`: Cohere model safety settings, options are `NONE`, `CONTEXTUAL`, `STRICT`, default is `NONE` +- `GEMINI_VISION_MAX_IMAGE_NUM`: Maximum number of images for Gemini models, default is `16` +- `MAX_FILE_DOWNLOAD_MB`: Maximum file download size in MB, default is `20` +- `CRYPTO_SECRET`: Encryption key used for encrypting database content +- `AZURE_DEFAULT_API_VERSION`: Azure channel default API version, default is `2025-04-01-preview` +- `NOTIFICATION_LIMIT_DURATION_MINUTE`: Notification limit duration, default is `10` minutes +- `NOTIFY_LIMIT_COUNT`: Maximum number of user notifications within the specified duration, default is `2` +- `ERROR_LOG_ENABLED=true`: Whether to record and display error logs, default is `false` + +## Deployment + +For detailed deployment guides, please refer to [Installation Guide-Deployment Methods](https://docs.newapi.pro/installation): + +> [!TIP] +> Latest Docker image: `calciumion/new-api:latest` + +### Multi-machine Deployment Considerations +- Environment variable `SESSION_SECRET` must be set, otherwise login status will be inconsistent across multiple machines +- If sharing Redis, `CRYPTO_SECRET` must be set, otherwise Redis content cannot be accessed across multiple machines + +### Deployment Requirements +- Local database (default): SQLite (Docker deployment must mount the `/data` directory) +- Remote database: MySQL version >= 5.7.8, PgSQL version >= 9.6 + +### Deployment Methods + +#### Using BaoTa Panel Docker Feature +Install BaoTa Panel (version **9.2.0** or above), find **New-API** in the application store and install it. +[Tutorial with images](./docs/BT.md) + +#### Using Docker Compose (Recommended) +```shell +# Download the project +git clone https://github.com/Calcium-Ion/new-api.git +cd new-api +# Edit docker-compose.yml as needed +# Start +docker-compose up -d +``` + +#### Using Docker Image Directly +```shell +# Using SQLite +docker run --name new-api -d --restart always -p 3000:3000 -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest + +# Using MySQL +docker run --name new-api -d --restart always -p 3000:3000 -e SQL_DSN="root:123456@tcp(localhost:3306)/oneapi" -e TZ=Asia/Shanghai -v /home/ubuntu/data/new-api:/data calciumion/new-api:latest +``` + +## Channel Retry and Cache +Channel retry functionality has been implemented, you can set the number of retries in `Settings->Operation Settings->General Settings`. It is **recommended to enable caching**. + +### Cache Configuration Method +1. `REDIS_CONN_STRING`: Set Redis as cache +2. `MEMORY_CACHE_ENABLED`: Enable memory cache (no need to set manually if Redis is set) + +## API Documentation + +For detailed API documentation, please refer to [API Documentation](https://docs.newapi.pro/api): + +- [Chat API](https://docs.newapi.pro/api/openai-chat) +- [Image API](https://docs.newapi.pro/api/openai-image) +- [Rerank API](https://docs.newapi.pro/api/jinaai-rerank) +- [Realtime API](https://docs.newapi.pro/api/openai-realtime) +- [Claude Chat API (messages)](https://docs.newapi.pro/api/anthropic-chat) + +## Related Projects +- [One API](https://github.com/songquanpeng/one-api): Original project +- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy): Midjourney interface support +- [chatnio](https://github.com/Deeptrain-Community/chatnio): Next-generation AI one-stop B/C-end solution +- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool): Query usage quota with key + +Other projects based on New API: +- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon): High-performance optimized version of New API +- [VoAPI](https://github.com/VoAPI/VoAPI): Frontend beautified version based on New API + +## Help and Support + +If you have any questions, please refer to [Help and Support](https://docs.newapi.pro/support): +- [Community Interaction](https://docs.newapi.pro/support/community-interaction) +- [Issue Feedback](https://docs.newapi.pro/support/feedback-issues) +- [FAQ](https://docs.newapi.pro/support/faq) + +## 🌟 Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date) diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..e69de29b diff --git a/bin/migration_v0.2-v0.3.sql b/bin/migration_v0.2-v0.3.sql new file mode 100644 index 00000000..6b08d7bf --- /dev/null +++ b/bin/migration_v0.2-v0.3.sql @@ -0,0 +1,6 @@ +UPDATE users +SET quota = quota + ( + SELECT SUM(remain_quota) + FROM tokens + WHERE tokens.user_id = users.id +) diff --git a/bin/migration_v0.3-v0.4.sql b/bin/migration_v0.3-v0.4.sql new file mode 100644 index 00000000..e6103c29 --- /dev/null +++ b/bin/migration_v0.3-v0.4.sql @@ -0,0 +1,17 @@ +INSERT INTO abilities (`group`, model, channel_id, enabled) +SELECT c.`group`, m.model, c.id, 1 +FROM channels c +CROSS JOIN ( + SELECT 'gpt-3.5-turbo' AS model UNION ALL + SELECT 'gpt-3.5-turbo-0301' AS model UNION ALL + SELECT 'gpt-4' AS model UNION ALL + SELECT 'gpt-4-0314' AS model +) AS m +WHERE c.status = 1 + AND NOT EXISTS ( + SELECT 1 + FROM abilities a + WHERE a.`group` = c.`group` + AND a.model = m.model + AND a.channel_id = c.id +); diff --git a/bin/time_test.sh b/bin/time_test.sh new file mode 100644 index 00000000..2cde4a65 --- /dev/null +++ b/bin/time_test.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +if [ $# -lt 3 ]; then + echo "Usage: time_test.sh []" + exit 1 +fi + +domain=$1 +key=$2 +count=$3 +model=${4:-"gpt-3.5-turbo"} # 设置默认模型为 gpt-3.5-turbo + +total_time=0 +times=() + +for ((i=1; i<=count; i++)); do + result=$(curl -o /dev/null -s -w "%{http_code} %{time_total}\\n" \ + https://"$domain"/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $key" \ + -d '{"messages": [{"content": "echo hi", "role": "user"}], "model": "'"$model"'", "stream": false, "max_tokens": 1}') + http_code=$(echo "$result" | awk '{print $1}') + time=$(echo "$result" | awk '{print $2}') + echo "HTTP status code: $http_code, Time taken: $time" + total_time=$(bc <<< "$total_time + $time") + times+=("$time") +done + +average_time=$(echo "scale=4; $total_time / $count" | bc) + +sum_of_squares=0 +for time in "${times[@]}"; do + difference=$(echo "scale=4; $time - $average_time" | bc) + square=$(echo "scale=4; $difference * $difference" | bc) + sum_of_squares=$(echo "scale=4; $sum_of_squares + $square" | bc) +done + +standard_deviation=$(echo "scale=4; sqrt($sum_of_squares / $count)" | bc) + +echo "Average time: $average_time±$standard_deviation" diff --git a/common/api_type.go b/common/api_type.go new file mode 100644 index 00000000..f045866a --- /dev/null +++ b/common/api_type.go @@ -0,0 +1,73 @@ +package common + +import "one-api/constant" + +func ChannelType2APIType(channelType int) (int, bool) { + apiType := -1 + switch channelType { + case constant.ChannelTypeOpenAI: + apiType = constant.APITypeOpenAI + case constant.ChannelTypeAnthropic: + apiType = constant.APITypeAnthropic + case constant.ChannelTypeBaidu: + apiType = constant.APITypeBaidu + case constant.ChannelTypePaLM: + apiType = constant.APITypePaLM + case constant.ChannelTypeZhipu: + apiType = constant.APITypeZhipu + case constant.ChannelTypeAli: + apiType = constant.APITypeAli + case constant.ChannelTypeXunfei: + apiType = constant.APITypeXunfei + case constant.ChannelTypeAIProxyLibrary: + apiType = constant.APITypeAIProxyLibrary + case constant.ChannelTypeTencent: + apiType = constant.APITypeTencent + case constant.ChannelTypeGemini: + apiType = constant.APITypeGemini + case constant.ChannelTypeZhipu_v4: + apiType = constant.APITypeZhipuV4 + case constant.ChannelTypeOllama: + apiType = constant.APITypeOllama + case constant.ChannelTypePerplexity: + apiType = constant.APITypePerplexity + case constant.ChannelTypeAws: + apiType = constant.APITypeAws + case constant.ChannelTypeCohere: + apiType = constant.APITypeCohere + case constant.ChannelTypeDify: + apiType = constant.APITypeDify + case constant.ChannelTypeJina: + apiType = constant.APITypeJina + case constant.ChannelCloudflare: + apiType = constant.APITypeCloudflare + case constant.ChannelTypeSiliconFlow: + apiType = constant.APITypeSiliconFlow + case constant.ChannelTypeVertexAi: + apiType = constant.APITypeVertexAi + case constant.ChannelTypeMistral: + apiType = constant.APITypeMistral + case constant.ChannelTypeDeepSeek: + apiType = constant.APITypeDeepSeek + case constant.ChannelTypeMokaAI: + apiType = constant.APITypeMokaAI + case constant.ChannelTypeVolcEngine: + apiType = constant.APITypeVolcEngine + case constant.ChannelTypeBaiduV2: + apiType = constant.APITypeBaiduV2 + case constant.ChannelTypeOpenRouter: + apiType = constant.APITypeOpenRouter + case constant.ChannelTypeXinference: + apiType = constant.APITypeXinference + case constant.ChannelTypeXai: + apiType = constant.APITypeXai + case constant.ChannelTypeCoze: + apiType = constant.APITypeCoze + case constant.ChannelTypeJimeng: + apiType = constant.APITypeJimeng + } + if apiType == -1 { + return constant.APITypeOpenAI, false + } + return apiType, true +} diff --git a/common/constants.go b/common/constants.go new file mode 100644 index 00000000..30522411 --- /dev/null +++ b/common/constants.go @@ -0,0 +1,201 @@ +package common + +import ( + //"os" + //"strconv" + "sync" + "time" + + "github.com/google/uuid" +) + +var StartTime = time.Now().Unix() // unit: second +var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change +var SystemName = "New API" +var Footer = "" +var Logo = "" +var TopUpLink = "" + +// var ChatLink = "" +// var ChatLink2 = "" +var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens +var DisplayInCurrencyEnabled = true +var DisplayTokenStatEnabled = true +var DrawingEnabled = true +var TaskEnabled = true +var DataExportEnabled = true +var DataExportInterval = 5 // unit: minute +var DataExportDefaultTime = "hour" // unit: minute +var DefaultCollapseSidebar = false // default value of collapse sidebar + +// Any options with "Secret", "Token" in its key won't be return by GetOptions + +var SessionSecret = uuid.New().String() +var CryptoSecret = uuid.New().String() + +var OptionMap map[string]string +var OptionMapRWMutex sync.RWMutex + +var ItemsPerPage = 10 +var MaxRecentItems = 100 + +var PasswordLoginEnabled = true +var PasswordRegisterEnabled = true +var EmailVerificationEnabled = false +var GitHubOAuthEnabled = false +var LinuxDOOAuthEnabled = false +var WeChatAuthEnabled = false +var TelegramOAuthEnabled = false +var TurnstileCheckEnabled = false +var RegisterEnabled = true + +var EmailDomainRestrictionEnabled = false // 是否启用邮箱域名限制 +var EmailAliasRestrictionEnabled = false // 是否启用邮箱别名限制 +var EmailDomainWhitelist = []string{ + "gmail.com", + "163.com", + "126.com", + "qq.com", + "outlook.com", + "hotmail.com", + "icloud.com", + "yahoo.com", + "foxmail.com", +} +var EmailLoginAuthServerList = []string{ + "smtp.sendcloud.net", + "smtp.azurecomm.net", +} + +var DebugEnabled bool +var MemoryCacheEnabled bool + +var LogConsumeEnabled = true + +var SMTPServer = "" +var SMTPPort = 587 +var SMTPSSLEnabled = false +var SMTPAccount = "" +var SMTPFrom = "" +var SMTPToken = "" + +var GitHubClientId = "" +var GitHubClientSecret = "" +var LinuxDOClientId = "" +var LinuxDOClientSecret = "" + +var WeChatServerAddress = "" +var WeChatServerToken = "" +var WeChatAccountQRCodeImageURL = "" + +var TurnstileSiteKey = "" +var TurnstileSecretKey = "" + +var TelegramBotToken = "" +var TelegramBotName = "" + +var QuotaForNewUser = 0 +var QuotaForInviter = 0 +var QuotaForInvitee = 0 +var ChannelDisableThreshold = 5.0 +var AutomaticDisableChannelEnabled = false +var AutomaticEnableChannelEnabled = false +var QuotaRemindThreshold = 1000 +var PreConsumedQuota = 500 + +var RetryTimes = 0 + +//var RootUserEmail = "" + +var IsMasterNode bool + +var requestInterval int +var RequestInterval time.Duration + +var SyncFrequency int // unit is second + +var BatchUpdateEnabled = false +var BatchUpdateInterval int + +var RelayTimeout int // unit is second + +var GeminiSafetySetting string + +// https://docs.cohere.com/docs/safety-modes Type; NONE/CONTEXTUAL/STRICT +var CohereSafetySetting string + +const ( + RequestIdKey = "X-Oneapi-Request-Id" +) + +const ( + RoleGuestUser = 0 + RoleCommonUser = 1 + RoleAdminUser = 10 + RoleRootUser = 100 +) + +func IsValidateRole(role int) bool { + return role == RoleGuestUser || role == RoleCommonUser || role == RoleAdminUser || role == RoleRootUser +} + +var ( + FileUploadPermission = RoleGuestUser + FileDownloadPermission = RoleGuestUser + ImageUploadPermission = RoleGuestUser + ImageDownloadPermission = RoleGuestUser +) + +// All duration's unit is seconds +// Shouldn't larger then RateLimitKeyExpirationDuration +var ( + GlobalApiRateLimitEnable bool + GlobalApiRateLimitNum int + GlobalApiRateLimitDuration int64 + + GlobalWebRateLimitEnable bool + GlobalWebRateLimitNum int + GlobalWebRateLimitDuration int64 + + UploadRateLimitNum = 10 + UploadRateLimitDuration int64 = 60 + + DownloadRateLimitNum = 10 + DownloadRateLimitDuration int64 = 60 + + CriticalRateLimitNum = 20 + CriticalRateLimitDuration int64 = 20 * 60 +) + +var RateLimitKeyExpirationDuration = 20 * time.Minute + +const ( + UserStatusEnabled = 1 // don't use 0, 0 is the default value! + UserStatusDisabled = 2 // also don't use 0 +) + +const ( + TokenStatusEnabled = 1 // don't use 0, 0 is the default value! + TokenStatusDisabled = 2 // also don't use 0 + TokenStatusExpired = 3 + TokenStatusExhausted = 4 +) + +const ( + RedemptionCodeStatusEnabled = 1 // don't use 0, 0 is the default value! + RedemptionCodeStatusDisabled = 2 // also don't use 0 + RedemptionCodeStatusUsed = 3 // also don't use 0 +) + +const ( + ChannelStatusUnknown = 0 + ChannelStatusEnabled = 1 // don't use 0, 0 is the default value! + ChannelStatusManuallyDisabled = 2 // also don't use 0 + ChannelStatusAutoDisabled = 3 +) + +const ( + TopUpStatusPending = "pending" + TopUpStatusSuccess = "success" + TopUpStatusExpired = "expired" +) diff --git a/common/crypto.go b/common/crypto.go new file mode 100644 index 00000000..c353188a --- /dev/null +++ b/common/crypto.go @@ -0,0 +1,31 @@ +package common + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "golang.org/x/crypto/bcrypt" +) + +func GenerateHMACWithKey(key []byte, data string) string { + h := hmac.New(sha256.New, key) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +func GenerateHMAC(data string) string { + h := hmac.New(sha256.New, []byte(CryptoSecret)) + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +func Password2Hash(password string) (string, error) { + passwordBytes := []byte(password) + hashedPassword, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost) + return string(hashedPassword), err +} + +func ValidatePasswordAndHash(password string, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} diff --git a/common/custom-event.go b/common/custom-event.go new file mode 100644 index 00000000..d8f9ec9f --- /dev/null +++ b/common/custom-event.go @@ -0,0 +1,82 @@ +// Copyright 2014 Manu Martinez-Almeida. All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package common + +import ( + "fmt" + "io" + "net/http" + "strings" +) + +type stringWriter interface { + io.Writer + writeString(string) (int, error) +} + +type stringWrapper struct { + io.Writer +} + +func (w stringWrapper) writeString(str string) (int, error) { + return w.Writer.Write([]byte(str)) +} + +func checkWriter(writer io.Writer) stringWriter { + if w, ok := writer.(stringWriter); ok { + return w + } else { + return stringWrapper{writer} + } +} + +// Server-Sent Events +// W3C Working Draft 29 October 2009 +// http://www.w3.org/TR/2009/WD-eventsource-20091029/ + +var contentType = []string{"text/event-stream"} +var noCache = []string{"no-cache"} + +var fieldReplacer = strings.NewReplacer( + "\n", "\\n", + "\r", "\\r") + +var dataReplacer = strings.NewReplacer( + "\n", "\n", + "\r", "\\r") + +type CustomEvent struct { + Event string + Id string + Retry uint + Data interface{} +} + +func encode(writer io.Writer, event CustomEvent) error { + w := checkWriter(writer) + return writeData(w, event.Data) +} + +func writeData(w stringWriter, data interface{}) error { + dataReplacer.WriteString(w, fmt.Sprint(data)) + if strings.HasPrefix(data.(string), "data") { + w.writeString("\n\n") + } + return nil +} + +func (r CustomEvent) Render(w http.ResponseWriter) error { + r.WriteContentType(w) + return encode(w, r) +} + +func (r CustomEvent) WriteContentType(w http.ResponseWriter) { + header := w.Header() + header["Content-Type"] = contentType + + if _, exist := header["Cache-Control"]; !exist { + header["Cache-Control"] = noCache + } +} diff --git a/common/database.go b/common/database.go new file mode 100644 index 00000000..9cbaf46a --- /dev/null +++ b/common/database.go @@ -0,0 +1,15 @@ +package common + +const ( + DatabaseTypeMySQL = "mysql" + DatabaseTypeSQLite = "sqlite" + DatabaseTypePostgreSQL = "postgres" +) + +var UsingSQLite = false +var UsingPostgreSQL = false +var LogSqlType = DatabaseTypeSQLite // Default to SQLite for logging SQL queries +var UsingMySQL = false +var UsingClickHouse = false + +var SQLitePath = "one-api.db?_busy_timeout=5000" diff --git a/common/email-outlook-auth.go b/common/email-outlook-auth.go new file mode 100644 index 00000000..f6a71b8e --- /dev/null +++ b/common/email-outlook-auth.go @@ -0,0 +1,40 @@ +package common + +import ( + "errors" + "net/smtp" + "strings" +) + +type outlookAuth struct { + username, password string +} + +func LoginAuth(username, password string) smtp.Auth { + return &outlookAuth{username, password} +} + +func (a *outlookAuth) Start(_ *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte{}, nil +} + +func (a *outlookAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + switch string(fromServer) { + case "Username:": + return []byte(a.username), nil + case "Password:": + return []byte(a.password), nil + default: + return nil, errors.New("unknown fromServer") + } + } + return nil, nil +} + +func isOutlookServer(server string) bool { + // 兼容多地区的outlook邮箱和ofb邮箱 + // 其实应该加一个Option来区分是否用LOGIN的方式登录 + // 先临时兼容一下 + return strings.Contains(server, "outlook") || strings.Contains(server, "onmicrosoft") +} diff --git a/common/email.go b/common/email.go new file mode 100644 index 00000000..18e6dbf7 --- /dev/null +++ b/common/email.go @@ -0,0 +1,90 @@ +package common + +import ( + "crypto/tls" + "encoding/base64" + "fmt" + "net/smtp" + "slices" + "strings" + "time" +) + +func generateMessageID() (string, error) { + split := strings.Split(SMTPFrom, "@") + if len(split) < 2 { + return "", fmt.Errorf("invalid SMTP account") + } + domain := strings.Split(SMTPFrom, "@")[1] + return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), GetRandomString(12), domain), nil +} + +func SendEmail(subject string, receiver string, content string) error { + if SMTPFrom == "" { // for compatibility + SMTPFrom = SMTPAccount + } + id, err2 := generateMessageID() + if err2 != nil { + return err2 + } + if SMTPServer == "" && SMTPAccount == "" { + return fmt.Errorf("SMTP 服务器未配置") + } + encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject))) + mail := []byte(fmt.Sprintf("To: %s\r\n"+ + "From: %s<%s>\r\n"+ + "Subject: %s\r\n"+ + "Date: %s\r\n"+ + "Message-ID: %s\r\n"+ // 添加 Message-ID 头 + "Content-Type: text/html; charset=UTF-8\r\n\r\n%s\r\n", + receiver, SystemName, SMTPFrom, encodedSubject, time.Now().Format(time.RFC1123Z), id, content)) + auth := smtp.PlainAuth("", SMTPAccount, SMTPToken, SMTPServer) + addr := fmt.Sprintf("%s:%d", SMTPServer, SMTPPort) + to := strings.Split(receiver, ";") + var err error + if SMTPPort == 465 || SMTPSSLEnabled { + tlsConfig := &tls.Config{ + InsecureSkipVerify: true, + ServerName: SMTPServer, + } + conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", SMTPServer, SMTPPort), tlsConfig) + if err != nil { + return err + } + client, err := smtp.NewClient(conn, SMTPServer) + if err != nil { + return err + } + defer client.Close() + if err = client.Auth(auth); err != nil { + return err + } + if err = client.Mail(SMTPFrom); err != nil { + return err + } + receiverEmails := strings.Split(receiver, ";") + for _, receiver := range receiverEmails { + if err = client.Rcpt(receiver); err != nil { + return err + } + } + w, err := client.Data() + if err != nil { + return err + } + _, err = w.Write(mail) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + } else if isOutlookServer(SMTPAccount) || slices.Contains(EmailLoginAuthServerList, SMTPServer) { + auth = LoginAuth(SMTPAccount, SMTPToken) + err = smtp.SendMail(addr, auth, SMTPFrom, to, mail) + } else { + err = smtp.SendMail(addr, auth, SMTPFrom, to, mail) + } + return err +} diff --git a/common/embed-file-system.go b/common/embed-file-system.go new file mode 100644 index 00000000..3ea02cf8 --- /dev/null +++ b/common/embed-file-system.go @@ -0,0 +1,32 @@ +package common + +import ( + "embed" + "github.com/gin-contrib/static" + "io/fs" + "net/http" +) + +// Credit: https://github.com/gin-contrib/static/issues/19 + +type embedFileSystem struct { + http.FileSystem +} + +func (e embedFileSystem) Exists(prefix string, path string) bool { + _, err := e.Open(path) + if err != nil { + return false + } + return true +} + +func EmbedFolder(fsEmbed embed.FS, targetPath string) static.ServeFileSystem { + efs, err := fs.Sub(fsEmbed, targetPath) + if err != nil { + panic(err) + } + return embedFileSystem{ + FileSystem: http.FS(efs), + } +} diff --git a/common/endpoint_type.go b/common/endpoint_type.go new file mode 100644 index 00000000..a0ca73ea --- /dev/null +++ b/common/endpoint_type.go @@ -0,0 +1,41 @@ +package common + +import "one-api/constant" + +// GetEndpointTypesByChannelType 获取渠道最优先端点类型(所有的渠道都支持 OpenAI 端点) +func GetEndpointTypesByChannelType(channelType int, modelName string) []constant.EndpointType { + var endpointTypes []constant.EndpointType + switch channelType { + case constant.ChannelTypeJina: + endpointTypes = []constant.EndpointType{constant.EndpointTypeJinaRerank} + //case constant.ChannelTypeMidjourney, constant.ChannelTypeMidjourneyPlus: + // endpointTypes = []constant.EndpointType{constant.EndpointTypeMidjourney} + //case constant.ChannelTypeSunoAPI: + // endpointTypes = []constant.EndpointType{constant.EndpointTypeSuno} + //case constant.ChannelTypeKling: + // endpointTypes = []constant.EndpointType{constant.EndpointTypeKling} + //case constant.ChannelTypeJimeng: + // endpointTypes = []constant.EndpointType{constant.EndpointTypeJimeng} + case constant.ChannelTypeAws: + fallthrough + case constant.ChannelTypeAnthropic: + endpointTypes = []constant.EndpointType{constant.EndpointTypeAnthropic, constant.EndpointTypeOpenAI} + case constant.ChannelTypeVertexAi: + fallthrough + case constant.ChannelTypeGemini: + endpointTypes = []constant.EndpointType{constant.EndpointTypeGemini, constant.EndpointTypeOpenAI} + case constant.ChannelTypeOpenRouter: // OpenRouter 只支持 OpenAI 端点 + endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI} + default: + if IsOpenAIResponseOnlyModel(modelName) { + endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAIResponse} + } else { + endpointTypes = []constant.EndpointType{constant.EndpointTypeOpenAI} + } + } + if IsImageGenerationModel(modelName) { + // add to first + endpointTypes = append([]constant.EndpointType{constant.EndpointTypeImageGeneration}, endpointTypes...) + } + return endpointTypes +} diff --git a/common/env.go b/common/env.go new file mode 100644 index 00000000..1aa340f8 --- /dev/null +++ b/common/env.go @@ -0,0 +1,38 @@ +package common + +import ( + "fmt" + "os" + "strconv" +) + +func GetEnvOrDefault(env string, defaultValue int) int { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + num, err := strconv.Atoi(os.Getenv(env)) + if err != nil { + SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %d", env, err.Error(), defaultValue)) + return defaultValue + } + return num +} + +func GetEnvOrDefaultString(env string, defaultValue string) string { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + return os.Getenv(env) +} + +func GetEnvOrDefaultBool(env string, defaultValue bool) bool { + if env == "" || os.Getenv(env) == "" { + return defaultValue + } + b, err := strconv.ParseBool(os.Getenv(env)) + if err != nil { + SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %t", env, err.Error(), defaultValue)) + return defaultValue + } + return b +} diff --git a/common/gin.go b/common/gin.go new file mode 100644 index 00000000..8c67bb4d --- /dev/null +++ b/common/gin.go @@ -0,0 +1,111 @@ +package common + +import ( + "bytes" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/constant" + "strings" + "time" +) + +const KeyRequestBody = "key_request_body" + +func GetRequestBody(c *gin.Context) ([]byte, error) { + requestBody, _ := c.Get(KeyRequestBody) + if requestBody != nil { + return requestBody.([]byte), nil + } + requestBody, err := io.ReadAll(c.Request.Body) + if err != nil { + return nil, err + } + _ = c.Request.Body.Close() + c.Set(KeyRequestBody, requestBody) + return requestBody.([]byte), nil +} + +func UnmarshalBodyReusable(c *gin.Context, v any) error { + requestBody, err := GetRequestBody(c) + if err != nil { + return err + } + contentType := c.Request.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "application/json") { + err = Unmarshal(requestBody, &v) + } else { + // skip for now + // TODO: someday non json request have variant model, we will need to implementation this + } + if err != nil { + return err + } + // Reset request body + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + return nil +} + +func SetContextKey(c *gin.Context, key constant.ContextKey, value any) { + c.Set(string(key), value) +} + +func GetContextKey(c *gin.Context, key constant.ContextKey) (any, bool) { + return c.Get(string(key)) +} + +func GetContextKeyString(c *gin.Context, key constant.ContextKey) string { + return c.GetString(string(key)) +} + +func GetContextKeyInt(c *gin.Context, key constant.ContextKey) int { + return c.GetInt(string(key)) +} + +func GetContextKeyBool(c *gin.Context, key constant.ContextKey) bool { + return c.GetBool(string(key)) +} + +func GetContextKeyStringSlice(c *gin.Context, key constant.ContextKey) []string { + return c.GetStringSlice(string(key)) +} + +func GetContextKeyStringMap(c *gin.Context, key constant.ContextKey) map[string]any { + return c.GetStringMap(string(key)) +} + +func GetContextKeyTime(c *gin.Context, key constant.ContextKey) time.Time { + return c.GetTime(string(key)) +} + +func GetContextKeyType[T any](c *gin.Context, key constant.ContextKey) (T, bool) { + if value, ok := c.Get(string(key)); ok { + if v, ok := value.(T); ok { + return v, true + } + } + var t T + return t, false +} + +func ApiError(c *gin.Context, err error) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) +} + +func ApiErrorMsg(c *gin.Context, msg string) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": msg, + }) +} + +func ApiSuccess(c *gin.Context, data any) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) +} diff --git a/common/go-channel.go b/common/go-channel.go new file mode 100644 index 00000000..f9168fc4 --- /dev/null +++ b/common/go-channel.go @@ -0,0 +1,53 @@ +package common + +import ( + "time" +) + +func SafeSendBool(ch chan bool, value bool) (closed bool) { + defer func() { + // Recover from panic if one occured. A panic would mean the channel was closed. + if recover() != nil { + closed = true + } + }() + + // This will panic if the channel is closed. + ch <- value + + // If the code reaches here, then the channel was not closed. + return false +} + +func SafeSendString(ch chan string, value string) (closed bool) { + defer func() { + // Recover from panic if one occured. A panic would mean the channel was closed. + if recover() != nil { + closed = true + } + }() + + // This will panic if the channel is closed. + ch <- value + + // If the code reaches here, then the channel was not closed. + return false +} + +// SafeSendStringTimeout send, return true, else return false +func SafeSendStringTimeout(ch chan string, value string, timeout int) (closed bool) { + defer func() { + // Recover from panic if one occured. A panic would mean the channel was closed. + if recover() != nil { + closed = false + } + }() + + // This will panic if the channel is closed. + select { + case ch <- value: + return true + case <-time.After(time.Duration(timeout) * time.Second): + return false + } +} diff --git a/common/gopool.go b/common/gopool.go new file mode 100644 index 00000000..bf5df311 --- /dev/null +++ b/common/gopool.go @@ -0,0 +1,24 @@ +package common + +import ( + "context" + "fmt" + "github.com/bytedance/gopkg/util/gopool" + "math" +) + +var relayGoPool gopool.Pool + +func init() { + relayGoPool = gopool.NewPool("gopool.RelayPool", math.MaxInt32, gopool.NewConfig()) + relayGoPool.SetPanicHandler(func(ctx context.Context, i interface{}) { + if stopChan, ok := ctx.Value("stop_chan").(chan bool); ok { + SafeSendBool(stopChan, true) + } + SysError(fmt.Sprintf("panic in gopool.RelayPool: %v", i)) + }) +} + +func RelayCtxGo(ctx context.Context, f func()) { + relayGoPool.CtxGo(ctx, f) +} diff --git a/common/hash.go b/common/hash.go new file mode 100644 index 00000000..50191938 --- /dev/null +++ b/common/hash.go @@ -0,0 +1,34 @@ +package common + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/hex" +) + +func Sha256Raw(data []byte) []byte { + h := sha256.New() + h.Write(data) + return h.Sum(nil) +} + +func Sha1Raw(data []byte) []byte { + h := sha1.New() + h.Write(data) + return h.Sum(nil) +} + +func Sha1(data []byte) string { + return hex.EncodeToString(Sha1Raw(data)) +} + +func HmacSha256Raw(message, key []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(message) + return h.Sum(nil) +} + +func HmacSha256(message, key string) string { + return hex.EncodeToString(HmacSha256Raw([]byte(message), []byte(key))) +} diff --git a/common/http.go b/common/http.go new file mode 100644 index 00000000..d2e824ef --- /dev/null +++ b/common/http.go @@ -0,0 +1,57 @@ +package common + +import ( + "bytes" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" +) + +func CloseResponseBodyGracefully(httpResponse *http.Response) { + if httpResponse == nil || httpResponse.Body == nil { + return + } + err := httpResponse.Body.Close() + if err != nil { + SysError("failed to close response body: " + err.Error()) + } +} + +func IOCopyBytesGracefully(c *gin.Context, src *http.Response, data []byte) { + if c.Writer == nil { + return + } + + body := io.NopCloser(bytes.NewBuffer(data)) + + // We shouldn't set the header before we parse the response body, because the parse part may fail. + // And then we will have to send an error response, but in this case, the header has already been set. + // So the httpClient will be confused by the response. + // For example, Postman will report error, and we cannot check the response at all. + if src != nil { + for k, v := range src.Header { + // avoid setting Content-Length + if k == "Content-Length" { + continue + } + c.Writer.Header().Set(k, v[0]) + } + } + + // set Content-Length header manually BEFORE calling WriteHeader + c.Writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + + // Write header with status code (this sends the headers) + if src != nil { + c.Writer.WriteHeader(src.StatusCode) + } else { + c.Writer.WriteHeader(http.StatusOK) + } + + _, err := io.Copy(c.Writer, body) + if err != nil { + LogError(c, fmt.Sprintf("failed to copy response body: %s", err.Error())) + } +} diff --git a/common/init.go b/common/init.go new file mode 100644 index 00000000..d70a09dd --- /dev/null +++ b/common/init.go @@ -0,0 +1,120 @@ +package common + +import ( + "flag" + "fmt" + "log" + "one-api/constant" + "os" + "path/filepath" + "strconv" + "time" +) + +var ( + Port = flag.Int("port", 3000, "the listening port") + PrintVersion = flag.Bool("version", false, "print version and exit") + PrintHelp = flag.Bool("help", false, "print help and exit") + LogDir = flag.String("log-dir", "./logs", "specify the log directory") +) + +func printHelp() { + fmt.Println("New API " + Version + " - All in one API service for OpenAI API.") + fmt.Println("Copyright (C) 2023 JustSong. All rights reserved.") + fmt.Println("GitHub: https://github.com/songquanpeng/one-api") + fmt.Println("Usage: one-api [--port ] [--log-dir ] [--version] [--help]") +} + +func InitEnv() { + flag.Parse() + + if *PrintVersion { + fmt.Println(Version) + os.Exit(0) + } + + if *PrintHelp { + printHelp() + os.Exit(0) + } + + if os.Getenv("SESSION_SECRET") != "" { + ss := os.Getenv("SESSION_SECRET") + if ss == "random_string" { + log.Println("WARNING: SESSION_SECRET is set to the default value 'random_string', please change it to a random string.") + log.Println("警告:SESSION_SECRET被设置为默认值'random_string',请修改为随机字符串。") + log.Fatal("Please set SESSION_SECRET to a random string.") + } else { + SessionSecret = ss + } + } + if os.Getenv("CRYPTO_SECRET") != "" { + CryptoSecret = os.Getenv("CRYPTO_SECRET") + } else { + CryptoSecret = SessionSecret + } + if os.Getenv("SQLITE_PATH") != "" { + SQLitePath = os.Getenv("SQLITE_PATH") + } + if *LogDir != "" { + var err error + *LogDir, err = filepath.Abs(*LogDir) + if err != nil { + log.Fatal(err) + } + if _, err := os.Stat(*LogDir); os.IsNotExist(err) { + err = os.Mkdir(*LogDir, 0777) + if err != nil { + log.Fatal(err) + } + } + } + + // Initialize variables from constants.go that were using environment variables + DebugEnabled = os.Getenv("DEBUG") == "true" + MemoryCacheEnabled = os.Getenv("MEMORY_CACHE_ENABLED") == "true" + IsMasterNode = os.Getenv("NODE_TYPE") != "slave" + + // Parse requestInterval and set RequestInterval + requestInterval, _ = strconv.Atoi(os.Getenv("POLLING_INTERVAL")) + RequestInterval = time.Duration(requestInterval) * time.Second + + // Initialize variables with GetEnvOrDefault + SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60) + BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5) + RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0) + + // Initialize string variables with GetEnvOrDefaultString + GeminiSafetySetting = GetEnvOrDefaultString("GEMINI_SAFETY_SETTING", "BLOCK_NONE") + CohereSafetySetting = GetEnvOrDefaultString("COHERE_SAFETY_SETTING", "NONE") + + // Initialize rate limit variables + GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true) + GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 180) + GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180)) + + GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true) + GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60) + GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180)) + + initConstantEnv() +} + +func initConstantEnv() { + constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 120) + constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true) + constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20) + // ForceStreamOption 覆盖请求参数,强制返回usage信息 + constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true) + constant.GetMediaToken = GetEnvOrDefaultBool("GET_MEDIA_TOKEN", true) + constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", true) + constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true) + constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview") + constant.GeminiVisionMaxImageNum = GetEnvOrDefault("GEMINI_VISION_MAX_IMAGE_NUM", 16) + constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2) + constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10) + // GenerateDefaultToken 是否生成初始令牌,默认关闭。 + constant.GenerateDefaultToken = GetEnvOrDefaultBool("GENERATE_DEFAULT_TOKEN", false) + // 是否启用错误日志 + constant.ErrorLogEnabled = GetEnvOrDefaultBool("ERROR_LOG_ENABLED", false) +} diff --git a/common/json.go b/common/json.go new file mode 100644 index 00000000..69aa952e --- /dev/null +++ b/common/json.go @@ -0,0 +1,22 @@ +package common + +import ( + "bytes" + "encoding/json" +) + +func Unmarshal(data []byte, v any) error { + return json.Unmarshal(data, v) +} + +func UnmarshalJsonStr(data string, v any) error { + return json.Unmarshal(StringToByteSlice(data), v) +} + +func DecodeJson(reader *bytes.Reader, v any) error { + return json.NewDecoder(reader).Decode(v) +} + +func Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} diff --git a/common/limiter/limiter.go b/common/limiter/limiter.go new file mode 100644 index 00000000..ef5d1935 --- /dev/null +++ b/common/limiter/limiter.go @@ -0,0 +1,89 @@ +package limiter + +import ( + "context" + _ "embed" + "fmt" + "github.com/go-redis/redis/v8" + "one-api/common" + "sync" +) + +//go:embed lua/rate_limit.lua +var rateLimitScript string + +type RedisLimiter struct { + client *redis.Client + limitScriptSHA string +} + +var ( + instance *RedisLimiter + once sync.Once +) + +func New(ctx context.Context, r *redis.Client) *RedisLimiter { + once.Do(func() { + // 预加载脚本 + limitSHA, err := r.ScriptLoad(ctx, rateLimitScript).Result() + if err != nil { + common.SysLog(fmt.Sprintf("Failed to load rate limit script: %v", err)) + } + instance = &RedisLimiter{ + client: r, + limitScriptSHA: limitSHA, + } + }) + + return instance +} + +func (rl *RedisLimiter) Allow(ctx context.Context, key string, opts ...Option) (bool, error) { + // 默认配置 + config := &Config{ + Capacity: 10, + Rate: 1, + Requested: 1, + } + + // 应用选项模式 + for _, opt := range opts { + opt(config) + } + + // 执行限流 + result, err := rl.client.EvalSha( + ctx, + rl.limitScriptSHA, + []string{key}, + config.Requested, + config.Rate, + config.Capacity, + ).Int() + + if err != nil { + return false, fmt.Errorf("rate limit failed: %w", err) + } + return result == 1, nil +} + +// Config 配置选项模式 +type Config struct { + Capacity int64 + Rate int64 + Requested int64 +} + +type Option func(*Config) + +func WithCapacity(c int64) Option { + return func(cfg *Config) { cfg.Capacity = c } +} + +func WithRate(r int64) Option { + return func(cfg *Config) { cfg.Rate = r } +} + +func WithRequested(n int64) Option { + return func(cfg *Config) { cfg.Requested = n } +} diff --git a/common/limiter/lua/rate_limit.lua b/common/limiter/lua/rate_limit.lua new file mode 100644 index 00000000..c07fd3a8 --- /dev/null +++ b/common/limiter/lua/rate_limit.lua @@ -0,0 +1,44 @@ +-- 令牌桶限流器 +-- KEYS[1]: 限流器唯一标识 +-- ARGV[1]: 请求令牌数 (通常为1) +-- ARGV[2]: 令牌生成速率 (每秒) +-- ARGV[3]: 桶容量 + +local key = KEYS[1] +local requested = tonumber(ARGV[1]) +local rate = tonumber(ARGV[2]) +local capacity = tonumber(ARGV[3]) + +-- 获取当前时间(Redis服务器时间) +local now = redis.call('TIME') +local nowInSeconds = tonumber(now[1]) + +-- 获取桶状态 +local bucket = redis.call('HMGET', key, 'tokens', 'last_time') +local tokens = tonumber(bucket[1]) +local last_time = tonumber(bucket[2]) + +-- 初始化桶(首次请求或过期) +if not tokens or not last_time then + tokens = capacity + last_time = nowInSeconds +else + -- 计算新增令牌 + local elapsed = nowInSeconds - last_time + local add_tokens = elapsed * rate + tokens = math.min(capacity, tokens + add_tokens) + last_time = nowInSeconds +end + +-- 判断是否允许请求 +local allowed = false +if tokens >= requested then + tokens = tokens - requested + allowed = true +end + +---- 更新桶状态并设置过期时间 +redis.call('HMSET', key, 'tokens', tokens, 'last_time', last_time) +--redis.call('EXPIRE', key, math.ceil(capacity / rate) + 60) -- 适当延长过期时间 + +return allowed and 1 or 0 \ No newline at end of file diff --git a/common/logger.go b/common/logger.go new file mode 100644 index 00000000..0f6dc3c3 --- /dev/null +++ b/common/logger.go @@ -0,0 +1,123 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" + "io" + "log" + "os" + "path/filepath" + "sync" + "time" +) + +const ( + loggerINFO = "INFO" + loggerWarn = "WARN" + loggerError = "ERR" +) + +const maxLogCount = 1000000 + +var logCount int +var setupLogLock sync.Mutex +var setupLogWorking bool + +func SetupLogger() { + if *LogDir != "" { + ok := setupLogLock.TryLock() + if !ok { + log.Println("setup log is already working") + return + } + defer func() { + setupLogLock.Unlock() + setupLogWorking = false + }() + logPath := filepath.Join(*LogDir, fmt.Sprintf("oneapi-%s.log", time.Now().Format("20060102150405"))) + fd, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatal("failed to open log file") + } + gin.DefaultWriter = io.MultiWriter(os.Stdout, fd) + gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd) + } +} + +func SysLog(s string) { + t := time.Now() + _, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) +} + +func SysError(s string) { + t := time.Now() + _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) +} + +func LogInfo(ctx context.Context, msg string) { + logHelper(ctx, loggerINFO, msg) +} + +func LogWarn(ctx context.Context, msg string) { + logHelper(ctx, loggerWarn, msg) +} + +func LogError(ctx context.Context, msg string) { + logHelper(ctx, loggerError, msg) +} + +func logHelper(ctx context.Context, level string, msg string) { + writer := gin.DefaultErrorWriter + if level == loggerINFO { + writer = gin.DefaultWriter + } + id := ctx.Value(RequestIdKey) + if id == nil { + id = "SYSTEM" + } + now := time.Now() + _, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg) + logCount++ // we don't need accurate count, so no lock here + if logCount > maxLogCount && !setupLogWorking { + logCount = 0 + setupLogWorking = true + gopool.Go(func() { + SetupLogger() + }) + } +} + +func FatalLog(v ...any) { + t := time.Now() + _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v) + os.Exit(1) +} + +func LogQuota(quota int) string { + if DisplayInCurrencyEnabled { + return fmt.Sprintf("$%.6f 额度", float64(quota)/QuotaPerUnit) + } else { + return fmt.Sprintf("%d 点额度", quota) + } +} + +func FormatQuota(quota int) string { + if DisplayInCurrencyEnabled { + return fmt.Sprintf("$%.6f", float64(quota)/QuotaPerUnit) + } else { + return fmt.Sprintf("%d", quota) + } +} + +// LogJson 仅供测试使用 only for test +func LogJson(ctx context.Context, msg string, obj any) { + jsonStr, err := json.Marshal(obj) + if err != nil { + LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error())) + return + } + LogInfo(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr))) +} diff --git a/common/model.go b/common/model.go new file mode 100644 index 00000000..14ca1911 --- /dev/null +++ b/common/model.go @@ -0,0 +1,42 @@ +package common + +import "strings" + +var ( + // OpenAIResponseOnlyModels is a list of models that are only available for OpenAI responses. + OpenAIResponseOnlyModels = []string{ + "o3-pro", + "o3-deep-research", + "o4-mini-deep-research", + } + ImageGenerationModels = []string{ + "dall-e-3", + "dall-e-2", + "gpt-image-1", + "prefix:imagen-", + "flux-", + "flux.1-", + } +) + +func IsOpenAIResponseOnlyModel(modelName string) bool { + for _, m := range OpenAIResponseOnlyModels { + if strings.Contains(modelName, m) { + return true + } + } + return false +} + +func IsImageGenerationModel(modelName string) bool { + modelName = strings.ToLower(modelName) + for _, m := range ImageGenerationModels { + if strings.Contains(modelName, m) { + return true + } + if strings.HasPrefix(m, "prefix:") && strings.HasPrefix(modelName, strings.TrimPrefix(m, "prefix:")) { + return true + } + } + return false +} diff --git a/common/page_info.go b/common/page_info.go new file mode 100644 index 00000000..5e4535e3 --- /dev/null +++ b/common/page_info.go @@ -0,0 +1,82 @@ +package common + +import ( + "strconv" + + "github.com/gin-gonic/gin" +) + +type PageInfo struct { + Page int `json:"page"` // page num 页码 + PageSize int `json:"page_size"` // page size 页大小 + + Total int `json:"total"` // 总条数,后设置 + Items any `json:"items"` // 数据,后设置 +} + +func (p *PageInfo) GetStartIdx() int { + return (p.Page - 1) * p.PageSize +} + +func (p *PageInfo) GetEndIdx() int { + return p.Page * p.PageSize +} + +func (p *PageInfo) GetPageSize() int { + return p.PageSize +} + +func (p *PageInfo) GetPage() int { + return p.Page +} + +func (p *PageInfo) SetTotal(total int) { + p.Total = total +} + +func (p *PageInfo) SetItems(items any) { + p.Items = items +} + +func GetPageQuery(c *gin.Context) *PageInfo { + pageInfo := &PageInfo{} + // 手动获取并处理每个参数 + if page, err := strconv.Atoi(c.Query("page")); err == nil { + pageInfo.Page = page + } + if pageSize, err := strconv.Atoi(c.Query("page_size")); err == nil { + pageInfo.PageSize = pageSize + } + if pageInfo.Page < 1 { + // 兼容 + page, _ := strconv.Atoi(c.Query("p")) + if page != 0 { + pageInfo.Page = page + } else { + pageInfo.Page = 1 + } + } + + if pageInfo.PageSize == 0 { + // 兼容 + pageSize, _ := strconv.Atoi(c.Query("ps")) + if pageSize != 0 { + pageInfo.PageSize = pageSize + } + if pageInfo.PageSize == 0 { + pageSize, _ = strconv.Atoi(c.Query("size")) // token page + if pageSize != 0 { + pageInfo.PageSize = pageSize + } + } + if pageInfo.PageSize == 0 { + pageInfo.PageSize = ItemsPerPage + } + } + + if pageInfo.PageSize > 100 { + pageInfo.PageSize = 100 + } + + return pageInfo +} diff --git a/common/pprof.go b/common/pprof.go new file mode 100644 index 00000000..4bec30f1 --- /dev/null +++ b/common/pprof.go @@ -0,0 +1,44 @@ +package common + +import ( + "fmt" + "github.com/shirou/gopsutil/cpu" + "os" + "runtime/pprof" + "time" +) + +// Monitor 定时监控cpu使用率,超过阈值输出pprof文件 +func Monitor() { + for { + percent, err := cpu.Percent(time.Second, false) + if err != nil { + panic(err) + } + if percent[0] > 80 { + fmt.Println("cpu usage too high") + // write pprof file + if _, err := os.Stat("./pprof"); os.IsNotExist(err) { + err := os.Mkdir("./pprof", os.ModePerm) + if err != nil { + SysLog("创建pprof文件夹失败 " + err.Error()) + continue + } + } + f, err := os.Create("./pprof/" + fmt.Sprintf("cpu-%s.pprof", time.Now().Format("20060102150405"))) + if err != nil { + SysLog("创建pprof文件失败 " + err.Error()) + continue + } + err = pprof.StartCPUProfile(f) + if err != nil { + SysLog("启动pprof失败 " + err.Error()) + continue + } + time.Sleep(10 * time.Second) // profile for 30 seconds + pprof.StopCPUProfile() + f.Close() + } + time.Sleep(30 * time.Second) + } +} diff --git a/common/rate-limit.go b/common/rate-limit.go new file mode 100644 index 00000000..301c101c --- /dev/null +++ b/common/rate-limit.go @@ -0,0 +1,70 @@ +package common + +import ( + "sync" + "time" +) + +type InMemoryRateLimiter struct { + store map[string]*[]int64 + mutex sync.Mutex + expirationDuration time.Duration +} + +func (l *InMemoryRateLimiter) Init(expirationDuration time.Duration) { + if l.store == nil { + l.mutex.Lock() + if l.store == nil { + l.store = make(map[string]*[]int64) + l.expirationDuration = expirationDuration + if expirationDuration > 0 { + go l.clearExpiredItems() + } + } + l.mutex.Unlock() + } +} + +func (l *InMemoryRateLimiter) clearExpiredItems() { + for { + time.Sleep(l.expirationDuration) + l.mutex.Lock() + now := time.Now().Unix() + for key := range l.store { + queue := l.store[key] + size := len(*queue) + if size == 0 || now-(*queue)[size-1] > int64(l.expirationDuration.Seconds()) { + delete(l.store, key) + } + } + l.mutex.Unlock() + } +} + +// Request parameter duration's unit is seconds +func (l *InMemoryRateLimiter) Request(key string, maxRequestNum int, duration int64) bool { + l.mutex.Lock() + defer l.mutex.Unlock() + // [old <-- new] + queue, ok := l.store[key] + now := time.Now().Unix() + if ok { + if len(*queue) < maxRequestNum { + *queue = append(*queue, now) + return true + } else { + if now-(*queue)[0] >= duration { + *queue = (*queue)[1:] + *queue = append(*queue, now) + return true + } else { + return false + } + } + } else { + s := make([]int64, 0, maxRequestNum) + l.store[key] = &s + *(l.store[key]) = append(*(l.store[key]), now) + } + return true +} diff --git a/common/redis.go b/common/redis.go new file mode 100644 index 00000000..c7287837 --- /dev/null +++ b/common/redis.go @@ -0,0 +1,327 @@ +package common + +import ( + "context" + "errors" + "fmt" + "os" + "reflect" + "strconv" + "time" + + "github.com/go-redis/redis/v8" + "gorm.io/gorm" +) + +var RDB *redis.Client +var RedisEnabled = true + +func RedisKeyCacheSeconds() int { + return SyncFrequency +} + +// InitRedisClient This function is called after init() +func InitRedisClient() (err error) { + if os.Getenv("REDIS_CONN_STRING") == "" { + RedisEnabled = false + SysLog("REDIS_CONN_STRING not set, Redis is not enabled") + return nil + } + if os.Getenv("SYNC_FREQUENCY") == "" { + SysLog("SYNC_FREQUENCY not set, use default value 60") + SyncFrequency = 60 + } + SysLog("Redis is enabled") + opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) + if err != nil { + FatalLog("failed to parse Redis connection string: " + err.Error()) + } + opt.PoolSize = GetEnvOrDefault("REDIS_POOL_SIZE", 10) + RDB = redis.NewClient(opt) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err = RDB.Ping(ctx).Result() + if err != nil { + FatalLog("Redis ping test failed: " + err.Error()) + } + if DebugEnabled { + SysLog(fmt.Sprintf("Redis connected to %s", opt.Addr)) + SysLog(fmt.Sprintf("Redis database: %d", opt.DB)) + } + return err +} + +func ParseRedisOption() *redis.Options { + opt, err := redis.ParseURL(os.Getenv("REDIS_CONN_STRING")) + if err != nil { + FatalLog("failed to parse Redis connection string: " + err.Error()) + } + return opt +} + +func RedisSet(key string, value string, expiration time.Duration) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis SET: key=%s, value=%s, expiration=%v", key, value, expiration)) + } + ctx := context.Background() + return RDB.Set(ctx, key, value, expiration).Err() +} + +func RedisGet(key string) (string, error) { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis GET: key=%s", key)) + } + ctx := context.Background() + val, err := RDB.Get(ctx, key).Result() + return val, err +} + +//func RedisExpire(key string, expiration time.Duration) error { +// ctx := context.Background() +// return RDB.Expire(ctx, key, expiration).Err() +//} +// +//func RedisGetEx(key string, expiration time.Duration) (string, error) { +// ctx := context.Background() +// return RDB.GetSet(ctx, key, expiration).Result() +//} + +func RedisDel(key string) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis DEL: key=%s", key)) + } + ctx := context.Background() + return RDB.Del(ctx, key).Err() +} + +func RedisDelKey(key string) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis DEL Key: key=%s", key)) + } + ctx := context.Background() + return RDB.Del(ctx, key).Err() +} + +func RedisHSetObj(key string, obj interface{}, expiration time.Duration) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis HSET: key=%s, obj=%+v, expiration=%v", key, obj, expiration)) + } + ctx := context.Background() + + data := make(map[string]interface{}) + + // 使用反射遍历结构体字段 + v := reflect.ValueOf(obj).Elem() + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + value := v.Field(i) + + // Skip DeletedAt field + if field.Type.String() == "gorm.DeletedAt" { + continue + } + + // 处理指针类型 + if value.Kind() == reflect.Ptr { + if value.IsNil() { + data[field.Name] = "" + continue + } + value = value.Elem() + } + + // 处理布尔类型 + if value.Kind() == reflect.Bool { + data[field.Name] = strconv.FormatBool(value.Bool()) + continue + } + + // 其他类型直接转换为字符串 + data[field.Name] = fmt.Sprintf("%v", value.Interface()) + } + + txn := RDB.TxPipeline() + txn.HSet(ctx, key, data) + + // 只有在 expiration 大于 0 时才设置过期时间 + if expiration > 0 { + txn.Expire(ctx, key, expiration) + } + + _, err := txn.Exec(ctx) + if err != nil { + return fmt.Errorf("failed to execute transaction: %w", err) + } + return nil +} + +func RedisHGetObj(key string, obj interface{}) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis HGETALL: key=%s", key)) + } + ctx := context.Background() + + result, err := RDB.HGetAll(ctx, key).Result() + if err != nil { + return fmt.Errorf("failed to load hash from Redis: %w", err) + } + + if len(result) == 0 { + return fmt.Errorf("key %s not found in Redis", key) + } + + // Handle both pointer and non-pointer values + val := reflect.ValueOf(obj) + if val.Kind() != reflect.Ptr { + return fmt.Errorf("obj must be a pointer to a struct, got %T", obj) + } + + v := val.Elem() + if v.Kind() != reflect.Struct { + return fmt.Errorf("obj must be a pointer to a struct, got pointer to %T", v.Interface()) + } + + t := v.Type() + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + fieldName := field.Name + if value, ok := result[fieldName]; ok { + fieldValue := v.Field(i) + + // Handle pointer types + if fieldValue.Kind() == reflect.Ptr { + if value == "" { + continue + } + if fieldValue.IsNil() { + fieldValue.Set(reflect.New(fieldValue.Type().Elem())) + } + fieldValue = fieldValue.Elem() + } + + // Enhanced type handling for Token struct + switch fieldValue.Kind() { + case reflect.String: + fieldValue.SetString(value) + case reflect.Int, reflect.Int64: + intValue, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Errorf("failed to parse int field %s: %w", fieldName, err) + } + fieldValue.SetInt(intValue) + case reflect.Bool: + boolValue, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("failed to parse bool field %s: %w", fieldName, err) + } + fieldValue.SetBool(boolValue) + case reflect.Struct: + // Special handling for gorm.DeletedAt + if fieldValue.Type().String() == "gorm.DeletedAt" { + if value != "" { + timeValue, err := time.Parse(time.RFC3339, value) + if err != nil { + return fmt.Errorf("failed to parse DeletedAt field %s: %w", fieldName, err) + } + fieldValue.Set(reflect.ValueOf(gorm.DeletedAt{Time: timeValue, Valid: true})) + } + } + default: + return fmt.Errorf("unsupported field type: %s for field %s", fieldValue.Kind(), fieldName) + } + } + } + + return nil +} + +// RedisIncr Add this function to handle atomic increments +func RedisIncr(key string, delta int64) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis INCR: key=%s, delta=%d", key, delta)) + } + // 检查键的剩余生存时间 + ttlCmd := RDB.TTL(context.Background(), key) + ttl, err := ttlCmd.Result() + if err != nil && !errors.Is(err, redis.Nil) { + return fmt.Errorf("failed to get TTL: %w", err) + } + + // 只有在 key 存在且有 TTL 时才需要特殊处理 + if ttl > 0 { + ctx := context.Background() + // 开始一个Redis事务 + txn := RDB.TxPipeline() + + // 减少余额 + decrCmd := txn.IncrBy(ctx, key, delta) + if err := decrCmd.Err(); err != nil { + return err // 如果减少失败,则直接返回错误 + } + + // 重新设置过期时间,使用原来的过期时间 + txn.Expire(ctx, key, ttl) + + // 执行事务 + _, err = txn.Exec(ctx) + return err + } + return nil +} + +func RedisHIncrBy(key, field string, delta int64) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis HINCRBY: key=%s, field=%s, delta=%d", key, field, delta)) + } + ttlCmd := RDB.TTL(context.Background(), key) + ttl, err := ttlCmd.Result() + if err != nil && !errors.Is(err, redis.Nil) { + return fmt.Errorf("failed to get TTL: %w", err) + } + + if ttl > 0 { + ctx := context.Background() + txn := RDB.TxPipeline() + + incrCmd := txn.HIncrBy(ctx, key, field, delta) + if err := incrCmd.Err(); err != nil { + return err + } + + txn.Expire(ctx, key, ttl) + + _, err = txn.Exec(ctx) + return err + } + return nil +} + +func RedisHSetField(key, field string, value interface{}) error { + if DebugEnabled { + SysLog(fmt.Sprintf("Redis HSET field: key=%s, field=%s, value=%v", key, field, value)) + } + ttlCmd := RDB.TTL(context.Background(), key) + ttl, err := ttlCmd.Result() + if err != nil && !errors.Is(err, redis.Nil) { + return fmt.Errorf("failed to get TTL: %w", err) + } + + if ttl > 0 { + ctx := context.Background() + txn := RDB.TxPipeline() + + hsetCmd := txn.HSet(ctx, key, field, value) + if err := hsetCmd.Err(); err != nil { + return err + } + + txn.Expire(ctx, key, ttl) + + _, err = txn.Exec(ctx) + return err + } + return nil +} diff --git a/common/str.go b/common/str.go new file mode 100644 index 00000000..88b58c72 --- /dev/null +++ b/common/str.go @@ -0,0 +1,97 @@ +package common + +import ( + "encoding/base64" + "encoding/json" + "math/rand" + "strconv" + "unsafe" +) + +func GetStringIfEmpty(str string, defaultValue string) string { + if str == "" { + return defaultValue + } + return str +} + +func GetRandomString(length int) string { + //rand.Seed(time.Now().UnixNano()) + key := make([]byte, length) + for i := 0; i < length; i++ { + key[i] = keyChars[rand.Intn(len(keyChars))] + } + return string(key) +} + +func MapToJsonStr(m map[string]interface{}) string { + bytes, err := json.Marshal(m) + if err != nil { + return "" + } + return string(bytes) +} + +func StrToMap(str string) (map[string]interface{}, error) { + m := make(map[string]interface{}) + err := Unmarshal([]byte(str), &m) + if err != nil { + return nil, err + } + return m, nil +} + +func StrToJsonArray(str string) ([]interface{}, error) { + var js []interface{} + err := json.Unmarshal([]byte(str), &js) + if err != nil { + return nil, err + } + return js, nil +} + +func IsJsonArray(str string) bool { + var js []interface{} + return json.Unmarshal([]byte(str), &js) == nil +} + +func IsJsonObject(str string) bool { + var js map[string]interface{} + return json.Unmarshal([]byte(str), &js) == nil +} + +func String2Int(str string) int { + num, err := strconv.Atoi(str) + if err != nil { + return 0 + } + return num +} + +func StringsContains(strs []string, str string) bool { + for _, s := range strs { + if s == str { + return true + } + } + return false +} + +// StringToByteSlice []byte only read, panic on append +func StringToByteSlice(s string) []byte { + tmp1 := (*[2]uintptr)(unsafe.Pointer(&s)) + tmp2 := [3]uintptr{tmp1[0], tmp1[1], tmp1[1]} + return *(*[]byte)(unsafe.Pointer(&tmp2)) +} + +func EncodeBase64(str string) string { + return base64.StdEncoding.EncodeToString([]byte(str)) +} + +func GetJsonString(data any) string { + if data == nil { + return "" + } + b, _ := json.Marshal(data) + return string(b) +} diff --git a/common/topup-ratio.go b/common/topup-ratio.go new file mode 100644 index 00000000..8f03395d --- /dev/null +++ b/common/topup-ratio.go @@ -0,0 +1,33 @@ +package common + +import ( + "encoding/json" +) + +var TopupGroupRatio = map[string]float64{ + "default": 1, + "vip": 1, + "svip": 1, +} + +func TopupGroupRatio2JSONString() string { + jsonBytes, err := json.Marshal(TopupGroupRatio) + if err != nil { + SysError("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateTopupGroupRatioByJSONString(jsonStr string) error { + TopupGroupRatio = make(map[string]float64) + return json.Unmarshal([]byte(jsonStr), &TopupGroupRatio) +} + +func GetTopupGroupRatio(name string) float64 { + ratio, ok := TopupGroupRatio[name] + if !ok { + SysError("topup group ratio not found: " + name) + return 1 + } + return ratio +} diff --git a/common/utils.go b/common/utils.go new file mode 100644 index 00000000..17aecd95 --- /dev/null +++ b/common/utils.go @@ -0,0 +1,304 @@ +package common + +import ( + "bytes" + "context" + crand "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "math/big" + "math/rand" + "net" + "net/url" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/pkg/errors" +) + +func OpenBrowser(url string) { + var err error + + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + } + if err != nil { + log.Println(err) + } +} + +func GetIp() (ip string) { + ips, err := net.InterfaceAddrs() + if err != nil { + log.Println(err) + return ip + } + + for _, a := range ips { + if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ipNet.IP.To4() != nil { + ip = ipNet.IP.String() + if strings.HasPrefix(ip, "10") { + return + } + if strings.HasPrefix(ip, "172") { + return + } + if strings.HasPrefix(ip, "192.168") { + return + } + ip = "" + } + } + } + return +} + +var sizeKB = 1024 +var sizeMB = sizeKB * 1024 +var sizeGB = sizeMB * 1024 + +func Bytes2Size(num int64) string { + numStr := "" + unit := "B" + if num/int64(sizeGB) > 1 { + numStr = fmt.Sprintf("%.2f", float64(num)/float64(sizeGB)) + unit = "GB" + } else if num/int64(sizeMB) > 1 { + numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeMB))) + unit = "MB" + } else if num/int64(sizeKB) > 1 { + numStr = fmt.Sprintf("%d", int(float64(num)/float64(sizeKB))) + unit = "KB" + } else { + numStr = fmt.Sprintf("%d", num) + } + return numStr + " " + unit +} + +func Seconds2Time(num int) (time string) { + if num/31104000 > 0 { + time += strconv.Itoa(num/31104000) + " 年 " + num %= 31104000 + } + if num/2592000 > 0 { + time += strconv.Itoa(num/2592000) + " 个月 " + num %= 2592000 + } + if num/86400 > 0 { + time += strconv.Itoa(num/86400) + " 天 " + num %= 86400 + } + if num/3600 > 0 { + time += strconv.Itoa(num/3600) + " 小时 " + num %= 3600 + } + if num/60 > 0 { + time += strconv.Itoa(num/60) + " 分钟 " + num %= 60 + } + time += strconv.Itoa(num) + " 秒" + return +} + +func Interface2String(inter interface{}) string { + switch inter.(type) { + case string: + return inter.(string) + case int: + return fmt.Sprintf("%d", inter.(int)) + case float64: + return fmt.Sprintf("%f", inter.(float64)) + } + return "Not Implemented" +} + +func UnescapeHTML(x string) interface{} { + return template.HTML(x) +} + +func IntMax(a int, b int) int { + if a >= b { + return a + } else { + return b + } +} + +func IsIP(s string) bool { + ip := net.ParseIP(s) + return ip != nil +} + +func GetUUID() string { + code := uuid.New().String() + code = strings.Replace(code, "-", "", -1) + return code +} + +const keyChars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func init() { + rand.New(rand.NewSource(time.Now().UnixNano())) +} + +func GenerateRandomCharsKey(length int) (string, error) { + b := make([]byte, length) + maxI := big.NewInt(int64(len(keyChars))) + + for i := range b { + n, err := crand.Int(crand.Reader, maxI) + if err != nil { + return "", err + } + b[i] = keyChars[n.Int64()] + } + + return string(b), nil +} + +func GenerateRandomKey(length int) (string, error) { + bytes := make([]byte, length*3/4) // 对于48位的输出,这里应该是36 + if _, err := crand.Read(bytes); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(bytes), nil +} + +func GenerateKey() (string, error) { + //rand.Seed(time.Now().UnixNano()) + return GenerateRandomCharsKey(48) +} + +func GetRandomInt(max int) int { + //rand.Seed(time.Now().UnixNano()) + return rand.Intn(max) +} + +func GetTimestamp() int64 { + return time.Now().Unix() +} + +func GetTimeString() string { + now := time.Now() + return fmt.Sprintf("%s%d", now.Format("20060102150405"), now.UnixNano()%1e9) +} + +func Max(a int, b int) int { + if a >= b { + return a + } else { + return b + } +} + +func MessageWithRequestId(message string, id string) string { + return fmt.Sprintf("%s (request id: %s)", message, id) +} + +func RandomSleep() { + // Sleep for 0-3000 ms + time.Sleep(time.Duration(rand.Intn(3000)) * time.Millisecond) +} + +func GetPointer[T any](v T) *T { + return &v +} + +func Any2Type[T any](data any) (T, error) { + var zero T + bytes, err := json.Marshal(data) + if err != nil { + return zero, err + } + var res T + err = json.Unmarshal(bytes, &res) + if err != nil { + return zero, err + } + return res, nil +} + +// SaveTmpFile saves data to a temporary file. The filename would be apppended with a random string. +func SaveTmpFile(filename string, data io.Reader) (string, error) { + f, err := os.CreateTemp(os.TempDir(), filename) + if err != nil { + return "", errors.Wrapf(err, "failed to create temporary file %s", filename) + } + defer f.Close() + + _, err = io.Copy(f, data) + if err != nil { + return "", errors.Wrapf(err, "failed to copy data to temporary file %s", filename) + } + + return f.Name(), nil +} + +// GetAudioDuration returns the duration of an audio file in seconds. +func GetAudioDuration(ctx context.Context, filename string, ext string) (float64, error) { + // ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 {{input}} + c := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", filename) + output, err := c.Output() + if err != nil { + return 0, errors.Wrap(err, "failed to get audio duration") + } + durationStr := string(bytes.TrimSpace(output)) + if durationStr == "N/A" { + // Create a temporary output file name + tmpFp, err := os.CreateTemp("", "audio-*"+ext) + if err != nil { + return 0, errors.Wrap(err, "failed to create temporary file") + } + tmpName := tmpFp.Name() + // Close immediately so ffmpeg can open the file on Windows. + _ = tmpFp.Close() + defer os.Remove(tmpName) + + // ffmpeg -y -i filename -vcodec copy -acodec copy + ffmpegCmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-i", filename, "-vcodec", "copy", "-acodec", "copy", tmpName) + if err := ffmpegCmd.Run(); err != nil { + return 0, errors.Wrap(err, "failed to run ffmpeg") + } + + // Recalculate the duration of the new file + c = exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", tmpName) + output, err := c.Output() + if err != nil { + return 0, errors.Wrap(err, "failed to get audio duration after ffmpeg") + } + durationStr = string(bytes.TrimSpace(output)) + } + return strconv.ParseFloat(durationStr, 64) +} + +// BuildURL concatenates base and endpoint, returns the complete url string +func BuildURL(base string, endpoint string) string { + u, err := url.Parse(base) + if err != nil { + return base + endpoint + } + end := endpoint + if end == "" { + end = "/" + } + ref, err := url.Parse(end) + if err != nil { + return base + endpoint + } + return u.ResolveReference(ref).String() +} diff --git a/common/validate.go b/common/validate.go new file mode 100644 index 00000000..b3c78591 --- /dev/null +++ b/common/validate.go @@ -0,0 +1,9 @@ +package common + +import "github.com/go-playground/validator/v10" + +var Validate *validator.Validate + +func init() { + Validate = validator.New() +} diff --git a/common/verification.go b/common/verification.go new file mode 100644 index 00000000..d8ccd6ea --- /dev/null +++ b/common/verification.go @@ -0,0 +1,77 @@ +package common + +import ( + "github.com/google/uuid" + "strings" + "sync" + "time" +) + +type verificationValue struct { + code string + time time.Time +} + +const ( + EmailVerificationPurpose = "v" + PasswordResetPurpose = "r" +) + +var verificationMutex sync.Mutex +var verificationMap map[string]verificationValue +var verificationMapMaxSize = 10 +var VerificationValidMinutes = 10 + +func GenerateVerificationCode(length int) string { + code := uuid.New().String() + code = strings.Replace(code, "-", "", -1) + if length == 0 { + return code + } + return code[:length] +} + +func RegisterVerificationCodeWithKey(key string, code string, purpose string) { + verificationMutex.Lock() + defer verificationMutex.Unlock() + verificationMap[purpose+key] = verificationValue{ + code: code, + time: time.Now(), + } + if len(verificationMap) > verificationMapMaxSize { + removeExpiredPairs() + } +} + +func VerifyCodeWithKey(key string, code string, purpose string) bool { + verificationMutex.Lock() + defer verificationMutex.Unlock() + value, okay := verificationMap[purpose+key] + now := time.Now() + if !okay || int(now.Sub(value.time).Seconds()) >= VerificationValidMinutes*60 { + return false + } + return code == value.code +} + +func DeleteKey(key string, purpose string) { + verificationMutex.Lock() + defer verificationMutex.Unlock() + delete(verificationMap, purpose+key) +} + +// no lock inside, so the caller must lock the verificationMap before calling! +func removeExpiredPairs() { + now := time.Now() + for key := range verificationMap { + if int(now.Sub(verificationMap[key].time).Seconds()) >= VerificationValidMinutes*60 { + delete(verificationMap, key) + } + } +} + +func init() { + verificationMutex.Lock() + defer verificationMutex.Unlock() + verificationMap = make(map[string]verificationValue) +} diff --git a/constant/README.md b/constant/README.md new file mode 100644 index 00000000..12a9ffad --- /dev/null +++ b/constant/README.md @@ -0,0 +1,26 @@ +# constant 包 (`/constant`) + +该目录仅用于放置全局可复用的**常量定义**,不包含任何业务逻辑或依赖关系。 + +## 当前文件 + +| 文件 | 说明 | +|----------------------|---------------------------------------------------------------------| +| `azure.go` | 定义与 Azure 相关的全局常量,如 `AzureNoRemoveDotTime`(控制删除 `.` 的截止时间)。 | +| `cache_key.go` | 缓存键格式字符串及 Token 相关字段常量,统一缓存命名规则。 | +| `channel_setting.go` | Channel 级别的设置键,如 `proxy`、`force_format` 等。 | +| `context_key.go` | 定义 `ContextKey` 类型以及在整个项目中使用的上下文键常量(请求时间、Token/Channel/User 相关信息等)。 | +| `env.go` | 环境配置相关的全局变量,在启动阶段根据配置文件或环境变量注入。 | +| `finish_reason.go` | OpenAI/GPT 请求返回的 `finish_reason` 字符串常量集合。 | +| `midjourney.go` | Midjourney 相关错误码及动作(Action)常量与模型到动作的映射表。 | +| `setup.go` | 标识项目是否已完成初始化安装 (`Setup` 布尔值)。 | +| `task.go` | 各种任务(Task)平台、动作常量及模型与动作映射表,如 Suno、Midjourney 等。 | +| `user_setting.go` | 用户设置相关键常量以及通知类型(Email/Webhook)等。 | + +## 使用约定 + +1. `constant` 包**只能被其他包引用**(import),**禁止在此包中引用项目内的其他自定义包**。如确有需要,仅允许引用 **Go 标准库**。 +2. 不允许在此目录内编写任何与业务流程、数据库操作、第三方服务调用等相关的逻辑代码。 +3. 新增类型时,请保持命名语义清晰,并在本 README 的 **当前文件** 表格中补充说明,确保团队成员能够快速了解其用途。 + +> ⚠️ 违反以上约定将导致包之间产生不必要的耦合,影响代码可维护性与可测试性。请在提交代码前自行检查。 \ No newline at end of file diff --git a/constant/api_type.go b/constant/api_type.go new file mode 100644 index 00000000..6ba5f257 --- /dev/null +++ b/constant/api_type.go @@ -0,0 +1,35 @@ +package constant + +const ( + APITypeOpenAI = iota + APITypeAnthropic + APITypePaLM + APITypeBaidu + APITypeZhipu + APITypeAli + APITypeXunfei + APITypeAIProxyLibrary + APITypeTencent + APITypeGemini + APITypeZhipuV4 + APITypeOllama + APITypePerplexity + APITypeAws + APITypeCohere + APITypeDify + APITypeJina + APITypeCloudflare + APITypeSiliconFlow + APITypeVertexAi + APITypeMistral + APITypeDeepSeek + APITypeMokaAI + APITypeVolcEngine + APITypeBaiduV2 + APITypeOpenRouter + APITypeXinference + APITypeXai + APITypeCoze + APITypeJimeng + APITypeDummy // this one is only for count, do not add any channel after this +) diff --git a/constant/azure.go b/constant/azure.go new file mode 100644 index 00000000..d84040ce --- /dev/null +++ b/constant/azure.go @@ -0,0 +1,5 @@ +package constant + +import "time" + +var AzureNoRemoveDotTime = time.Date(2025, time.May, 10, 0, 0, 0, 0, time.UTC).Unix() diff --git a/constant/cache_key.go b/constant/cache_key.go new file mode 100644 index 00000000..0601396a --- /dev/null +++ b/constant/cache_key.go @@ -0,0 +1,14 @@ +package constant + +// Cache keys +const ( + UserGroupKeyFmt = "user_group:%d" + UserQuotaKeyFmt = "user_quota:%d" + UserEnabledKeyFmt = "user_enabled:%d" + UserUsernameKeyFmt = "user_name:%d" +) + +const ( + TokenFiledRemainQuota = "RemainQuota" + TokenFieldGroup = "Group" +) diff --git a/constant/channel.go b/constant/channel.go new file mode 100644 index 00000000..224121e7 --- /dev/null +++ b/constant/channel.go @@ -0,0 +1,109 @@ +package constant + +const ( + ChannelTypeUnknown = 0 + ChannelTypeOpenAI = 1 + ChannelTypeMidjourney = 2 + ChannelTypeAzure = 3 + ChannelTypeOllama = 4 + ChannelTypeMidjourneyPlus = 5 + ChannelTypeOpenAIMax = 6 + ChannelTypeOhMyGPT = 7 + ChannelTypeCustom = 8 + ChannelTypeAILS = 9 + ChannelTypeAIProxy = 10 + ChannelTypePaLM = 11 + ChannelTypeAPI2GPT = 12 + ChannelTypeAIGC2D = 13 + ChannelTypeAnthropic = 14 + ChannelTypeBaidu = 15 + ChannelTypeZhipu = 16 + ChannelTypeAli = 17 + ChannelTypeXunfei = 18 + ChannelType360 = 19 + ChannelTypeOpenRouter = 20 + ChannelTypeAIProxyLibrary = 21 + ChannelTypeFastGPT = 22 + ChannelTypeTencent = 23 + ChannelTypeGemini = 24 + ChannelTypeMoonshot = 25 + ChannelTypeZhipu_v4 = 26 + ChannelTypePerplexity = 27 + ChannelTypeLingYiWanWu = 31 + ChannelTypeAws = 33 + ChannelTypeCohere = 34 + ChannelTypeMiniMax = 35 + ChannelTypeSunoAPI = 36 + ChannelTypeDify = 37 + ChannelTypeJina = 38 + ChannelCloudflare = 39 + ChannelTypeSiliconFlow = 40 + ChannelTypeVertexAi = 41 + ChannelTypeMistral = 42 + ChannelTypeDeepSeek = 43 + ChannelTypeMokaAI = 44 + ChannelTypeVolcEngine = 45 + ChannelTypeBaiduV2 = 46 + ChannelTypeXinference = 47 + ChannelTypeXai = 48 + ChannelTypeCoze = 49 + ChannelTypeKling = 50 + ChannelTypeJimeng = 51 + ChannelTypeDummy // this one is only for count, do not add any channel after this + +) + +var ChannelBaseURLs = []string{ + "", // 0 + "https://api.openai.com", // 1 + "https://oa.api2d.net", // 2 + "", // 3 + "http://localhost:11434", // 4 + "https://api.openai-sb.com", // 5 + "https://api.openaimax.com", // 6 + "https://api.ohmygpt.com", // 7 + "", // 8 + "https://api.caipacity.com", // 9 + "https://api.aiproxy.io", // 10 + "", // 11 + "https://api.api2gpt.com", // 12 + "https://api.aigc2d.com", // 13 + "https://api.anthropic.com", // 14 + "https://aip.baidubce.com", // 15 + "https://open.bigmodel.cn", // 16 + "https://dashscope.aliyuncs.com", // 17 + "", // 18 + "https://api.360.cn", // 19 + "https://openrouter.ai/api", // 20 + "https://api.aiproxy.io", // 21 + "https://fastgpt.run/api/openapi", // 22 + "https://hunyuan.tencentcloudapi.com", //23 + "https://generativelanguage.googleapis.com", //24 + "https://api.moonshot.cn", //25 + "https://open.bigmodel.cn", //26 + "https://api.perplexity.ai", //27 + "", //28 + "", //29 + "", //30 + "https://api.lingyiwanwu.com", //31 + "", //32 + "", //33 + "https://api.cohere.ai", //34 + "https://api.minimax.chat", //35 + "", //36 + "https://api.dify.ai", //37 + "https://api.jina.ai", //38 + "https://api.cloudflare.com", //39 + "https://api.siliconflow.cn", //40 + "", //41 + "https://api.mistral.ai", //42 + "https://api.deepseek.com", //43 + "https://api.moka.ai", //44 + "https://ark.cn-beijing.volces.com", //45 + "https://qianfan.baidubce.com", //46 + "", //47 + "https://api.x.ai", //48 + "https://api.coze.cn", //49 + "https://api.klingai.com", //50 + "https://visual.volcengineapi.com", //51 +} diff --git a/constant/context_key.go b/constant/context_key.go new file mode 100644 index 00000000..4eaf3d00 --- /dev/null +++ b/constant/context_key.go @@ -0,0 +1,44 @@ +package constant + +type ContextKey string + +const ( + ContextKeyOriginalModel ContextKey = "original_model" + ContextKeyRequestStartTime ContextKey = "request_start_time" + + /* token related keys */ + ContextKeyTokenUnlimited ContextKey = "token_unlimited_quota" + ContextKeyTokenKey ContextKey = "token_key" + ContextKeyTokenId ContextKey = "token_id" + ContextKeyTokenGroup ContextKey = "token_group" + ContextKeyTokenAllowIps ContextKey = "allow_ips" + ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id" + ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" + ContextKeyTokenModelLimit ContextKey = "token_model_limit" + + /* channel related keys */ + ContextKeyChannelId ContextKey = "channel_id" + ContextKeyChannelName ContextKey = "channel_name" + ContextKeyChannelCreateTime ContextKey = "channel_create_time" + ContextKeyChannelBaseUrl ContextKey = "base_url" + ContextKeyChannelType ContextKey = "channel_type" + ContextKeyChannelSetting ContextKey = "channel_setting" + ContextKeyChannelParamOverride ContextKey = "param_override" + ContextKeyChannelOrganization ContextKey = "channel_organization" + ContextKeyChannelAutoBan ContextKey = "auto_ban" + ContextKeyChannelModelMapping ContextKey = "model_mapping" + ContextKeyChannelStatusCodeMapping ContextKey = "status_code_mapping" + ContextKeyChannelIsMultiKey ContextKey = "channel_is_multi_key" + ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index" + ContextKeyChannelKey ContextKey = "channel_key" + + /* user related keys */ + ContextKeyUserId ContextKey = "id" + ContextKeyUserSetting ContextKey = "user_setting" + ContextKeyUserQuota ContextKey = "user_quota" + ContextKeyUserStatus ContextKey = "user_status" + ContextKeyUserEmail ContextKey = "user_email" + ContextKeyUserGroup ContextKey = "user_group" + ContextKeyUsingGroup ContextKey = "group" + ContextKeyUserName ContextKey = "username" +) diff --git a/constant/endpoint_type.go b/constant/endpoint_type.go new file mode 100644 index 00000000..ef096b75 --- /dev/null +++ b/constant/endpoint_type.go @@ -0,0 +1,16 @@ +package constant + +type EndpointType string + +const ( + EndpointTypeOpenAI EndpointType = "openai" + EndpointTypeOpenAIResponse EndpointType = "openai-response" + EndpointTypeAnthropic EndpointType = "anthropic" + EndpointTypeGemini EndpointType = "gemini" + EndpointTypeJinaRerank EndpointType = "jina-rerank" + EndpointTypeImageGeneration EndpointType = "image-generation" + //EndpointTypeMidjourney EndpointType = "midjourney-proxy" + //EndpointTypeSuno EndpointType = "suno-proxy" + //EndpointTypeKling EndpointType = "kling" + //EndpointTypeJimeng EndpointType = "jimeng" +) diff --git a/constant/env.go b/constant/env.go new file mode 100644 index 00000000..8bc2f131 --- /dev/null +++ b/constant/env.go @@ -0,0 +1,15 @@ +package constant + +var StreamingTimeout int +var DifyDebug bool +var MaxFileDownloadMB int +var ForceStreamOption bool +var GetMediaToken bool +var GetMediaTokenNotStream bool +var UpdateTask bool +var AzureDefaultAPIVersion string +var GeminiVisionMaxImageNum int +var NotifyLimitCount int +var NotificationLimitDurationMinute int +var GenerateDefaultToken bool +var ErrorLogEnabled bool diff --git a/constant/finish_reason.go b/constant/finish_reason.go new file mode 100644 index 00000000..5a752a5f --- /dev/null +++ b/constant/finish_reason.go @@ -0,0 +1,9 @@ +package constant + +var ( + FinishReasonStop = "stop" + FinishReasonToolCalls = "tool_calls" + FinishReasonLength = "length" + FinishReasonFunctionCall = "function_call" + FinishReasonContentFilter = "content_filter" +) diff --git a/constant/midjourney.go b/constant/midjourney.go new file mode 100644 index 00000000..5934be2f --- /dev/null +++ b/constant/midjourney.go @@ -0,0 +1,48 @@ +package constant + +const ( + MjErrorUnknown = 5 + MjRequestError = 4 +) + +const ( + MjActionImagine = "IMAGINE" + MjActionDescribe = "DESCRIBE" + MjActionBlend = "BLEND" + MjActionUpscale = "UPSCALE" + MjActionVariation = "VARIATION" + MjActionReRoll = "REROLL" + MjActionInPaint = "INPAINT" + MjActionModal = "MODAL" + MjActionZoom = "ZOOM" + MjActionCustomZoom = "CUSTOM_ZOOM" + MjActionShorten = "SHORTEN" + MjActionHighVariation = "HIGH_VARIATION" + MjActionLowVariation = "LOW_VARIATION" + MjActionPan = "PAN" + MjActionSwapFace = "SWAP_FACE" + MjActionUpload = "UPLOAD" + MjActionVideo = "VIDEO" + MjActionEdits = "EDITS" +) + +var MidjourneyModel2Action = map[string]string{ + "mj_imagine": MjActionImagine, + "mj_describe": MjActionDescribe, + "mj_blend": MjActionBlend, + "mj_upscale": MjActionUpscale, + "mj_variation": MjActionVariation, + "mj_reroll": MjActionReRoll, + "mj_modal": MjActionModal, + "mj_inpaint": MjActionInPaint, + "mj_zoom": MjActionZoom, + "mj_custom_zoom": MjActionCustomZoom, + "mj_shorten": MjActionShorten, + "mj_high_variation": MjActionHighVariation, + "mj_low_variation": MjActionLowVariation, + "mj_pan": MjActionPan, + "swap_face": MjActionSwapFace, + "mj_upload": MjActionUpload, + "mj_video": MjActionVideo, + "mj_edits": MjActionEdits, +} diff --git a/constant/multi_key_mode.go b/constant/multi_key_mode.go new file mode 100644 index 00000000..cd0cdbff --- /dev/null +++ b/constant/multi_key_mode.go @@ -0,0 +1,8 @@ +package constant + +type MultiKeyMode string + +const ( + MultiKeyModeRandom MultiKeyMode = "random" // 随机 + MultiKeyModePolling MultiKeyMode = "polling" // 轮询 +) diff --git a/constant/setup.go b/constant/setup.go new file mode 100644 index 00000000..26ecc883 --- /dev/null +++ b/constant/setup.go @@ -0,0 +1,3 @@ +package constant + +var Setup = false diff --git a/constant/task.go b/constant/task.go new file mode 100644 index 00000000..e7af39a6 --- /dev/null +++ b/constant/task.go @@ -0,0 +1,23 @@ +package constant + +type TaskPlatform string + +const ( + TaskPlatformSuno TaskPlatform = "suno" + TaskPlatformMidjourney = "mj" + TaskPlatformKling TaskPlatform = "kling" + TaskPlatformJimeng TaskPlatform = "jimeng" +) + +const ( + SunoActionMusic = "MUSIC" + SunoActionLyrics = "LYRICS" + + TaskActionGenerate = "generate" + TaskActionTextGenerate = "textGenerate" +) + +var SunoModel2Action = map[string]string{ + "suno_music": SunoActionMusic, + "suno_lyrics": SunoActionLyrics, +} diff --git a/controller/billing.go b/controller/billing.go new file mode 100644 index 00000000..1fb83633 --- /dev/null +++ b/controller/billing.go @@ -0,0 +1,92 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + "one-api/common" + "one-api/dto" + "one-api/model" +) + +func GetSubscription(c *gin.Context) { + var remainQuota int + var usedQuota int + var err error + var token *model.Token + var expiredTime int64 + if common.DisplayTokenStatEnabled { + tokenId := c.GetInt("token_id") + token, err = model.GetTokenById(tokenId) + expiredTime = token.ExpiredTime + remainQuota = token.RemainQuota + usedQuota = token.UsedQuota + } else { + userId := c.GetInt("id") + remainQuota, err = model.GetUserQuota(userId, false) + usedQuota, err = model.GetUserUsedQuota(userId) + } + if expiredTime <= 0 { + expiredTime = 0 + } + if err != nil { + openAIError := dto.OpenAIError{ + Message: err.Error(), + Type: "upstream_error", + } + c.JSON(200, gin.H{ + "error": openAIError, + }) + return + } + quota := remainQuota + usedQuota + amount := float64(quota) + if common.DisplayInCurrencyEnabled { + amount /= common.QuotaPerUnit + } + if token != nil && token.UnlimitedQuota { + amount = 100000000 + } + subscription := OpenAISubscriptionResponse{ + Object: "billing_subscription", + HasPaymentMethod: true, + SoftLimitUSD: amount, + HardLimitUSD: amount, + SystemHardLimitUSD: amount, + AccessUntil: expiredTime, + } + c.JSON(200, subscription) + return +} + +func GetUsage(c *gin.Context) { + var quota int + var err error + var token *model.Token + if common.DisplayTokenStatEnabled { + tokenId := c.GetInt("token_id") + token, err = model.GetTokenById(tokenId) + quota = token.UsedQuota + } else { + userId := c.GetInt("id") + quota, err = model.GetUserUsedQuota(userId) + } + if err != nil { + openAIError := dto.OpenAIError{ + Message: err.Error(), + Type: "new_api_error", + } + c.JSON(200, gin.H{ + "error": openAIError, + }) + return + } + amount := float64(quota) + if common.DisplayInCurrencyEnabled { + amount /= common.QuotaPerUnit + } + usage := OpenAIUsageResponse{ + Object: "list", + TotalUsage: amount * 100, + } + c.JSON(200, usage) + return +} diff --git a/controller/channel-billing.go b/controller/channel-billing.go new file mode 100644 index 00000000..5152e060 --- /dev/null +++ b/controller/channel-billing.go @@ -0,0 +1,492 @@ +package controller + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/model" + "one-api/service" + "one-api/setting" + "one-api/types" + "strconv" + "time" + + "github.com/shopspring/decimal" + + "github.com/gin-gonic/gin" +) + +// https://github.com/songquanpeng/one-api/issues/79 + +type OpenAISubscriptionResponse struct { + Object string `json:"object"` + HasPaymentMethod bool `json:"has_payment_method"` + SoftLimitUSD float64 `json:"soft_limit_usd"` + HardLimitUSD float64 `json:"hard_limit_usd"` + SystemHardLimitUSD float64 `json:"system_hard_limit_usd"` + AccessUntil int64 `json:"access_until"` +} + +type OpenAIUsageDailyCost struct { + Timestamp float64 `json:"timestamp"` + LineItems []struct { + Name string `json:"name"` + Cost float64 `json:"cost"` + } +} + +type OpenAICreditGrants struct { + Object string `json:"object"` + TotalGranted float64 `json:"total_granted"` + TotalUsed float64 `json:"total_used"` + TotalAvailable float64 `json:"total_available"` +} + +type OpenAIUsageResponse struct { + Object string `json:"object"` + //DailyCosts []OpenAIUsageDailyCost `json:"daily_costs"` + TotalUsage float64 `json:"total_usage"` // unit: 0.01 dollar +} + +type OpenAISBUsageResponse struct { + Msg string `json:"msg"` + Data *struct { + Credit string `json:"credit"` + } `json:"data"` +} + +type AIProxyUserOverviewResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + ErrorCode int `json:"error_code"` + Data struct { + TotalPoints float64 `json:"totalPoints"` + } `json:"data"` +} + +type API2GPTUsageResponse struct { + Object string `json:"object"` + TotalGranted float64 `json:"total_granted"` + TotalUsed float64 `json:"total_used"` + TotalRemaining float64 `json:"total_remaining"` +} + +type APGC2DGPTUsageResponse struct { + //Grants interface{} `json:"grants"` + Object string `json:"object"` + TotalAvailable float64 `json:"total_available"` + TotalGranted float64 `json:"total_granted"` + TotalUsed float64 `json:"total_used"` +} + +type SiliconFlowUsageResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Status bool `json:"status"` + Data struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + Email string `json:"email"` + IsAdmin bool `json:"isAdmin"` + Balance string `json:"balance"` + Status string `json:"status"` + Introduction string `json:"introduction"` + Role string `json:"role"` + ChargeBalance string `json:"chargeBalance"` + TotalBalance string `json:"totalBalance"` + Category string `json:"category"` + } `json:"data"` +} + +type DeepSeekUsageResponse struct { + IsAvailable bool `json:"is_available"` + BalanceInfos []struct { + Currency string `json:"currency"` + TotalBalance string `json:"total_balance"` + GrantedBalance string `json:"granted_balance"` + ToppedUpBalance string `json:"topped_up_balance"` + } `json:"balance_infos"` +} + +type OpenRouterCreditResponse struct { + Data struct { + TotalCredits float64 `json:"total_credits"` + TotalUsage float64 `json:"total_usage"` + } `json:"data"` +} + +// GetAuthHeader get auth header +func GetAuthHeader(token string) http.Header { + h := http.Header{} + h.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + return h +} + +func GetResponseBody(method, url string, channel *model.Channel, headers http.Header) ([]byte, error) { + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, err + } + for k := range headers { + req.Header.Add(k, headers.Get(k)) + } + res, err := service.GetHttpClient().Do(req) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status code: %d", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + err = res.Body.Close() + if err != nil { + return nil, err + } + return body, nil +} + +func updateChannelCloseAIBalance(channel *model.Channel) (float64, error) { + url := fmt.Sprintf("%s/dashboard/billing/credit_grants", channel.GetBaseURL()) + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + + if err != nil { + return 0, err + } + response := OpenAICreditGrants{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + channel.UpdateBalance(response.TotalAvailable) + return response.TotalAvailable, nil +} + +func updateChannelOpenAISBBalance(channel *model.Channel) (float64, error) { + url := fmt.Sprintf("https://api.openai-sb.com/sb-api/user/status?api_key=%s", channel.Key) + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := OpenAISBUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if response.Data == nil { + return 0, errors.New(response.Msg) + } + balance, err := strconv.ParseFloat(response.Data.Credit, 64) + if err != nil { + return 0, err + } + channel.UpdateBalance(balance) + return balance, nil +} + +func updateChannelAIProxyBalance(channel *model.Channel) (float64, error) { + url := "https://aiproxy.io/api/report/getUserOverview" + headers := http.Header{} + headers.Add("Api-Key", channel.Key) + body, err := GetResponseBody("GET", url, channel, headers) + if err != nil { + return 0, err + } + response := AIProxyUserOverviewResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if !response.Success { + return 0, fmt.Errorf("code: %d, message: %s", response.ErrorCode, response.Message) + } + channel.UpdateBalance(response.Data.TotalPoints) + return response.Data.TotalPoints, nil +} + +func updateChannelAPI2GPTBalance(channel *model.Channel) (float64, error) { + url := "https://api.api2gpt.com/dashboard/billing/credit_grants" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + + if err != nil { + return 0, err + } + response := API2GPTUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + channel.UpdateBalance(response.TotalRemaining) + return response.TotalRemaining, nil +} + +func updateChannelSiliconFlowBalance(channel *model.Channel) (float64, error) { + url := "https://api.siliconflow.cn/v1/user/info" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := SiliconFlowUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if response.Code != 20000 { + return 0, fmt.Errorf("code: %d, message: %s", response.Code, response.Message) + } + balance, err := strconv.ParseFloat(response.Data.TotalBalance, 64) + if err != nil { + return 0, err + } + channel.UpdateBalance(balance) + return balance, nil +} + +func updateChannelDeepSeekBalance(channel *model.Channel) (float64, error) { + url := "https://api.deepseek.com/user/balance" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := DeepSeekUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + index := -1 + for i, balanceInfo := range response.BalanceInfos { + if balanceInfo.Currency == "CNY" { + index = i + break + } + } + if index == -1 { + return 0, errors.New("currency CNY not found") + } + balance, err := strconv.ParseFloat(response.BalanceInfos[index].TotalBalance, 64) + if err != nil { + return 0, err + } + channel.UpdateBalance(balance) + return balance, nil +} + +func updateChannelAIGC2DBalance(channel *model.Channel) (float64, error) { + url := "https://api.aigc2d.com/dashboard/billing/credit_grants" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := APGC2DGPTUsageResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + channel.UpdateBalance(response.TotalAvailable) + return response.TotalAvailable, nil +} + +func updateChannelOpenRouterBalance(channel *model.Channel) (float64, error) { + url := "https://openrouter.ai/api/v1/credits" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + response := OpenRouterCreditResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + balance := response.Data.TotalCredits - response.Data.TotalUsage + channel.UpdateBalance(balance) + return balance, nil +} + +func updateChannelMoonshotBalance(channel *model.Channel) (float64, error) { + url := "https://api.moonshot.cn/v1/users/me/balance" + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + + type MoonshotBalanceData struct { + AvailableBalance float64 `json:"available_balance"` + VoucherBalance float64 `json:"voucher_balance"` + CashBalance float64 `json:"cash_balance"` + } + + type MoonshotBalanceResponse struct { + Code int `json:"code"` + Data MoonshotBalanceData `json:"data"` + Scode string `json:"scode"` + Status bool `json:"status"` + } + + response := MoonshotBalanceResponse{} + err = json.Unmarshal(body, &response) + if err != nil { + return 0, err + } + if !response.Status || response.Code != 0 { + return 0, fmt.Errorf("failed to update moonshot balance, status: %v, code: %d, scode: %s", response.Status, response.Code, response.Scode) + } + availableBalanceCny := response.Data.AvailableBalance + availableBalanceUsd := decimal.NewFromFloat(availableBalanceCny).Div(decimal.NewFromFloat(setting.Price)).InexactFloat64() + channel.UpdateBalance(availableBalanceUsd) + return availableBalanceUsd, nil +} + +func updateChannelBalance(channel *model.Channel) (float64, error) { + baseURL := constant.ChannelBaseURLs[channel.Type] + if channel.GetBaseURL() == "" { + channel.BaseURL = &baseURL + } + switch channel.Type { + case constant.ChannelTypeOpenAI: + if channel.GetBaseURL() != "" { + baseURL = channel.GetBaseURL() + } + case constant.ChannelTypeAzure: + return 0, errors.New("尚未实现") + case constant.ChannelTypeCustom: + baseURL = channel.GetBaseURL() + //case common.ChannelTypeOpenAISB: + // return updateChannelOpenAISBBalance(channel) + case constant.ChannelTypeAIProxy: + return updateChannelAIProxyBalance(channel) + case constant.ChannelTypeAPI2GPT: + return updateChannelAPI2GPTBalance(channel) + case constant.ChannelTypeAIGC2D: + return updateChannelAIGC2DBalance(channel) + case constant.ChannelTypeSiliconFlow: + return updateChannelSiliconFlowBalance(channel) + case constant.ChannelTypeDeepSeek: + return updateChannelDeepSeekBalance(channel) + case constant.ChannelTypeOpenRouter: + return updateChannelOpenRouterBalance(channel) + case constant.ChannelTypeMoonshot: + return updateChannelMoonshotBalance(channel) + default: + return 0, errors.New("尚未实现") + } + url := fmt.Sprintf("%s/v1/dashboard/billing/subscription", baseURL) + + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + subscription := OpenAISubscriptionResponse{} + err = json.Unmarshal(body, &subscription) + if err != nil { + return 0, err + } + now := time.Now() + startDate := fmt.Sprintf("%s-01", now.Format("2006-01")) + endDate := now.Format("2006-01-02") + if !subscription.HasPaymentMethod { + startDate = now.AddDate(0, 0, -100).Format("2006-01-02") + } + url = fmt.Sprintf("%s/v1/dashboard/billing/usage?start_date=%s&end_date=%s", baseURL, startDate, endDate) + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + return 0, err + } + usage := OpenAIUsageResponse{} + err = json.Unmarshal(body, &usage) + if err != nil { + return 0, err + } + balance := subscription.HardLimitUSD - usage.TotalUsage/100 + channel.UpdateBalance(balance) + return balance, nil +} + +func UpdateChannelBalance(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + channel, err := model.CacheGetChannel(id) + if err != nil { + common.ApiError(c, err) + return + } + if channel.ChannelInfo.IsMultiKey { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "多密钥渠道不支持余额查询", + }) + return + } + balance, err := updateChannelBalance(channel) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "balance": balance, + }) +} + +func updateAllChannelsBalance() error { + channels, err := model.GetAllChannels(0, 0, true, false) + if err != nil { + return err + } + for _, channel := range channels { + if channel.Status != common.ChannelStatusEnabled { + continue + } + if channel.ChannelInfo.IsMultiKey { + continue // skip multi-key channels + } + // TODO: support Azure + //if channel.Type != common.ChannelTypeOpenAI && channel.Type != common.ChannelTypeCustom { + // continue + //} + balance, err := updateChannelBalance(channel) + if err != nil { + continue + } else { + // err is nil & balance <= 0 means quota is used up + if balance <= 0 { + service.DisableChannel(*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, "", channel.GetAutoBan()), "余额不足") + } + } + time.Sleep(common.RequestInterval) + } + return nil +} + +func UpdateAllChannelsBalance(c *gin.Context) { + // TODO: make it async + err := updateAllChannelsBalance() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func AutomaticallyUpdateChannels(frequency int) { + for { + time.Sleep(time.Duration(frequency) * time.Minute) + common.SysLog("updating all channels") + _ = updateAllChannelsBalance() + common.SysLog("channels update done") + } +} diff --git a/controller/channel-test.go b/controller/channel-test.go new file mode 100644 index 00000000..8c4a26ae --- /dev/null +++ b/controller/channel-test.go @@ -0,0 +1,464 @@ +package controller + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/http/httptest" + "net/url" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/middleware" + "one-api/model" + "one-api/relay" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strconv" + "strings" + "sync" + "time" + + "github.com/bytedance/gopkg/util/gopool" + + "github.com/gin-gonic/gin" +) + +type testResult struct { + context *gin.Context + localErr error + newAPIError *types.NewAPIError +} + +func testChannel(channel *model.Channel, testModel string) testResult { + tik := time.Now() + if channel.Type == constant.ChannelTypeMidjourney { + return testResult{ + localErr: errors.New("midjourney channel test is not supported"), + newAPIError: nil, + } + } + if channel.Type == constant.ChannelTypeMidjourneyPlus { + return testResult{ + localErr: errors.New("midjourney plus channel test is not supported"), + newAPIError: nil, + } + } + if channel.Type == constant.ChannelTypeSunoAPI { + return testResult{ + localErr: errors.New("suno channel test is not supported"), + newAPIError: nil, + } + } + if channel.Type == constant.ChannelTypeKling { + return testResult{ + localErr: errors.New("kling channel test is not supported"), + newAPIError: nil, + } + } + if channel.Type == constant.ChannelTypeJimeng { + return testResult{ + localErr: errors.New("jimeng channel test is not supported"), + newAPIError: nil, + } + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + requestPath := "/v1/chat/completions" + + // 先判断是否为 Embedding 模型 + if strings.Contains(strings.ToLower(testModel), "embedding") || + strings.HasPrefix(testModel, "m3e") || // m3e 系列模型 + strings.Contains(testModel, "bge-") || // bge 系列模型 + strings.Contains(testModel, "embed") || + channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型 + requestPath = "/v1/embeddings" // 修改请求路径 + } + + c.Request = &http.Request{ + Method: "POST", + URL: &url.URL{Path: requestPath}, // 使用动态路径 + Body: nil, + Header: make(http.Header), + } + + if testModel == "" { + if channel.TestModel != nil && *channel.TestModel != "" { + testModel = *channel.TestModel + } else { + if len(channel.GetModels()) > 0 { + testModel = channel.GetModels()[0] + } else { + testModel = "gpt-4o-mini" + } + } + } + + cache, err := model.GetUserCache(1) + if err != nil { + return testResult{ + localErr: err, + newAPIError: nil, + } + } + cache.WriteContext(c) + + //c.Request.Header.Set("Authorization", "Bearer "+channel.Key) + c.Request.Header.Set("Content-Type", "application/json") + c.Set("channel", channel.Type) + c.Set("base_url", channel.GetBaseURL()) + group, _ := model.GetUserGroup(1, false) + c.Set("group", group) + + newAPIError := middleware.SetupContextForSelectedChannel(c, channel, testModel) + if newAPIError != nil { + return testResult{ + context: c, + localErr: newAPIError, + newAPIError: newAPIError, + } + } + + info := relaycommon.GenRelayInfo(c) + + err = helper.ModelMappedHelper(c, info, nil) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError), + } + } + testModel = info.UpstreamModelName + + apiType, _ := common.ChannelType2APIType(channel.Type) + adaptor := relay.GetAdaptor(apiType) + if adaptor == nil { + return testResult{ + context: c, + localErr: fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), + newAPIError: types.NewError(fmt.Errorf("invalid api type: %d, adaptor is nil", apiType), types.ErrorCodeInvalidApiType), + } + } + + request := buildTestRequest(testModel) + // 创建一个用于日志的 info 副本,移除 ApiKey + logInfo := *info + logInfo.ApiKey = "" + common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, logInfo)) + + priceData, err := helper.ModelPriceHelper(c, info, 0, int(request.MaxTokens)) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeModelPriceError), + } + } + + adaptor.Init(info) + + var convertedRequest any + // 根据 RelayMode 选择正确的转换函数 + if info.RelayMode == relayconstant.RelayModeEmbeddings { + // 创建一个 EmbeddingRequest + embeddingRequest := dto.EmbeddingRequest{ + Input: request.Input, + Model: request.Model, + } + // 调用专门用于 Embedding 的转换函数 + convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, embeddingRequest) + } else { + // 对其他所有请求类型(如 Chat),保持原有逻辑 + convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, request) + } + + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeConvertRequestFailed), + } + } + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeJsonMarshalFailed), + } + } + requestBody := bytes.NewBuffer(jsonData) + c.Request.Body = io.NopCloser(requestBody) + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeDoRequestFailed), + } + } + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + if httpResp.StatusCode != http.StatusOK { + err := service.RelayErrorHandler(httpResp, true) + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeBadResponse), + } + } + } + usageA, respErr := adaptor.DoResponse(c, httpResp, info) + if respErr != nil { + return testResult{ + context: c, + localErr: respErr, + newAPIError: respErr, + } + } + if usageA == nil { + return testResult{ + context: c, + localErr: errors.New("usage is nil"), + newAPIError: types.NewError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody), + } + } + usage := usageA.(*dto.Usage) + result := w.Result() + respBody, err := io.ReadAll(result.Body) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeReadResponseBodyFailed), + } + } + info.PromptTokens = usage.PromptTokens + + quota := 0 + if !priceData.UsePrice { + quota = usage.PromptTokens + int(math.Round(float64(usage.CompletionTokens)*priceData.CompletionRatio)) + quota = int(math.Round(float64(quota) * priceData.ModelRatio)) + if priceData.ModelRatio != 0 && quota <= 0 { + quota = 1 + } + } else { + quota = int(priceData.ModelPrice * common.QuotaPerUnit) + } + tok := time.Now() + milliseconds := tok.Sub(tik).Milliseconds() + consumedTime := float64(milliseconds) / 1000.0 + other := service.GenerateTextOtherInfo(c, info, priceData.ModelRatio, priceData.GroupRatioInfo.GroupRatio, priceData.CompletionRatio, + usage.PromptTokensDetails.CachedTokens, priceData.CacheRatio, priceData.ModelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) + model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{ + ChannelId: channel.Id, + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + ModelName: info.OriginModelName, + TokenName: "模型测试", + Quota: quota, + Content: "模型测试", + UseTimeSeconds: int(consumedTime), + IsStream: false, + Group: info.UsingGroup, + Other: other, + }) + common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody))) + return testResult{ + context: c, + localErr: nil, + newAPIError: nil, + } +} + +func buildTestRequest(model string) *dto.GeneralOpenAIRequest { + testRequest := &dto.GeneralOpenAIRequest{ + Model: "", // this will be set later + Stream: false, + } + + // 先判断是否为 Embedding 模型 + if strings.Contains(strings.ToLower(model), "embedding") || // 其他 embedding 模型 + strings.HasPrefix(model, "m3e") || // m3e 系列模型 + strings.Contains(model, "bge-") { + testRequest.Model = model + // Embedding 请求 + testRequest.Input = []any{"hello world"} // 修改为any,因为dto/openai_request.go 的ParseInput方法无法处理[]string类型 + return testRequest + } + // 并非Embedding 模型 + if strings.HasPrefix(model, "o") { + testRequest.MaxCompletionTokens = 10 + } else if strings.Contains(model, "thinking") { + if !strings.Contains(model, "claude") { + testRequest.MaxTokens = 50 + } + } else if strings.Contains(model, "gemini") { + testRequest.MaxTokens = 3000 + } else { + testRequest.MaxTokens = 10 + } + + testMessage := dto.Message{ + Role: "user", + Content: "hi", + } + testRequest.Model = model + testRequest.Messages = append(testRequest.Messages, testMessage) + return testRequest +} + +func TestChannel(c *gin.Context) { + channelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + channel, err := model.CacheGetChannel(channelId) + if err != nil { + common.ApiError(c, err) + return + } + //defer func() { + // if channel.ChannelInfo.IsMultiKey { + // go func() { _ = channel.SaveChannelInfo() }() + // } + //}() + testModel := c.Query("model") + tik := time.Now() + result := testChannel(channel, testModel) + if result.localErr != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": result.localErr.Error(), + "time": 0.0, + }) + return + } + tok := time.Now() + milliseconds := tok.Sub(tik).Milliseconds() + go channel.UpdateResponseTime(milliseconds) + consumedTime := float64(milliseconds) / 1000.0 + if result.newAPIError != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": result.newAPIError.Error(), + "time": consumedTime, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "time": consumedTime, + }) + return +} + +var testAllChannelsLock sync.Mutex +var testAllChannelsRunning bool = false + +func testAllChannels(notify bool) error { + + testAllChannelsLock.Lock() + if testAllChannelsRunning { + testAllChannelsLock.Unlock() + return errors.New("测试已在运行中") + } + testAllChannelsRunning = true + testAllChannelsLock.Unlock() + channels, getChannelErr := model.GetAllChannels(0, 0, true, false) + if getChannelErr != nil { + return getChannelErr + } + var disableThreshold = int64(common.ChannelDisableThreshold * 1000) + if disableThreshold == 0 { + disableThreshold = 10000000 // a impossible value + } + gopool.Go(func() { + // 使用 defer 确保无论如何都会重置运行状态,防止死锁 + defer func() { + testAllChannelsLock.Lock() + testAllChannelsRunning = false + testAllChannelsLock.Unlock() + }() + + for _, channel := range channels { + isChannelEnabled := channel.Status == common.ChannelStatusEnabled + tik := time.Now() + result := testChannel(channel, "") + tok := time.Now() + milliseconds := tok.Sub(tik).Milliseconds() + + shouldBanChannel := false + newAPIError := result.newAPIError + // request error disables the channel + if newAPIError != nil { + shouldBanChannel = service.ShouldDisableChannel(channel.Type, result.newAPIError) + } + + // 当错误检查通过,才检查响应时间 + if common.AutomaticDisableChannelEnabled && !shouldBanChannel { + if milliseconds > disableThreshold { + err := errors.New(fmt.Sprintf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0)) + newAPIError = types.NewError(err, types.ErrorCodeChannelResponseTimeExceeded) + shouldBanChannel = true + } + } + + // disable channel + if isChannelEnabled && shouldBanChannel && channel.GetAutoBan() { + go processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) + } + + // enable channel + if !isChannelEnabled && service.ShouldEnableChannel(newAPIError, channel.Status) { + service.EnableChannel(channel.Id, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.Name) + } + + channel.UpdateResponseTime(milliseconds) + time.Sleep(common.RequestInterval) + } + + if notify { + service.NotifyRootUser(dto.NotifyTypeChannelTest, "通道测试完成", "所有通道测试已完成") + } + }) + return nil +} + +func TestAllChannels(c *gin.Context) { + err := testAllChannels(true) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func AutomaticallyTestChannels(frequency int) { + if frequency <= 0 { + common.SysLog("CHANNEL_TEST_FREQUENCY is not set or invalid, skipping automatic channel test") + return + } + for { + time.Sleep(time.Duration(frequency) * time.Minute) + common.SysLog("testing all channels") + _ = testAllChannels(false) + common.SysLog("channel test finished") + } +} diff --git a/controller/channel.go b/controller/channel.go new file mode 100644 index 00000000..d3bfa202 --- /dev/null +++ b/controller/channel.go @@ -0,0 +1,916 @@ +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/model" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +type OpenAIModel struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` + Permission []struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + AllowCreateEngine bool `json:"allow_create_engine"` + AllowSampling bool `json:"allow_sampling"` + AllowLogprobs bool `json:"allow_logprobs"` + AllowSearchIndices bool `json:"allow_search_indices"` + AllowView bool `json:"allow_view"` + AllowFineTuning bool `json:"allow_fine_tuning"` + Organization string `json:"organization"` + Group string `json:"group"` + IsBlocking bool `json:"is_blocking"` + } `json:"permission"` + Root string `json:"root"` + Parent string `json:"parent"` +} + +type OpenAIModelsResponse struct { + Data []OpenAIModel `json:"data"` + Success bool `json:"success"` +} + +func parseStatusFilter(statusParam string) int { + switch strings.ToLower(statusParam) { + case "enabled", "1": + return common.ChannelStatusEnabled + case "disabled", "0": + return 0 + default: + return -1 + } +} + +func GetAllChannels(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + channelData := make([]*model.Channel, 0) + idSort, _ := strconv.ParseBool(c.Query("id_sort")) + enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode")) + statusParam := c.Query("status") + // statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual) + statusFilter := parseStatusFilter(statusParam) + // type filter + typeStr := c.Query("type") + typeFilter := -1 + if typeStr != "" { + if t, err := strconv.Atoi(typeStr); err == nil { + typeFilter = t + } + } + + var total int64 + + if enableTagMode { + tags, err := model.GetPaginatedTags(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + for _, tag := range tags { + if tag == nil || *tag == "" { + continue + } + tagChannels, err := model.GetChannelsByTag(*tag, idSort) + if err != nil { + continue + } + filtered := make([]*model.Channel, 0) + for _, ch := range tagChannels { + if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled { + continue + } + if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled { + continue + } + if typeFilter >= 0 && ch.Type != typeFilter { + continue + } + filtered = append(filtered, ch) + } + channelData = append(channelData, filtered...) + } + total, _ = model.CountAllTags() + } else { + baseQuery := model.DB.Model(&model.Channel{}) + if typeFilter >= 0 { + baseQuery = baseQuery.Where("type = ?", typeFilter) + } + if statusFilter == common.ChannelStatusEnabled { + baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled) + } else if statusFilter == 0 { + baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled) + } + + baseQuery.Count(&total) + + order := "priority desc" + if idSort { + order = "id desc" + } + + err := baseQuery.Order(order).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + } + + countQuery := model.DB.Model(&model.Channel{}) + if statusFilter == common.ChannelStatusEnabled { + countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled) + } else if statusFilter == 0 { + countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled) + } + var results []struct { + Type int64 + Count int64 + } + _ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error + typeCounts := make(map[int64]int64) + for _, r := range results { + typeCounts[r.Type] = r.Count + } + common.ApiSuccess(c, gin.H{ + "items": channelData, + "total": total, + "page": pageInfo.GetPage(), + "page_size": pageInfo.GetPageSize(), + "type_counts": typeCounts, + }) + return +} + +func FetchUpstreamModels(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + + channel, err := model.GetChannelById(id, true) + if err != nil { + common.ApiError(c, err) + return + } + + baseURL := constant.ChannelBaseURLs[channel.Type] + if channel.GetBaseURL() != "" { + baseURL = channel.GetBaseURL() + } + url := fmt.Sprintf("%s/v1/models", baseURL) + switch channel.Type { + case constant.ChannelTypeGemini: + url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) + case constant.ChannelTypeAli: + url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL) + } + body, err := GetResponseBody("GET", url, channel, GetAuthHeader(channel.Key)) + if err != nil { + common.ApiError(c, err) + return + } + + var result OpenAIModelsResponse + if err = json.Unmarshal(body, &result); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("解析响应失败: %s", err.Error()), + }) + return + } + + var ids []string + for _, model := range result.Data { + id := model.ID + if channel.Type == constant.ChannelTypeGemini { + id = strings.TrimPrefix(id, "models/") + } + ids = append(ids, id) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": ids, + }) +} + +func FixChannelsAbilities(c *gin.Context) { + success, fails, err := model.FixAbility() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "success": success, + "fails": fails, + }, + }) +} + +func SearchChannels(c *gin.Context) { + keyword := c.Query("keyword") + group := c.Query("group") + modelKeyword := c.Query("model") + statusParam := c.Query("status") + statusFilter := parseStatusFilter(statusParam) + idSort, _ := strconv.ParseBool(c.Query("id_sort")) + enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode")) + channelData := make([]*model.Channel, 0) + if enableTagMode { + tags, err := model.SearchTags(keyword, group, modelKeyword, idSort) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + for _, tag := range tags { + if tag != nil && *tag != "" { + tagChannel, err := model.GetChannelsByTag(*tag, idSort) + if err == nil { + channelData = append(channelData, tagChannel...) + } + } + } + } else { + channels, err := model.SearchChannels(keyword, group, modelKeyword, idSort) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + channelData = channels + } + + if statusFilter == common.ChannelStatusEnabled || statusFilter == 0 { + filtered := make([]*model.Channel, 0, len(channelData)) + for _, ch := range channelData { + if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled { + continue + } + if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled { + continue + } + filtered = append(filtered, ch) + } + channelData = filtered + } + + // calculate type counts for search results + typeCounts := make(map[int64]int64) + for _, channel := range channelData { + typeCounts[int64(channel.Type)]++ + } + + typeParam := c.Query("type") + typeFilter := -1 + if typeParam != "" { + if tp, err := strconv.Atoi(typeParam); err == nil { + typeFilter = tp + } + } + + if typeFilter >= 0 { + filtered := make([]*model.Channel, 0, len(channelData)) + for _, ch := range channelData { + if ch.Type == typeFilter { + filtered = append(filtered, ch) + } + } + channelData = filtered + } + + page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize <= 0 { + pageSize = 20 + } + + total := len(channelData) + startIdx := (page - 1) * pageSize + if startIdx > total { + startIdx = total + } + endIdx := startIdx + pageSize + if endIdx > total { + endIdx = total + } + + pagedData := channelData[startIdx:endIdx] + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "items": pagedData, + "total": total, + "type_counts": typeCounts, + }, + }) + return +} + +func GetChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + channel, err := model.GetChannelById(id, false) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": channel, + }) + return +} + +// validateChannel 通用的渠道校验函数 +func validateChannel(channel *model.Channel, isAdd bool) error { + // 校验 channel settings + if err := channel.ValidateSettings(); err != nil { + return fmt.Errorf("渠道额外设置[channel setting] 格式错误:%s", err.Error()) + } + + // 如果是添加操作,检查 channel 和 key 是否为空 + if isAdd { + if channel == nil || channel.Key == "" { + return fmt.Errorf("channel cannot be empty") + } + + // 检查模型名称长度是否超过 255 + for _, m := range channel.GetModels() { + if len(m) > 255 { + return fmt.Errorf("模型名称过长: %s", m) + } + } + } + + // VertexAI 特殊校验 + if channel.Type == constant.ChannelTypeVertexAi { + if channel.Other == "" { + return fmt.Errorf("部署地区不能为空") + } + + regionMap, err := common.StrToMap(channel.Other) + if err != nil { + return fmt.Errorf("部署地区必须是标准的Json格式,例如{\"default\": \"us-central1\", \"region2\": \"us-east1\"}") + } + + if regionMap["default"] == nil { + return fmt.Errorf("部署地区必须包含default字段") + } + } + + return nil +} + +type AddChannelRequest struct { + Mode string `json:"mode"` + MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` + Channel *model.Channel `json:"channel"` +} + +func getVertexArrayKeys(keys string) ([]string, error) { + if keys == "" { + return nil, nil + } + var keyArray []interface{} + err := common.Unmarshal([]byte(keys), &keyArray) + if err != nil { + return nil, fmt.Errorf("批量添加 Vertex AI 必须使用标准的JsonArray格式,例如[{key1}, {key2}...],请检查输入: %w", err) + } + cleanKeys := make([]string, 0, len(keyArray)) + for _, key := range keyArray { + var keyStr string + switch v := key.(type) { + case string: + keyStr = strings.TrimSpace(v) + default: + bytes, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("Vertex AI key JSON 编码失败: %w", err) + } + keyStr = string(bytes) + } + if keyStr != "" { + cleanKeys = append(cleanKeys, keyStr) + } + } + if len(cleanKeys) == 0 { + return nil, fmt.Errorf("批量添加 Vertex AI 的 keys 不能为空") + } + return cleanKeys, nil +} + +func AddChannel(c *gin.Context) { + addChannelRequest := AddChannelRequest{} + err := c.ShouldBindJSON(&addChannelRequest) + if err != nil { + common.ApiError(c, err) + return + } + + // 使用统一的校验函数 + if err := validateChannel(addChannelRequest.Channel, true); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + addChannelRequest.Channel.CreatedTime = common.GetTimestamp() + keys := make([]string, 0) + switch addChannelRequest.Mode { + case "multi_to_single": + addChannelRequest.Channel.ChannelInfo.IsMultiKey = true + addChannelRequest.Channel.ChannelInfo.MultiKeyMode = addChannelRequest.MultiKeyMode + if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi { + array, err := getVertexArrayKeys(addChannelRequest.Channel.Key) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(array) + addChannelRequest.Channel.Key = strings.Join(array, "\n") + } else { + cleanKeys := make([]string, 0) + for _, key := range strings.Split(addChannelRequest.Channel.Key, "\n") { + if key == "" { + continue + } + key = strings.TrimSpace(key) + cleanKeys = append(cleanKeys, key) + } + addChannelRequest.Channel.ChannelInfo.MultiKeySize = len(cleanKeys) + addChannelRequest.Channel.Key = strings.Join(cleanKeys, "\n") + } + keys = []string{addChannelRequest.Channel.Key} + case "batch": + if addChannelRequest.Channel.Type == constant.ChannelTypeVertexAi { + // multi json + keys, err = getVertexArrayKeys(addChannelRequest.Channel.Key) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + keys = strings.Split(addChannelRequest.Channel.Key, "\n") + } + case "single": + keys = []string{addChannelRequest.Channel.Key} + default: + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不支持的添加模式", + }) + return + } + + channels := make([]model.Channel, 0, len(keys)) + for _, key := range keys { + if key == "" { + continue + } + localChannel := addChannelRequest.Channel + localChannel.Key = key + channels = append(channels, *localChannel) + } + err = model.BatchInsertChannels(channels) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func DeleteChannel(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + channel := model.Channel{Id: id} + err := channel.Delete() + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func DeleteDisabledChannel(c *gin.Context) { + rows, err := model.DeleteDisabledChannel() + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": rows, + }) + return +} + +type ChannelTag struct { + Tag string `json:"tag"` + NewTag *string `json:"new_tag"` + Priority *int64 `json:"priority"` + Weight *uint `json:"weight"` + ModelMapping *string `json:"model_mapping"` + Models *string `json:"models"` + Groups *string `json:"groups"` +} + +func DisableTagChannels(c *gin.Context) { + channelTag := ChannelTag{} + err := c.ShouldBindJSON(&channelTag) + if err != nil || channelTag.Tag == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + err = model.DisableChannelByTag(channelTag.Tag) + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func EnableTagChannels(c *gin.Context) { + channelTag := ChannelTag{} + err := c.ShouldBindJSON(&channelTag) + if err != nil || channelTag.Tag == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + err = model.EnableChannelByTag(channelTag.Tag) + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func EditTagChannels(c *gin.Context) { + channelTag := ChannelTag{} + err := c.ShouldBindJSON(&channelTag) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + if channelTag.Tag == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "tag不能为空", + }) + return + } + err = model.EditChannelByTag(channelTag.Tag, channelTag.NewTag, channelTag.ModelMapping, channelTag.Models, channelTag.Groups, channelTag.Priority, channelTag.Weight) + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +type ChannelBatch struct { + Ids []int `json:"ids"` + Tag *string `json:"tag"` +} + +func DeleteChannelBatch(c *gin.Context) { + channelBatch := ChannelBatch{} + err := c.ShouldBindJSON(&channelBatch) + if err != nil || len(channelBatch.Ids) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + err = model.BatchDeleteChannels(channelBatch.Ids) + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": len(channelBatch.Ids), + }) + return +} + +type PatchChannel struct { + model.Channel + MultiKeyMode *string `json:"multi_key_mode"` +} + +func UpdateChannel(c *gin.Context) { + channel := PatchChannel{} + err := c.ShouldBindJSON(&channel) + if err != nil { + common.ApiError(c, err) + return + } + + // 使用统一的校验函数 + if err := validateChannel(&channel.Channel, false); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + // Preserve existing ChannelInfo to ensure multi-key channels keep correct state even if the client does not send ChannelInfo in the request. + originChannel, err := model.GetChannelById(channel.Id, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // Always copy the original ChannelInfo so that fields like IsMultiKey and MultiKeySize are retained. + channel.ChannelInfo = originChannel.ChannelInfo + + // If the request explicitly specifies a new MultiKeyMode, apply it on top of the original info. + if channel.MultiKeyMode != nil && *channel.MultiKeyMode != "" { + channel.ChannelInfo.MultiKeyMode = constant.MultiKeyMode(*channel.MultiKeyMode) + } + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + channel.Key = "" + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": channel, + }) + return +} + +func FetchModels(c *gin.Context) { + var req struct { + BaseURL string `json:"base_url"` + Type int `json:"type"` + Key string `json:"key"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "Invalid request", + }) + return + } + + baseURL := req.BaseURL + if baseURL == "" { + baseURL = constant.ChannelBaseURLs[req.Type] + } + + client := &http.Client{} + url := fmt.Sprintf("%s/v1/models", baseURL) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + // remove line breaks and extra spaces. + key := strings.TrimSpace(req.Key) + // If the key contains a line break, only take the first part. + key = strings.Split(key, "\n")[0] + request.Header.Set("Authorization", "Bearer "+key) + + response, err := client.Do(request) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + //check status code + if response.StatusCode != http.StatusOK { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": "Failed to fetch models", + }) + return + } + defer response.Body.Close() + + var result struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var models []string + for _, model := range result.Data { + models = append(models, model.ID) + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": models, + }) +} + +func BatchSetChannelTag(c *gin.Context) { + channelBatch := ChannelBatch{} + err := c.ShouldBindJSON(&channelBatch) + if err != nil || len(channelBatch.Ids) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + err = model.BatchSetChannelTag(channelBatch.Ids, channelBatch.Tag) + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": len(channelBatch.Ids), + }) + return +} + +func GetTagModels(c *gin.Context) { + tag := c.Query("tag") + if tag == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "tag不能为空", + }) + return + } + + channels, err := model.GetChannelsByTag(tag, false) // Assuming false for idSort is fine here + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var longestModels string + maxLength := 0 + + // Find the longest models string among all channels with the given tag + for _, channel := range channels { + if channel.Models != "" { + currentModels := strings.Split(channel.Models, ",") + if len(currentModels) > maxLength { + maxLength = len(currentModels) + longestModels = channel.Models + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": longestModels, + }) + return +} + +// CopyChannel handles cloning an existing channel with its key. +// POST /api/channel/copy/:id +// Optional query params: +// +// suffix - string appended to the original name (default "_复制") +// reset_balance - bool, when true will reset balance & used_quota to 0 (default true) +func CopyChannel(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid id"}) + return + } + + suffix := c.DefaultQuery("suffix", "_复制") + resetBalance := true + if rbStr := c.DefaultQuery("reset_balance", "true"); rbStr != "" { + if v, err := strconv.ParseBool(rbStr); err == nil { + resetBalance = v + } + } + + // fetch original channel with key + origin, err := model.GetChannelById(id, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + // clone channel + clone := *origin // shallow copy is sufficient as we will overwrite primitives + clone.Id = 0 // let DB auto-generate + clone.CreatedTime = common.GetTimestamp() + clone.Name = origin.Name + suffix + clone.TestTime = 0 + clone.ResponseTime = 0 + if resetBalance { + clone.Balance = 0 + clone.UsedQuota = 0 + } + + // insert + if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + model.InitChannelCache() + // success + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": gin.H{"id": clone.Id}}) +} diff --git a/controller/console_migrate.go b/controller/console_migrate.go new file mode 100644 index 00000000..d25f199b --- /dev/null +++ b/controller/console_migrate.go @@ -0,0 +1,103 @@ +// 用于迁移检测的旧键,该文件下个版本会删除 + +package controller + +import ( + "encoding/json" + "net/http" + "one-api/common" + "one-api/model" + "github.com/gin-gonic/gin" +) + +// MigrateConsoleSetting 迁移旧的控制台相关配置到 console_setting.* +func MigrateConsoleSetting(c *gin.Context) { + // 读取全部 option + opts, err := model.AllOption() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + // 建立 map + valMap := map[string]string{} + for _, o := range opts { + valMap[o.Key] = o.Value + } + + // 处理 APIInfo + if v := valMap["ApiInfo"]; v != "" { + var arr []map[string]interface{} + if err := json.Unmarshal([]byte(v), &arr); err == nil { + if len(arr) > 50 { + arr = arr[:50] + } + bytes, _ := json.Marshal(arr) + model.UpdateOption("console_setting.api_info", string(bytes)) + } + model.UpdateOption("ApiInfo", "") + } + // Announcements 直接搬 + if v := valMap["Announcements"]; v != "" { + model.UpdateOption("console_setting.announcements", v) + model.UpdateOption("Announcements", "") + } + // FAQ 转换 + if v := valMap["FAQ"]; v != "" { + var arr []map[string]interface{} + if err := json.Unmarshal([]byte(v), &arr); err == nil { + out := []map[string]interface{}{} + for _, item := range arr { + q, _ := item["question"].(string) + if q == "" { + q, _ = item["title"].(string) + } + a, _ := item["answer"].(string) + if a == "" { + a, _ = item["content"].(string) + } + if q != "" && a != "" { + out = append(out, map[string]interface{}{"question": q, "answer": a}) + } + } + if len(out) > 50 { + out = out[:50] + } + bytes, _ := json.Marshal(out) + model.UpdateOption("console_setting.faq", string(bytes)) + } + model.UpdateOption("FAQ", "") + } + // Uptime Kuma 迁移到新的 groups 结构(console_setting.uptime_kuma_groups) + url := valMap["UptimeKumaUrl"] + slug := valMap["UptimeKumaSlug"] + if url != "" && slug != "" { + // 仅当同时存在 URL 与 Slug 时才进行迁移 + groups := []map[string]interface{}{ + { + "id": 1, + "categoryName": "old", + "url": url, + "slug": slug, + "description": "", + }, + } + bytes, _ := json.Marshal(groups) + model.UpdateOption("console_setting.uptime_kuma_groups", string(bytes)) + } + // 清空旧键内容 + if url != "" { + model.UpdateOption("UptimeKumaUrl", "") + } + if slug != "" { + model.UpdateOption("UptimeKumaSlug", "") + } + + // 删除旧键记录 + oldKeys := []string{"ApiInfo", "Announcements", "FAQ", "UptimeKumaUrl", "UptimeKumaSlug"} + model.DB.Where("key IN ?", oldKeys).Delete(&model.Option{}) + + // 重新加载 OptionMap + model.InitOptionMap() + common.SysLog("console setting migrated") + c.JSON(http.StatusOK, gin.H{"success": true, "message": "migrated"}) +} \ No newline at end of file diff --git a/controller/github.go b/controller/github.go new file mode 100644 index 00000000..881d6dc1 --- /dev/null +++ b/controller/github.go @@ -0,0 +1,239 @@ +package controller + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "one-api/common" + "one-api/model" + "strconv" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type GitHubOAuthResponse struct { + AccessToken string `json:"access_token"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` +} + +type GitHubUser struct { + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` +} + +func getGitHubUserInfoByCode(code string) (*GitHubUser, error) { + if code == "" { + return nil, errors.New("无效的参数") + } + values := map[string]string{"client_id": common.GitHubClientId, "client_secret": common.GitHubClientSecret, "code": code} + jsonData, err := json.Marshal(values) + if err != nil { + return nil, err + } + req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + client := http.Client{ + Timeout: 5 * time.Second, + } + res, err := client.Do(req) + if err != nil { + common.SysLog(err.Error()) + return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!") + } + defer res.Body.Close() + var oAuthResponse GitHubOAuthResponse + err = json.NewDecoder(res.Body).Decode(&oAuthResponse) + if err != nil { + return nil, err + } + req, err = http.NewRequest("GET", "https://api.github.com/user", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oAuthResponse.AccessToken)) + res2, err := client.Do(req) + if err != nil { + common.SysLog(err.Error()) + return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试!") + } + defer res2.Body.Close() + var githubUser GitHubUser + err = json.NewDecoder(res2.Body).Decode(&githubUser) + if err != nil { + return nil, err + } + if githubUser.Login == "" { + return nil, errors.New("返回值非法,用户字段为空,请稍后重试!") + } + return &githubUser, nil +} + +func GitHubOAuth(c *gin.Context) { + session := sessions.Default(c) + state := c.Query("state") + if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "state is empty or not same", + }) + return + } + username := session.Get("username") + if username != nil { + GitHubBind(c) + return + } + + if !common.GitHubOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 GitHub 登录以及注册", + }) + return + } + code := c.Query("code") + githubUser, err := getGitHubUserInfoByCode(code) + if err != nil { + common.ApiError(c, err) + return + } + user := model.User{ + GitHubId: githubUser.Login, + } + // IsGitHubIdAlreadyTaken is unscoped + if model.IsGitHubIdAlreadyTaken(user.GitHubId) { + // FillUserByGitHubId is scoped + err := user.FillUserByGitHubId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + // if user.Id == 0 , user has been deleted + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已注销", + }) + return + } + } else { + if common.RegisterEnabled { + user.Username = "github_" + strconv.Itoa(model.GetMaxUserId()+1) + if githubUser.Name != "" { + user.DisplayName = githubUser.Name + } else { + user.DisplayName = "GitHub User" + } + user.Email = githubUser.Email + user.Role = common.RoleCommonUser + user.Status = common.UserStatusEnabled + affCode := session.Get("aff") + inviterId := 0 + if affCode != nil { + inviterId, _ = model.GetUserIdByAffCode(affCode.(string)) + } + + if err := user.Insert(inviterId); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + setupLogin(&user, c) +} + +func GitHubBind(c *gin.Context) { + if !common.GitHubOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 GitHub 登录以及注册", + }) + return + } + code := c.Query("code") + githubUser, err := getGitHubUserInfoByCode(code) + if err != nil { + common.ApiError(c, err) + return + } + user := model.User{ + GitHubId: githubUser.Login, + } + if model.IsGitHubIdAlreadyTaken(user.GitHubId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该 GitHub 账户已被绑定", + }) + return + } + session := sessions.Default(c) + id := session.Get("id") + // id := c.GetInt("id") // critical bug! + user.Id = id.(int) + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + user.GitHubId = githubUser.Login + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "bind", + }) + return +} + +func GenerateOAuthCode(c *gin.Context) { + session := sessions.Default(c) + state := common.GetRandomString(12) + affCode := c.Query("aff") + if affCode != "" { + session.Set("aff", affCode) + } + session.Set("oauth_state", state) + err := session.Save() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": state, + }) +} diff --git a/controller/group.go b/controller/group.go new file mode 100644 index 00000000..2565b6ea --- /dev/null +++ b/controller/group.go @@ -0,0 +1,50 @@ +package controller + +import ( + "net/http" + "one-api/model" + "one-api/setting" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +func GetGroups(c *gin.Context) { + groupNames := make([]string, 0) + for groupName := range ratio_setting.GetGroupRatioCopy() { + groupNames = append(groupNames, groupName) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": groupNames, + }) +} + +func GetUserGroups(c *gin.Context) { + usableGroups := make(map[string]map[string]interface{}) + userGroup := "" + userId := c.GetInt("id") + userGroup, _ = model.GetUserGroup(userId, false) + for groupName, ratio := range ratio_setting.GetGroupRatioCopy() { + // UserUsableGroups contains the groups that the user can use + userUsableGroups := setting.GetUserUsableGroups(userGroup) + if desc, ok := userUsableGroups[groupName]; ok { + usableGroups[groupName] = map[string]interface{}{ + "ratio": ratio, + "desc": desc, + } + } + } + if setting.GroupInUserUsableGroups("auto") { + usableGroups["auto"] = map[string]interface{}{ + "ratio": "自动", + "desc": setting.GetUsableGroupDescription("auto"), + } + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": usableGroups, + }) +} diff --git a/controller/image.go b/controller/image.go new file mode 100644 index 00000000..d6e8806a --- /dev/null +++ b/controller/image.go @@ -0,0 +1,9 @@ +package controller + +import ( + "github.com/gin-gonic/gin" +) + +func GetImage(c *gin.Context) { + +} diff --git a/controller/linuxdo.go b/controller/linuxdo.go new file mode 100644 index 00000000..65380b65 --- /dev/null +++ b/controller/linuxdo.go @@ -0,0 +1,259 @@ +package controller + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "one-api/common" + "one-api/model" + "strconv" + "strings" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type LinuxdoUser struct { + Id int `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Active bool `json:"active"` + TrustLevel int `json:"trust_level"` + Silenced bool `json:"silenced"` +} + +func LinuxDoBind(c *gin.Context) { + if !common.LinuxDOOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 Linux DO 登录以及注册", + }) + return + } + + code := c.Query("code") + linuxdoUser, err := getLinuxdoUserInfoByCode(code, c) + if err != nil { + common.ApiError(c, err) + return + } + + user := model.User{ + LinuxDOId: strconv.Itoa(linuxdoUser.Id), + } + + if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该 Linux DO 账户已被绑定", + }) + return + } + + session := sessions.Default(c) + id := session.Get("id") + user.Id = id.(int) + + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + + user.LinuxDOId = strconv.Itoa(linuxdoUser.Id) + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "bind", + }) +} + +func getLinuxdoUserInfoByCode(code string, c *gin.Context) (*LinuxdoUser, error) { + if code == "" { + return nil, errors.New("invalid code") + } + + // Get access token using Basic auth + tokenEndpoint := "https://connect.linux.do/oauth2/token" + credentials := common.LinuxDOClientId + ":" + common.LinuxDOClientSecret + basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(credentials)) + + // Get redirect URI from request + scheme := "http" + if c.Request.TLS != nil { + scheme = "https" + } + redirectURI := fmt.Sprintf("%s://%s/api/oauth/linuxdo", scheme, c.Request.Host) + + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + + req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", basicAuth) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + client := http.Client{Timeout: 5 * time.Second} + res, err := client.Do(req) + if err != nil { + return nil, errors.New("failed to connect to Linux DO server") + } + defer res.Body.Close() + + var tokenRes struct { + AccessToken string `json:"access_token"` + Message string `json:"message"` + } + if err := json.NewDecoder(res.Body).Decode(&tokenRes); err != nil { + return nil, err + } + + if tokenRes.AccessToken == "" { + return nil, fmt.Errorf("failed to get access token: %s", tokenRes.Message) + } + + // Get user info + userEndpoint := "https://connect.linux.do/api/user" + req, err = http.NewRequest("GET", userEndpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tokenRes.AccessToken) + req.Header.Set("Accept", "application/json") + + res2, err := client.Do(req) + if err != nil { + return nil, errors.New("failed to get user info from Linux DO") + } + defer res2.Body.Close() + + var linuxdoUser LinuxdoUser + if err := json.NewDecoder(res2.Body).Decode(&linuxdoUser); err != nil { + return nil, err + } + + if linuxdoUser.Id == 0 { + return nil, errors.New("invalid user info returned") + } + + return &linuxdoUser, nil +} + +func LinuxdoOAuth(c *gin.Context) { + session := sessions.Default(c) + + errorCode := c.Query("error") + if errorCode != "" { + errorDescription := c.Query("error_description") + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": errorDescription, + }) + return + } + + state := c.Query("state") + if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "state is empty or not same", + }) + return + } + + username := session.Get("username") + if username != nil { + LinuxDoBind(c) + return + } + + if !common.LinuxDOOAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 Linux DO 登录以及注册", + }) + return + } + + code := c.Query("code") + linuxdoUser, err := getLinuxdoUserInfoByCode(code, c) + if err != nil { + common.ApiError(c, err) + return + } + + user := model.User{ + LinuxDOId: strconv.Itoa(linuxdoUser.Id), + } + + // Check if user exists + if model.IsLinuxDOIdAlreadyTaken(user.LinuxDOId) { + err := user.FillUserByLinuxDOId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已注销", + }) + return + } + } else { + if common.RegisterEnabled { + user.Username = "linuxdo_" + strconv.Itoa(model.GetMaxUserId()+1) + user.DisplayName = linuxdoUser.Name + user.Role = common.RoleCommonUser + user.Status = common.UserStatusEnabled + + affCode := session.Get("aff") + inviterId := 0 + if affCode != nil { + inviterId, _ = model.GetUserIdByAffCode(affCode.(string)) + } + + if err := user.Insert(inviterId); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + + setupLogin(&user, c) +} diff --git a/controller/log.go b/controller/log.go new file mode 100644 index 00000000..042fa725 --- /dev/null +++ b/controller/log.go @@ -0,0 +1,168 @@ +package controller + +import ( + "net/http" + "one-api/common" + "one-api/model" + "strconv" + + "github.com/gin-gonic/gin" +) + +func GetAllLogs(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + username := c.Query("username") + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Query("group") + logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(logs) + common.ApiSuccess(c, pageInfo) + return +} + +func GetUserLogs(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + userId := c.GetInt("id") + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + group := c.Query("group") + logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(logs) + common.ApiSuccess(c, pageInfo) + return +} + +func SearchAllLogs(c *gin.Context) { + keyword := c.Query("keyword") + logs, err := model.SearchAllLogs(keyword) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logs, + }) + return +} + +func SearchUserLogs(c *gin.Context) { + keyword := c.Query("keyword") + userId := c.GetInt("id") + logs, err := model.SearchUserLogs(userId, keyword) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": logs, + }) + return +} + +func GetLogByKey(c *gin.Context) { + key := c.Query("key") + logs, err := model.GetLogByKey(key) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(200, gin.H{ + "success": true, + "message": "", + "data": logs, + }) +} + +func GetLogsStat(c *gin.Context) { + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenName := c.Query("token_name") + username := c.Query("username") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Query("group") + stat := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) + //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, "") + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "quota": stat.Quota, + "rpm": stat.Rpm, + "tpm": stat.Tpm, + }, + }) + return +} + +func GetLogsSelfStat(c *gin.Context) { + username := c.GetString("username") + logType, _ := strconv.Atoi(c.Query("type")) + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + tokenName := c.Query("token_name") + modelName := c.Query("model_name") + channel, _ := strconv.Atoi(c.Query("channel")) + group := c.Query("group") + quotaNum := model.SumUsedQuota(logType, startTimestamp, endTimestamp, modelName, username, tokenName, channel, group) + //tokenNum := model.SumUsedToken(logType, startTimestamp, endTimestamp, modelName, username, tokenName) + c.JSON(200, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "quota": quotaNum.Quota, + "rpm": quotaNum.Rpm, + "tpm": quotaNum.Tpm, + //"token": tokenNum, + }, + }) + return +} + +func DeleteHistoryLogs(c *gin.Context) { + targetTimestamp, _ := strconv.ParseInt(c.Query("target_timestamp"), 10, 64) + if targetTimestamp == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "target timestamp is required", + }) + return + } + count, err := model.DeleteOldLog(c.Request.Context(), targetTimestamp, 100) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": count, + }) + return +} diff --git a/controller/midjourney.go b/controller/midjourney.go new file mode 100644 index 00000000..02ad708f --- /dev/null +++ b/controller/midjourney.go @@ -0,0 +1,263 @@ +package controller + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/model" + "one-api/service" + "one-api/setting" + "time" + + "github.com/gin-gonic/gin" +) + +func UpdateMidjourneyTaskBulk() { + //imageModel := "midjourney" + ctx := context.TODO() + for { + time.Sleep(time.Duration(15) * time.Second) + + tasks := model.GetAllUnFinishTasks() + if len(tasks) == 0 { + continue + } + + common.LogInfo(ctx, fmt.Sprintf("检测到未完成的任务数有: %v", len(tasks))) + taskChannelM := make(map[int][]string) + taskM := make(map[string]*model.Midjourney) + nullTaskIds := make([]int, 0) + for _, task := range tasks { + if task.MjId == "" { + // 统计失败的未完成任务 + nullTaskIds = append(nullTaskIds, task.Id) + continue + } + taskM[task.MjId] = task + taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.MjId) + } + if len(nullTaskIds) > 0 { + err := model.MjBulkUpdateByTaskIds(nullTaskIds, map[string]any{ + "status": "FAILURE", + "progress": "100%", + }) + if err != nil { + common.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err)) + } else { + common.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds)) + } + } + if len(taskChannelM) == 0 { + continue + } + + for channelId, taskIds := range taskChannelM { + common.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds))) + if len(taskIds) == 0 { + continue + } + midjourneyChannel, err := model.CacheGetChannel(channelId) + if err != nil { + common.LogError(ctx, fmt.Sprintf("CacheGetChannel: %v", err)) + err := model.MjBulkUpdate(taskIds, map[string]any{ + "fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId), + "status": "FAILURE", + "progress": "100%", + }) + if err != nil { + common.LogInfo(ctx, fmt.Sprintf("UpdateMidjourneyTask error: %v", err)) + } + continue + } + requestUrl := fmt.Sprintf("%s/mj/task/list-by-condition", *midjourneyChannel.BaseURL) + + body, _ := json.Marshal(map[string]any{ + "ids": taskIds, + }) + req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(body)) + if err != nil { + common.LogError(ctx, fmt.Sprintf("Get Task error: %v", err)) + continue + } + // 设置超时时间 + timeout := time.Second * 15 + ctx, cancel := context.WithTimeout(context.Background(), timeout) + // 使用带有超时的 context 创建新的请求 + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("mj-api-secret", midjourneyChannel.Key) + resp, err := service.GetHttpClient().Do(req) + if err != nil { + common.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err)) + continue + } + if resp.StatusCode != http.StatusOK { + common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode)) + continue + } + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + common.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err)) + continue + } + var responseItems []dto.MidjourneyDto + err = json.Unmarshal(responseBody, &responseItems) + if err != nil { + common.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody))) + continue + } + resp.Body.Close() + req.Body.Close() + cancel() + + for _, responseItem := range responseItems { + task := taskM[responseItem.MjId] + + useTime := (time.Now().UnixNano() / int64(time.Millisecond)) - task.SubmitTime + // 如果时间超过一小时,且进度不是100%,则认为任务失败 + if useTime > 3600000 && task.Progress != "100%" { + responseItem.FailReason = "上游任务超时(超过1小时)" + responseItem.Status = "FAILURE" + } + if !checkMjTaskNeedUpdate(task, responseItem) { + continue + } + task.Code = 1 + task.Progress = responseItem.Progress + task.PromptEn = responseItem.PromptEn + task.State = responseItem.State + task.SubmitTime = responseItem.SubmitTime + task.StartTime = responseItem.StartTime + task.FinishTime = responseItem.FinishTime + task.ImageUrl = responseItem.ImageUrl + task.Status = responseItem.Status + task.FailReason = responseItem.FailReason + if responseItem.Properties != nil { + propertiesStr, _ := json.Marshal(responseItem.Properties) + task.Properties = string(propertiesStr) + } + if responseItem.Buttons != nil { + buttonStr, _ := json.Marshal(responseItem.Buttons) + task.Buttons = string(buttonStr) + } + shouldReturnQuota := false + if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") { + common.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason) + task.Progress = "100%" + if task.Quota != 0 { + shouldReturnQuota = true + } + } + err = task.Update() + if err != nil { + common.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error()) + } else { + if shouldReturnQuota { + err = model.IncreaseUserQuota(task.UserId, task.Quota, false) + if err != nil { + common.LogError(ctx, "fail to increase user quota: "+err.Error()) + } + logContent := fmt.Sprintf("构图失败 %s,补偿 %s", task.MjId, common.LogQuota(task.Quota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } + } + } + } +} + +func checkMjTaskNeedUpdate(oldTask *model.Midjourney, newTask dto.MidjourneyDto) bool { + if oldTask.Code != 1 { + return true + } + if oldTask.Progress != newTask.Progress { + return true + } + if oldTask.PromptEn != newTask.PromptEn { + return true + } + if oldTask.State != newTask.State { + return true + } + if oldTask.SubmitTime != newTask.SubmitTime { + return true + } + if oldTask.StartTime != newTask.StartTime { + return true + } + if oldTask.FinishTime != newTask.FinishTime { + return true + } + if oldTask.ImageUrl != newTask.ImageUrl { + return true + } + if oldTask.Status != newTask.Status { + return true + } + if oldTask.FailReason != newTask.FailReason { + return true + } + if oldTask.FinishTime != newTask.FinishTime { + return true + } + if oldTask.Progress != "100%" && newTask.FailReason != "" { + return true + } + + return false +} + +func GetAllMidjourney(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + + // 解析其他查询参数 + queryParams := model.TaskQueryParams{ + ChannelID: c.Query("channel_id"), + MjID: c.Query("mj_id"), + StartTimestamp: c.Query("start_timestamp"), + EndTimestamp: c.Query("end_timestamp"), + } + + items := model.GetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.CountAllTasks(queryParams) + + if setting.MjForwardUrlEnabled { + for i, midjourney := range items { + midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId + items[i] = midjourney + } + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(items) + common.ApiSuccess(c, pageInfo) +} + +func GetUserMidjourney(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + + userId := c.GetInt("id") + + queryParams := model.TaskQueryParams{ + MjID: c.Query("mj_id"), + StartTimestamp: c.Query("start_timestamp"), + EndTimestamp: c.Query("end_timestamp"), + } + + items := model.GetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.CountAllUserTask(userId, queryParams) + + if setting.MjForwardUrlEnabled { + for i, midjourney := range items { + midjourney.ImageUrl = setting.ServerAddress + "/mj/image/" + midjourney.MjId + items[i] = midjourney + } + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(items) + common.ApiSuccess(c, pageInfo) +} diff --git a/controller/misc.go b/controller/misc.go new file mode 100644 index 00000000..a3ed9be9 --- /dev/null +++ b/controller/misc.go @@ -0,0 +1,302 @@ +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/middleware" + "one-api/model" + "one-api/setting" + "one-api/setting/console_setting" + "one-api/setting/operation_setting" + "one-api/setting/system_setting" + "strings" + + "github.com/gin-gonic/gin" +) + +func TestStatus(c *gin.Context) { + err := model.PingDB() + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "success": false, + "message": "数据库连接失败", + }) + return + } + // 获取HTTP统计信息 + httpStats := middleware.GetStats() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Server is running", + "http_stats": httpStats, + }) + return +} + +func GetStatus(c *gin.Context) { + + cs := console_setting.GetConsoleSetting() + + data := gin.H{ + "version": common.Version, + "start_time": common.StartTime, + "email_verification": common.EmailVerificationEnabled, + "github_oauth": common.GitHubOAuthEnabled, + "github_client_id": common.GitHubClientId, + "linuxdo_oauth": common.LinuxDOOAuthEnabled, + "linuxdo_client_id": common.LinuxDOClientId, + "telegram_oauth": common.TelegramOAuthEnabled, + "telegram_bot_name": common.TelegramBotName, + "system_name": common.SystemName, + "logo": common.Logo, + "footer_html": common.Footer, + "wechat_qrcode": common.WeChatAccountQRCodeImageURL, + "wechat_login": common.WeChatAuthEnabled, + "server_address": setting.ServerAddress, + "price": setting.Price, + "stripe_unit_price": setting.StripeUnitPrice, + "min_topup": setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, + "turnstile_check": common.TurnstileCheckEnabled, + "turnstile_site_key": common.TurnstileSiteKey, + "top_up_link": common.TopUpLink, + "docs_link": operation_setting.GetGeneralSetting().DocsLink, + "quota_per_unit": common.QuotaPerUnit, + "display_in_currency": common.DisplayInCurrencyEnabled, + "enable_batch_update": common.BatchUpdateEnabled, + "enable_drawing": common.DrawingEnabled, + "enable_task": common.TaskEnabled, + "enable_data_export": common.DataExportEnabled, + "data_export_default_time": common.DataExportDefaultTime, + "default_collapse_sidebar": common.DefaultCollapseSidebar, + "enable_online_topup": setting.PayAddress != "" && setting.EpayId != "" && setting.EpayKey != "", + "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", + "mj_notify_enabled": setting.MjNotifyEnabled, + "chats": setting.Chats, + "demo_site_enabled": operation_setting.DemoSiteEnabled, + "self_use_mode_enabled": operation_setting.SelfUseModeEnabled, + "default_use_auto_group": setting.DefaultUseAutoGroup, + "pay_methods": setting.PayMethods, + "usd_exchange_rate": setting.USDExchangeRate, + + // 面板启用开关 + "api_info_enabled": cs.ApiInfoEnabled, + "uptime_kuma_enabled": cs.UptimeKumaEnabled, + "announcements_enabled": cs.AnnouncementsEnabled, + "faq_enabled": cs.FAQEnabled, + + "oidc_enabled": system_setting.GetOIDCSettings().Enabled, + "oidc_client_id": system_setting.GetOIDCSettings().ClientId, + "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, + "setup": constant.Setup, + } + + // 根据启用状态注入可选内容 + if cs.ApiInfoEnabled { + data["api_info"] = console_setting.GetApiInfo() + } + if cs.AnnouncementsEnabled { + data["announcements"] = console_setting.GetAnnouncements() + } + if cs.FAQEnabled { + data["faq"] = console_setting.GetFAQ() + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) + return +} + +func GetNotice(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["Notice"], + }) + return +} + +func GetAbout(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["About"], + }) + return +} + +func GetMidjourney(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["Midjourney"], + }) + return +} + +func GetHomePageContent(c *gin.Context) { + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": common.OptionMap["HomePageContent"], + }) + return +} + +func SendEmailVerification(c *gin.Context) { + email := c.Query("email") + if err := common.Validate.Var(email, "required,email"); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + parts := strings.Split(email, "@") + if len(parts) != 2 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的邮箱地址", + }) + return + } + localPart := parts[0] + domainPart := parts[1] + if common.EmailDomainRestrictionEnabled { + allowed := false + for _, domain := range common.EmailDomainWhitelist { + if domainPart == domain { + allowed = true + break + } + } + if !allowed { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "The administrator has enabled the email domain name whitelist, and your email address is not allowed due to special symbols or it's not in the whitelist.", + }) + return + } + } + if common.EmailAliasRestrictionEnabled { + containsSpecialSymbols := strings.Contains(localPart, "+") || strings.Contains(localPart, ".") + if containsSpecialSymbols { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员已启用邮箱地址别名限制,您的邮箱地址由于包含特殊符号而被拒绝。", + }) + return + } + } + + if model.IsEmailAlreadyTaken(email) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "邮箱地址已被占用", + }) + return + } + code := common.GenerateVerificationCode(6) + common.RegisterVerificationCodeWithKey(email, code, common.EmailVerificationPurpose) + subject := fmt.Sprintf("%s邮箱验证邮件", common.SystemName) + content := fmt.Sprintf("

您好,你正在进行%s邮箱验证。

"+ + "

您的验证码为: %s

"+ + "

验证码 %d 分钟内有效,如果不是本人操作,请忽略。

", common.SystemName, code, common.VerificationValidMinutes) + err := common.SendEmail(subject, email, content) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func SendPasswordResetEmail(c *gin.Context) { + email := c.Query("email") + if err := common.Validate.Var(email, "required,email"); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if !model.IsEmailAlreadyTaken(email) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该邮箱地址未注册", + }) + return + } + code := common.GenerateVerificationCode(0) + common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose) + link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", setting.ServerAddress, email, code) + subject := fmt.Sprintf("%s密码重置", common.SystemName) + content := fmt.Sprintf("

您好,你正在进行%s密码重置。

"+ + "

点击 此处 进行密码重置。

"+ + "

如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:
%s

"+ + "

重置链接 %d 分钟内有效,如果不是本人操作,请忽略。

", common.SystemName, link, link, common.VerificationValidMinutes) + err := common.SendEmail(subject, email, content) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +type PasswordResetRequest struct { + Email string `json:"email"` + Token string `json:"token"` +} + +func ResetPassword(c *gin.Context) { + var req PasswordResetRequest + err := json.NewDecoder(c.Request.Body).Decode(&req) + if req.Email == "" || req.Token == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if !common.VerifyCodeWithKey(req.Email, req.Token, common.PasswordResetPurpose) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "重置链接非法或已过期", + }) + return + } + password := common.GenerateVerificationCode(12) + err = model.ResetUserPasswordByEmail(req.Email, password) + if err != nil { + common.ApiError(c, err) + return + } + common.DeleteKey(req.Email, common.PasswordResetPurpose) + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": password, + }) + return +} diff --git a/controller/model.go b/controller/model.go new file mode 100644 index 00000000..31a66b29 --- /dev/null +++ b/controller/model.go @@ -0,0 +1,216 @@ +package controller + +import ( + "fmt" + "github.com/gin-gonic/gin" + "github.com/samber/lo" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/model" + "one-api/relay" + "one-api/relay/channel/ai360" + "one-api/relay/channel/lingyiwanwu" + "one-api/relay/channel/minimax" + "one-api/relay/channel/moonshot" + relaycommon "one-api/relay/common" + "one-api/setting" +) + +// https://platform.openai.com/docs/api-reference/models/list + +var openAIModels []dto.OpenAIModels +var openAIModelsMap map[string]dto.OpenAIModels +var channelId2Models map[int][]string + +func init() { + // https://platform.openai.com/docs/models/model-endpoint-compatibility + for i := 0; i < constant.APITypeDummy; i++ { + if i == constant.APITypeAIProxyLibrary { + continue + } + adaptor := relay.GetAdaptor(i) + channelName := adaptor.GetChannelName() + modelNames := adaptor.GetModelList() + for _, modelName := range modelNames { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: channelName, + }) + } + } + for _, modelName := range ai360.ModelList { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: ai360.ChannelName, + }) + } + for _, modelName := range moonshot.ModelList { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: moonshot.ChannelName, + }) + } + for _, modelName := range lingyiwanwu.ModelList { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: lingyiwanwu.ChannelName, + }) + } + for _, modelName := range minimax.ModelList { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: minimax.ChannelName, + }) + } + for modelName, _ := range constant.MidjourneyModel2Action { + openAIModels = append(openAIModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: "midjourney", + }) + } + openAIModelsMap = make(map[string]dto.OpenAIModels) + for _, aiModel := range openAIModels { + openAIModelsMap[aiModel.Id] = aiModel + } + channelId2Models = make(map[int][]string) + for i := 1; i <= constant.ChannelTypeDummy; i++ { + apiType, success := common.ChannelType2APIType(i) + if !success || apiType == constant.APITypeAIProxyLibrary { + continue + } + meta := &relaycommon.RelayInfo{ChannelType: i} + adaptor := relay.GetAdaptor(apiType) + adaptor.Init(meta) + channelId2Models[i] = adaptor.GetModelList() + } + openAIModels = lo.UniqBy(openAIModels, func(m dto.OpenAIModels) string { + return m.Id + }) +} + +func ListModels(c *gin.Context) { + userOpenAiModels := make([]dto.OpenAIModels, 0) + + modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) + if modelLimitEnable { + s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit) + var tokenModelLimit map[string]bool + if ok { + tokenModelLimit = s.(map[string]bool) + } else { + tokenModelLimit = map[string]bool{} + } + for allowModel, _ := range tokenModelLimit { + if oaiModel, ok := openAIModelsMap[allowModel]; ok { + oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(allowModel) + userOpenAiModels = append(userOpenAiModels, oaiModel) + } else { + userOpenAiModels = append(userOpenAiModels, dto.OpenAIModels{ + Id: allowModel, + Object: "model", + Created: 1626777600, + OwnedBy: "custom", + SupportedEndpointTypes: model.GetModelSupportEndpointTypes(allowModel), + }) + } + } + } else { + userId := c.GetInt("id") + userGroup, err := model.GetUserGroup(userId, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "get user group failed", + }) + return + } + group := userGroup + tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) + if tokenGroup != "" { + group = tokenGroup + } + var models []string + if tokenGroup == "auto" { + for _, autoGroup := range setting.AutoGroups { + groupModels := model.GetGroupEnabledModels(autoGroup) + for _, g := range groupModels { + if !common.StringsContains(models, g) { + models = append(models, g) + } + } + } + } else { + models = model.GetGroupEnabledModels(group) + } + for _, modelName := range models { + if oaiModel, ok := openAIModelsMap[modelName]; ok { + oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName) + userOpenAiModels = append(userOpenAiModels, oaiModel) + } else { + userOpenAiModels = append(userOpenAiModels, dto.OpenAIModels{ + Id: modelName, + Object: "model", + Created: 1626777600, + OwnedBy: "custom", + SupportedEndpointTypes: model.GetModelSupportEndpointTypes(modelName), + }) + } + } + } + c.JSON(200, gin.H{ + "success": true, + "data": userOpenAiModels, + }) +} + +func ChannelListModels(c *gin.Context) { + c.JSON(200, gin.H{ + "success": true, + "data": openAIModels, + }) +} + +func DashboardListModels(c *gin.Context) { + c.JSON(200, gin.H{ + "success": true, + "data": channelId2Models, + }) +} + +func EnabledListModels(c *gin.Context) { + c.JSON(200, gin.H{ + "success": true, + "data": model.GetEnabledModels(), + }) +} + +func RetrieveModel(c *gin.Context) { + modelId := c.Param("model") + if aiModel, ok := openAIModelsMap[modelId]; ok { + c.JSON(200, aiModel) + } else { + openAIError := dto.OpenAIError{ + Message: fmt.Sprintf("The model '%s' does not exist", modelId), + Type: "invalid_request_error", + Param: "model", + Code: "model_not_found", + } + c.JSON(200, gin.H{ + "error": openAIError, + }) + } +} diff --git a/controller/oidc.go b/controller/oidc.go new file mode 100644 index 00000000..df8ea1c4 --- /dev/null +++ b/controller/oidc.go @@ -0,0 +1,228 @@ +package controller + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "one-api/common" + "one-api/model" + "one-api/setting" + "one-api/setting/system_setting" + "strconv" + "strings" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type OidcResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + Scope string `json:"scope"` +} + +type OidcUser struct { + OpenID string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + PreferredUsername string `json:"preferred_username"` + Picture string `json:"picture"` +} + +func getOidcUserInfoByCode(code string) (*OidcUser, error) { + if code == "" { + return nil, errors.New("无效的参数") + } + + values := url.Values{} + values.Set("client_id", system_setting.GetOIDCSettings().ClientId) + values.Set("client_secret", system_setting.GetOIDCSettings().ClientSecret) + values.Set("code", code) + values.Set("grant_type", "authorization_code") + values.Set("redirect_uri", fmt.Sprintf("%s/oauth/oidc", setting.ServerAddress)) + formData := values.Encode() + req, err := http.NewRequest("POST", system_setting.GetOIDCSettings().TokenEndpoint, strings.NewReader(formData)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + client := http.Client{ + Timeout: 5 * time.Second, + } + res, err := client.Do(req) + if err != nil { + common.SysLog(err.Error()) + return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!") + } + defer res.Body.Close() + var oidcResponse OidcResponse + err = json.NewDecoder(res.Body).Decode(&oidcResponse) + if err != nil { + return nil, err + } + + if oidcResponse.AccessToken == "" { + common.SysError("OIDC 获取 Token 失败,请检查设置!") + return nil, errors.New("OIDC 获取 Token 失败,请检查设置!") + } + + req, err = http.NewRequest("GET", system_setting.GetOIDCSettings().UserInfoEndpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+oidcResponse.AccessToken) + res2, err := client.Do(req) + if err != nil { + common.SysLog(err.Error()) + return nil, errors.New("无法连接至 OIDC 服务器,请稍后重试!") + } + defer res2.Body.Close() + if res2.StatusCode != http.StatusOK { + common.SysError("OIDC 获取用户信息失败!请检查设置!") + return nil, errors.New("OIDC 获取用户信息失败!请检查设置!") + } + + var oidcUser OidcUser + err = json.NewDecoder(res2.Body).Decode(&oidcUser) + if err != nil { + return nil, err + } + if oidcUser.OpenID == "" || oidcUser.Email == "" { + common.SysError("OIDC 获取用户信息为空!请检查设置!") + return nil, errors.New("OIDC 获取用户信息为空!请检查设置!") + } + return &oidcUser, nil +} + +func OidcAuth(c *gin.Context) { + session := sessions.Default(c) + state := c.Query("state") + if state == "" || session.Get("oauth_state") == nil || state != session.Get("oauth_state").(string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "state is empty or not same", + }) + return + } + username := session.Get("username") + if username != nil { + OidcBind(c) + return + } + if !system_setting.GetOIDCSettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 OIDC 登录以及注册", + }) + return + } + code := c.Query("code") + oidcUser, err := getOidcUserInfoByCode(code) + if err != nil { + common.ApiError(c, err) + return + } + user := model.User{ + OidcId: oidcUser.OpenID, + } + if model.IsOidcIdAlreadyTaken(user.OidcId) { + err := user.FillUserByOidcId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + if common.RegisterEnabled { + user.Email = oidcUser.Email + if oidcUser.PreferredUsername != "" { + user.Username = oidcUser.PreferredUsername + } else { + user.Username = "oidc_" + strconv.Itoa(model.GetMaxUserId()+1) + } + if oidcUser.Name != "" { + user.DisplayName = oidcUser.Name + } else { + user.DisplayName = "OIDC User" + } + err := user.Insert(0) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + setupLogin(&user, c) +} + +func OidcBind(c *gin.Context) { + if !system_setting.GetOIDCSettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未开启通过 OIDC 登录以及注册", + }) + return + } + code := c.Query("code") + oidcUser, err := getOidcUserInfoByCode(code) + if err != nil { + common.ApiError(c, err) + return + } + user := model.User{ + OidcId: oidcUser.OpenID, + } + if model.IsOidcIdAlreadyTaken(user.OidcId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该 OIDC 账户已被绑定", + }) + return + } + session := sessions.Default(c) + id := session.Get("id") + // id := c.GetInt("id") // critical bug! + user.Id = id.(int) + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + user.OidcId = oidcUser.OpenID + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "bind", + }) + return +} diff --git a/controller/option.go b/controller/option.go new file mode 100644 index 00000000..decdb0d4 --- /dev/null +++ b/controller/option.go @@ -0,0 +1,171 @@ +package controller + +import ( + "encoding/json" + "net/http" + "one-api/common" + "one-api/model" + "one-api/setting" + "one-api/setting/console_setting" + "one-api/setting/ratio_setting" + "one-api/setting/system_setting" + "strings" + + "github.com/gin-gonic/gin" +) + +func GetOptions(c *gin.Context) { + var options []*model.Option + common.OptionMapRWMutex.Lock() + for k, v := range common.OptionMap { + if strings.HasSuffix(k, "Token") || strings.HasSuffix(k, "Secret") || strings.HasSuffix(k, "Key") { + continue + } + options = append(options, &model.Option{ + Key: k, + Value: common.Interface2String(v), + }) + } + common.OptionMapRWMutex.Unlock() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": options, + }) + return +} + +func UpdateOption(c *gin.Context) { + var option model.Option + err := json.NewDecoder(c.Request.Body).Decode(&option) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + switch option.Key { + case "GitHubOAuthEnabled": + if option.Value == "true" && common.GitHubClientId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 GitHub OAuth,请先填入 GitHub Client Id 以及 GitHub Client Secret!", + }) + return + } + case "oidc.enabled": + if option.Value == "true" && system_setting.GetOIDCSettings().ClientId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 OIDC 登录,请先填入 OIDC Client Id 以及 OIDC Client Secret!", + }) + return + } + case "LinuxDOOAuthEnabled": + if option.Value == "true" && common.LinuxDOClientId == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 LinuxDO OAuth,请先填入 LinuxDO Client Id 以及 LinuxDO Client Secret!", + }) + return + } + case "EmailDomainRestrictionEnabled": + if option.Value == "true" && len(common.EmailDomainWhitelist) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用邮箱域名限制,请先填入限制的邮箱域名!", + }) + return + } + case "WeChatAuthEnabled": + if option.Value == "true" && common.WeChatServerAddress == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用微信登录,请先填入微信登录相关配置信息!", + }) + return + } + case "TurnstileCheckEnabled": + if option.Value == "true" && common.TurnstileSiteKey == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 Turnstile 校验,请先填入 Turnstile 校验相关配置信息!", + }) + + return + } + case "TelegramOAuthEnabled": + if option.Value == "true" && common.TelegramBotToken == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法启用 Telegram OAuth,请先填入 Telegram Bot Token!", + }) + return + } + case "GroupRatio": + err = ratio_setting.CheckGroupRatio(option.Value) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "ModelRequestRateLimitGroup": + err = setting.CheckModelRequestRateLimitGroup(option.Value) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.api_info": + err = console_setting.ValidateConsoleSettings(option.Value, "ApiInfo") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.announcements": + err = console_setting.ValidateConsoleSettings(option.Value, "Announcements") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.faq": + err = console_setting.ValidateConsoleSettings(option.Value, "FAQ") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "console_setting.uptime_kuma_groups": + err = console_setting.ValidateConsoleSettings(option.Value, "UptimeKumaGroups") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + err = model.UpdateOption(option.Key, option.Value) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} diff --git a/controller/playground.go b/controller/playground.go new file mode 100644 index 00000000..0073cf06 --- /dev/null +++ b/controller/playground.go @@ -0,0 +1,84 @@ +package controller + +import ( + "errors" + "fmt" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/middleware" + "one-api/model" + "one-api/setting" + "one-api/types" + "time" + + "github.com/gin-gonic/gin" +) + +func Playground(c *gin.Context) { + var newAPIError *types.NewAPIError + + defer func() { + if newAPIError != nil { + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + }() + + useAccessToken := c.GetBool("use_access_token") + if useAccessToken { + newAPIError = types.NewError(errors.New("暂不支持使用 access token"), types.ErrorCodeAccessDenied) + return + } + + playgroundRequest := &dto.PlayGroundRequest{} + err := common.UnmarshalBodyReusable(c, playgroundRequest) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest) + return + } + + if playgroundRequest.Model == "" { + newAPIError = types.NewError(errors.New("请选择模型"), types.ErrorCodeInvalidRequest) + return + } + c.Set("original_model", playgroundRequest.Model) + group := playgroundRequest.Group + userGroup := c.GetString("group") + + if group == "" { + group = userGroup + } else { + if !setting.GroupInUserUsableGroups(group) && group != userGroup { + newAPIError = types.NewError(errors.New("无权访问该分组"), types.ErrorCodeAccessDenied) + return + } + c.Set("group", group) + } + + userId := c.GetInt("id") + + // Write user context to ensure acceptUnsetRatio is available + userCache, err := model.GetUserCache(userId) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeQueryDataError) + return + } + userCache.WriteContext(c) + + tempToken := &model.Token{ + UserId: userId, + Name: fmt.Sprintf("playground-%s", group), + Group: group, + } + _ = middleware.SetupContextForToken(c, tempToken) + _, newAPIError = getChannel(c, group, playgroundRequest.Model, 0) + if newAPIError != nil { + return + } + //middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model) + common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now()) + + Relay(c) +} diff --git a/controller/pricing.go b/controller/pricing.go new file mode 100644 index 00000000..f27336b7 --- /dev/null +++ b/controller/pricing.go @@ -0,0 +1,71 @@ +package controller + +import ( + "one-api/model" + "one-api/setting" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +func GetPricing(c *gin.Context) { + pricing := model.GetPricing() + userId, exists := c.Get("id") + usableGroup := map[string]string{} + groupRatio := map[string]float64{} + for s, f := range ratio_setting.GetGroupRatioCopy() { + groupRatio[s] = f + } + var group string + if exists { + user, err := model.GetUserCache(userId.(int)) + if err == nil { + group = user.Group + for g := range groupRatio { + ratio, ok := ratio_setting.GetGroupGroupRatio(group, g) + if ok { + groupRatio[g] = ratio + } + } + } + } + + usableGroup = setting.GetUserUsableGroups(group) + // check groupRatio contains usableGroup + for group := range ratio_setting.GetGroupRatioCopy() { + if _, ok := usableGroup[group]; !ok { + delete(groupRatio, group) + } + } + + c.JSON(200, gin.H{ + "success": true, + "data": pricing, + "group_ratio": groupRatio, + "usable_group": usableGroup, + }) +} + +func ResetModelRatio(c *gin.Context) { + defaultStr := ratio_setting.DefaultModelRatio2JSONString() + err := model.UpdateOption("ModelRatio", defaultStr) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + err = ratio_setting.UpdateModelRatioByJSONString(defaultStr) + if err != nil { + c.JSON(200, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + c.JSON(200, gin.H{ + "success": true, + "message": "重置模型倍率成功", + }) +} diff --git a/controller/ratio_config.go b/controller/ratio_config.go new file mode 100644 index 00000000..6ddc3d9e --- /dev/null +++ b/controller/ratio_config.go @@ -0,0 +1,24 @@ +package controller + +import ( + "net/http" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +func GetRatioConfig(c *gin.Context) { + if !ratio_setting.IsExposeRatioEnabled() { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "倍率配置接口未启用", + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": ratio_setting.GetExposedData(), + }) +} \ No newline at end of file diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go new file mode 100644 index 00000000..0453870d --- /dev/null +++ b/controller/ratio_sync.go @@ -0,0 +1,474 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "one-api/common" + "one-api/dto" + "one-api/model" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +const ( + defaultTimeoutSeconds = 10 + defaultEndpoint = "/api/ratio_config" + maxConcurrentFetches = 8 +) + +var ratioTypes = []string{"model_ratio", "completion_ratio", "cache_ratio", "model_price"} + +type upstreamResult struct { + Name string `json:"name"` + Data map[string]any `json:"data,omitempty"` + Err string `json:"err,omitempty"` +} + +func FetchUpstreamRatios(c *gin.Context) { + var req dto.UpstreamRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + + if req.Timeout <= 0 { + req.Timeout = defaultTimeoutSeconds + } + + var upstreams []dto.UpstreamDTO + + if len(req.Upstreams) > 0 { + for _, u := range req.Upstreams { + if strings.HasPrefix(u.BaseURL, "http") { + if u.Endpoint == "" { + u.Endpoint = defaultEndpoint + } + u.BaseURL = strings.TrimRight(u.BaseURL, "/") + upstreams = append(upstreams, u) + } + } + } else if len(req.ChannelIDs) > 0 { + intIds := make([]int, 0, len(req.ChannelIDs)) + for _, id64 := range req.ChannelIDs { + intIds = append(intIds, int(id64)) + } + dbChannels, err := model.GetChannelsByIds(intIds) + if err != nil { + common.LogError(c.Request.Context(), "failed to query channels: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "查询渠道失败"}) + return + } + for _, ch := range dbChannels { + if base := ch.GetBaseURL(); strings.HasPrefix(base, "http") { + upstreams = append(upstreams, dto.UpstreamDTO{ + ID: ch.Id, + Name: ch.Name, + BaseURL: strings.TrimRight(base, "/"), + Endpoint: "", + }) + } + } + } + + if len(upstreams) == 0 { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "无有效上游渠道"}) + return + } + + var wg sync.WaitGroup + ch := make(chan upstreamResult, len(upstreams)) + + sem := make(chan struct{}, maxConcurrentFetches) + + client := &http.Client{Transport: &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second}} + + for _, chn := range upstreams { + wg.Add(1) + go func(chItem dto.UpstreamDTO) { + defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + + endpoint := chItem.Endpoint + if endpoint == "" { + endpoint = defaultEndpoint + } else if !strings.HasPrefix(endpoint, "/") { + endpoint = "/" + endpoint + } + fullURL := chItem.BaseURL + endpoint + + uniqueName := chItem.Name + if chItem.ID != 0 { + uniqueName = fmt.Sprintf("%s(%d)", chItem.Name, chItem.ID) + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(req.Timeout)*time.Second) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + common.LogWarn(c.Request.Context(), "build request failed: "+err.Error()) + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} + return + } + + resp, err := client.Do(httpReq) + if err != nil { + common.LogWarn(c.Request.Context(), "http error on "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + common.LogWarn(c.Request.Context(), "non-200 from "+chItem.Name+": "+resp.Status) + ch <- upstreamResult{Name: uniqueName, Err: resp.Status} + return + } + // 兼容两种上游接口格式: + // type1: /api/ratio_config -> data 为 map[string]any,包含 model_ratio/completion_ratio/cache_ratio/model_price + // type2: /api/pricing -> data 为 []Pricing 列表,需要转换为与 type1 相同的 map 格式 + var body struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Message string `json:"message"` + } + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + common.LogWarn(c.Request.Context(), "json decode failed from "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} + return + } + + if !body.Success { + ch <- upstreamResult{Name: uniqueName, Err: body.Message} + return + } + + // 尝试按 type1 解析 + var type1Data map[string]any + if err := json.Unmarshal(body.Data, &type1Data); err == nil { + // 如果包含至少一个 ratioTypes 字段,则认为是 type1 + isType1 := false + for _, rt := range ratioTypes { + if _, ok := type1Data[rt]; ok { + isType1 = true + break + } + } + if isType1 { + ch <- upstreamResult{Name: uniqueName, Data: type1Data} + return + } + } + + // 如果不是 type1,则尝试按 type2 (/api/pricing) 解析 + var pricingItems []struct { + ModelName string `json:"model_name"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + CompletionRatio float64 `json:"completion_ratio"` + } + if err := json.Unmarshal(body.Data, &pricingItems); err != nil { + common.LogWarn(c.Request.Context(), "unrecognized data format from "+chItem.Name+": "+err.Error()) + ch <- upstreamResult{Name: uniqueName, Err: "无法解析上游返回数据"} + return + } + + modelRatioMap := make(map[string]float64) + completionRatioMap := make(map[string]float64) + modelPriceMap := make(map[string]float64) + + for _, item := range pricingItems { + if item.QuotaType == 1 { + modelPriceMap[item.ModelName] = item.ModelPrice + } else { + modelRatioMap[item.ModelName] = item.ModelRatio + // completionRatio 可能为 0,此时也直接赋值,保持与上游一致 + completionRatioMap[item.ModelName] = item.CompletionRatio + } + } + + converted := make(map[string]any) + + if len(modelRatioMap) > 0 { + ratioAny := make(map[string]any, len(modelRatioMap)) + for k, v := range modelRatioMap { + ratioAny[k] = v + } + converted["model_ratio"] = ratioAny + } + + if len(completionRatioMap) > 0 { + compAny := make(map[string]any, len(completionRatioMap)) + for k, v := range completionRatioMap { + compAny[k] = v + } + converted["completion_ratio"] = compAny + } + + if len(modelPriceMap) > 0 { + priceAny := make(map[string]any, len(modelPriceMap)) + for k, v := range modelPriceMap { + priceAny[k] = v + } + converted["model_price"] = priceAny + } + + ch <- upstreamResult{Name: uniqueName, Data: converted} + }(chn) + } + + wg.Wait() + close(ch) + + localData := ratio_setting.GetExposedData() + + var testResults []dto.TestResult + var successfulChannels []struct { + name string + data map[string]any + } + + for r := range ch { + if r.Err != "" { + testResults = append(testResults, dto.TestResult{ + Name: r.Name, + Status: "error", + Error: r.Err, + }) + } else { + testResults = append(testResults, dto.TestResult{ + Name: r.Name, + Status: "success", + }) + successfulChannels = append(successfulChannels, struct { + name string + data map[string]any + }{name: r.Name, data: r.Data}) + } + } + + differences := buildDifferences(localData, successfulChannels) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "differences": differences, + "test_results": testResults, + }, + }) +} + +func buildDifferences(localData map[string]any, successfulChannels []struct { + name string + data map[string]any +}) map[string]map[string]dto.DifferenceItem { + differences := make(map[string]map[string]dto.DifferenceItem) + + allModels := make(map[string]struct{}) + + for _, ratioType := range ratioTypes { + if localRatioAny, ok := localData[ratioType]; ok { + if localRatio, ok := localRatioAny.(map[string]float64); ok { + for modelName := range localRatio { + allModels[modelName] = struct{}{} + } + } + } + } + + for _, channel := range successfulChannels { + for _, ratioType := range ratioTypes { + if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { + for modelName := range upstreamRatio { + allModels[modelName] = struct{}{} + } + } + } + } + + confidenceMap := make(map[string]map[string]bool) + + // 预处理阶段:检查pricing接口的可信度 + for _, channel := range successfulChannels { + confidenceMap[channel.name] = make(map[string]bool) + + modelRatios, hasModelRatio := channel.data["model_ratio"].(map[string]any) + completionRatios, hasCompletionRatio := channel.data["completion_ratio"].(map[string]any) + + if hasModelRatio && hasCompletionRatio { + // 遍历所有模型,检查是否满足不可信条件 + for modelName := range allModels { + // 默认为可信 + confidenceMap[channel.name][modelName] = true + + // 检查是否满足不可信条件:model_ratio为37.5且completion_ratio为1 + if modelRatioVal, ok := modelRatios[modelName]; ok { + if completionRatioVal, ok := completionRatios[modelName]; ok { + // 转换为float64进行比较 + if modelRatioFloat, ok := modelRatioVal.(float64); ok { + if completionRatioFloat, ok := completionRatioVal.(float64); ok { + if modelRatioFloat == 37.5 && completionRatioFloat == 1.0 { + confidenceMap[channel.name][modelName] = false + } + } + } + } + } + } + } else { + // 如果不是从pricing接口获取的数据,则全部标记为可信 + for modelName := range allModels { + confidenceMap[channel.name][modelName] = true + } + } + } + + for modelName := range allModels { + for _, ratioType := range ratioTypes { + var localValue interface{} = nil + if localRatioAny, ok := localData[ratioType]; ok { + if localRatio, ok := localRatioAny.(map[string]float64); ok { + if val, exists := localRatio[modelName]; exists { + localValue = val + } + } + } + + upstreamValues := make(map[string]interface{}) + confidenceValues := make(map[string]bool) + hasUpstreamValue := false + hasDifference := false + + for _, channel := range successfulChannels { + var upstreamValue interface{} = nil + + if upstreamRatio, ok := channel.data[ratioType].(map[string]any); ok { + if val, exists := upstreamRatio[modelName]; exists { + upstreamValue = val + hasUpstreamValue = true + + if localValue != nil && localValue != val { + hasDifference = true + } else if localValue == val { + upstreamValue = "same" + } + } + } + if upstreamValue == nil && localValue == nil { + upstreamValue = "same" + } + + if localValue == nil && upstreamValue != nil && upstreamValue != "same" { + hasDifference = true + } + + upstreamValues[channel.name] = upstreamValue + + confidenceValues[channel.name] = confidenceMap[channel.name][modelName] + } + + shouldInclude := false + + if localValue != nil { + if hasDifference { + shouldInclude = true + } + } else { + if hasUpstreamValue { + shouldInclude = true + } + } + + if shouldInclude { + if differences[modelName] == nil { + differences[modelName] = make(map[string]dto.DifferenceItem) + } + differences[modelName][ratioType] = dto.DifferenceItem{ + Current: localValue, + Upstreams: upstreamValues, + Confidence: confidenceValues, + } + } + } + } + + channelHasDiff := make(map[string]bool) + for _, ratioMap := range differences { + for _, item := range ratioMap { + for chName, val := range item.Upstreams { + if val != nil && val != "same" { + channelHasDiff[chName] = true + } + } + } + } + + for modelName, ratioMap := range differences { + for ratioType, item := range ratioMap { + for chName := range item.Upstreams { + if !channelHasDiff[chName] { + delete(item.Upstreams, chName) + delete(item.Confidence, chName) + } + } + + allSame := true + for _, v := range item.Upstreams { + if v != "same" { + allSame = false + break + } + } + if len(item.Upstreams) == 0 || allSame { + delete(ratioMap, ratioType) + } else { + differences[modelName][ratioType] = item + } + } + + if len(ratioMap) == 0 { + delete(differences, modelName) + } + } + + return differences +} + +func GetSyncableChannels(c *gin.Context) { + channels, err := model.GetAllChannels(0, 0, true, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + var syncableChannels []dto.SyncableChannel + for _, channel := range channels { + if channel.GetBaseURL() != "" { + syncableChannels = append(syncableChannels, dto.SyncableChannel{ + ID: channel.Id, + Name: channel.Name, + BaseURL: channel.GetBaseURL(), + Status: channel.Status, + }) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": syncableChannels, + }) +} \ No newline at end of file diff --git a/controller/redemption.go b/controller/redemption.go new file mode 100644 index 00000000..83ec19ad --- /dev/null +++ b/controller/redemption.go @@ -0,0 +1,193 @@ +package controller + +import ( + "errors" + "net/http" + "one-api/common" + "one-api/model" + "strconv" + + "github.com/gin-gonic/gin" +) + +func GetAllRedemptions(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + redemptions, total, err := model.GetAllRedemptions(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(redemptions) + common.ApiSuccess(c, pageInfo) + return +} + +func SearchRedemptions(c *gin.Context) { + keyword := c.Query("keyword") + pageInfo := common.GetPageQuery(c) + redemptions, total, err := model.SearchRedemptions(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(redemptions) + common.ApiSuccess(c, pageInfo) + return +} + +func GetRedemption(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + redemption, err := model.GetRedemptionById(id) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": redemption, + }) + return +} + +func AddRedemption(c *gin.Context) { + redemption := model.Redemption{} + err := c.ShouldBindJSON(&redemption) + if err != nil { + common.ApiError(c, err) + return + } + if len(redemption.Name) == 0 || len(redemption.Name) > 20 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "兑换码名称长度必须在1-20之间", + }) + return + } + if redemption.Count <= 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "兑换码个数必须大于0", + }) + return + } + if redemption.Count > 100 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "一次兑换码批量生成的个数不能大于 100", + }) + return + } + if err := validateExpiredTime(redemption.ExpiredTime); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + var keys []string + for i := 0; i < redemption.Count; i++ { + key := common.GetUUID() + cleanRedemption := model.Redemption{ + UserId: c.GetInt("id"), + Name: redemption.Name, + Key: key, + CreatedTime: common.GetTimestamp(), + Quota: redemption.Quota, + ExpiredTime: redemption.ExpiredTime, + } + err = cleanRedemption.Insert() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + "data": keys, + }) + return + } + keys = append(keys, key) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": keys, + }) + return +} + +func DeleteRedemption(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + err := model.DeleteRedemptionById(id) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func UpdateRedemption(c *gin.Context) { + statusOnly := c.Query("status_only") + redemption := model.Redemption{} + err := c.ShouldBindJSON(&redemption) + if err != nil { + common.ApiError(c, err) + return + } + cleanRedemption, err := model.GetRedemptionById(redemption.Id) + if err != nil { + common.ApiError(c, err) + return + } + if statusOnly == "" { + if err := validateExpiredTime(redemption.ExpiredTime); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + // If you add more fields, please also update redemption.Update() + cleanRedemption.Name = redemption.Name + cleanRedemption.Quota = redemption.Quota + cleanRedemption.ExpiredTime = redemption.ExpiredTime + } + if statusOnly != "" { + cleanRedemption.Status = redemption.Status + } + err = cleanRedemption.Update() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": cleanRedemption, + }) + return +} + +func DeleteInvalidRedemption(c *gin.Context) { + rows, err := model.DeleteInvalidRedemptions() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": rows, + }) + return +} + +func validateExpiredTime(expired int64) error { + if expired != 0 && expired < common.GetTimestamp() { + return errors.New("过期时间不能早于当前时间") + } + return nil +} diff --git a/controller/relay.go b/controller/relay.go new file mode 100644 index 00000000..b224b42c --- /dev/null +++ b/controller/relay.go @@ -0,0 +1,476 @@ +package controller + +import ( + "bytes" + "errors" + "fmt" + "io" + "log" + "net/http" + "one-api/common" + "one-api/constant" + constant2 "one-api/constant" + "one-api/dto" + "one-api/middleware" + "one-api/model" + "one-api/relay" + relayconstant "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func relayHandler(c *gin.Context, relayMode int) *types.NewAPIError { + var err *types.NewAPIError + switch relayMode { + case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits: + err = relay.ImageHelper(c) + case relayconstant.RelayModeAudioSpeech: + fallthrough + case relayconstant.RelayModeAudioTranslation: + fallthrough + case relayconstant.RelayModeAudioTranscription: + err = relay.AudioHelper(c) + case relayconstant.RelayModeRerank: + err = relay.RerankHelper(c, relayMode) + case relayconstant.RelayModeEmbeddings: + err = relay.EmbeddingHelper(c) + case relayconstant.RelayModeResponses: + err = relay.ResponsesHelper(c) + case relayconstant.RelayModeGemini: + err = relay.GeminiHelper(c) + default: + err = relay.TextHelper(c) + } + + if constant2.ErrorLogEnabled && err != nil { + // 保存错误日志到mysql中 + userId := c.GetInt("id") + tokenName := c.GetString("token_name") + modelName := c.GetString("original_model") + tokenId := c.GetInt("token_id") + userGroup := c.GetString("group") + channelId := c.GetInt("channel_id") + other := make(map[string]interface{}) + other["error_type"] = err.ErrorType + other["error_code"] = err.GetErrorCode() + other["status_code"] = err.StatusCode + other["channel_id"] = channelId + other["channel_name"] = c.GetString("channel_name") + other["channel_type"] = c.GetInt("channel_type") + + model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.Error(), tokenId, 0, false, userGroup, other) + } + + return err +} + +func Relay(c *gin.Context) { + relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path) + requestId := c.GetString(common.RequestIdKey) + group := c.GetString("group") + originalModel := c.GetString("original_model") + var newAPIError *types.NewAPIError + + for i := 0; i <= common.RetryTimes; i++ { + channel, err := getChannel(c, group, originalModel, i) + if err != nil { + common.LogError(c, err.Error()) + newAPIError = err + break + } + + newAPIError = relayRequest(c, relayMode, channel) + + if newAPIError == nil { + return // 成功处理请求,直接返回 + } + + go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) + + if !shouldRetry(c, newAPIError, common.RetryTimes-i) { + break + } + } + useChannel := c.GetStringSlice("use_channel") + if len(useChannel) > 1 { + retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]")) + common.LogInfo(c, retryLogStr) + } + + if newAPIError != nil { + //if newAPIError.StatusCode == http.StatusTooManyRequests { + // common.LogError(c, fmt.Sprintf("origin 429 error: %s", newAPIError.Error())) + // newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试") + //} + newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId)) + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } +} + +var upgrader = websocket.Upgrader{ + Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol + CheckOrigin: func(r *http.Request) bool { + return true // 允许跨域 + }, +} + +func WssRelay(c *gin.Context) { + // 将 HTTP 连接升级为 WebSocket 连接 + + ws, err := upgrader.Upgrade(c.Writer, c.Request, nil) + defer ws.Close() + + if err != nil { + helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed).ToOpenAIError()) + return + } + + relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path) + requestId := c.GetString(common.RequestIdKey) + group := c.GetString("group") + //wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01 + originalModel := c.GetString("original_model") + var newAPIError *types.NewAPIError + + for i := 0; i <= common.RetryTimes; i++ { + channel, err := getChannel(c, group, originalModel, i) + if err != nil { + common.LogError(c, err.Error()) + newAPIError = err + break + } + + newAPIError = wssRequest(c, ws, relayMode, channel) + + if newAPIError == nil { + return // 成功处理请求,直接返回 + } + + go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) + + if !shouldRetry(c, newAPIError, common.RetryTimes-i) { + break + } + } + useChannel := c.GetStringSlice("use_channel") + if len(useChannel) > 1 { + retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]")) + common.LogInfo(c, retryLogStr) + } + + if newAPIError != nil { + //if newAPIError.StatusCode == http.StatusTooManyRequests { + // newAPIError.SetMessage("当前分组上游负载已饱和,请稍后再试") + //} + newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId)) + helper.WssError(c, ws, newAPIError.ToOpenAIError()) + } +} + +func RelayClaude(c *gin.Context) { + //relayMode := constant.Path2RelayMode(c.Request.URL.Path) + requestId := c.GetString(common.RequestIdKey) + group := c.GetString("group") + originalModel := c.GetString("original_model") + var newAPIError *types.NewAPIError + + for i := 0; i <= common.RetryTimes; i++ { + channel, err := getChannel(c, group, originalModel, i) + if err != nil { + common.LogError(c, err.Error()) + newAPIError = err + break + } + + newAPIError = claudeRequest(c, channel) + + if newAPIError == nil { + return // 成功处理请求,直接返回 + } + + go processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) + + if !shouldRetry(c, newAPIError, common.RetryTimes-i) { + break + } + } + useChannel := c.GetStringSlice("use_channel") + if len(useChannel) > 1 { + retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]")) + common.LogInfo(c, retryLogStr) + } + + if newAPIError != nil { + newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId)) + c.JSON(newAPIError.StatusCode, gin.H{ + "type": "error", + "error": newAPIError.ToClaudeError(), + }) + } +} + +func relayRequest(c *gin.Context, relayMode int, channel *model.Channel) *types.NewAPIError { + addUsedChannel(c, channel.Id) + requestBody, _ := common.GetRequestBody(c) + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + return relayHandler(c, relayMode) +} + +func wssRequest(c *gin.Context, ws *websocket.Conn, relayMode int, channel *model.Channel) *types.NewAPIError { + addUsedChannel(c, channel.Id) + requestBody, _ := common.GetRequestBody(c) + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + return relay.WssHelper(c, ws) +} + +func claudeRequest(c *gin.Context, channel *model.Channel) *types.NewAPIError { + addUsedChannel(c, channel.Id) + requestBody, _ := common.GetRequestBody(c) + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + return relay.ClaudeHelper(c) +} + +func addUsedChannel(c *gin.Context, channelId int) { + useChannel := c.GetStringSlice("use_channel") + useChannel = append(useChannel, fmt.Sprintf("%d", channelId)) + c.Set("use_channel", useChannel) +} + +func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*model.Channel, *types.NewAPIError) { + if retryCount == 0 { + autoBan := c.GetBool("auto_ban") + autoBanInt := 1 + if !autoBan { + autoBanInt = 0 + } + return &model.Channel{ + Id: c.GetInt("channel_id"), + Type: c.GetInt("channel_type"), + Name: c.GetString("channel_name"), + AutoBan: &autoBanInt, + }, nil + } + channel, selectGroup, err := model.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount) + if err != nil { + if group == "auto" { + return nil, types.NewError(errors.New(fmt.Sprintf("获取自动分组下模型 %s 的可用渠道失败: %s", originalModel, err.Error())), types.ErrorCodeGetChannelFailed) + } + return nil, types.NewError(errors.New(fmt.Sprintf("获取分组 %s 下模型 %s 的可用渠道失败: %s", selectGroup, originalModel, err.Error())), types.ErrorCodeGetChannelFailed) + } + newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel) + if newAPIError != nil { + return nil, newAPIError + } + return channel, nil +} + +func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) bool { + if openaiErr == nil { + return false + } + if types.IsChannelError(openaiErr) { + return true + } + if types.IsLocalError(openaiErr) { + return false + } + if retryTimes <= 0 { + return false + } + if _, ok := c.Get("specific_channel_id"); ok { + return false + } + if openaiErr.StatusCode == http.StatusTooManyRequests { + return true + } + if openaiErr.StatusCode == 307 { + return true + } + if openaiErr.StatusCode/100 == 5 { + // 超时不重试 + if openaiErr.StatusCode == 504 || openaiErr.StatusCode == 524 { + return false + } + return true + } + if openaiErr.StatusCode == http.StatusBadRequest { + channelType := c.GetInt("channel_type") + if channelType == constant.ChannelTypeAnthropic { + return true + } + return false + } + if openaiErr.StatusCode == 408 { + // azure处理超时不重试 + return false + } + if openaiErr.StatusCode/100 == 2 { + return false + } + return true +} + +func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) { + // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 + // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously + common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error())) + if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan { + service.DisableChannel(channelError, err.Error()) + } +} + +func RelayMidjourney(c *gin.Context) { + relayMode := c.GetInt("relay_mode") + var err *dto.MidjourneyResponse + switch relayMode { + case relayconstant.RelayModeMidjourneyNotify: + err = relay.RelayMidjourneyNotify(c) + case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition: + err = relay.RelayMidjourneyTask(c, relayMode) + case relayconstant.RelayModeMidjourneyTaskImageSeed: + err = relay.RelayMidjourneyTaskImageSeed(c) + case relayconstant.RelayModeSwapFace: + err = relay.RelaySwapFace(c) + default: + err = relay.RelayMidjourneySubmit(c, relayMode) + } + //err = relayMidjourneySubmit(c, relayMode) + log.Println(err) + if err != nil { + statusCode := http.StatusBadRequest + if err.Code == 30 { + err.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。" + statusCode = http.StatusTooManyRequests + } + c.JSON(statusCode, gin.H{ + "description": fmt.Sprintf("%s %s", err.Description, err.Result), + "type": "upstream_error", + "code": err.Code, + }) + channelId := c.GetInt("channel_id") + common.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", err.Description, err.Result))) + } +} + +func RelayNotImplemented(c *gin.Context) { + err := dto.OpenAIError{ + Message: "API not implemented", + Type: "new_api_error", + Param: "", + Code: "api_not_implemented", + } + c.JSON(http.StatusNotImplemented, gin.H{ + "error": err, + }) +} + +func RelayNotFound(c *gin.Context) { + err := dto.OpenAIError{ + Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path), + Type: "invalid_request_error", + Param: "", + Code: "", + } + c.JSON(http.StatusNotFound, gin.H{ + "error": err, + }) +} + +func RelayTask(c *gin.Context) { + retryTimes := common.RetryTimes + channelId := c.GetInt("channel_id") + relayMode := c.GetInt("relay_mode") + group := c.GetString("group") + originalModel := c.GetString("original_model") + c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)}) + taskErr := taskRelayHandler(c, relayMode) + if taskErr == nil { + retryTimes = 0 + } + for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ { + channel, newAPIError := getChannel(c, group, originalModel, i) + if newAPIError != nil { + common.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error())) + taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError) + break + } + channelId = channel.Id + useChannel := c.GetStringSlice("use_channel") + useChannel = append(useChannel, fmt.Sprintf("%d", channelId)) + c.Set("use_channel", useChannel) + common.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i)) + //middleware.SetupContextForSelectedChannel(c, channel, originalModel) + + requestBody, _ := common.GetRequestBody(c) + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + taskErr = taskRelayHandler(c, relayMode) + } + useChannel := c.GetStringSlice("use_channel") + if len(useChannel) > 1 { + retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]")) + common.LogInfo(c, retryLogStr) + } + if taskErr != nil { + if taskErr.StatusCode == http.StatusTooManyRequests { + taskErr.Message = "当前分组上游负载已饱和,请稍后再试" + } + c.JSON(taskErr.StatusCode, taskErr) + } +} + +func taskRelayHandler(c *gin.Context, relayMode int) *dto.TaskError { + var err *dto.TaskError + switch relayMode { + case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeKlingFetchByID: + err = relay.RelayTaskFetch(c, relayMode) + default: + err = relay.RelayTaskSubmit(c, relayMode) + } + return err +} + +func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, retryTimes int) bool { + if taskErr == nil { + return false + } + if retryTimes <= 0 { + return false + } + if _, ok := c.Get("specific_channel_id"); ok { + return false + } + if taskErr.StatusCode == http.StatusTooManyRequests { + return true + } + if taskErr.StatusCode == 307 { + return true + } + if taskErr.StatusCode/100 == 5 { + // 超时不重试 + if taskErr.StatusCode == 504 || taskErr.StatusCode == 524 { + return false + } + return true + } + if taskErr.StatusCode == http.StatusBadRequest { + return false + } + if taskErr.StatusCode == 408 { + // azure处理超时不重试 + return false + } + if taskErr.LocalError { + return false + } + if taskErr.StatusCode/100 == 2 { + return false + } + return true +} diff --git a/controller/setup.go b/controller/setup.go new file mode 100644 index 00000000..8943a1a0 --- /dev/null +++ b/controller/setup.go @@ -0,0 +1,181 @@ +package controller + +import ( + "github.com/gin-gonic/gin" + "one-api/common" + "one-api/constant" + "one-api/model" + "one-api/setting/operation_setting" + "time" +) + +type Setup struct { + Status bool `json:"status"` + RootInit bool `json:"root_init"` + DatabaseType string `json:"database_type"` +} + +type SetupRequest struct { + Username string `json:"username"` + Password string `json:"password"` + ConfirmPassword string `json:"confirmPassword"` + SelfUseModeEnabled bool `json:"SelfUseModeEnabled"` + DemoSiteEnabled bool `json:"DemoSiteEnabled"` +} + +func GetSetup(c *gin.Context) { + setup := Setup{ + Status: constant.Setup, + } + if constant.Setup { + c.JSON(200, gin.H{ + "success": true, + "data": setup, + }) + return + } + setup.RootInit = model.RootUserExists() + if common.UsingMySQL { + setup.DatabaseType = "mysql" + } + if common.UsingPostgreSQL { + setup.DatabaseType = "postgres" + } + if common.UsingSQLite { + setup.DatabaseType = "sqlite" + } + c.JSON(200, gin.H{ + "success": true, + "data": setup, + }) +} + +func PostSetup(c *gin.Context) { + // Check if setup is already completed + if constant.Setup { + c.JSON(400, gin.H{ + "success": false, + "message": "系统已经初始化完成", + }) + return + } + + // Check if root user already exists + rootExists := model.RootUserExists() + + var req SetupRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(400, gin.H{ + "success": false, + "message": "请求参数有误", + }) + return + } + + // If root doesn't exist, validate and create admin account + if !rootExists { + // Validate username length: max 12 characters to align with model.User validation + if len(req.Username) > 12 { + c.JSON(400, gin.H{ + "success": false, + "message": "用户名长度不能超过12个字符", + }) + return + } + // Validate password + if req.Password != req.ConfirmPassword { + c.JSON(400, gin.H{ + "success": false, + "message": "两次输入的密码不一致", + }) + return + } + + if len(req.Password) < 8 { + c.JSON(400, gin.H{ + "success": false, + "message": "密码长度至少为8个字符", + }) + return + } + + // Create root user + hashedPassword, err := common.Password2Hash(req.Password) + if err != nil { + c.JSON(500, gin.H{ + "success": false, + "message": "系统错误: " + err.Error(), + }) + return + } + rootUser := model.User{ + Username: req.Username, + Password: hashedPassword, + Role: common.RoleRootUser, + Status: common.UserStatusEnabled, + DisplayName: "Root User", + AccessToken: nil, + Quota: 100000000, + } + err = model.DB.Create(&rootUser).Error + if err != nil { + c.JSON(500, gin.H{ + "success": false, + "message": "创建管理员账号失败: " + err.Error(), + }) + return + } + } + + // Set operation modes + operation_setting.SelfUseModeEnabled = req.SelfUseModeEnabled + operation_setting.DemoSiteEnabled = req.DemoSiteEnabled + + // Save operation modes to database for persistence + err = model.UpdateOption("SelfUseModeEnabled", boolToString(req.SelfUseModeEnabled)) + if err != nil { + c.JSON(500, gin.H{ + "success": false, + "message": "保存自用模式设置失败: " + err.Error(), + }) + return + } + + err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled)) + if err != nil { + c.JSON(500, gin.H{ + "success": false, + "message": "保存演示站点模式设置失败: " + err.Error(), + }) + return + } + + // Update setup status + constant.Setup = true + + setup := model.Setup{ + Version: common.Version, + InitializedAt: time.Now().Unix(), + } + err = model.DB.Create(&setup).Error + if err != nil { + c.JSON(500, gin.H{ + "success": false, + "message": "系统初始化失败: " + err.Error(), + }) + return + } + + c.JSON(200, gin.H{ + "success": true, + "message": "系统初始化成功", + }) +} + +func boolToString(b bool) string { + if b { + return "true" + } + return "false" +} diff --git a/controller/swag_video.go b/controller/swag_video.go new file mode 100644 index 00000000..185fd515 --- /dev/null +++ b/controller/swag_video.go @@ -0,0 +1,116 @@ +package controller + +import ( + "github.com/gin-gonic/gin" +) + +// VideoGenerations +// @Summary 生成视频 +// @Description 调用视频生成接口生成视频 +// @Description 支持多种视频生成服务: +// @Description - 可灵AI (Kling): https://app.klingai.com/cn/dev/document-api/apiReference/commonInfo +// @Description - 即梦 (Jimeng): https://www.volcengine.com/docs/85621/1538636 +// @Tags Video +// @Accept json +// @Produce json +// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)" +// @Param request body dto.VideoRequest true "视频生成请求参数" +// @Failure 400 {object} dto.OpenAIError "请求参数错误" +// @Failure 401 {object} dto.OpenAIError "未授权" +// @Failure 403 {object} dto.OpenAIError "无权限" +// @Failure 500 {object} dto.OpenAIError "服务器内部错误" +// @Router /v1/video/generations [post] +func VideoGenerations(c *gin.Context) { +} + +// VideoGenerationsTaskId +// @Summary 查询视频 +// @Description 根据任务ID查询视频生成任务的状态和结果 +// @Tags Video +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param task_id path string true "Task ID" +// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果" +// @Failure 400 {object} dto.OpenAIError "请求参数错误" +// @Failure 401 {object} dto.OpenAIError "未授权" +// @Failure 403 {object} dto.OpenAIError "无权限" +// @Failure 500 {object} dto.OpenAIError "服务器内部错误" +// @Router /v1/video/generations/{task_id} [get] +func VideoGenerationsTaskId(c *gin.Context) { +} + +// KlingText2VideoGenerations +// @Summary 可灵文生视频 +// @Description 调用可灵AI文生视频接口,生成视频内容 +// @Tags Video +// @Accept json +// @Produce json +// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)" +// @Param request body KlingText2VideoRequest true "视频生成请求参数" +// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果" +// @Failure 400 {object} dto.OpenAIError "请求参数错误" +// @Failure 401 {object} dto.OpenAIError "未授权" +// @Failure 403 {object} dto.OpenAIError "无权限" +// @Failure 500 {object} dto.OpenAIError "服务器内部错误" +// @Router /kling/v1/videos/text2video [post] +func KlingText2VideoGenerations(c *gin.Context) { +} + +type KlingText2VideoRequest struct { + ModelName string `json:"model_name,omitempty" example:"kling-v1"` + Prompt string `json:"prompt" binding:"required" example:"A cat playing piano in the garden"` + NegativePrompt string `json:"negative_prompt,omitempty" example:"blurry, low quality"` + CfgScale float64 `json:"cfg_scale,omitempty" example:"0.7"` + Mode string `json:"mode,omitempty" example:"std"` + CameraControl *KlingCameraControl `json:"camera_control,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"` + Duration string `json:"duration,omitempty" example:"5"` + CallbackURL string `json:"callback_url,omitempty" example:"https://your.domain/callback"` + ExternalTaskId string `json:"external_task_id,omitempty" example:"custom-task-001"` +} + +type KlingCameraControl struct { + Type string `json:"type,omitempty" example:"simple"` + Config *KlingCameraConfig `json:"config,omitempty"` +} + +type KlingCameraConfig struct { + Horizontal float64 `json:"horizontal,omitempty" example:"2.5"` + Vertical float64 `json:"vertical,omitempty" example:"0"` + Pan float64 `json:"pan,omitempty" example:"0"` + Tilt float64 `json:"tilt,omitempty" example:"0"` + Roll float64 `json:"roll,omitempty" example:"0"` + Zoom float64 `json:"zoom,omitempty" example:"0"` +} + +// KlingImage2VideoGenerations +// @Summary 可灵官方-图生视频 +// @Description 调用可灵AI图生视频接口,生成视频内容 +// @Tags Video +// @Accept json +// @Produce json +// @Param Authorization header string true "用户认证令牌 (Aeess-Token: sk-xxxx)" +// @Param request body KlingImage2VideoRequest true "图生视频请求参数" +// @Success 200 {object} dto.VideoTaskResponse "任务状态和结果" +// @Failure 400 {object} dto.OpenAIError "请求参数错误" +// @Failure 401 {object} dto.OpenAIError "未授权" +// @Failure 403 {object} dto.OpenAIError "无权限" +// @Failure 500 {object} dto.OpenAIError "服务器内部错误" +// @Router /kling/v1/videos/image2video [post] +func KlingImage2VideoGenerations(c *gin.Context) { +} + +type KlingImage2VideoRequest struct { + ModelName string `json:"model_name,omitempty" example:"kling-v2-master"` + Image string `json:"image" binding:"required" example:"https://h2.inkwai.com/bs2/upload-ylab-stunt/se/ai_portal_queue_mmu_image_upscale_aiweb/3214b798-e1b4-4b00-b7af-72b5b0417420_raw_image_0.jpg"` + Prompt string `json:"prompt,omitempty" example:"A cat playing piano in the garden"` + NegativePrompt string `json:"negative_prompt,omitempty" example:"blurry, low quality"` + CfgScale float64 `json:"cfg_scale,omitempty" example:"0.7"` + Mode string `json:"mode,omitempty" example:"std"` + CameraControl *KlingCameraControl `json:"camera_control,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty" example:"16:9"` + Duration string `json:"duration,omitempty" example:"5"` + CallbackURL string `json:"callback_url,omitempty" example:"https://your.domain/callback"` + ExternalTaskId string `json:"external_task_id,omitempty" example:"custom-task-002"` +} diff --git a/controller/task.go b/controller/task.go new file mode 100644 index 00000000..78674d8b --- /dev/null +++ b/controller/task.go @@ -0,0 +1,273 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/model" + "one-api/relay" + "sort" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/samber/lo" +) + +func UpdateTaskBulk() { + //revocer + //imageModel := "midjourney" + for { + time.Sleep(time.Duration(15) * time.Second) + common.SysLog("任务进度轮询开始") + ctx := context.TODO() + allTasks := model.GetAllUnFinishSyncTasks(500) + platformTask := make(map[constant.TaskPlatform][]*model.Task) + for _, t := range allTasks { + platformTask[t.Platform] = append(platformTask[t.Platform], t) + } + for platform, tasks := range platformTask { + if len(tasks) == 0 { + continue + } + taskChannelM := make(map[int][]string) + taskM := make(map[string]*model.Task) + nullTaskIds := make([]int64, 0) + for _, task := range tasks { + if task.TaskID == "" { + // 统计失败的未完成任务 + nullTaskIds = append(nullTaskIds, task.ID) + continue + } + taskM[task.TaskID] = task + taskChannelM[task.ChannelId] = append(taskChannelM[task.ChannelId], task.TaskID) + } + if len(nullTaskIds) > 0 { + err := model.TaskBulkUpdateByID(nullTaskIds, map[string]any{ + "status": "FAILURE", + "progress": "100%", + }) + if err != nil { + common.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err)) + } else { + common.LogInfo(ctx, fmt.Sprintf("Fix null task_id task success: %v", nullTaskIds)) + } + } + if len(taskChannelM) == 0 { + continue + } + + UpdateTaskByPlatform(platform, taskChannelM, taskM) + } + common.SysLog("任务进度轮询完成") + } +} + +func UpdateTaskByPlatform(platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) { + switch platform { + case constant.TaskPlatformMidjourney: + //_ = UpdateMidjourneyTaskAll(context.Background(), tasks) + case constant.TaskPlatformSuno: + _ = UpdateSunoTaskAll(context.Background(), taskChannelM, taskM) + case constant.TaskPlatformKling, constant.TaskPlatformJimeng: + _ = UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM) + default: + common.SysLog("未知平台") + } +} + +func UpdateSunoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM map[string]*model.Task) error { + for channelId, taskIds := range taskChannelM { + err := updateSunoTaskAll(ctx, channelId, taskIds, taskM) + if err != nil { + common.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error())) + } + } + return nil +} + +func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, taskM map[string]*model.Task) error { + common.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds))) + if len(taskIds) == 0 { + return nil + } + channel, err := model.CacheGetChannel(channelId) + if err != nil { + common.SysLog(fmt.Sprintf("CacheGetChannel: %v", err)) + err = model.TaskBulkUpdate(taskIds, map[string]any{ + "fail_reason": fmt.Sprintf("获取渠道信息失败,请联系管理员,渠道ID:%d", channelId), + "status": "FAILURE", + "progress": "100%", + }) + if err != nil { + common.SysError(fmt.Sprintf("UpdateMidjourneyTask error2: %v", err)) + } + return err + } + adaptor := relay.GetTaskAdaptor(constant.TaskPlatformSuno) + if adaptor == nil { + return errors.New("adaptor not found") + } + resp, err := adaptor.FetchTask(*channel.BaseURL, channel.Key, map[string]any{ + "ids": taskIds, + }) + if err != nil { + common.SysError(fmt.Sprintf("Get Task Do req error: %v", err)) + return err + } + if resp.StatusCode != http.StatusOK { + common.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode)) + return errors.New(fmt.Sprintf("Get Task status code: %d", resp.StatusCode)) + } + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + common.SysError(fmt.Sprintf("Get Task parse body error: %v", err)) + return err + } + var responseItems dto.TaskResponse[[]dto.SunoDataResponse] + err = json.Unmarshal(responseBody, &responseItems) + if err != nil { + common.LogError(ctx, fmt.Sprintf("Get Task parse body error2: %v, body: %s", err, string(responseBody))) + return err + } + if !responseItems.IsSuccess() { + common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %d", channelId, len(taskIds), string(responseBody))) + return err + } + + for _, responseItem := range responseItems.Data { + task := taskM[responseItem.TaskID] + if !checkTaskNeedUpdate(task, responseItem) { + continue + } + + task.Status = lo.If(model.TaskStatus(responseItem.Status) != "", model.TaskStatus(responseItem.Status)).Else(task.Status) + task.FailReason = lo.If(responseItem.FailReason != "", responseItem.FailReason).Else(task.FailReason) + task.SubmitTime = lo.If(responseItem.SubmitTime != 0, responseItem.SubmitTime).Else(task.SubmitTime) + task.StartTime = lo.If(responseItem.StartTime != 0, responseItem.StartTime).Else(task.StartTime) + task.FinishTime = lo.If(responseItem.FinishTime != 0, responseItem.FinishTime).Else(task.FinishTime) + if responseItem.FailReason != "" || task.Status == model.TaskStatusFailure { + common.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason) + task.Progress = "100%" + //err = model.CacheUpdateUserQuota(task.UserId) ? + if err != nil { + common.LogError(ctx, "error update user quota cache: "+err.Error()) + } else { + quota := task.Quota + if quota != 0 { + err = model.IncreaseUserQuota(task.UserId, quota, false) + if err != nil { + common.LogError(ctx, "fail to increase user quota: "+err.Error()) + } + logContent := fmt.Sprintf("异步任务执行失败 %s,补偿 %s", task.TaskID, common.LogQuota(quota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + } + } + if responseItem.Status == model.TaskStatusSuccess { + task.Progress = "100%" + } + task.Data = responseItem.Data + + err = task.Update() + if err != nil { + common.SysError("UpdateMidjourneyTask task error: " + err.Error()) + } + } + return nil +} + +func checkTaskNeedUpdate(oldTask *model.Task, newTask dto.SunoDataResponse) bool { + + if oldTask.SubmitTime != newTask.SubmitTime { + return true + } + if oldTask.StartTime != newTask.StartTime { + return true + } + if oldTask.FinishTime != newTask.FinishTime { + return true + } + if string(oldTask.Status) != newTask.Status { + return true + } + if oldTask.FailReason != newTask.FailReason { + return true + } + if oldTask.FinishTime != newTask.FinishTime { + return true + } + + if (oldTask.Status == model.TaskStatusFailure || oldTask.Status == model.TaskStatusSuccess) && oldTask.Progress != "100%" { + return true + } + + oldData, _ := json.Marshal(oldTask.Data) + newData, _ := json.Marshal(newTask.Data) + + sort.Slice(oldData, func(i, j int) bool { + return oldData[i] < oldData[j] + }) + sort.Slice(newData, func(i, j int) bool { + return newData[i] < newData[j] + }) + + if string(oldData) != string(newData) { + return true + } + return false +} + +func GetAllTask(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + // 解析其他查询参数 + queryParams := model.SyncTaskQueryParams{ + Platform: constant.TaskPlatform(c.Query("platform")), + TaskID: c.Query("task_id"), + Status: c.Query("status"), + Action: c.Query("action"), + StartTimestamp: startTimestamp, + EndTimestamp: endTimestamp, + ChannelID: c.Query("channel_id"), + } + + items := model.TaskGetAllTasks(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.TaskCountAllTasks(queryParams) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(items) + common.ApiSuccess(c, pageInfo) +} + +func GetUserTask(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + + userId := c.GetInt("id") + + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + + queryParams := model.SyncTaskQueryParams{ + Platform: constant.TaskPlatform(c.Query("platform")), + TaskID: c.Query("task_id"), + Status: c.Query("status"), + Action: c.Query("action"), + StartTimestamp: startTimestamp, + EndTimestamp: endTimestamp, + } + + items := model.TaskGetAllUserTask(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), queryParams) + total := model.TaskCountAllUserTask(userId, queryParams) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(items) + common.ApiSuccess(c, pageInfo) +} diff --git a/controller/task_video.go b/controller/task_video.go new file mode 100644 index 00000000..b62978a7 --- /dev/null +++ b/controller/task_video.go @@ -0,0 +1,138 @@ +package controller + +import ( + "context" + "fmt" + "io" + "one-api/common" + "one-api/constant" + "one-api/model" + "one-api/relay" + "one-api/relay/channel" + "time" +) + +func UpdateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, taskChannelM map[int][]string, taskM map[string]*model.Task) error { + for channelId, taskIds := range taskChannelM { + if err := updateVideoTaskAll(ctx, platform, channelId, taskIds, taskM); err != nil { + common.LogError(ctx, fmt.Sprintf("Channel #%d failed to update video async tasks: %s", channelId, err.Error())) + } + } + return nil +} + +func updateVideoTaskAll(ctx context.Context, platform constant.TaskPlatform, channelId int, taskIds []string, taskM map[string]*model.Task) error { + common.LogInfo(ctx, fmt.Sprintf("Channel #%d pending video tasks: %d", channelId, len(taskIds))) + if len(taskIds) == 0 { + return nil + } + cacheGetChannel, err := model.CacheGetChannel(channelId) + if err != nil { + errUpdate := model.TaskBulkUpdate(taskIds, map[string]any{ + "fail_reason": fmt.Sprintf("Failed to get channel info, channel ID: %d", channelId), + "status": "FAILURE", + "progress": "100%", + }) + if errUpdate != nil { + common.SysError(fmt.Sprintf("UpdateVideoTask error: %v", errUpdate)) + } + return fmt.Errorf("CacheGetChannel failed: %w", err) + } + adaptor := relay.GetTaskAdaptor(platform) + if adaptor == nil { + return fmt.Errorf("video adaptor not found") + } + for _, taskId := range taskIds { + if err := updateVideoSingleTask(ctx, adaptor, cacheGetChannel, taskId, taskM); err != nil { + common.LogError(ctx, fmt.Sprintf("Failed to update video task %s: %s", taskId, err.Error())) + } + } + return nil +} + +func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, channel *model.Channel, taskId string, taskM map[string]*model.Task) error { + baseURL := constant.ChannelBaseURLs[channel.Type] + if channel.GetBaseURL() != "" { + baseURL = channel.GetBaseURL() + } + + task := taskM[taskId] + if task == nil { + common.LogError(ctx, fmt.Sprintf("Task %s not found in taskM", taskId)) + return fmt.Errorf("task %s not found", taskId) + } + resp, err := adaptor.FetchTask(baseURL, channel.Key, map[string]any{ + "task_id": taskId, + "action": task.Action, + }) + if err != nil { + return fmt.Errorf("fetchTask failed for task %s: %w", taskId, err) + } + //if resp.StatusCode != http.StatusOK { + //return fmt.Errorf("get Video Task status code: %d", resp.StatusCode) + //} + defer resp.Body.Close() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("readAll failed for task %s: %w", taskId, err) + } + + taskResult, err := adaptor.ParseTaskResult(responseBody) + if err != nil { + return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) + } + //if taskResult.Code != 0 { + // return fmt.Errorf("video task fetch failed for task %s", taskId) + //} + + now := time.Now().Unix() + if taskResult.Status == "" { + return fmt.Errorf("task %s status is empty", taskId) + } + task.Status = model.TaskStatus(taskResult.Status) + switch taskResult.Status { + case model.TaskStatusSubmitted: + task.Progress = "10%" + case model.TaskStatusQueued: + task.Progress = "20%" + case model.TaskStatusInProgress: + task.Progress = "30%" + if task.StartTime == 0 { + task.StartTime = now + } + case model.TaskStatusSuccess: + task.Progress = "100%" + if task.FinishTime == 0 { + task.FinishTime = now + } + task.FailReason = taskResult.Url + case model.TaskStatusFailure: + task.Status = model.TaskStatusFailure + task.Progress = "100%" + if task.FinishTime == 0 { + task.FinishTime = now + } + task.FailReason = taskResult.Reason + common.LogInfo(ctx, fmt.Sprintf("Task %s failed: %s", task.TaskID, task.FailReason)) + quota := task.Quota + if quota != 0 { + if err := model.IncreaseUserQuota(task.UserId, quota, false); err != nil { + common.LogError(ctx, "Failed to increase user quota: "+err.Error()) + } + logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, common.LogQuota(quota)) + model.RecordLog(task.UserId, model.LogTypeSystem, logContent) + } + default: + return fmt.Errorf("unknown task status %s for task %s", taskResult.Status, taskId) + } + if taskResult.Progress != "" { + task.Progress = taskResult.Progress + } + + task.Data = responseBody + if err := task.Update(); err != nil { + common.SysError("UpdateVideoTask task error: " + err.Error()) + } + + return nil +} diff --git a/controller/telegram.go b/controller/telegram.go new file mode 100644 index 00000000..8d07fc94 --- /dev/null +++ b/controller/telegram.go @@ -0,0 +1,124 @@ +package controller + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "net/http" + "one-api/common" + "one-api/model" + "sort" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +func TelegramBind(c *gin.Context) { + if !common.TelegramOAuthEnabled { + c.JSON(200, gin.H{ + "message": "管理员未开启通过 Telegram 登录以及注册", + "success": false, + }) + return + } + params := c.Request.URL.Query() + if !checkTelegramAuthorization(params, common.TelegramBotToken) { + c.JSON(200, gin.H{ + "message": "无效的请求", + "success": false, + }) + return + } + telegramId := params["id"][0] + if model.IsTelegramIdAlreadyTaken(telegramId) { + c.JSON(200, gin.H{ + "message": "该 Telegram 账户已被绑定", + "success": false, + }) + return + } + + session := sessions.Default(c) + id := session.Get("id") + user := model.User{Id: id.(int)} + if err := user.FillUserById(); err != nil { + c.JSON(200, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已注销", + }) + return + } + user.TelegramId = telegramId + if err := user.Update(false); err != nil { + c.JSON(200, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + + c.Redirect(302, "/setting") +} + +func TelegramLogin(c *gin.Context) { + if !common.TelegramOAuthEnabled { + c.JSON(200, gin.H{ + "message": "管理员未开启通过 Telegram 登录以及注册", + "success": false, + }) + return + } + params := c.Request.URL.Query() + if !checkTelegramAuthorization(params, common.TelegramBotToken) { + c.JSON(200, gin.H{ + "message": "无效的请求", + "success": false, + }) + return + } + + telegramId := params["id"][0] + user := model.User{TelegramId: telegramId} + if err := user.FillUserByTelegramId(); err != nil { + c.JSON(200, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + setupLogin(&user, c) +} + +func checkTelegramAuthorization(params map[string][]string, token string) bool { + strs := []string{} + var hash = "" + for k, v := range params { + if k == "hash" { + hash = v[0] + continue + } + strs = append(strs, k+"="+v[0]) + } + sort.Strings(strs) + var imploded = "" + for _, s := range strs { + if imploded != "" { + imploded += "\n" + } + imploded += s + } + sha256hash := sha256.New() + io.WriteString(sha256hash, token) + hmachash := hmac.New(sha256.New, sha256hash.Sum(nil)) + io.WriteString(hmachash, imploded) + ss := hex.EncodeToString(hmachash.Sum(nil)) + return hash == ss +} diff --git a/controller/token.go b/controller/token.go new file mode 100644 index 00000000..62eb5474 --- /dev/null +++ b/controller/token.go @@ -0,0 +1,236 @@ +package controller + +import ( + "net/http" + "one-api/common" + "one-api/model" + "strconv" + + "github.com/gin-gonic/gin" +) + +func GetAllTokens(c *gin.Context) { + userId := c.GetInt("id") + pageInfo := common.GetPageQuery(c) + tokens, err := model.GetAllUserTokens(userId, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + total, _ := model.CountUserTokens(userId) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(tokens) + common.ApiSuccess(c, pageInfo) + return +} + +func SearchTokens(c *gin.Context) { + userId := c.GetInt("id") + keyword := c.Query("keyword") + token := c.Query("token") + tokens, err := model.SearchUserTokens(userId, keyword, token) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": tokens, + }) + return +} + +func GetToken(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + userId := c.GetInt("id") + if err != nil { + common.ApiError(c, err) + return + } + token, err := model.GetTokenByIds(id, userId) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": token, + }) + return +} + +func GetTokenStatus(c *gin.Context) { + tokenId := c.GetInt("token_id") + userId := c.GetInt("id") + token, err := model.GetTokenByIds(tokenId, userId) + if err != nil { + common.ApiError(c, err) + return + } + expiredAt := token.ExpiredTime + if expiredAt == -1 { + expiredAt = 0 + } + c.JSON(http.StatusOK, gin.H{ + "object": "credit_summary", + "total_granted": token.RemainQuota, + "total_used": 0, // not supported currently + "total_available": token.RemainQuota, + "expires_at": expiredAt * 1000, + }) +} + +func AddToken(c *gin.Context) { + token := model.Token{} + err := c.ShouldBindJSON(&token) + if err != nil { + common.ApiError(c, err) + return + } + if len(token.Name) > 30 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌名称过长", + }) + return + } + key, err := common.GenerateKey() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成令牌失败", + }) + common.SysError("failed to generate token key: " + err.Error()) + return + } + cleanToken := model.Token{ + UserId: c.GetInt("id"), + Name: token.Name, + Key: key, + CreatedTime: common.GetTimestamp(), + AccessedTime: common.GetTimestamp(), + ExpiredTime: token.ExpiredTime, + RemainQuota: token.RemainQuota, + UnlimitedQuota: token.UnlimitedQuota, + ModelLimitsEnabled: token.ModelLimitsEnabled, + ModelLimits: token.ModelLimits, + AllowIps: token.AllowIps, + Group: token.Group, + } + err = cleanToken.Insert() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func DeleteToken(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + userId := c.GetInt("id") + err := model.DeleteTokenById(id, userId) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func UpdateToken(c *gin.Context) { + userId := c.GetInt("id") + statusOnly := c.Query("status_only") + token := model.Token{} + err := c.ShouldBindJSON(&token) + if err != nil { + common.ApiError(c, err) + return + } + if len(token.Name) > 30 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌名称过长", + }) + return + } + cleanToken, err := model.GetTokenByIds(token.Id, userId) + if err != nil { + common.ApiError(c, err) + return + } + if token.Status == common.TokenStatusEnabled { + if cleanToken.Status == common.TokenStatusExpired && cleanToken.ExpiredTime <= common.GetTimestamp() && cleanToken.ExpiredTime != -1 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌已过期,无法启用,请先修改令牌过期时间,或者设置为永不过期", + }) + return + } + if cleanToken.Status == common.TokenStatusExhausted && cleanToken.RemainQuota <= 0 && !cleanToken.UnlimitedQuota { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "令牌可用额度已用尽,无法启用,请先修改令牌剩余额度,或者设置为无限额度", + }) + return + } + } + if statusOnly != "" { + cleanToken.Status = token.Status + } else { + // If you add more fields, please also update token.Update() + cleanToken.Name = token.Name + cleanToken.ExpiredTime = token.ExpiredTime + cleanToken.RemainQuota = token.RemainQuota + cleanToken.UnlimitedQuota = token.UnlimitedQuota + cleanToken.ModelLimitsEnabled = token.ModelLimitsEnabled + cleanToken.ModelLimits = token.ModelLimits + cleanToken.AllowIps = token.AllowIps + cleanToken.Group = token.Group + } + err = cleanToken.Update() + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": cleanToken, + }) + return +} + +type TokenBatch struct { + Ids []int `json:"ids"` +} + +func DeleteTokenBatch(c *gin.Context) { + tokenBatch := TokenBatch{} + if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + userId := c.GetInt("id") + count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": count, + }) +} diff --git a/controller/topup.go b/controller/topup.go new file mode 100644 index 00000000..827dda39 --- /dev/null +++ b/controller/topup.go @@ -0,0 +1,265 @@ +package controller + +import ( + "fmt" + "log" + "net/url" + "one-api/common" + "one-api/model" + "one-api/service" + "one-api/setting" + "strconv" + "sync" + "time" + + "github.com/Calcium-Ion/go-epay/epay" + "github.com/gin-gonic/gin" + "github.com/samber/lo" + "github.com/shopspring/decimal" +) + +type EpayRequest struct { + Amount int64 `json:"amount"` + PaymentMethod string `json:"payment_method"` + TopUpCode string `json:"top_up_code"` +} + +type AmountRequest struct { + Amount int64 `json:"amount"` + TopUpCode string `json:"top_up_code"` +} + +func GetEpayClient() *epay.Client { + if setting.PayAddress == "" || setting.EpayId == "" || setting.EpayKey == "" { + return nil + } + withUrl, err := epay.NewClient(&epay.Config{ + PartnerID: setting.EpayId, + Key: setting.EpayKey, + }, setting.PayAddress) + if err != nil { + return nil + } + return withUrl +} + +func getPayMoney(amount int64, group string) float64 { + dAmount := decimal.NewFromInt(amount) + + if !common.DisplayInCurrencyEnabled { + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + dAmount = dAmount.Div(dQuotaPerUnit) + } + + topupGroupRatio := common.GetTopupGroupRatio(group) + if topupGroupRatio == 0 { + topupGroupRatio = 1 + } + + dTopupGroupRatio := decimal.NewFromFloat(topupGroupRatio) + dPrice := decimal.NewFromFloat(setting.Price) + + payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio) + + return payMoney.InexactFloat64() +} + +func getMinTopup() int64 { + minTopup := setting.MinTopUp + if !common.DisplayInCurrencyEnabled { + dMinTopup := decimal.NewFromInt(int64(minTopup)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + minTopup = int(dMinTopup.Mul(dQuotaPerUnit).IntPart()) + } + return int64(minTopup) +} + +func RequestEpay(c *gin.Context) { + var req EpayRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + if req.Amount < getMinTopup() { + c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())}) + return + } + + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + payMoney := getPayMoney(req.Amount, group) + if payMoney < 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + if !setting.ContainsPayMethod(req.PaymentMethod) { + c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"}) + return + } + + callBackAddress := service.GetCallbackAddress() + returnUrl, _ := url.Parse(setting.ServerAddress + "/console/log") + notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") + tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix()) + tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) + client := GetEpayClient() + if client == nil { + c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置支付信息"}) + return + } + uri, params, err := client.Purchase(&epay.PurchaseArgs{ + Type: req.PaymentMethod, + ServiceTradeNo: tradeNo, + Name: fmt.Sprintf("TUC%d", req.Amount), + Money: strconv.FormatFloat(payMoney, 'f', 2, 64), + Device: epay.PC, + NotifyUrl: notifyUrl, + ReturnUrl: returnUrl, + }) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + amount := req.Amount + if !common.DisplayInCurrencyEnabled { + dAmount := decimal.NewFromInt(int64(amount)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + amount = dAmount.Div(dQuotaPerUnit).IntPart() + } + topUp := &model.TopUp{ + UserId: id, + Amount: amount, + Money: payMoney, + TradeNo: tradeNo, + CreateTime: time.Now().Unix(), + Status: "pending", + } + err = topUp.Insert() + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + c.JSON(200, gin.H{"message": "success", "data": params, "url": uri}) +} + +// tradeNo lock +var orderLocks sync.Map +var createLock sync.Mutex + +// LockOrder 尝试对给定订单号加锁 +func LockOrder(tradeNo string) { + lock, ok := orderLocks.Load(tradeNo) + if !ok { + createLock.Lock() + defer createLock.Unlock() + lock, ok = orderLocks.Load(tradeNo) + if !ok { + lock = new(sync.Mutex) + orderLocks.Store(tradeNo, lock) + } + } + lock.(*sync.Mutex).Lock() +} + +// UnlockOrder 释放给定订单号的锁 +func UnlockOrder(tradeNo string) { + lock, ok := orderLocks.Load(tradeNo) + if ok { + lock.(*sync.Mutex).Unlock() + } +} + +func EpayNotify(c *gin.Context) { + params := lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string { + r[t] = c.Request.URL.Query().Get(t) + return r + }, map[string]string{}) + client := GetEpayClient() + if client == nil { + log.Println("易支付回调失败 未找到配置信息") + _, err := c.Writer.Write([]byte("fail")) + if err != nil { + log.Println("易支付回调写入失败") + return + } + } + verifyInfo, err := client.Verify(params) + if err == nil && verifyInfo.VerifyStatus { + _, err := c.Writer.Write([]byte("success")) + if err != nil { + log.Println("易支付回调写入失败") + } + } else { + _, err := c.Writer.Write([]byte("fail")) + if err != nil { + log.Println("易支付回调写入失败") + } + log.Println("易支付回调签名验证失败") + return + } + + if verifyInfo.TradeStatus == epay.StatusTradeSuccess { + log.Println(verifyInfo) + LockOrder(verifyInfo.ServiceTradeNo) + defer UnlockOrder(verifyInfo.ServiceTradeNo) + topUp := model.GetTopUpByTradeNo(verifyInfo.ServiceTradeNo) + if topUp == nil { + log.Printf("易支付回调未找到订单: %v", verifyInfo) + return + } + if topUp.Status == "pending" { + topUp.Status = "success" + err := topUp.Update() + if err != nil { + log.Printf("易支付回调更新订单失败: %v", topUp) + return + } + //user, _ := model.GetUserById(topUp.UserId, false) + //user.Quota += topUp.Amount * 500000 + dAmount := decimal.NewFromInt(int64(topUp.Amount)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart()) + err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true) + if err != nil { + log.Printf("易支付回调更新用户失败: %v", topUp) + return + } + log.Printf("易支付回调更新用户成功 %v", topUp) + model.RecordLog(topUp.UserId, model.LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%f", common.LogQuota(quotaToAdd), topUp.Money)) + } + } else { + log.Printf("易支付异常回调: %v", verifyInfo) + } +} + +func RequestAmount(c *gin.Context) { + var req AmountRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + + if req.Amount < getMinTopup() { + c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())}) + return + } + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + payMoney := getPayMoney(req.Amount, group) + if payMoney <= 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) +} diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go new file mode 100644 index 00000000..eb320809 --- /dev/null +++ b/controller/topup_stripe.go @@ -0,0 +1,275 @@ +package controller + +import ( + "fmt" + "io" + "log" + "net/http" + "one-api/common" + "one-api/model" + "one-api/setting" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/stripe/stripe-go/v81" + "github.com/stripe/stripe-go/v81/checkout/session" + "github.com/stripe/stripe-go/v81/webhook" + "github.com/thanhpk/randstr" +) + +const ( + PaymentMethodStripe = "stripe" +) + +var stripeAdaptor = &StripeAdaptor{} + +type StripePayRequest struct { + Amount int64 `json:"amount"` + PaymentMethod string `json:"payment_method"` +} + +type StripeAdaptor struct { +} + +func (*StripeAdaptor) RequestAmount(c *gin.Context, req *StripePayRequest) { + if req.Amount < getStripeMinTopup() { + c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup())}) + return + } + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + payMoney := getStripePayMoney(float64(req.Amount), group) + if payMoney <= 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)}) +} + +func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) { + if req.PaymentMethod != PaymentMethodStripe { + c.JSON(200, gin.H{"message": "error", "data": "不支持的支付渠道"}) + return + } + if req.Amount < getStripeMinTopup() { + c.JSON(200, gin.H{"message": fmt.Sprintf("充值数量不能小于 %d", getStripeMinTopup()), "data": 10}) + return + } + if req.Amount > 10000 { + c.JSON(200, gin.H{"message": "充值数量不能大于 10000", "data": 10}) + return + } + + id := c.GetInt("id") + user, _ := model.GetUserById(id, false) + chargedMoney := GetChargedAmount(float64(req.Amount), *user) + + reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4)) + referenceId := "ref_" + common.Sha1([]byte(reference)) + + payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount) + if err != nil { + log.Println("获取Stripe Checkout支付链接失败", err) + c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + + topUp := &model.TopUp{ + UserId: id, + Amount: req.Amount, + Money: chargedMoney, + TradeNo: referenceId, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + err = topUp.Insert() + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "pay_link": payLink, + }, + }) +} + +func RequestStripeAmount(c *gin.Context) { + var req StripePayRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + stripeAdaptor.RequestAmount(c, &req) +} + +func RequestStripePay(c *gin.Context) { + var req StripePayRequest + err := c.ShouldBindJSON(&req) + if err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + stripeAdaptor.RequestPay(c, &req) +} + +func StripeWebhook(c *gin.Context) { + payload, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("解析Stripe Webhook参数失败: %v\n", err) + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } + + signature := c.GetHeader("Stripe-Signature") + endpointSecret := setting.StripeWebhookSecret + event, err := webhook.ConstructEventWithOptions(payload, signature, endpointSecret, webhook.ConstructEventOptions{ + IgnoreAPIVersionMismatch: true, + }) + + if err != nil { + log.Printf("Stripe Webhook验签失败: %v\n", err) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + switch event.Type { + case stripe.EventTypeCheckoutSessionCompleted: + sessionCompleted(event) + case stripe.EventTypeCheckoutSessionExpired: + sessionExpired(event) + default: + log.Printf("不支持的Stripe Webhook事件类型: %s\n", event.Type) + } + + c.Status(http.StatusOK) +} + +func sessionCompleted(event stripe.Event) { + customerId := event.GetObjectValue("customer") + referenceId := event.GetObjectValue("client_reference_id") + status := event.GetObjectValue("status") + if "complete" != status { + log.Println("错误的Stripe Checkout完成状态:", status, ",", referenceId) + return + } + + err := model.Recharge(referenceId, customerId) + if err != nil { + log.Println(err.Error(), referenceId) + return + } + + total, _ := strconv.ParseFloat(event.GetObjectValue("amount_total"), 64) + currency := strings.ToUpper(event.GetObjectValue("currency")) + log.Printf("收到款项:%s, %.2f(%s)", referenceId, total/100, currency) +} + +func sessionExpired(event stripe.Event) { + referenceId := event.GetObjectValue("client_reference_id") + status := event.GetObjectValue("status") + if "expired" != status { + log.Println("错误的Stripe Checkout过期状态:", status, ",", referenceId) + return + } + + if len(referenceId) == 0 { + log.Println("未提供支付单号") + return + } + + topUp := model.GetTopUpByTradeNo(referenceId) + if topUp == nil { + log.Println("充值订单不存在", referenceId) + return + } + + if topUp.Status != common.TopUpStatusPending { + log.Println("充值订单状态错误", referenceId) + } + + topUp.Status = common.TopUpStatusExpired + err := topUp.Update() + if err != nil { + log.Println("过期充值订单失败", referenceId, ", err:", err.Error()) + return + } + + log.Println("充值订单已过期", referenceId) +} + +func genStripeLink(referenceId string, customerId string, email string, amount int64) (string, error) { + if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") { + return "", fmt.Errorf("无效的Stripe API密钥") + } + + stripe.Key = setting.StripeApiSecret + + params := &stripe.CheckoutSessionParams{ + ClientReferenceID: stripe.String(referenceId), + SuccessURL: stripe.String(setting.ServerAddress + "/log"), + CancelURL: stripe.String(setting.ServerAddress + "/topup"), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(setting.StripePriceId), + Quantity: stripe.Int64(amount), + }, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + } + + if "" == customerId { + if "" != email { + params.CustomerEmail = stripe.String(email) + } + + params.CustomerCreation = stripe.String(string(stripe.CheckoutSessionCustomerCreationAlways)) + } else { + params.Customer = stripe.String(customerId) + } + + result, err := session.New(params) + if err != nil { + return "", err + } + + return result.URL, nil +} + +func GetChargedAmount(count float64, user model.User) float64 { + topUpGroupRatio := common.GetTopupGroupRatio(user.Group) + if topUpGroupRatio == 0 { + topUpGroupRatio = 1 + } + + return count * topUpGroupRatio +} + +func getStripePayMoney(amount float64, group string) float64 { + if !common.DisplayInCurrencyEnabled { + amount = amount / common.QuotaPerUnit + } + // Using float64 for monetary calculations is acceptable here due to the small amounts involved + topupGroupRatio := common.GetTopupGroupRatio(group) + if topupGroupRatio == 0 { + topupGroupRatio = 1 + } + payMoney := amount * setting.StripeUnitPrice * topupGroupRatio + return payMoney +} + +func getStripeMinTopup() int64 { + minTopup := setting.StripeMinTopUp + if !common.DisplayInCurrencyEnabled { + minTopup = minTopup * int(common.QuotaPerUnit) + } + return int64(minTopup) +} diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go new file mode 100644 index 00000000..05d6297e --- /dev/null +++ b/controller/uptime_kuma.go @@ -0,0 +1,154 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "one-api/setting/console_setting" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" +) + +const ( + requestTimeout = 30 * time.Second + httpTimeout = 10 * time.Second + uptimeKeySuffix = "_24" + apiStatusPath = "/api/status-page/" + apiHeartbeatPath = "/api/status-page/heartbeat/" +) + +type Monitor struct { + Name string `json:"name"` + Uptime float64 `json:"uptime"` + Status int `json:"status"` + Group string `json:"group,omitempty"` +} + +type UptimeGroupResult struct { + CategoryName string `json:"categoryName"` + Monitors []Monitor `json:"monitors"` +} + +func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return errors.New("non-200 status") + } + + return json.NewDecoder(resp.Body).Decode(dest) +} + +func fetchGroupData(ctx context.Context, client *http.Client, groupConfig map[string]interface{}) UptimeGroupResult { + url, _ := groupConfig["url"].(string) + slug, _ := groupConfig["slug"].(string) + categoryName, _ := groupConfig["categoryName"].(string) + + result := UptimeGroupResult{ + CategoryName: categoryName, + Monitors: []Monitor{}, + } + + if url == "" || slug == "" { + return result + } + + baseURL := strings.TrimSuffix(url, "/") + + var statusData struct { + PublicGroupList []struct { + ID int `json:"id"` + Name string `json:"name"` + MonitorList []struct { + ID int `json:"id"` + Name string `json:"name"` + } `json:"monitorList"` + } `json:"publicGroupList"` + } + + var heartbeatData struct { + HeartbeatList map[string][]struct { + Status int `json:"status"` + } `json:"heartbeatList"` + UptimeList map[string]float64 `json:"uptimeList"` + } + + g, gCtx := errgroup.WithContext(ctx) + g.Go(func() error { + return getAndDecode(gCtx, client, baseURL+apiStatusPath+slug, &statusData) + }) + g.Go(func() error { + return getAndDecode(gCtx, client, baseURL+apiHeartbeatPath+slug, &heartbeatData) + }) + + if g.Wait() != nil { + return result + } + + for _, pg := range statusData.PublicGroupList { + if len(pg.MonitorList) == 0 { + continue + } + + for _, m := range pg.MonitorList { + monitor := Monitor{ + Name: m.Name, + Group: pg.Name, + } + + monitorID := strconv.Itoa(m.ID) + + if uptime, exists := heartbeatData.UptimeList[monitorID+uptimeKeySuffix]; exists { + monitor.Uptime = uptime + } + + if heartbeats, exists := heartbeatData.HeartbeatList[monitorID]; exists && len(heartbeats) > 0 { + monitor.Status = heartbeats[0].Status + } + + result.Monitors = append(result.Monitors, monitor) + } + } + + return result +} + +func GetUptimeKumaStatus(c *gin.Context) { + groups := console_setting.GetUptimeKumaGroups() + if len(groups) == 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": []UptimeGroupResult{}}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout) + defer cancel() + + client := &http.Client{Timeout: httpTimeout} + results := make([]UptimeGroupResult, len(groups)) + + g, gCtx := errgroup.WithContext(ctx) + for i, group := range groups { + i, group := i, group + g.Go(func() error { + results[i] = fetchGroupData(gCtx, client, group) + return nil + }) + } + + g.Wait() + c.JSON(http.StatusOK, gin.H{"success": true, "message": "", "data": results}) +} \ No newline at end of file diff --git a/controller/usedata.go b/controller/usedata.go new file mode 100644 index 00000000..4adee50f --- /dev/null +++ b/controller/usedata.go @@ -0,0 +1,52 @@ +package controller + +import ( + "net/http" + "one-api/common" + "one-api/model" + "strconv" + + "github.com/gin-gonic/gin" +) + +func GetAllQuotaDates(c *gin.Context) { + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + username := c.Query("username") + dates, err := model.GetAllQuotaDates(startTimestamp, endTimestamp, username) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dates, + }) + return +} + +func GetUserQuotaDates(c *gin.Context) { + userId := c.GetInt("id") + startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64) + endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64) + // 判断时间跨度是否超过 1 个月 + if endTimestamp-startTimestamp > 2592000 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "时间跨度不能超过 1 个月", + }) + return + } + dates, err := model.GetQuotaDataByUserId(userId, startTimestamp, endTimestamp) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": dates, + }) + return +} diff --git a/controller/user.go b/controller/user.go new file mode 100644 index 00000000..292ed8c6 --- /dev/null +++ b/controller/user.go @@ -0,0 +1,956 @@ +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "one-api/common" + "one-api/dto" + "one-api/model" + "one-api/setting" + "strconv" + "strings" + "sync" + + "one-api/constant" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func Login(c *gin.Context) { + if !common.PasswordLoginEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员关闭了密码登录", + "success": false, + }) + return + } + var loginRequest LoginRequest + err := json.NewDecoder(c.Request.Body).Decode(&loginRequest) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "无效的参数", + "success": false, + }) + return + } + username := loginRequest.Username + password := loginRequest.Password + if username == "" || password == "" { + c.JSON(http.StatusOK, gin.H{ + "message": "无效的参数", + "success": false, + }) + return + } + user := model.User{ + Username: username, + Password: password, + } + err = user.ValidateAndFill() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + setupLogin(&user, c) +} + +// setup session & cookies and then return user info +func setupLogin(user *model.User, c *gin.Context) { + session := sessions.Default(c) + session.Set("id", user.Id) + session.Set("username", user.Username) + session.Set("role", user.Role) + session.Set("status", user.Status) + session.Set("group", user.Group) + err := session.Save() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "无法保存会话信息,请重试", + "success": false, + }) + return + } + cleanUser := model.User{ + Id: user.Id, + Username: user.Username, + DisplayName: user.DisplayName, + Role: user.Role, + Status: user.Status, + Group: user.Group, + } + c.JSON(http.StatusOK, gin.H{ + "message": "", + "success": true, + "data": cleanUser, + }) +} + +func Logout(c *gin.Context) { + session := sessions.Default(c) + session.Clear() + err := session.Save() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "", + "success": true, + }) +} + +func Register(c *gin.Context) { + if !common.RegisterEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员关闭了新用户注册", + "success": false, + }) + return + } + if !common.PasswordRegisterEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员关闭了通过密码进行注册,请使用第三方账户验证的形式进行注册", + "success": false, + }) + return + } + var user model.User + err := json.NewDecoder(c.Request.Body).Decode(&user) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if err := common.Validate.Struct(&user); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "输入不合法 " + err.Error(), + }) + return + } + if common.EmailVerificationEnabled { + if user.Email == "" || user.VerificationCode == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员开启了邮箱验证,请输入邮箱地址和验证码", + }) + return + } + if !common.VerifyCodeWithKey(user.Email, user.VerificationCode, common.EmailVerificationPurpose) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码错误或已过期", + }) + return + } + } + exist, err := model.CheckUserExistOrDeleted(user.Username, user.Email) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "数据库错误,请稍后重试", + }) + common.SysError(fmt.Sprintf("CheckUserExistOrDeleted error: %v", err)) + return + } + if exist { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户名已存在,或已注销", + }) + return + } + affCode := user.AffCode // this code is the inviter's code, not the user's own code + inviterId, _ := model.GetUserIdByAffCode(affCode) + cleanUser := model.User{ + Username: user.Username, + Password: user.Password, + DisplayName: user.Username, + InviterId: inviterId, + } + if common.EmailVerificationEnabled { + cleanUser.Email = user.Email + } + if err := cleanUser.Insert(inviterId); err != nil { + common.ApiError(c, err) + return + } + + // 获取插入后的用户ID + var insertedUser model.User + if err := model.DB.Where("username = ?", cleanUser.Username).First(&insertedUser).Error; err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户注册失败或用户ID获取失败", + }) + return + } + // 生成默认令牌 + if constant.GenerateDefaultToken { + key, err := common.GenerateKey() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成默认令牌失败", + }) + common.SysError("failed to generate token key: " + err.Error()) + return + } + // 生成默认令牌 + token := model.Token{ + UserId: insertedUser.Id, // 使用插入后的用户ID + Name: cleanUser.Username + "的初始令牌", + Key: key, + CreatedTime: common.GetTimestamp(), + AccessedTime: common.GetTimestamp(), + ExpiredTime: -1, // 永不过期 + RemainQuota: 500000, // 示例额度 + UnlimitedQuota: true, + ModelLimitsEnabled: false, + } + if setting.DefaultUseAutoGroup { + token.Group = "auto" + } + if err := token.Insert(); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "创建默认令牌失败", + }) + return + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func GetAllUsers(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + users, total, err := model.GetAllUsers(pageInfo) + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(users) + + common.ApiSuccess(c, pageInfo) + return +} + +func SearchUsers(c *gin.Context) { + keyword := c.Query("keyword") + group := c.Query("group") + pageInfo := common.GetPageQuery(c) + users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(users) + common.ApiSuccess(c, pageInfo) + return +} + +func GetUser(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + user, err := model.GetUserById(id, false) + if err != nil { + common.ApiError(c, err) + return + } + myRole := c.GetInt("role") + if myRole <= user.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权获取同级或更高等级用户的信息", + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": user, + }) + return +} + +func GenerateAccessToken(c *gin.Context) { + id := c.GetInt("id") + user, err := model.GetUserById(id, true) + if err != nil { + common.ApiError(c, err) + return + } + // get rand int 28-32 + randI := common.GetRandomInt(4) + key, err := common.GenerateRandomKey(29 + randI) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成失败", + }) + common.SysError("failed to generate key: " + err.Error()) + return + } + user.SetAccessToken(key) + + if model.DB.Where("access_token = ?", user.AccessToken).First(user).RowsAffected != 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "请重试,系统生成的 UUID 竟然重复了!", + }) + return + } + + if err := user.Update(false); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": user.AccessToken, + }) + return +} + +type TransferAffQuotaRequest struct { + Quota int `json:"quota" binding:"required"` +} + +func TransferAffQuota(c *gin.Context) { + id := c.GetInt("id") + user, err := model.GetUserById(id, true) + if err != nil { + common.ApiError(c, err) + return + } + tran := TransferAffQuotaRequest{} + if err := c.ShouldBindJSON(&tran); err != nil { + common.ApiError(c, err) + return + } + err = user.TransferAffQuotaToQuota(tran.Quota) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "划转失败 " + err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "划转成功", + }) +} + +func GetAffCode(c *gin.Context) { + id := c.GetInt("id") + user, err := model.GetUserById(id, true) + if err != nil { + common.ApiError(c, err) + return + } + if user.AffCode == "" { + user.AffCode = common.GetRandomString(4) + if err := user.Update(false); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": user.AffCode, + }) + return +} + +func GetSelf(c *gin.Context) { + id := c.GetInt("id") + user, err := model.GetUserById(id, false) + if err != nil { + common.ApiError(c, err) + return + } + // Hide admin remarks: set to empty to trigger omitempty tag, ensuring the remark field is not included in JSON returned to regular users + user.Remark = "" + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": user, + }) + return +} + +func GetUserModels(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + id = c.GetInt("id") + } + user, err := model.GetUserCache(id) + if err != nil { + common.ApiError(c, err) + return + } + groups := setting.GetUserUsableGroups(user.Group) + var models []string + for group := range groups { + for _, g := range model.GetGroupEnabledModels(group) { + if !common.StringsContains(models, g) { + models = append(models, g) + } + } + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": models, + }) + return +} + +func UpdateUser(c *gin.Context) { + var updatedUser model.User + err := json.NewDecoder(c.Request.Body).Decode(&updatedUser) + if err != nil || updatedUser.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if updatedUser.Password == "" { + updatedUser.Password = "$I_LOVE_U" // make Validator happy :) + } + if err := common.Validate.Struct(&updatedUser); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "输入不合法 " + err.Error(), + }) + return + } + originUser, err := model.GetUserById(updatedUser.Id, false) + if err != nil { + common.ApiError(c, err) + return + } + myRole := c.GetInt("role") + if myRole <= originUser.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权更新同权限等级或更高权限等级的用户信息", + }) + return + } + if myRole <= updatedUser.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权将其他用户权限等级提升到大于等于自己的权限等级", + }) + return + } + if updatedUser.Password == "$I_LOVE_U" { + updatedUser.Password = "" // rollback to what it should be + } + updatePassword := updatedUser.Password != "" + if err := updatedUser.Edit(updatePassword); err != nil { + common.ApiError(c, err) + return + } + if originUser.Quota != updatedUser.Quota { + model.RecordLog(originUser.Id, model.LogTypeManage, fmt.Sprintf("管理员将用户额度从 %s修改为 %s", common.LogQuota(originUser.Quota), common.LogQuota(updatedUser.Quota))) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func UpdateSelf(c *gin.Context) { + var user model.User + err := json.NewDecoder(c.Request.Body).Decode(&user) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if user.Password == "" { + user.Password = "$I_LOVE_U" // make Validator happy :) + } + if err := common.Validate.Struct(&user); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "输入不合法 " + err.Error(), + }) + return + } + + cleanUser := model.User{ + Id: c.GetInt("id"), + Username: user.Username, + Password: user.Password, + DisplayName: user.DisplayName, + } + if user.Password == "$I_LOVE_U" { + user.Password = "" // rollback to what it should be + cleanUser.Password = "" + } + updatePassword, err := checkUpdatePassword(user.OriginalPassword, user.Password, cleanUser.Id) + if err != nil { + common.ApiError(c, err) + return + } + if err := cleanUser.Update(updatePassword); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func checkUpdatePassword(originalPassword string, newPassword string, userId int) (updatePassword bool, err error) { + var currentUser *model.User + currentUser, err = model.GetUserById(userId, true) + if err != nil { + return + } + if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) { + err = fmt.Errorf("原密码错误") + return + } + if newPassword == "" { + return + } + updatePassword = true + return +} + +func DeleteUser(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, err) + return + } + originUser, err := model.GetUserById(id, false) + if err != nil { + common.ApiError(c, err) + return + } + myRole := c.GetInt("role") + if myRole <= originUser.Role { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权删除同权限等级或更高权限等级的用户", + }) + return + } + err = model.HardDeleteUserById(id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return + } +} + +func DeleteSelf(c *gin.Context) { + id := c.GetInt("id") + user, _ := model.GetUserById(id, false) + + if user.Role == common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不能删除超级管理员账户", + }) + return + } + + err := model.DeleteUserById(id) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func CreateUser(c *gin.Context) { + var user model.User + err := json.NewDecoder(c.Request.Body).Decode(&user) + user.Username = strings.TrimSpace(user.Username) + if err != nil || user.Username == "" || user.Password == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + if err := common.Validate.Struct(&user); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "输入不合法 " + err.Error(), + }) + return + } + if user.DisplayName == "" { + user.DisplayName = user.Username + } + myRole := c.GetInt("role") + if user.Role >= myRole { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法创建权限大于等于自己的用户", + }) + return + } + // Even for admin users, we cannot fully trust them! + cleanUser := model.User{ + Username: user.Username, + Password: user.Password, + DisplayName: user.DisplayName, + } + if err := cleanUser.Insert(0); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +type ManageRequest struct { + Id int `json:"id"` + Action string `json:"action"` +} + +// ManageUser Only admin user can do this +func ManageUser(c *gin.Context) { + var req ManageRequest + err := json.NewDecoder(c.Request.Body).Decode(&req) + + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + user := model.User{ + Id: req.Id, + } + // Fill attributes + model.DB.Unscoped().Where(&user).First(&user) + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户不存在", + }) + return + } + myRole := c.GetInt("role") + if myRole <= user.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权更新同权限等级或更高权限等级的用户信息", + }) + return + } + switch req.Action { + case "disable": + user.Status = common.UserStatusDisabled + if user.Role == common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法禁用超级管理员用户", + }) + return + } + case "enable": + user.Status = common.UserStatusEnabled + case "delete": + if user.Role == common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法删除超级管理员用户", + }) + return + } + if err := user.Delete(); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "promote": + if myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "普通管理员用户无法提升其他用户为管理员", + }) + return + } + if user.Role >= common.RoleAdminUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户已经是管理员", + }) + return + } + user.Role = common.RoleAdminUser + case "demote": + if user.Role == common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无法降级超级管理员用户", + }) + return + } + if user.Role == common.RoleCommonUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户已经是普通用户", + }) + return + } + user.Role = common.RoleCommonUser + } + + if err := user.Update(false); err != nil { + common.ApiError(c, err) + return + } + clearUser := model.User{ + Role: user.Role, + Status: user.Status, + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": clearUser, + }) + return +} + +func EmailBind(c *gin.Context) { + email := c.Query("email") + code := c.Query("code") + if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码错误或已过期", + }) + return + } + session := sessions.Default(c) + id := session.Get("id") + user := model.User{ + Id: id.(int), + } + err := user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + user.Email = email + // no need to check if this email already taken, because we have used verification code to check it + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +type topUpRequest struct { + Key string `json:"key"` +} + +var topUpLock = sync.Mutex{} + +func TopUp(c *gin.Context) { + topUpLock.Lock() + defer topUpLock.Unlock() + req := topUpRequest{} + err := c.ShouldBindJSON(&req) + if err != nil { + common.ApiError(c, err) + return + } + id := c.GetInt("id") + quota, err := model.Redeem(req.Key, id) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": quota, + }) + return +} + +type UpdateUserSettingRequest struct { + QuotaWarningType string `json:"notify_type"` + QuotaWarningThreshold float64 `json:"quota_warning_threshold"` + WebhookUrl string `json:"webhook_url,omitempty"` + WebhookSecret string `json:"webhook_secret,omitempty"` + NotificationEmail string `json:"notification_email,omitempty"` + AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` + RecordIpLog bool `json:"record_ip_log"` +} + +func UpdateUserSetting(c *gin.Context) { + var req UpdateUserSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + // 验证预警类型 + if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的预警类型", + }) + return + } + + // 验证预警阈值 + if req.QuotaWarningThreshold <= 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "预警阈值必须大于0", + }) + return + } + + // 如果是webhook类型,验证webhook地址 + if req.QuotaWarningType == dto.NotifyTypeWebhook { + if req.WebhookUrl == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Webhook地址不能为空", + }) + return + } + // 验证URL格式 + if _, err := url.ParseRequestURI(req.WebhookUrl); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的Webhook地址", + }) + return + } + } + + // 如果是邮件类型,验证邮箱地址 + if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" { + // 验证邮箱格式 + if !strings.Contains(req.NotificationEmail, "@") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的邮箱地址", + }) + return + } + } + + userId := c.GetInt("id") + user, err := model.GetUserById(userId, true) + if err != nil { + common.ApiError(c, err) + return + } + + // 构建设置 + settings := dto.UserSetting{ + NotifyType: req.QuotaWarningType, + QuotaWarningThreshold: req.QuotaWarningThreshold, + AcceptUnsetRatioModel: req.AcceptUnsetModelRatioModel, + RecordIpLog: req.RecordIpLog, + } + + // 如果是webhook类型,添加webhook相关设置 + if req.QuotaWarningType == dto.NotifyTypeWebhook { + settings.WebhookUrl = req.WebhookUrl + if req.WebhookSecret != "" { + settings.WebhookSecret = req.WebhookSecret + } + } + + // 如果提供了通知邮箱,添加到设置中 + if req.QuotaWarningType == dto.NotifyTypeEmail && req.NotificationEmail != "" { + settings.NotificationEmail = req.NotificationEmail + } + + // 更新用户设置 + user.SetSetting(settings) + if err := user.Update(false); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "更新设置失败: " + err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "设置已更新", + }) +} diff --git a/controller/wechat.go b/controller/wechat.go new file mode 100644 index 00000000..9a4bdfed --- /dev/null +++ b/controller/wechat.go @@ -0,0 +1,168 @@ +package controller + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "one-api/common" + "one-api/model" + "strconv" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +type wechatLoginResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data string `json:"data"` +} + +func getWeChatIdByCode(code string) (string, error) { + if code == "" { + return "", errors.New("无效的参数") + } + req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", common.WeChatServerToken) + client := http.Client{ + Timeout: 5 * time.Second, + } + httpResponse, err := client.Do(req) + if err != nil { + return "", err + } + defer httpResponse.Body.Close() + var res wechatLoginResponse + err = json.NewDecoder(httpResponse.Body).Decode(&res) + if err != nil { + return "", err + } + if !res.Success { + return "", errors.New(res.Message) + } + if res.Data == "" { + return "", errors.New("验证码错误或已过期") + } + return res.Data, nil +} + +func WeChatAuth(c *gin.Context) { + if !common.WeChatAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员未开启通过微信登录以及注册", + "success": false, + }) + return + } + code := c.Query("code") + wechatId, err := getWeChatIdByCode(code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + user := model.User{ + WeChatId: wechatId, + } + if model.IsWeChatIdAlreadyTaken(wechatId) { + err := user.FillUserByWeChatId() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if user.Id == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已注销", + }) + return + } + } else { + if common.RegisterEnabled { + user.Username = "wechat_" + strconv.Itoa(model.GetMaxUserId()+1) + user.DisplayName = "WeChat User" + user.Role = common.RoleCommonUser + user.Status = common.UserStatusEnabled + + if err := user.Insert(0); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员关闭了新用户注册", + }) + return + } + } + + if user.Status != common.UserStatusEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "用户已被封禁", + "success": false, + }) + return + } + setupLogin(&user, c) +} + +func WeChatBind(c *gin.Context) { + if !common.WeChatAuthEnabled { + c.JSON(http.StatusOK, gin.H{ + "message": "管理员未开启通过微信登录以及注册", + "success": false, + }) + return + } + code := c.Query("code") + wechatId, err := getWeChatIdByCode(code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": err.Error(), + "success": false, + }) + return + } + if model.IsWeChatIdAlreadyTaken(wechatId) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该微信账号已被绑定", + }) + return + } + session := sessions.Default(c) + id := session.Get("id") + user := model.User{ + Id: id.(int), + } + err = user.FillUserById() + if err != nil { + common.ApiError(c, err) + return + } + user.WeChatId = wechatId + err = user.Update(false) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..57ad0b30 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,52 @@ +version: '3.4' + +services: + new-api: + image: calciumion/new-api:latest + container_name: new-api + restart: always + command: --log-dir /app/logs + ports: + - "3000:3000" + volumes: + - ./data:/data + - ./logs:/app/logs + environment: + - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service + - REDIS_CONN_STRING=redis://redis + - TZ=Asia/Shanghai + - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 + # - STREAMING_TIMEOUT=120 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 + # - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!!!!!!! + # - NODE_TYPE=slave # Uncomment for slave node in multi-node deployment + # - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed + # - FRONTEND_BASE_URL=https://openai.justsong.cn # Uncomment for multi-node deployment with front-end URL + + depends_on: + - redis + - mysql + healthcheck: + test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' | awk -F: '{print $$2}'"] + interval: 30s + timeout: 10s + retries: 3 + + redis: + image: redis:latest + container_name: redis + restart: always + + mysql: + image: mysql:8.2 + container_name: mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: 123456 # Ensure this matches the password in SQL_DSN + MYSQL_DATABASE: new-api + volumes: + - mysql_data:/var/lib/mysql + # ports: + # - "3306:3306" # If you want to access MySQL from outside Docker, uncomment + +volumes: + mysql_data: diff --git a/docs/api/api_auth.md b/docs/api/api_auth.md new file mode 100644 index 00000000..798ca374 --- /dev/null +++ b/docs/api/api_auth.md @@ -0,0 +1,53 @@ +# API 鉴权文档 + +## 认证方式 + +### Access Token + +对于需要鉴权的 API 接口,必须同时提供以下两个请求头来进行 Access Token 认证: + +1. **请求头中的 `Authorization` 字段** + + 将 Access Token 放置于 HTTP 请求头部的 `Authorization` 字段中,格式如下: + + ``` + Authorization: + ``` + + 其中 `` 需要替换为实际的 Access Token 值。 + +2. **请求头中的 `New-Api-User` 字段** + + 将用户 ID 放置于 HTTP 请求头部的 `New-Api-User` 字段中,格式如下: + + ``` + New-Api-User: + ``` + + 其中 `` 需要替换为实际的用户 ID。 + +**注意:** + +* **必须同时提供 `Authorization` 和 `New-Api-User` 两个请求头才能通过鉴权。** +* 如果只提供其中一个请求头,或者两个请求头都未提供,则会返回 `401 Unauthorized` 错误。 +* 如果 `Authorization` 中的 Access Token 无效,则会返回 `401 Unauthorized` 错误,并提示“无权进行此操作,access token 无效”。 +* 如果 `New-Api-User` 中的用户 ID 与 Access Token 不匹配,则会返回 `401 Unauthorized` 错误,并提示“无权进行此操作,与登录用户不匹配,请重新登录”。 +* 如果没有提供 `New-Api-User` 请求头,则会返回 `401 Unauthorized` 错误,并提示“无权进行此操作,未提供 New-Api-User”。 +* 如果 `New-Api-User` 请求头格式错误,则会返回 `401 Unauthorized` 错误,并提示“无权进行此操作,New-Api-User 格式错误”。 +* 如果用户已被禁用,则会返回 `403 Forbidden` 错误,并提示“用户已被封禁”。 +* 如果用户权限不足,则会返回 `403 Forbidden` 错误,并提示“无权进行此操作,权限不足”。 +* 如果用户信息无效,则会返回 `403 Forbidden` 错误,并提示“无权进行此操作,用户信息无效”。 + +## Curl 示例 + +假设您的 Access Token 为 `access_token`,用户 ID 为 `123`,要访问的 API 接口为 `/api/user/self`,则可以使用以下 curl 命令: + +```bash +curl -X GET \ + -H "Authorization: access_token" \ + -H "New-Api-User: 123" \ + https://your-domain.com/api/user/self +``` + +请将 `access_token`、`123` 和 `https://your-domain.com` 替换为实际的值。 + diff --git a/docs/api/web_api.md b/docs/api/web_api.md new file mode 100644 index 00000000..e64fd359 --- /dev/null +++ b/docs/api/web_api.md @@ -0,0 +1,197 @@ +# New API – Web 界面后端接口文档 + +> 本文档汇总了 **New API** 后端提供给前端 Web 界面的全部 REST 接口(不含 *Relay* 相关接口)。 +> +> 接口前缀统一为 `https://`,以下仅列出 **路径**、**HTTP 方法**、**鉴权要求** 与 **功能简介**。 +> +> 鉴权级别说明: +> * **公开** – 不需要登录即可调用 +> * **用户** – 需携带用户 Token(`middleware.UserAuth`) +> * **管理员** – 需管理员 Token(`middleware.AdminAuth`) +> * **Root** – 仅限最高权限 Root 用户(`middleware.RootAuth`) + +--- + +## 1. 初始化 / 系统状态 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/setup | 公开 | 获取系统初始化状态 | +| POST | /api/setup | 公开 | 完成首次安装向导 | +| GET | /api/status | 公开 | 获取运行状态摘要 | +| GET | /api/uptime/status | 公开 | Uptime-Kuma 兼容状态探针 | +| GET | /api/status/test | 管理员 | 测试后端与依赖组件是否正常 | + +## 2. 公共信息 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/models | 用户 | 获取前端可用模型列表 | +| GET | /api/notice | 公开 | 获取公告栏内容 | +| GET | /api/about | 公开 | 关于页面信息 | +| GET | /api/home_page_content | 公开 | 首页自定义内容 | +| GET | /api/pricing | 可匿名/用户 | 价格与套餐信息 | +| GET | /api/ratio_config | 公开 | 模型倍率配置(仅公开字段) | + +## 3. 邮件 / 身份验证 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/verification | 公开 (限流) | 发送邮箱验证邮件 | +| GET | /api/reset_password | 公开 (限流) | 发送重置密码邮件 | +| POST | /api/user/reset | 公开 | 提交重置密码请求 | + +## 4. OAuth / 第三方登录 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/oauth/github | 公开 | GitHub OAuth 跳转 | +| GET | /api/oauth/oidc | 公开 | OIDC 通用 OAuth 跳转 | +| GET | /api/oauth/linuxdo | 公开 | LinuxDo OAuth 跳转 | +| GET | /api/oauth/wechat | 公开 | 微信扫码登录跳转 | +| GET | /api/oauth/wechat/bind | 公开 | 微信账户绑定 | +| GET | /api/oauth/email/bind | 公开 | 邮箱绑定 | +| GET | /api/oauth/telegram/login | 公开 | Telegram 登录 | +| GET | /api/oauth/telegram/bind | 公开 | Telegram 账户绑定 | +| GET | /api/oauth/state | 公开 | 获取随机 state(防 CSRF) | + +## 5. 用户模块 +### 5.1 账号注册/登录 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| POST | /api/user/register | 公开 | 注册新账号 | +| POST | /api/user/login | 公开 | 用户登录 | +| GET | /api/user/logout | 用户 | 退出登录 | +| GET | /api/user/epay/notify | 公开 | Epay 支付回调 | +| GET | /api/user/groups | 公开 | 列出所有分组(无鉴权版) | + +### 5.2 用户自身操作 (需登录) +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/user/self/groups | 用户 | 获取自己所在分组 | +| GET | /api/user/self | 用户 | 获取个人资料 | +| GET | /api/user/models | 用户 | 获取模型可见性 | +| PUT | /api/user/self | 用户 | 修改个人资料 | +| DELETE | /api/user/self | 用户 | 注销账号 | +| GET | /api/user/token | 用户 | 生成用户级别 Access Token | +| GET | /api/user/aff | 用户 | 获取推广码信息 | +| POST | /api/user/topup | 用户 | 余额直充 | +| POST | /api/user/pay | 用户 | 提交支付订单 | +| POST | /api/user/amount | 用户 | 余额支付 | +| POST | /api/user/aff_transfer | 用户 | 推广额度转账 | +| PUT | /api/user/setting | 用户 | 更新用户设置 | + +### 5.3 管理员用户管理 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/user/ | 管理员 | 获取全部用户列表 | +| GET | /api/user/search | 管理员 | 搜索用户 | +| GET | /api/user/:id | 管理员 | 获取单个用户信息 | +| POST | /api/user/ | 管理员 | 创建用户 | +| POST | /api/user/manage | 管理员 | 冻结/重置等管理操作 | +| PUT | /api/user/ | 管理员 | 更新用户 | +| DELETE | /api/user/:id | 管理员 | 删除用户 | + +## 6. 站点选项 (Root) +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/option/ | Root | 获取全局配置 | +| PUT | /api/option/ | Root | 更新全局配置 | +| POST | /api/option/rest_model_ratio | Root | 重置模型倍率 | +| POST | /api/option/migrate_console_setting | Root | 迁移旧版控制台配置 | + +## 7. 模型倍率同步 (Root) +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/ratio_sync/channels | Root | 获取可同步渠道列表 | +| POST | /api/ratio_sync/fetch | Root | 从上游拉取倍率 | + +## 8. 渠道管理 (管理员) +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/channel/ | 获取渠道列表 | +| GET | /api/channel/search | 搜索渠道 | +| GET | /api/channel/models | 查询渠道模型能力 | +| GET | /api/channel/models_enabled | 查询启用模型能力 | +| GET | /api/channel/:id | 获取单个渠道 | +| GET | /api/channel/test | 批量测试渠道连通性 | +| GET | /api/channel/test/:id | 单个渠道测试 | +| GET | /api/channel/update_balance | 批量刷新余额 | +| GET | /api/channel/update_balance/:id | 单个刷新余额 | +| POST | /api/channel/ | 新增渠道 | +| PUT | /api/channel/ | 更新渠道 | +| DELETE | /api/channel/disabled | 删除已禁用渠道 | +| POST | /api/channel/tag/disabled | 批量禁用标签渠道 | +| POST | /api/channel/tag/enabled | 批量启用标签渠道 | +| PUT | /api/channel/tag | 编辑渠道标签 | +| DELETE | /api/channel/:id | 删除渠道 | +| POST | /api/channel/batch | 批量删除渠道 | +| POST | /api/channel/fix | 修复渠道能力表 | +| GET | /api/channel/fetch_models/:id | 拉取单渠道模型 | +| POST | /api/channel/fetch_models | 拉取全部渠道模型 | +| POST | /api/channel/batch/tag | 批量设置渠道标签 | +| GET | /api/channel/tag/models | 根据标签获取模型 | +| POST | /api/channel/copy/:id | 复制渠道 | + +## 9. Token 管理 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/token/ | 用户 | 获取全部 Token | +| GET | /api/token/search | 用户 | 搜索 Token | +| GET | /api/token/:id | 用户 | 获取单个 Token | +| POST | /api/token/ | 用户 | 创建 Token | +| PUT | /api/token/ | 用户 | 更新 Token | +| DELETE | /api/token/:id | 用户 | 删除 Token | +| POST | /api/token/batch | 用户 | 批量删除 Token | + +## 10. 兑换码管理 (管理员) +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | /api/redemption/ | 获取兑换码列表 | +| GET | /api/redemption/search | 搜索兑换码 | +| GET | /api/redemption/:id | 获取单个兑换码 | +| POST | /api/redemption/ | 创建兑换码 | +| PUT | /api/redemption/ | 更新兑换码 | +| DELETE | /api/redemption/invalid | 删除无效兑换码 | +| DELETE | /api/redemption/:id | 删除兑换码 | + +## 11. 日志 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/log/ | 管理员 | 获取全部日志 | +| DELETE | /api/log/ | 管理员 | 删除历史日志 | +| GET | /api/log/stat | 管理员 | 日志统计 | +| GET | /api/log/self/stat | 用户 | 我的日志统计 | +| GET | /api/log/search | 管理员 | 搜索全部日志 | +| GET | /api/log/self | 用户 | 获取我的日志 | +| GET | /api/log/self/search | 用户 | 搜索我的日志 | +| GET | /api/log/token | 公开 | 根据 Token 查询日志(支持 CORS) | + +## 12. 数据统计 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/data/ | 管理员 | 全站用量按日期统计 | +| GET | /api/data/self | 用户 | 我的用量按日期统计 | + +## 13. 分组 +| GET | /api/group/ | 管理员 | 获取全部分组列表 | + +## 14. Midjourney 任务 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/mj/self | 用户 | 获取自己的 MJ 任务 | +| GET | /api/mj/ | 管理员 | 获取全部 MJ 任务 | + +## 15. 任务中心 +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /api/task/self | 用户 | 获取我的任务 | +| GET | /api/task/ | 管理员 | 获取全部任务 | + +## 16. 账户计费面板 (Dashboard) +| 方法 | 路径 | 鉴权 | 说明 | +|------|------|------|------| +| GET | /dashboard/billing/subscription | 用户 Token | 获取订阅额度信息 | +| GET | /v1/dashboard/billing/subscription | 同上 | 兼容 OpenAI SDK 路径 | +| GET | /dashboard/billing/usage | 用户 Token | 获取使用量信息 | +| GET | /v1/dashboard/billing/usage | 同上 | 兼容 OpenAI SDK 路径 | + +--- + +> **更新日期**:2025.07.17 diff --git a/docs/channel/other_setting.md b/docs/channel/other_setting.md new file mode 100644 index 00000000..43341660 --- /dev/null +++ b/docs/channel/other_setting.md @@ -0,0 +1,33 @@ +# 渠道而外设置说明 + +该配置用于设置一些额外的渠道参数,可以通过 JSON 对象进行配置。主要包含以下两个设置项: + +1. force_format + - 用于标识是否对数据进行强制格式化为 OpenAI 格式 + - 类型为布尔值,设置为 true 时启用强制格式化 + +2. proxy + - 用于配置网络代理 + - 类型为字符串,填写代理地址(例如 socks5 协议的代理地址) + +3. thinking_to_content + - 用于标识是否将思考内容`reasoning_content`转换为``标签拼接到内容中返回 + - 类型为布尔值,设置为 true 时启用思考内容转换 + +-------------------------------------------------------------- + +## JSON 格式示例 + +以下是一个示例配置,启用强制格式化并设置了代理地址: + +```json +{ + "force_format": true, + "thinking_to_content": true, + "proxy": "socks5://xxxxxxx" +} +``` + +-------------------------------------------------------------- + +通过调整上述 JSON 配置中的值,可以灵活控制渠道的额外行为,比如是否进行格式化以及使用特定的网络代理。 diff --git a/docs/images/aliyun.png b/docs/images/aliyun.png new file mode 100644 index 0000000000000000000000000000000000000000..6266bfbff3603b0969ee557143a5ec18e7d9e045 GIT binary patch literal 5102 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 A1ONa4 literal 0 HcmV?d00001 diff --git a/docs/images/io-net.png b/docs/images/io-net.png new file mode 100644 index 0000000000000000000000000000000000000000..fb47534d3d60553ab7a0b2331ead32c652e30a2f GIT binary patch literal 2016 zcmb8w`6CmI1IO{LHPw1ZjA>%N(d1n5j5$lr#U!-l>z*NnCgq&FT(Q2WSVrN&3d_;s zNM=QO9%qi4F!mId2b&|d%<=U76P_PF@6Vs`%5rhGmxjP0002N5>tN&h^Z)ocmxEG2 z>lJ2N4*(pf#@bl9$IzGQScaD^G`&+`Y=V4@NdskOuO}n3A=F`vzB`$HH(THKXhdGF z{uIXML@Gx2;E9oh5$GV>Mz=PGBn57>ek&25$r9ybjQ8u?1P3QvJcN9Zb4iy3%5 z7^66@xT|_=PMM8h+p}-{Men1_4@F+&k^FpnFTD@p>F^3xq7@i0!D{4X9+W3Lq2ty$ zmcmKtOa>)4T~$3$GQX-|)f?p6 zWMRan$s{(F>;ymt#uz3{s*Jp!?STi!|l!Qp!+x+hfyFd7~WY*~GZPwv5UG-|UI_)^(&7u8d zRMk1=sf~O$Q-Gs{qa<#M=DAt z3WzM|w1|5NY?T%lV7fN4?JEa?r5en{J2Nl!<-3oyDKhtDrVdi9O!8EWvjEdl)Nd;$ zg>zSV9)3bHiz@wnqlku1-|(&8h9w$b}ViQA= zN?r8kp3_OBX5cr_wIlt{jyn$W*})}QIk8b`#nE4{WQeRy7hyB8P@&|Y=SOzA*JY=a zi(UA!s3Fk>kXTk+D8S8~`O2*J3mmROn zrYi9O)CTcpAaAT2zW>?!=NlQfuPNsGTb4x1k&WjuGh@}iL`G<=oNVBly^{nPP4(zm zH}Ez1$yn+==N0$J1y1^~jjvZ~-h@t+NZyz`lwU&+Aw&a8q#RoPZlG_l{Of z`A>sCGuwj4uH~Dm%&ID~_T9U~eT+4F5@F=$KW7}`gHt>aFs?yXS#iMd3sQ?MKjx35 zlAzl2gIhAX*{d4!x8gJ^5EMTOgnncSyq#8uwDxFp41#;+Ax<)tSmn328tXVO)sBI| z5tPjuP)~4wi>^qSW^rTO-Y^o-mZ(`fl{#%BfjHZQV_`YrH z&~2~gFmR2qhYhv?FOW8`!?Kjo6npfaLZi@)?K^AJX%qw%Hg?V68stmN6;#U$my3xl zqj5Dc?fiL=W7ckZ@w3I`>U6C!O5hl@n~*cwz@-;!2`6)J)B}GdT#4N``N(yaz!Uc0 zob%He-dhQS_0_uw`41#W3laWNnIQzShH9|pQoU0<+_Wm*STJ{GHk&60sKi<>p|I<8 zQ~yIw@Z9zK58oY@lyEVKNr6m*g$U?VHq~)O6W}}gXY;$>iNhNmu6Xcj zZqT&WLRAx9%;e~?^}34^)c>p~JW?U1LvuH_aiPFINl1b?$bQ~szL z+$ahjA9%FEyCWC2K?|OFu;YtkvzjL_NWNv6MJX())2xrZ{=wJb5SI|f+b2bRI8(t_8GH`eQwSWH?b68;yw7@xZJ7WMA_)~5G*4EjEdEPJe EAFe&he*gdg literal 0 HcmV?d00001 diff --git a/docs/images/pku.png b/docs/images/pku.png new file mode 100644 index 0000000000000000000000000000000000000000..a058c3ce2338608f24c8051925850d89d71dc926 GIT binary patch literal 12247 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 Qc2)xrd3Cu;8MCne1MB)l@c;k- literal 0 HcmV?d00001 diff --git a/docs/installation/BT.md b/docs/installation/BT.md new file mode 100644 index 00000000..b4ea5b2f --- /dev/null +++ b/docs/installation/BT.md @@ -0,0 +1,3 @@ +密钥为环境变量SESSION_SECRET + +![8285bba413e770fe9620f1bf9b40d44e](https://github.com/user-attachments/assets/7a6fc03e-c457-45e4-b8f9-184508fc26b0) diff --git a/docs/models/Midjourney.md b/docs/models/Midjourney.md new file mode 100644 index 00000000..478115b7 --- /dev/null +++ b/docs/models/Midjourney.md @@ -0,0 +1,82 @@ +# Midjourney Proxy API文档 + +**简介**:Midjourney Proxy API文档 + +## 接口列表 +支持的接口如下: ++ [x] /mj/submit/imagine ++ [x] /mj/submit/change ++ [x] /mj/submit/blend ++ [x] /mj/submit/describe ++ [x] /mj/image/{id} (通过此接口获取图片,**请必须在系统设置中填写服务器地址!!**) ++ [x] /mj/task/{id}/fetch (此接口返回的图片地址为经过One API转发的地址) ++ [x] /task/list-by-condition ++ [x] /mj/submit/action (仅midjourney-proxy-plus支持,下同) ++ [x] /mj/submit/modal ++ [x] /mj/submit/shorten ++ [x] /mj/task/{id}/image-seed ++ [x] /mj/insight-face/swap (InsightFace) + +## 模型列表 + +### midjourney-proxy支持 + +- mj_imagine (绘图) +- mj_variation (变换) +- mj_reroll (重绘) +- mj_blend (混合) +- mj_upscale (放大) +- mj_describe (图生文) + +### 仅midjourney-proxy-plus支持 + +- mj_zoom (比例变焦) +- mj_shorten (提示词缩短) +- mj_modal (窗口提交,局部重绘和自定义比例变焦必须和mj_modal一同添加) +- mj_inpaint (局部重绘提交,必须和mj_modal一同添加) +- mj_custom_zoom (自定义比例变焦,必须和mj_modal一同添加) +- mj_high_variation (强变换) +- mj_low_variation (弱变换) +- mj_pan (平移) +- swap_face (换脸) + +## 模型价格设置(在设置-运营设置-模型固定价格设置中设置) +```json +{ + "mj_imagine": 0.1, + "mj_variation": 0.1, + "mj_reroll": 0.1, + "mj_blend": 0.1, + "mj_modal": 0.1, + "mj_zoom": 0.1, + "mj_shorten": 0.1, + "mj_high_variation": 0.1, + "mj_low_variation": 0.1, + "mj_pan": 0.1, + "mj_inpaint": 0, + "mj_custom_zoom": 0, + "mj_describe": 0.05, + "mj_upscale": 0.05, + "swap_face": 0.05 +} +``` +其中mj_inpaint和mj_custom_zoom的价格设置为0,是因为这两个模型需要搭配mj_modal使用,所以价格由mj_modal决定。 + +## 渠道设置 + +### 对接 midjourney-proxy(plus) + +1. + +部署Midjourney-Proxy,并配置好midjourney账号等(强烈建议设置密钥),[项目地址](https://github.com/novicezk/midjourney-proxy) + +2. 在渠道管理中添加渠道,渠道类型选择**Midjourney Proxy**,如果是plus版本选择**Midjourney Proxy Plus** + ,模型请参考上方模型列表 +3. **代理**填写midjourney-proxy部署的地址,例如:http://localhost:8080 +4. 密钥填写midjourney-proxy的密钥,如果没有设置密钥,可以随便填 + +### 对接上游new api + +1. 在渠道管理中添加渠道,渠道类型选择**Midjourney Proxy Plus**,模型请参考上方模型列表 +2. **代理**填写上游new api的地址,例如:http://localhost:3000 +3. 密钥填写上游new api的密钥 \ No newline at end of file diff --git a/docs/models/Rerank.md b/docs/models/Rerank.md new file mode 100644 index 00000000..dc57d99b --- /dev/null +++ b/docs/models/Rerank.md @@ -0,0 +1,62 @@ +# Rerank API文档 + +**简介**:Rerank API文档 + +## 接入Dify +模型供应商选择Jina,按要求填写模型信息即可接入Dify。 + +## 请求方式 + +Post: /v1/rerank + +Request: + +```json +{ + "model": "jina-reranker-v2-base-multilingual", + "query": "What is the capital of the United States?", + "top_n": 3, + "documents": [ + "Carson City is the capital city of the American state of Nevada.", + "The Commonwealth of the Northern Mariana Islands is a group of islands in the Pacific Ocean. Its capital is Saipan.", + "Washington, D.C. (also known as simply Washington or D.C., and officially as the District of Columbia) is the capital of the United States. It is a federal district.", + "Capitalization or capitalisation in English grammar is the use of a capital letter at the start of a word. English usage varies from capitalization in other languages.", + "Capital punishment (the death penalty) has existed in the United States since beforethe United States was a country. As of 2017, capital punishment is legal in 30 of the 50 states." + ] +} +``` + +Response: + +```json +{ + "results": [ + { + "document": { + "text": "Washington, D.C. (also known as simply Washington or D.C., and officially as the District of Columbia) is the capital of the United States. It is a federal district." + }, + "index": 2, + "relevance_score": 0.9999702 + }, + { + "document": { + "text": "Carson City is the capital city of the American state of Nevada." + }, + "index": 0, + "relevance_score": 0.67800725 + }, + { + "document": { + "text": "Capitalization or capitalisation in English grammar is the use of a capital letter at the start of a word. English usage varies from capitalization in other languages." + }, + "index": 3, + "relevance_score": 0.02800752 + } + ], + "usage": { + "prompt_tokens": 158, + "completion_tokens": 0, + "total_tokens": 158 + } +} +``` \ No newline at end of file diff --git a/docs/models/Suno.md b/docs/models/Suno.md new file mode 100644 index 00000000..840ca8e4 --- /dev/null +++ b/docs/models/Suno.md @@ -0,0 +1,44 @@ +# Suno API文档 + +**简介**:Suno API文档 + +## 接口列表 +支持的接口如下: ++ [x] /suno/submit/music ++ [x] /suno/submit/lyrics ++ [x] /suno/fetch ++ [x] /suno/fetch/:id + +## 模型列表 + +### Suno API支持 + +- suno_music (自定义模式、灵感模式、续写) +- suno_lyrics (生成歌词) + + +## 模型价格设置(在设置-运营设置-模型固定价格设置中设置) +```json +{ + "suno_music": 0.3, + "suno_lyrics": 0.01 +} +``` + +## 渠道设置 + +### 对接 Suno API + +1. +部署 Suno API,并配置好suno账号等(强烈建议设置密钥),[项目地址](https://github.com/Suno-API/Suno-API) + +2. 在渠道管理中添加渠道,渠道类型选择**Suno API** + ,模型请参考上方模型列表 +3. **代理**填写 Suno API 部署的地址,例如:http://localhost:8080 +4. 密钥填写 Suno API 的密钥,如果没有设置密钥,可以随便填 + +### 对接上游new api + +1. 在渠道管理中添加渠道,渠道类型选择**Suno API**,或任意类型,只需模型包含上方模型列表的模型 +2. **代理**填写上游new api的地址,例如:http://localhost:3000 +3. 密钥填写上游new api的密钥 \ No newline at end of file diff --git a/dto/audio.go b/dto/audio.go new file mode 100644 index 00000000..c36b3da5 --- /dev/null +++ b/dto/audio.go @@ -0,0 +1,34 @@ +package dto + +type AudioRequest struct { + Model string `json:"model"` + Input string `json:"input"` + Voice string `json:"voice"` + Speed float64 `json:"speed,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` +} + +type AudioResponse struct { + Text string `json:"text"` +} + +type WhisperVerboseJSONResponse struct { + Task string `json:"task,omitempty"` + Language string `json:"language,omitempty"` + Duration float64 `json:"duration,omitempty"` + Text string `json:"text,omitempty"` + Segments []Segment `json:"segments,omitempty"` +} + +type Segment struct { + Id int `json:"id"` + Seek int `json:"seek"` + Start float64 `json:"start"` + End float64 `json:"end"` + Text string `json:"text"` + Tokens []int `json:"tokens"` + Temperature float64 `json:"temperature"` + AvgLogprob float64 `json:"avg_logprob"` + CompressionRatio float64 `json:"compression_ratio"` + NoSpeechProb float64 `json:"no_speech_prob"` +} diff --git a/dto/channel_settings.go b/dto/channel_settings.go new file mode 100644 index 00000000..871d6716 --- /dev/null +++ b/dto/channel_settings.go @@ -0,0 +1,7 @@ +package dto + +type ChannelSettings struct { + ForceFormat bool `json:"force_format,omitempty"` + ThinkingToContent bool `json:"thinking_to_content,omitempty"` + Proxy string `json:"proxy"` +} diff --git a/dto/claude.go b/dto/claude.go new file mode 100644 index 00000000..1a7eacb1 --- /dev/null +++ b/dto/claude.go @@ -0,0 +1,337 @@ +package dto + +import ( + "encoding/json" + "one-api/common" + "one-api/types" +) + +type ClaudeMetadata struct { + UserId string `json:"user_id"` +} + +type ClaudeMediaMessage struct { + Type string `json:"type,omitempty"` + Text *string `json:"text,omitempty"` + Model string `json:"model,omitempty"` + Source *ClaudeMessageSource `json:"source,omitempty"` + Usage *ClaudeUsage `json:"usage,omitempty"` + StopReason *string `json:"stop_reason,omitempty"` + PartialJson *string `json:"partial_json,omitempty"` + Role string `json:"role,omitempty"` + Thinking string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` + Delta string `json:"delta,omitempty"` + CacheControl json.RawMessage `json:"cache_control,omitempty"` + // tool_calls + Id string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input any `json:"input,omitempty"` + Content any `json:"content,omitempty"` + ToolUseId string `json:"tool_use_id,omitempty"` +} + +func (c *ClaudeMediaMessage) SetText(s string) { + c.Text = &s +} + +func (c *ClaudeMediaMessage) GetText() string { + if c.Text == nil { + return "" + } + return *c.Text +} + +func (c *ClaudeMediaMessage) IsStringContent() bool { + if c.Content == nil { + return false + } + _, ok := c.Content.(string) + if ok { + return true + } + return false +} + +func (c *ClaudeMediaMessage) GetStringContent() string { + if c.Content == nil { + return "" + } + switch c.Content.(type) { + case string: + return c.Content.(string) + case []any: + var contentStr string + for _, contentItem := range c.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + + return "" +} + +func (c *ClaudeMediaMessage) GetJsonRowString() string { + jsonContent, _ := json.Marshal(c) + return string(jsonContent) +} + +func (c *ClaudeMediaMessage) SetContent(content any) { + c.Content = content +} + +func (c *ClaudeMediaMessage) ParseMediaContent() []ClaudeMediaMessage { + mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.Content) + return mediaContent +} + +type ClaudeMessageSource struct { + Type string `json:"type"` + MediaType string `json:"media_type,omitempty"` + Data any `json:"data,omitempty"` + Url string `json:"url,omitempty"` +} + +type ClaudeMessage struct { + Role string `json:"role"` + Content any `json:"content"` +} + +func (c *ClaudeMessage) IsStringContent() bool { + if c.Content == nil { + return false + } + _, ok := c.Content.(string) + return ok +} + +func (c *ClaudeMessage) GetStringContent() string { + if c.Content == nil { + return "" + } + switch c.Content.(type) { + case string: + return c.Content.(string) + case []any: + var contentStr string + for _, contentItem := range c.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + + return "" +} + +func (c *ClaudeMessage) SetStringContent(content string) { + c.Content = content +} + +func (c *ClaudeMessage) ParseContent() ([]ClaudeMediaMessage, error) { + return common.Any2Type[[]ClaudeMediaMessage](c.Content) +} + +type Tool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema map[string]interface{} `json:"input_schema"` +} + +type InputSchema struct { + Type string `json:"type"` + Properties any `json:"properties,omitempty"` + Required any `json:"required,omitempty"` +} + +type ClaudeWebSearchTool struct { + Type string `json:"type"` + Name string `json:"name"` + MaxUses int `json:"max_uses,omitempty"` + UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"` +} + +type ClaudeWebSearchUserLocation struct { + Type string `json:"type"` + Timezone string `json:"timezone,omitempty"` + Country string `json:"country,omitempty"` + Region string `json:"region,omitempty"` + City string `json:"city,omitempty"` +} + +type ClaudeToolChoice struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` +} + +type ClaudeRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt,omitempty"` + System any `json:"system,omitempty"` + Messages []ClaudeMessage `json:"messages,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + MaxTokensToSample uint `json:"max_tokens_to_sample,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + //ClaudeMetadata `json:"metadata,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Thinking *Thinking `json:"thinking,omitempty"` +} + +// AddTool 添加工具到请求中 +func (c *ClaudeRequest) AddTool(tool any) { + if c.Tools == nil { + c.Tools = make([]any, 0) + } + + switch tools := c.Tools.(type) { + case []any: + c.Tools = append(tools, tool) + default: + // 如果Tools不是[]any类型,重新初始化为[]any + c.Tools = []any{tool} + } +} + +// GetTools 获取工具列表 +func (c *ClaudeRequest) GetTools() []any { + if c.Tools == nil { + return nil + } + + switch tools := c.Tools.(type) { + case []any: + return tools + default: + return nil + } +} + +// ProcessTools 处理工具列表,支持类型断言 +func ProcessTools(tools []any) ([]*Tool, []*ClaudeWebSearchTool) { + var normalTools []*Tool + var webSearchTools []*ClaudeWebSearchTool + + for _, tool := range tools { + switch t := tool.(type) { + case *Tool: + normalTools = append(normalTools, t) + case *ClaudeWebSearchTool: + webSearchTools = append(webSearchTools, t) + case Tool: + normalTools = append(normalTools, &t) + case ClaudeWebSearchTool: + webSearchTools = append(webSearchTools, &t) + default: + // 未知类型,跳过 + continue + } + } + + return normalTools, webSearchTools +} + +type Thinking struct { + Type string `json:"type"` + BudgetTokens *int `json:"budget_tokens,omitempty"` +} + +func (c *Thinking) GetBudgetTokens() int { + if c.BudgetTokens == nil { + return 0 + } + return *c.BudgetTokens +} + +func (c *ClaudeRequest) IsStringSystem() bool { + _, ok := c.System.(string) + return ok +} + +func (c *ClaudeRequest) GetStringSystem() string { + if c.IsStringSystem() { + return c.System.(string) + } + return "" +} + +func (c *ClaudeRequest) SetStringSystem(system string) { + c.System = system +} + +func (c *ClaudeRequest) ParseSystem() []ClaudeMediaMessage { + mediaContent, _ := common.Any2Type[[]ClaudeMediaMessage](c.System) + return mediaContent +} + +type ClaudeError struct { + Type string `json:"type,omitempty"` + Message string `json:"message,omitempty"` +} + +type ClaudeErrorWithStatusCode struct { + Error ClaudeError `json:"error"` + StatusCode int `json:"status_code"` + LocalError bool +} + +type ClaudeResponse struct { + Id string `json:"id,omitempty"` + Type string `json:"type"` + Role string `json:"role,omitempty"` + Content []ClaudeMediaMessage `json:"content,omitempty"` + Completion string `json:"completion,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + Model string `json:"model,omitempty"` + Error *types.ClaudeError `json:"error,omitempty"` + Usage *ClaudeUsage `json:"usage,omitempty"` + Index *int `json:"index,omitempty"` + ContentBlock *ClaudeMediaMessage `json:"content_block,omitempty"` + Delta *ClaudeMediaMessage `json:"delta,omitempty"` + Message *ClaudeMediaMessage `json:"message,omitempty"` +} + +// set index +func (c *ClaudeResponse) SetIndex(i int) { + c.Index = &i +} + +// get index +func (c *ClaudeResponse) GetIndex() int { + if c.Index == nil { + return 0 + } + return *c.Index +} + +type ClaudeUsage struct { + InputTokens int `json:"input_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` + OutputTokens int `json:"output_tokens"` + ServerToolUse *ClaudeServerToolUse `json:"server_tool_use"` +} + +type ClaudeServerToolUse struct { + WebSearchRequests int `json:"web_search_requests"` +} diff --git a/dto/dalle.go b/dto/dalle.go new file mode 100644 index 00000000..ce2f6361 --- /dev/null +++ b/dto/dalle.go @@ -0,0 +1,29 @@ +package dto + +import "encoding/json" + +type ImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt" binding:"required"` + N int `json:"n,omitempty"` + Size string `json:"size,omitempty"` + Quality string `json:"quality,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + Style string `json:"style,omitempty"` + User string `json:"user,omitempty"` + ExtraFields json.RawMessage `json:"extra_fields,omitempty"` + Background string `json:"background,omitempty"` + Moderation string `json:"moderation,omitempty"` + OutputFormat string `json:"output_format,omitempty"` + Watermark *bool `json:"watermark,omitempty"` +} + +type ImageResponse struct { + Data []ImageData `json:"data"` + Created int64 `json:"created"` +} +type ImageData struct { + Url string `json:"url"` + B64Json string `json:"b64_json"` + RevisedPrompt string `json:"revised_prompt"` +} diff --git a/dto/embedding.go b/dto/embedding.go new file mode 100644 index 00000000..9d722292 --- /dev/null +++ b/dto/embedding.go @@ -0,0 +1,57 @@ +package dto + +type EmbeddingOptions struct { + Seed int `json:"seed,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopK int `json:"top_k,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + NumPredict int `json:"num_predict,omitempty"` + NumCtx int `json:"num_ctx,omitempty"` +} + +type EmbeddingRequest struct { + Model string `json:"model"` + Input any `json:"input"` + EncodingFormat string `json:"encoding_format,omitempty"` + Dimensions int `json:"dimensions,omitempty"` + User string `json:"user,omitempty"` + Seed float64 `json:"seed,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` +} + +func (r EmbeddingRequest) ParseInput() []string { + if r.Input == nil { + return nil + } + var input []string + switch r.Input.(type) { + case string: + input = []string{r.Input.(string)} + case []any: + input = make([]string, 0, len(r.Input.([]any))) + for _, item := range r.Input.([]any) { + if str, ok := item.(string); ok { + input = append(input, str) + } + } + } + return input +} + +type EmbeddingResponseItem struct { + Object string `json:"object"` + Index int `json:"index"` + Embedding []float64 `json:"embedding"` +} + +type EmbeddingResponse struct { + Object string `json:"object"` + Data []EmbeddingResponseItem `json:"data"` + Model string `json:"model"` + Usage `json:"usage"` +} diff --git a/dto/error.go b/dto/error.go new file mode 100644 index 00000000..d7f6824d --- /dev/null +++ b/dto/error.go @@ -0,0 +1,57 @@ +package dto + +import "one-api/types" + +type OpenAIError struct { + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` + Code any `json:"code"` +} + +type OpenAIErrorWithStatusCode struct { + Error OpenAIError `json:"error"` + StatusCode int `json:"status_code"` + LocalError bool +} + +type GeneralErrorResponse struct { + Error types.OpenAIError `json:"error"` + Message string `json:"message"` + Msg string `json:"msg"` + Err string `json:"err"` + ErrorMsg string `json:"error_msg"` + Header struct { + Message string `json:"message"` + } `json:"header"` + Response struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } `json:"response"` +} + +func (e GeneralErrorResponse) ToMessage() string { + if e.Error.Message != "" { + return e.Error.Message + } + if e.Message != "" { + return e.Message + } + if e.Msg != "" { + return e.Msg + } + if e.Err != "" { + return e.Err + } + if e.ErrorMsg != "" { + return e.ErrorMsg + } + if e.Header.Message != "" { + return e.Header.Message + } + if e.Response.Error.Message != "" { + return e.Response.Error.Message + } + return "" +} diff --git a/dto/file_data.go b/dto/file_data.go new file mode 100644 index 00000000..d5cf0f68 --- /dev/null +++ b/dto/file_data.go @@ -0,0 +1,8 @@ +package dto + +type LocalFileData struct { + MimeType string + Base64Data string + Url string + Size int64 +} diff --git a/dto/midjourney.go b/dto/midjourney.go new file mode 100644 index 00000000..6fbcb357 --- /dev/null +++ b/dto/midjourney.go @@ -0,0 +1,107 @@ +package dto + +//type SimpleMjRequest struct { +// Prompt string `json:"prompt"` +// CustomId string `json:"customId"` +// Action string `json:"action"` +// Content string `json:"content"` +//} + +type SwapFaceRequest struct { + SourceBase64 string `json:"sourceBase64"` + TargetBase64 string `json:"targetBase64"` +} + +type MidjourneyRequest struct { + Prompt string `json:"prompt"` + CustomId string `json:"customId"` + BotType string `json:"botType"` + NotifyHook string `json:"notifyHook"` + Action string `json:"action"` + Index int `json:"index"` + State string `json:"state"` + TaskId string `json:"taskId"` + Base64Array []string `json:"base64Array"` + Content string `json:"content"` + MaskBase64 string `json:"maskBase64"` +} + +type MidjourneyResponse struct { + Code int `json:"code"` + Description string `json:"description"` + Properties interface{} `json:"properties"` + Result string `json:"result"` +} + +type MidjourneyUploadResponse struct { + Code int `json:"code"` + Description string `json:"description"` + Result []string `json:"result"` +} + +type MidjourneyResponseWithStatusCode struct { + StatusCode int `json:"statusCode"` + Response MidjourneyResponse +} + +type MidjourneyDto struct { + MjId string `json:"id"` + Action string `json:"action"` + CustomId string `json:"customId"` + BotType string `json:"botType"` + Prompt string `json:"prompt"` + PromptEn string `json:"promptEn"` + Description string `json:"description"` + State string `json:"state"` + SubmitTime int64 `json:"submitTime"` + StartTime int64 `json:"startTime"` + FinishTime int64 `json:"finishTime"` + ImageUrl string `json:"imageUrl"` + VideoUrl string `json:"videoUrl"` + VideoUrls []ImgUrls `json:"videoUrls"` + Status string `json:"status"` + Progress string `json:"progress"` + FailReason string `json:"failReason"` + Buttons any `json:"buttons"` + MaskBase64 string `json:"maskBase64"` + Properties *Properties `json:"properties"` +} + +type ImgUrls struct { + Url string `json:"url"` +} + +type MidjourneyStatus struct { + Status int `json:"status"` +} +type MidjourneyWithoutStatus struct { + Id int `json:"id"` + Code int `json:"code"` + UserId int `json:"user_id" gorm:"index"` + Action string `json:"action"` + MjId string `json:"mj_id" gorm:"index"` + Prompt string `json:"prompt"` + PromptEn string `json:"prompt_en"` + Description string `json:"description"` + State string `json:"state"` + SubmitTime int64 `json:"submit_time"` + StartTime int64 `json:"start_time"` + FinishTime int64 `json:"finish_time"` + ImageUrl string `json:"image_url"` + Progress string `json:"progress"` + FailReason string `json:"fail_reason"` + ChannelId int `json:"channel_id"` +} + +type ActionButton struct { + CustomId any `json:"customId"` + Emoji any `json:"emoji"` + Label any `json:"label"` + Type any `json:"type"` + Style any `json:"style"` +} + +type Properties struct { + FinalPrompt string `json:"finalPrompt"` + FinalZhPrompt string `json:"finalZhPrompt"` +} diff --git a/dto/notify.go b/dto/notify.go new file mode 100644 index 00000000..b75cec70 --- /dev/null +++ b/dto/notify.go @@ -0,0 +1,25 @@ +package dto + +type Notify struct { + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` + Values []interface{} `json:"values"` +} + +const ContentValueParam = "{{value}}" + +const ( + NotifyTypeQuotaExceed = "quota_exceed" + NotifyTypeChannelUpdate = "channel_update" + NotifyTypeChannelTest = "channel_test" +) + +func NewNotify(t string, title string, content string, values []interface{}) Notify { + return Notify{ + Type: t, + Title: title, + Content: content, + Values: values, + } +} diff --git a/dto/openai_request.go b/dto/openai_request.go new file mode 100644 index 00000000..88d3bd6c --- /dev/null +++ b/dto/openai_request.go @@ -0,0 +1,655 @@ +package dto + +import ( + "encoding/json" + "one-api/common" + "strings" +) + +type ResponseFormat struct { + Type string `json:"type,omitempty"` + JsonSchema *FormatJsonSchema `json:"json_schema,omitempty"` +} + +type FormatJsonSchema struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` + Schema any `json:"schema,omitempty"` + Strict any `json:"strict,omitempty"` +} + +type GeneralOpenAIRequest struct { + Model string `json:"model,omitempty"` + Messages []Message `json:"messages,omitempty"` + Prompt any `json:"prompt,omitempty"` + Prefix any `json:"prefix,omitempty"` + Suffix any `json:"suffix,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + MaxCompletionTokens uint `json:"max_completion_tokens,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Stop any `json:"stop,omitempty"` + N int `json:"n,omitempty"` + Input any `json:"input,omitempty"` + Instruction string `json:"instruction,omitempty"` + Size string `json:"size,omitempty"` + Functions json.RawMessage `json:"functions,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + EncodingFormat json.RawMessage `json:"encoding_format,omitempty"` + Seed float64 `json:"seed,omitempty"` + ParallelTooCalls *bool `json:"parallel_tool_calls,omitempty"` + Tools []ToolCallRequest `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + User string `json:"user,omitempty"` + LogProbs bool `json:"logprobs,omitempty"` + TopLogProbs int `json:"top_logprobs,omitempty"` + Dimensions int `json:"dimensions,omitempty"` + Modalities json.RawMessage `json:"modalities,omitempty"` + Audio json.RawMessage `json:"audio,omitempty"` + EnableThinking any `json:"enable_thinking,omitempty"` // ali + THINKING json.RawMessage `json:"thinking,omitempty"` // doubao + ExtraBody json.RawMessage `json:"extra_body,omitempty"` + SearchParameters any `json:"search_parameters,omitempty"` //xai + WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"` + // OpenRouter Params + Usage json.RawMessage `json:"usage,omitempty"` + Reasoning json.RawMessage `json:"reasoning,omitempty"` + // Ali Qwen Params + VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"` +} + +func (r *GeneralOpenAIRequest) ToMap() map[string]any { + result := make(map[string]any) + data, _ := common.Marshal(r) + _ = common.Unmarshal(data, &result) + return result +} + +type ToolCallRequest struct { + ID string `json:"id,omitempty"` + Type string `json:"type"` + Function FunctionRequest `json:"function"` +} + +type FunctionRequest struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` + Parameters any `json:"parameters,omitempty"` + Arguments string `json:"arguments,omitempty"` +} + +type StreamOptions struct { + IncludeUsage bool `json:"include_usage,omitempty"` +} + +func (r *GeneralOpenAIRequest) GetMaxTokens() int { + return int(r.MaxTokens) +} + +func (r *GeneralOpenAIRequest) ParseInput() []string { + if r.Input == nil { + return nil + } + var input []string + switch r.Input.(type) { + case string: + input = []string{r.Input.(string)} + case []any: + input = make([]string, 0, len(r.Input.([]any))) + for _, item := range r.Input.([]any) { + if str, ok := item.(string); ok { + input = append(input, str) + } + } + } + return input +} + +type Message struct { + Role string `json:"role"` + Content any `json:"content"` + Name *string `json:"name,omitempty"` + Prefix *bool `json:"prefix,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + ToolCalls json.RawMessage `json:"tool_calls,omitempty"` + ToolCallId string `json:"tool_call_id,omitempty"` + parsedContent []MediaContent + //parsedStringContent *string +} + +type MediaContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageUrl any `json:"image_url,omitempty"` + InputAudio any `json:"input_audio,omitempty"` + File any `json:"file,omitempty"` + VideoUrl any `json:"video_url,omitempty"` + // OpenRouter Params + CacheControl json.RawMessage `json:"cache_control,omitempty"` +} + +func (m *MediaContent) GetImageMedia() *MessageImageUrl { + if m.ImageUrl != nil { + if _, ok := m.ImageUrl.(*MessageImageUrl); ok { + return m.ImageUrl.(*MessageImageUrl) + } + if itemMap, ok := m.ImageUrl.(map[string]any); ok { + out := &MessageImageUrl{ + Url: common.Interface2String(itemMap["url"]), + Detail: common.Interface2String(itemMap["detail"]), + MimeType: common.Interface2String(itemMap["mime_type"]), + } + return out + } + } + return nil +} + +func (m *MediaContent) GetInputAudio() *MessageInputAudio { + if m.InputAudio != nil { + if _, ok := m.InputAudio.(*MessageInputAudio); ok { + return m.InputAudio.(*MessageInputAudio) + } + if itemMap, ok := m.InputAudio.(map[string]any); ok { + out := &MessageInputAudio{ + Data: common.Interface2String(itemMap["data"]), + Format: common.Interface2String(itemMap["format"]), + } + return out + } + } + return nil +} + +func (m *MediaContent) GetFile() *MessageFile { + if m.File != nil { + if _, ok := m.File.(*MessageFile); ok { + return m.File.(*MessageFile) + } + if itemMap, ok := m.File.(map[string]any); ok { + out := &MessageFile{ + FileName: common.Interface2String(itemMap["file_name"]), + FileData: common.Interface2String(itemMap["file_data"]), + FileId: common.Interface2String(itemMap["file_id"]), + } + return out + } + } + return nil +} + +type MessageImageUrl struct { + Url string `json:"url"` + Detail string `json:"detail"` + MimeType string +} + +func (m *MessageImageUrl) IsRemoteImage() bool { + return strings.HasPrefix(m.Url, "http") +} + +type MessageInputAudio struct { + Data string `json:"data"` //base64 + Format string `json:"format"` +} + +type MessageFile struct { + FileName string `json:"filename,omitempty"` + FileData string `json:"file_data,omitempty"` + FileId string `json:"file_id,omitempty"` +} + +type MessageVideoUrl struct { + Url string `json:"url"` +} + +const ( + ContentTypeText = "text" + ContentTypeImageURL = "image_url" + ContentTypeInputAudio = "input_audio" + ContentTypeFile = "file" + ContentTypeVideoUrl = "video_url" // 阿里百炼视频识别 +) + +func (m *Message) GetPrefix() bool { + if m.Prefix == nil { + return false + } + return *m.Prefix +} + +func (m *Message) SetPrefix(prefix bool) { + m.Prefix = &prefix +} + +func (m *Message) ParseToolCalls() []ToolCallRequest { + if m.ToolCalls == nil { + return nil + } + var toolCalls []ToolCallRequest + if err := json.Unmarshal(m.ToolCalls, &toolCalls); err == nil { + return toolCalls + } + return toolCalls +} + +func (m *Message) SetToolCalls(toolCalls any) { + toolCallsJson, _ := json.Marshal(toolCalls) + m.ToolCalls = toolCallsJson +} + +func (m *Message) StringContent() string { + switch m.Content.(type) { + case string: + return m.Content.(string) + case []any: + var contentStr string + for _, contentItem := range m.Content.([]any) { + contentMap, ok := contentItem.(map[string]any) + if !ok { + continue + } + if contentMap["type"] == ContentTypeText { + if subStr, ok := contentMap["text"].(string); ok { + contentStr += subStr + } + } + } + return contentStr + } + + return "" +} + +func (m *Message) SetNullContent() { + m.Content = nil + m.parsedContent = nil +} + +func (m *Message) SetStringContent(content string) { + m.Content = content + m.parsedContent = nil +} + +func (m *Message) SetMediaContent(content []MediaContent) { + m.Content = content + m.parsedContent = content +} + +func (m *Message) IsStringContent() bool { + _, ok := m.Content.(string) + if ok { + return true + } + return false +} + +func (m *Message) ParseContent() []MediaContent { + if m.Content == nil { + return nil + } + if len(m.parsedContent) > 0 { + return m.parsedContent + } + + var contentList []MediaContent + // 先尝试解析为字符串 + content, ok := m.Content.(string) + if ok { + contentList = []MediaContent{{ + Type: ContentTypeText, + Text: content, + }} + m.parsedContent = contentList + return contentList + } + + // 尝试解析为数组 + //var arrayContent []map[string]interface{} + + arrayContent, ok := m.Content.([]any) + if !ok { + return contentList + } + + for _, contentItemAny := range arrayContent { + mediaItem, ok := contentItemAny.(MediaContent) + if ok { + contentList = append(contentList, mediaItem) + continue + } + + contentItem, ok := contentItemAny.(map[string]any) + if !ok { + continue + } + contentType, ok := contentItem["type"].(string) + if !ok { + continue + } + + switch contentType { + case ContentTypeText: + if text, ok := contentItem["text"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeText, + Text: text, + }) + } + + case ContentTypeImageURL: + imageUrl := contentItem["image_url"] + temp := &MessageImageUrl{ + Detail: "high", + } + switch v := imageUrl.(type) { + case string: + temp.Url = v + case map[string]interface{}: + url, ok1 := v["url"].(string) + detail, ok2 := v["detail"].(string) + if ok2 { + temp.Detail = detail + } + if ok1 { + temp.Url = url + } + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeImageURL, + ImageUrl: temp, + }) + + case ContentTypeInputAudio: + if audioData, ok := contentItem["input_audio"].(map[string]interface{}); ok { + data, ok1 := audioData["data"].(string) + format, ok2 := audioData["format"].(string) + if ok1 && ok2 { + temp := &MessageInputAudio{ + Data: data, + Format: format, + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeInputAudio, + InputAudio: temp, + }) + } + } + case ContentTypeFile: + if fileData, ok := contentItem["file"].(map[string]interface{}); ok { + fileId, ok3 := fileData["file_id"].(string) + if ok3 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileId: fileId, + }, + }) + } else { + fileName, ok1 := fileData["filename"].(string) + fileDataStr, ok2 := fileData["file_data"].(string) + if ok1 && ok2 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileName: fileName, + FileData: fileDataStr, + }, + }) + } + } + } + case ContentTypeVideoUrl: + if videoUrl, ok := contentItem["video_url"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeVideoUrl, + VideoUrl: &MessageVideoUrl{ + Url: videoUrl, + }, + }) + } + } + } + + if len(contentList) > 0 { + m.parsedContent = contentList + } + return contentList +} + +// old code +/*func (m *Message) StringContent() string { + if m.parsedStringContent != nil { + return *m.parsedStringContent + } + + var stringContent string + if err := json.Unmarshal(m.Content, &stringContent); err == nil { + m.parsedStringContent = &stringContent + return stringContent + } + + contentStr := new(strings.Builder) + arrayContent := m.ParseContent() + for _, content := range arrayContent { + if content.Type == ContentTypeText { + contentStr.WriteString(content.Text) + } + } + stringContent = contentStr.String() + m.parsedStringContent = &stringContent + + return stringContent +} + +func (m *Message) SetNullContent() { + m.Content = nil + m.parsedStringContent = nil + m.parsedContent = nil +} + +func (m *Message) SetStringContent(content string) { + jsonContent, _ := json.Marshal(content) + m.Content = jsonContent + m.parsedStringContent = &content + m.parsedContent = nil +} + +func (m *Message) SetMediaContent(content []MediaContent) { + jsonContent, _ := json.Marshal(content) + m.Content = jsonContent + m.parsedContent = nil + m.parsedStringContent = nil +} + +func (m *Message) IsStringContent() bool { + if m.parsedStringContent != nil { + return true + } + var stringContent string + if err := json.Unmarshal(m.Content, &stringContent); err == nil { + m.parsedStringContent = &stringContent + return true + } + return false +} + +func (m *Message) ParseContent() []MediaContent { + if m.parsedContent != nil { + return m.parsedContent + } + + var contentList []MediaContent + + // 先尝试解析为字符串 + var stringContent string + if err := json.Unmarshal(m.Content, &stringContent); err == nil { + contentList = []MediaContent{{ + Type: ContentTypeText, + Text: stringContent, + }} + m.parsedContent = contentList + return contentList + } + + // 尝试解析为数组 + var arrayContent []map[string]interface{} + if err := json.Unmarshal(m.Content, &arrayContent); err == nil { + for _, contentItem := range arrayContent { + contentType, ok := contentItem["type"].(string) + if !ok { + continue + } + + switch contentType { + case ContentTypeText: + if text, ok := contentItem["text"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeText, + Text: text, + }) + } + + case ContentTypeImageURL: + imageUrl := contentItem["image_url"] + temp := &MessageImageUrl{ + Detail: "high", + } + switch v := imageUrl.(type) { + case string: + temp.Url = v + case map[string]interface{}: + url, ok1 := v["url"].(string) + detail, ok2 := v["detail"].(string) + if ok2 { + temp.Detail = detail + } + if ok1 { + temp.Url = url + } + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeImageURL, + ImageUrl: temp, + }) + + case ContentTypeInputAudio: + if audioData, ok := contentItem["input_audio"].(map[string]interface{}); ok { + data, ok1 := audioData["data"].(string) + format, ok2 := audioData["format"].(string) + if ok1 && ok2 { + temp := &MessageInputAudio{ + Data: data, + Format: format, + } + contentList = append(contentList, MediaContent{ + Type: ContentTypeInputAudio, + InputAudio: temp, + }) + } + } + case ContentTypeFile: + if fileData, ok := contentItem["file"].(map[string]interface{}); ok { + fileId, ok3 := fileData["file_id"].(string) + if ok3 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileId: fileId, + }, + }) + } else { + fileName, ok1 := fileData["filename"].(string) + fileDataStr, ok2 := fileData["file_data"].(string) + if ok1 && ok2 { + contentList = append(contentList, MediaContent{ + Type: ContentTypeFile, + File: &MessageFile{ + FileName: fileName, + FileData: fileDataStr, + }, + }) + } + } + } + case ContentTypeVideoUrl: + if videoUrl, ok := contentItem["video_url"].(string); ok { + contentList = append(contentList, MediaContent{ + Type: ContentTypeVideoUrl, + VideoUrl: &MessageVideoUrl{ + Url: videoUrl, + }, + }) + } + } + } + } + + if len(contentList) > 0 { + m.parsedContent = contentList + } + return contentList +}*/ + +type WebSearchOptions struct { + SearchContextSize string `json:"search_context_size,omitempty"` + UserLocation json.RawMessage `json:"user_location,omitempty"` +} + +// https://platform.openai.com/docs/api-reference/responses/create +type OpenAIResponsesRequest struct { + Model string `json:"model"` + Input json.RawMessage `json:"input,omitempty"` + Include json.RawMessage `json:"include,omitempty"` + Instructions json.RawMessage `json:"instructions,omitempty"` + MaxOutputTokens uint `json:"max_output_tokens,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + ParallelToolCalls bool `json:"parallel_tool_calls,omitempty"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + Store bool `json:"store,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Text json.RawMessage `json:"text,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Tools []map[string]any `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map + TopP float64 `json:"top_p,omitempty"` + Truncation string `json:"truncation,omitempty"` + User string `json:"user,omitempty"` + MaxToolCalls uint `json:"max_tool_calls,omitempty"` + Prompt json.RawMessage `json:"prompt,omitempty"` +} + +type Reasoning struct { + Effort string `json:"effort,omitempty"` + Summary string `json:"summary,omitempty"` +} + +//type ResponsesToolsCall struct { +// Type string `json:"type"` +// // Web Search +// UserLocation json.RawMessage `json:"user_location,omitempty"` +// SearchContextSize string `json:"search_context_size,omitempty"` +// // File Search +// VectorStoreIds []string `json:"vector_store_ids,omitempty"` +// MaxNumResults uint `json:"max_num_results,omitempty"` +// Filters json.RawMessage `json:"filters,omitempty"` +// // Computer Use +// DisplayWidth uint `json:"display_width,omitempty"` +// DisplayHeight uint `json:"display_height,omitempty"` +// Environment string `json:"environment,omitempty"` +// // Function +// Name string `json:"name,omitempty"` +// Description string `json:"description,omitempty"` +// Parameters json.RawMessage `json:"parameters,omitempty"` +// Function json.RawMessage `json:"function,omitempty"` +// Container json.RawMessage `json:"container,omitempty"` +//} diff --git a/dto/openai_response.go b/dto/openai_response.go new file mode 100644 index 00000000..4e534823 --- /dev/null +++ b/dto/openai_response.go @@ -0,0 +1,278 @@ +package dto + +import ( + "encoding/json" + "one-api/types" +) + +type SimpleResponse struct { + Usage `json:"usage"` + Error *OpenAIError `json:"error"` +} + +type TextResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAITextResponseChoice `json:"choices"` + Usage `json:"usage"` +} + +type OpenAITextResponseChoice struct { + Index int `json:"index"` + Message `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type OpenAITextResponse struct { + Id string `json:"id"` + Model string `json:"model"` + Object string `json:"object"` + Created any `json:"created"` + Choices []OpenAITextResponseChoice `json:"choices"` + Error *types.OpenAIError `json:"error,omitempty"` + Usage `json:"usage"` +} + +type OpenAIEmbeddingResponseItem struct { + Object string `json:"object"` + Index int `json:"index"` + Embedding []float64 `json:"embedding"` +} + +type OpenAIEmbeddingResponse struct { + Object string `json:"object"` + Data []OpenAIEmbeddingResponseItem `json:"data"` + Model string `json:"model"` + Usage `json:"usage"` +} + +type FlexibleEmbeddingResponseItem struct { + Object string `json:"object"` + Index int `json:"index"` + Embedding any `json:"embedding"` +} + +type FlexibleEmbeddingResponse struct { + Object string `json:"object"` + Data []FlexibleEmbeddingResponseItem `json:"data"` + Model string `json:"model"` + Usage `json:"usage"` +} + +type ChatCompletionsStreamResponseChoice struct { + Delta ChatCompletionsStreamResponseChoiceDelta `json:"delta,omitempty"` + Logprobs *any `json:"logprobs"` + FinishReason *string `json:"finish_reason"` + Index int `json:"index"` +} + +type ChatCompletionsStreamResponseChoiceDelta struct { + Content *string `json:"content,omitempty"` + ReasoningContent *string `json:"reasoning_content,omitempty"` + Reasoning *string `json:"reasoning,omitempty"` + Role string `json:"role,omitempty"` + ToolCalls []ToolCallResponse `json:"tool_calls,omitempty"` +} + +func (c *ChatCompletionsStreamResponseChoiceDelta) SetContentString(s string) { + c.Content = &s +} + +func (c *ChatCompletionsStreamResponseChoiceDelta) GetContentString() string { + if c.Content == nil { + return "" + } + return *c.Content +} + +func (c *ChatCompletionsStreamResponseChoiceDelta) GetReasoningContent() string { + if c.ReasoningContent == nil && c.Reasoning == nil { + return "" + } + if c.ReasoningContent != nil { + return *c.ReasoningContent + } + return *c.Reasoning +} + +func (c *ChatCompletionsStreamResponseChoiceDelta) SetReasoningContent(s string) { + c.ReasoningContent = &s + c.Reasoning = &s +} + +type ToolCallResponse struct { + // Index is not nil only in chat completion chunk object + Index *int `json:"index,omitempty"` + ID string `json:"id,omitempty"` + Type any `json:"type"` + Function FunctionResponse `json:"function"` +} + +func (c *ToolCallResponse) SetIndex(i int) { + c.Index = &i +} + +type FunctionResponse struct { + Description string `json:"description,omitempty"` + Name string `json:"name,omitempty"` + // call function with arguments in JSON format + Parameters any `json:"parameters,omitempty"` // request + Arguments string `json:"arguments"` // response +} + +type ChatCompletionsStreamResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + SystemFingerprint *string `json:"system_fingerprint"` + Choices []ChatCompletionsStreamResponseChoice `json:"choices"` + Usage *Usage `json:"usage"` +} + +func (c *ChatCompletionsStreamResponse) IsToolCall() bool { + if len(c.Choices) == 0 { + return false + } + return len(c.Choices[0].Delta.ToolCalls) > 0 +} + +func (c *ChatCompletionsStreamResponse) GetFirstToolCall() *ToolCallResponse { + if c.IsToolCall() { + return &c.Choices[0].Delta.ToolCalls[0] + } + return nil +} + +func (c *ChatCompletionsStreamResponse) Copy() *ChatCompletionsStreamResponse { + choices := make([]ChatCompletionsStreamResponseChoice, len(c.Choices)) + copy(choices, c.Choices) + return &ChatCompletionsStreamResponse{ + Id: c.Id, + Object: c.Object, + Created: c.Created, + Model: c.Model, + SystemFingerprint: c.SystemFingerprint, + Choices: choices, + Usage: c.Usage, + } +} + +func (c *ChatCompletionsStreamResponse) GetSystemFingerprint() string { + if c.SystemFingerprint == nil { + return "" + } + return *c.SystemFingerprint +} + +func (c *ChatCompletionsStreamResponse) SetSystemFingerprint(s string) { + c.SystemFingerprint = &s +} + +type ChatCompletionsStreamResponseSimple struct { + Choices []ChatCompletionsStreamResponseChoice `json:"choices"` + Usage *Usage `json:"usage"` +} + +type CompletionsStreamResponse struct { + Choices []struct { + Text string `json:"text"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` +} + +type Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"` + + PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"` + CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + InputTokensDetails *InputTokenDetails `json:"input_tokens_details"` + // OpenRouter Params + Cost any `json:"cost,omitempty"` +} + +type InputTokenDetails struct { + CachedTokens int `json:"cached_tokens"` + CachedCreationTokens int `json:"-"` + TextTokens int `json:"text_tokens"` + AudioTokens int `json:"audio_tokens"` + ImageTokens int `json:"image_tokens"` +} + +type OutputTokenDetails struct { + TextTokens int `json:"text_tokens"` + AudioTokens int `json:"audio_tokens"` + ReasoningTokens int `json:"reasoning_tokens"` +} + +type OpenAIResponsesResponse struct { + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int `json:"created_at"` + Status string `json:"status"` + Error *types.OpenAIError `json:"error,omitempty"` + IncompleteDetails *IncompleteDetails `json:"incomplete_details,omitempty"` + Instructions string `json:"instructions"` + MaxOutputTokens int `json:"max_output_tokens"` + Model string `json:"model"` + Output []ResponsesOutput `json:"output"` + ParallelToolCalls bool `json:"parallel_tool_calls"` + PreviousResponseID string `json:"previous_response_id"` + Reasoning *Reasoning `json:"reasoning"` + Store bool `json:"store"` + Temperature float64 `json:"temperature"` + ToolChoice string `json:"tool_choice"` + Tools []map[string]any `json:"tools"` + TopP float64 `json:"top_p"` + Truncation string `json:"truncation"` + Usage *Usage `json:"usage"` + User json.RawMessage `json:"user"` + Metadata json.RawMessage `json:"metadata"` +} + +type IncompleteDetails struct { + Reasoning string `json:"reasoning"` +} + +type ResponsesOutput struct { + Type string `json:"type"` + ID string `json:"id"` + Status string `json:"status"` + Role string `json:"role"` + Content []ResponsesOutputContent `json:"content"` +} + +type ResponsesOutputContent struct { + Type string `json:"type"` + Text string `json:"text"` + Annotations []interface{} `json:"annotations"` +} + +const ( + BuildInToolWebSearchPreview = "web_search_preview" + BuildInToolFileSearch = "file_search" +) + +const ( + BuildInCallWebSearchCall = "web_search_call" +) + +const ( + ResponsesOutputTypeItemAdded = "response.output_item.added" + ResponsesOutputTypeItemDone = "response.output_item.done" +) + +// ResponsesStreamResponse 用于处理 /v1/responses 流式响应 +type ResponsesStreamResponse struct { + Type string `json:"type"` + Response *OpenAIResponsesResponse `json:"response,omitempty"` + Delta string `json:"delta,omitempty"` + Item *ResponsesOutput `json:"item,omitempty"` +} diff --git a/dto/playground.go b/dto/playground.go new file mode 100644 index 00000000..47eddaec --- /dev/null +++ b/dto/playground.go @@ -0,0 +1,6 @@ +package dto + +type PlayGroundRequest struct { + Model string `json:"model,omitempty"` + Group string `json:"group,omitempty"` +} diff --git a/dto/pricing.go b/dto/pricing.go new file mode 100644 index 00000000..0f317d9d --- /dev/null +++ b/dto/pricing.go @@ -0,0 +1,11 @@ +package dto + +import "one-api/constant" + +type OpenAIModels struct { + Id string `json:"id"` + Object string `json:"object"` + Created int `json:"created"` + OwnedBy string `json:"owned_by"` + SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` +} diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go new file mode 100644 index 00000000..6315f31a --- /dev/null +++ b/dto/ratio_sync.go @@ -0,0 +1,38 @@ +package dto + +type UpstreamDTO struct { + ID int `json:"id,omitempty"` + Name string `json:"name" binding:"required"` + BaseURL string `json:"base_url" binding:"required"` + Endpoint string `json:"endpoint"` +} + +type UpstreamRequest struct { + ChannelIDs []int64 `json:"channel_ids"` + Upstreams []UpstreamDTO `json:"upstreams"` + Timeout int `json:"timeout"` +} + +// TestResult 上游测试连通性结果 +type TestResult struct { + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// DifferenceItem 差异项 +// Current 为本地值,可能为 nil +// Upstreams 为各渠道的上游值,具体数值 / "same" / nil + +type DifferenceItem struct { + Current interface{} `json:"current"` + Upstreams map[string]interface{} `json:"upstreams"` + Confidence map[string]bool `json:"confidence"` +} + +type SyncableChannel struct { + ID int `json:"id"` + Name string `json:"name"` + BaseURL string `json:"base_url"` + Status int `json:"status"` +} \ No newline at end of file diff --git a/dto/realtime.go b/dto/realtime.go new file mode 100644 index 00000000..32a69056 --- /dev/null +++ b/dto/realtime.go @@ -0,0 +1,88 @@ +package dto + +import "one-api/types" + +const ( + RealtimeEventTypeError = "error" + RealtimeEventTypeSessionUpdate = "session.update" + RealtimeEventTypeConversationCreate = "conversation.item.create" + RealtimeEventTypeResponseCreate = "response.create" + RealtimeEventInputAudioBufferAppend = "input_audio_buffer.append" +) + +const ( + RealtimeEventTypeResponseDone = "response.done" + RealtimeEventTypeSessionUpdated = "session.updated" + RealtimeEventTypeSessionCreated = "session.created" + RealtimeEventResponseAudioDelta = "response.audio.delta" + RealtimeEventResponseAudioTranscriptionDelta = "response.audio_transcript.delta" + RealtimeEventResponseFunctionCallArgumentsDelta = "response.function_call_arguments.delta" + RealtimeEventResponseFunctionCallArgumentsDone = "response.function_call_arguments.done" + RealtimeEventConversationItemCreated = "conversation.item.created" +) + +type RealtimeEvent struct { + EventId string `json:"event_id"` + Type string `json:"type"` + //PreviousItemId string `json:"previous_item_id"` + Session *RealtimeSession `json:"session,omitempty"` + Item *RealtimeItem `json:"item,omitempty"` + Error *types.OpenAIError `json:"error,omitempty"` + Response *RealtimeResponse `json:"response,omitempty"` + Delta string `json:"delta,omitempty"` + Audio string `json:"audio,omitempty"` +} + +type RealtimeResponse struct { + Usage *RealtimeUsage `json:"usage"` +} + +type RealtimeUsage struct { + TotalTokens int `json:"total_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + InputTokenDetails InputTokenDetails `json:"input_token_details"` + OutputTokenDetails OutputTokenDetails `json:"output_token_details"` +} + +type RealtimeSession struct { + Modalities []string `json:"modalities"` + Instructions string `json:"instructions"` + Voice string `json:"voice"` + InputAudioFormat string `json:"input_audio_format"` + OutputAudioFormat string `json:"output_audio_format"` + InputAudioTranscription InputAudioTranscription `json:"input_audio_transcription"` + TurnDetection interface{} `json:"turn_detection"` + Tools []RealTimeTool `json:"tools"` + ToolChoice string `json:"tool_choice"` + Temperature float64 `json:"temperature"` + //MaxResponseOutputTokens int `json:"max_response_output_tokens"` +} + +type InputAudioTranscription struct { + Model string `json:"model"` +} + +type RealTimeTool struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Parameters any `json:"parameters"` +} + +type RealtimeItem struct { + Id string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Role string `json:"role"` + Content []RealtimeContent `json:"content"` + Name *string `json:"name,omitempty"` + ToolCalls any `json:"tool_calls,omitempty"` + CallId string `json:"call_id,omitempty"` +} +type RealtimeContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Audio string `json:"audio,omitempty"` // Base64-encoded audio bytes. + Transcript string `json:"transcript,omitempty"` +} diff --git a/dto/rerank.go b/dto/rerank.go new file mode 100644 index 00000000..5ea68cba --- /dev/null +++ b/dto/rerank.go @@ -0,0 +1,33 @@ +package dto + +type RerankRequest struct { + Documents []any `json:"documents"` + Query string `json:"query"` + Model string `json:"model"` + TopN int `json:"top_n,omitempty"` + ReturnDocuments *bool `json:"return_documents,omitempty"` + MaxChunkPerDoc int `json:"max_chunk_per_doc,omitempty"` + OverLapTokens int `json:"overlap_tokens,omitempty"` +} + +func (r *RerankRequest) GetReturnDocuments() bool { + if r.ReturnDocuments == nil { + return false + } + return *r.ReturnDocuments +} + +type RerankResponseResult struct { + Document any `json:"document,omitempty"` + Index int `json:"index"` + RelevanceScore float64 `json:"relevance_score"` +} + +type RerankDocument struct { + Text any `json:"text"` +} + +type RerankResponse struct { + Results []RerankResponseResult `json:"results"` + Usage Usage `json:"usage"` +} diff --git a/dto/sensitive.go b/dto/sensitive.go new file mode 100644 index 00000000..0bfbc6fb --- /dev/null +++ b/dto/sensitive.go @@ -0,0 +1,6 @@ +package dto + +type SensitiveResponse struct { + SensitiveWords []string `json:"sensitive_words"` + Content string `json:"content"` +} diff --git a/dto/suno.go b/dto/suno.go new file mode 100644 index 00000000..a6bb3eba --- /dev/null +++ b/dto/suno.go @@ -0,0 +1,129 @@ +package dto + +import ( + "encoding/json" +) + +type TaskData interface { + SunoDataResponse | []SunoDataResponse | string | any +} + +type SunoSubmitReq struct { + GptDescriptionPrompt string `json:"gpt_description_prompt,omitempty"` + Prompt string `json:"prompt,omitempty"` + Mv string `json:"mv,omitempty"` + Title string `json:"title,omitempty"` + Tags string `json:"tags,omitempty"` + ContinueAt float64 `json:"continue_at,omitempty"` + TaskID string `json:"task_id,omitempty"` + ContinueClipId string `json:"continue_clip_id,omitempty"` + MakeInstrumental bool `json:"make_instrumental"` +} + +type FetchReq struct { + IDs []string `json:"ids"` +} + +type SunoDataResponse struct { + TaskID string `json:"task_id" gorm:"type:varchar(50);index"` + Action string `json:"action" gorm:"type:varchar(40);index"` // 任务类型, song, lyrics, description-mode + Status string `json:"status" gorm:"type:varchar(20);index"` // 任务状态, submitted, queueing, processing, success, failed + FailReason string `json:"fail_reason"` + SubmitTime int64 `json:"submit_time" gorm:"index"` + StartTime int64 `json:"start_time" gorm:"index"` + FinishTime int64 `json:"finish_time" gorm:"index"` + Data json.RawMessage `json:"data" gorm:"type:json"` +} + +type SunoSong struct { + ID string `json:"id"` + VideoURL string `json:"video_url"` + AudioURL string `json:"audio_url"` + ImageURL string `json:"image_url"` + ImageLargeURL string `json:"image_large_url"` + MajorModelVersion string `json:"major_model_version"` + ModelName string `json:"model_name"` + Status string `json:"status"` + Title string `json:"title"` + Text string `json:"text"` + Metadata SunoMetadata `json:"metadata"` +} + +type SunoMetadata struct { + Tags string `json:"tags"` + Prompt string `json:"prompt"` + GPTDescriptionPrompt interface{} `json:"gpt_description_prompt"` + AudioPromptID interface{} `json:"audio_prompt_id"` + Duration interface{} `json:"duration"` + ErrorType interface{} `json:"error_type"` + ErrorMessage interface{} `json:"error_message"` +} + +type SunoLyrics struct { + ID string `json:"id"` + Status string `json:"status"` + Title string `json:"title"` + Text string `json:"text"` +} + +const TaskSuccessCode = "success" + +type TaskResponse[T TaskData] struct { + Code string `json:"code"` + Message string `json:"message"` + Data T `json:"data"` +} + +func (t *TaskResponse[T]) IsSuccess() bool { + return t.Code == TaskSuccessCode +} + +type TaskDto struct { + TaskID string `json:"task_id"` // 第三方id,不一定有/ song id\ Task id + Action string `json:"action"` // 任务类型, song, lyrics, description-mode + Status string `json:"status"` // 任务状态, submitted, queueing, processing, success, failed + FailReason string `json:"fail_reason"` + SubmitTime int64 `json:"submit_time"` + StartTime int64 `json:"start_time"` + FinishTime int64 `json:"finish_time"` + Progress string `json:"progress"` + Data json.RawMessage `json:"data"` +} + +type SunoGoAPISubmitReq struct { + CustomMode bool `json:"custom_mode"` + + Input SunoGoAPISubmitReqInput `json:"input"` + + NotifyHook string `json:"notify_hook,omitempty"` +} + +type SunoGoAPISubmitReqInput struct { + GptDescriptionPrompt string `json:"gpt_description_prompt"` + Prompt string `json:"prompt"` + Mv string `json:"mv"` + Title string `json:"title"` + Tags string `json:"tags"` + ContinueAt float64 `json:"continue_at"` + TaskID string `json:"task_id"` + ContinueClipId string `json:"continue_clip_id"` + MakeInstrumental bool `json:"make_instrumental"` +} + +type GoAPITaskResponse[T any] struct { + Code int `json:"code"` + Message string `json:"message"` + Data T `json:"data"` + ErrorMessage string `json:"error_message,omitempty"` +} + +type GoAPITaskResponseData struct { + TaskID string `json:"task_id"` +} + +type GoAPIFetchResponseData struct { + TaskID string `json:"task_id"` + Status string `json:"status"` + Input string `json:"input"` + Clips map[string]SunoSong `json:"clips"` +} diff --git a/dto/task.go b/dto/task.go new file mode 100644 index 00000000..afc186b4 --- /dev/null +++ b/dto/task.go @@ -0,0 +1,10 @@ +package dto + +type TaskError struct { + Code string `json:"code"` + Message string `json:"message"` + Data any `json:"data"` + StatusCode int `json:"-"` + LocalError bool `json:"-"` + Error error `json:"-"` +} diff --git a/dto/user_settings.go b/dto/user_settings.go new file mode 100644 index 00000000..2e1a1541 --- /dev/null +++ b/dto/user_settings.go @@ -0,0 +1,16 @@ +package dto + +type UserSetting struct { + NotifyType string `json:"notify_type,omitempty"` // QuotaWarningType 额度预警类型 + QuotaWarningThreshold float64 `json:"quota_warning_threshold,omitempty"` // QuotaWarningThreshold 额度预警阈值 + WebhookUrl string `json:"webhook_url,omitempty"` // WebhookUrl webhook地址 + WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥 + NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址 + AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 + RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP +} + +var ( + NotifyTypeEmail = "email" // Email 邮件 + NotifyTypeWebhook = "webhook" // Webhook +) diff --git a/dto/video.go b/dto/video.go new file mode 100644 index 00000000..5b48146a --- /dev/null +++ b/dto/video.go @@ -0,0 +1,47 @@ +package dto + +type VideoRequest struct { + Model string `json:"model,omitempty" example:"kling-v1"` // Model/style ID + Prompt string `json:"prompt,omitempty" example:"宇航员站起身走了"` // Text prompt + Image string `json:"image,omitempty" example:"https://h2.inkwai.com/bs2/upload-ylab-stunt/se/ai_portal_queue_mmu_image_upscale_aiweb/3214b798-e1b4-4b00-b7af-72b5b0417420_raw_image_0.jpg"` // Image input (URL/Base64) + Duration float64 `json:"duration" example:"5.0"` // Video duration (seconds) + Width int `json:"width" example:"512"` // Video width + Height int `json:"height" example:"512"` // Video height + Fps int `json:"fps,omitempty" example:"30"` // Video frame rate + Seed int `json:"seed,omitempty" example:"20231234"` // Random seed + N int `json:"n,omitempty" example:"1"` // Number of videos to generate + ResponseFormat string `json:"response_format,omitempty" example:"url"` // Response format + User string `json:"user,omitempty" example:"user-1234"` // User identifier + Metadata map[string]any `json:"metadata,omitempty"` // Vendor-specific/custom params (e.g. negative_prompt, style, quality_level, etc.) +} + +// VideoResponse 视频生成提交任务后的响应 +type VideoResponse struct { + TaskId string `json:"task_id"` + Status string `json:"status"` +} + +// VideoTaskResponse 查询视频生成任务状态的响应 +type VideoTaskResponse struct { + TaskId string `json:"task_id" example:"abcd1234efgh"` // 任务ID + Status string `json:"status" example:"succeeded"` // 任务状态 + Url string `json:"url,omitempty"` // 视频资源URL(成功时) + Format string `json:"format,omitempty" example:"mp4"` // 视频格式 + Metadata *VideoTaskMetadata `json:"metadata,omitempty"` // 结果元数据 + Error *VideoTaskError `json:"error,omitempty"` // 错误信息(失败时) +} + +// VideoTaskMetadata 视频任务元数据 +type VideoTaskMetadata struct { + Duration float64 `json:"duration" example:"5.0"` // 实际生成的视频时长 + Fps int `json:"fps" example:"30"` // 实际帧率 + Width int `json:"width" example:"512"` // 实际宽度 + Height int `json:"height" example:"512"` // 实际高度 + Seed int `json:"seed" example:"20231234"` // 使用的随机种子 +} + +// VideoTaskError 视频任务错误信息 +type VideoTaskError struct { + Code int `json:"code"` + Message string `json:"message"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..94873c88 --- /dev/null +++ b/go.mod @@ -0,0 +1,98 @@ +module one-api + +// +heroku goVersion go1.18 +go 1.23.4 + +require ( + github.com/Calcium-Ion/go-epay v0.0.4 + github.com/andybalholm/brotli v1.1.1 + github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 + github.com/aws/aws-sdk-go-v2 v1.26.1 + github.com/aws/aws-sdk-go-v2/credentials v1.17.11 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4 + github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b + github.com/gin-contrib/cors v1.7.2 + github.com/gin-contrib/gzip v0.0.6 + github.com/gin-contrib/sessions v0.0.5 + github.com/gin-contrib/static v0.0.1 + github.com/gin-gonic/gin v1.9.1 + github.com/glebarez/sqlite v1.9.0 + github.com/go-playground/validator/v10 v10.20.0 + github.com/go-redis/redis/v8 v8.11.5 + github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.0 + github.com/joho/godotenv v1.5.1 + github.com/pkg/errors v0.9.1 + github.com/samber/lo v1.39.0 + github.com/shirou/gopsutil v3.21.11+incompatible + github.com/shopspring/decimal v1.4.0 + github.com/stripe/stripe-go/v81 v81.4.0 + github.com/thanhpk/randstr v1.0.6 + github.com/tiktoken-go/tokenizer v0.6.2 + golang.org/x/crypto v0.35.0 + golang.org/x/image v0.23.0 + golang.org/x/net v0.35.0 + golang.org/x/sync v0.11.0 + gorm.io/driver/mysql v1.4.3 + gorm.io/driver/postgres v1.5.2 + gorm.io/gorm v1.25.2 +) + +require ( + github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect + github.com/aws/smithy-go v1.20.2 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/gorilla/context v1.1.1 // indirect + github.com/gorilla/securecookie v1.1.1 // indirect + github.com/gorilla/sessions v1.2.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.1 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + golang.org/x/arch v0.12.0 // indirect + golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..74eecd4c --- /dev/null +++ b/go.sum @@ -0,0 +1,293 @@ +github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A= +github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+KcxaMk1lfrRnwCd1UUuOjJM/lri5eM1qMs= +github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI= +github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI= +github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8= +github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA= +github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 h1:x6xsQXGSmW6frevwDA+vi/wqhp1ct18mVXYN08/93to= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2/go.mod h1:lPprDr1e6cJdyYeGXnRaJoP4Md+cDBvi2eOj00BlGmg= +github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs= +github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4 h1:JgHnonzbnA3pbqj76wYsSZIZZQYBxkmMEjvL6GHy8XU= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.7.4/go.mod h1:nZspkhg+9p8iApLFoyAqfyuMP0F38acy2Hm3r5r95Cg= +github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= +github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= +github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b h1:LTGVFpNmNHhj0vhOlfgWueFJ32eK9blaIlHR2ciXOT0= +github.com/bytedance/gopkg v0.0.0-20220118071334-3db87571198b/go.mod h1:2ZlV9BaUH4+NXIBF0aMdKKAnHTzqH+iMU4KUjAbL23Q= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= +github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= +github.com/gin-contrib/sessions v0.0.5 h1:CATtfHmLMQrMNpJRgzjWXD7worTh7g7ritsQfmF+0jE= +github.com/gin-contrib/sessions v0.0.5/go.mod h1:vYAuaUPqie3WUSsft6HUlCjlwwoJQs97miaG2+7neKY= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/static v0.0.1 h1:JVxuvHPuUfkoul12N7dtQw7KRn/pSMq7Ue1Va9Swm1U= +github.com/gin-contrib/static v0.0.1/go.mod h1:CSxeF+wep05e0kCOsqWdAWbSszmc31zTIbD8TvWl7Hs= +github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.9.0 h1:Aj6bPA12ZEx5GbSF6XADmCkYXlljPNUY+Zf1EQxynXs= +github.com/glebarez/sqlite v1.9.0/go.mod h1:YBYCoyupOao60lzp1MVBLEjZfgkq0tdB1voAQ09K9zw= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= +github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= +github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= +github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= +github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= +github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= +github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= +github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= +github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw= +github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo= +github.com/thanhpk/randstr v1.0.6 h1:psAOktJFD4vV9NEVb3qkhRSMvYh4ORRaj1+w/hn4B+o= +github.com/thanhpk/randstr v1.0.6/go.mod h1:M/H2P1eNLZzlDwAzpkkkUvoyNNMbzRGhESZuEQk3r0U= +github.com/tiktoken-go/tokenizer v0.6.2 h1:t0GN2DvcUZSFWT/62YOgoqb10y7gSXBGs0A+4VCQK+g= +github.com/tiktoken-go/tokenizer v0.6.2/go.mod h1:6UCYI/DtOallbmL7sSy30p6YQv60qNyU/4aVigPOx6w= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= +golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= +golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= +golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220110181412-a018aaa089fe/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.4.3 h1:/JhWJhO2v17d8hjApTltKNADm7K7YI2ogkR7avJUL3k= +gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= +gorm.io/driver/postgres v1.5.2 h1:ytTDxxEv+MplXOfFe3Lzm7SjG09fcdb3Z/c056DTBx0= +gorm.io/driver/postgres v1.5.2/go.mod h1:fmpX0m2I1PKuR7mKZiEluwrP3hbs+ps7JIGMUBpCgl8= +gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= +gorm.io/gorm v1.25.2 h1:gs1o6Vsa+oVKG/a9ElL3XgyGfghFfkKA2SInQaCyMho= +gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/i18n/zh-cn.json b/i18n/zh-cn.json new file mode 100644 index 00000000..7b57b51a --- /dev/null +++ b/i18n/zh-cn.json @@ -0,0 +1,1041 @@ +{ + "未登录或登录已过期,请重新登录": "未登录或登录已过期,请重新登录", + "登 录": "登 录", + "使用 微信 继续": "使用 微信 继续", + "使用 GitHub 继续": "使用 GitHub 继续", + "使用 LinuxDO 继续": "使用 LinuxDO 继续", + "使用 邮箱或用户名 登录": "使用 邮箱或用户名 登录", + "没有账户?": "没有账户?", + "用户名或邮箱": "用户名或邮箱", + "请输入您的用户名或邮箱地址": "请输入您的用户名或邮箱地址", + "请输入您的密码": "请输入您的密码", + "继续": "继续", + "忘记密码?": "忘记密码?", + "其他登录选项": "其他登录选项", + "微信扫码登录": "微信扫码登录", + "登录": "登录", + "微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)": "微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)", + "验证码": "验证码", + "处理中...": "处理中...", + "绑定成功!": "绑定成功!", + "登录成功!": "登录成功!", + "操作失败,重定向至登录界面中...": "操作失败,重定向至登录界面中...", + "出现错误,第 ${count} 次重试中...": "出现错误,第 ${count} 次重试中...", + "无效的重置链接,请重新发起密码重置请求": "无效的重置链接,请重新发起密码重置请求", + "密码已重置并已复制到剪贴板:": "密码已重置并已复制到剪贴板:", + "密码重置确认": "密码重置确认", + "等待获取邮箱信息...": "等待获取邮箱信息...", + "新密码": "新密码", + "密码已复制到剪贴板:": "密码已复制到剪贴板:", + "密码重置完成": "密码重置完成", + "确认重置密码": "确认重置密码", + "返回登录": "返回登录", + "请输入邮箱地址": "请输入邮箱地址", + "请稍后几秒重试,Turnstile 正在检查用户环境!": "请稍后几秒重试,Turnstile 正在检查用户环境!", + "重置邮件发送成功,请检查邮箱!": "重置邮件发送成功,请检查邮箱!", + "密码重置": "密码重置", + "请输入您的邮箱地址": "请输入您的邮箱地址", + "重试": "重试", + "想起来了?": "想起来了?", + "注 册": "注 册", + "使用 用户名 注册": "使用 用户名 注册", + "已有账户?": "已有账户?", + "用户名": "用户名", + "请输入用户名": "请输入用户名", + "输入密码,最短 8 位,最长 20 位": "输入密码,最短 8 位,最长 20 位", + "确认密码": "确认密码", + "输入邮箱地址": "输入邮箱地址", + "获取验证码": "获取验证码", + "输入验证码": "输入验证码", + "或": "或", + "其他注册选项": "其他注册选项", + "加载中...": "加载中...", + "复制代码": "复制代码", + "代码已复制到剪贴板": "代码已复制到剪贴板", + "复制失败,请手动复制": "复制失败,请手动复制", + "显示更多": "显示更多", + "关于我们": "关于我们", + "关于项目": "关于项目", + "联系我们": "联系我们", + "功能特性": "功能特性", + "快速开始": "快速开始", + "安装指南": "安装指南", + "API 文档": "API 文档", + "基于New API的项目": "基于New API的项目", + "版权所有": "版权所有", + "设计与开发由": "设计与开发由", + "首页": "首页", + "控制台": "控制台", + "文档": "文档", + "关于": "关于", + "注销成功!": "注销成功!", + "个人设置": "个人设置", + "API令牌": "API令牌", + "退出": "退出", + "关闭侧边栏": "关闭侧边栏", + "打开侧边栏": "打开侧边栏", + "关闭菜单": "关闭菜单", + "打开菜单": "打开菜单", + "演示站点": "演示站点", + "自用模式": "自用模式", + "系统公告": "系统公告", + "切换主题": "切换主题", + "切换语言": "切换语言", + "暂无公告": "暂无公告", + "暂无系统公告": "暂无系统公告", + "今日关闭": "今日关闭", + "关闭公告": "关闭公告", + "数据看板": "数据看板", + "绘图日志": "绘图日志", + "任务日志": "任务日志", + "渠道": "渠道", + "兑换码": "兑换码", + "用户管理": "用户管理", + "操练场": "操练场", + "聊天": "聊天", + "管理员": "管理员", + "个人中心": "个人中心", + "展开侧边栏": "展开侧边栏", + "AI 对话": "AI 对话", + "选择模型开始对话": "选择模型开始对话", + "显示调试": "显示调试", + "请输入您的问题...": "请输入您的问题...", + "已复制到剪贴板": "已复制到剪贴板", + "复制失败": "复制失败", + "正在构造请求体预览...": "正在构造请求体预览...", + "暂无请求数据": "暂无请求数据", + "暂无响应数据": "暂无响应数据", + "内容较大,已启用性能优化模式": "内容较大,已启用性能优化模式", + "内容较大,部分功能可能受限": "内容较大,部分功能可能受限", + "已复制": "已复制", + "正在处理大内容...": "正在处理大内容...", + "显示完整内容": "显示完整内容", + "收起": "收起", + "配置已导出到下载文件夹": "配置已导出到下载文件夹", + "导出配置失败: ": "导出配置失败: ", + "确认导入配置": "确认导入配置", + "导入的配置将覆盖当前设置,是否继续?": "导入的配置将覆盖当前设置,是否继续?", + "取消": "取消", + "配置导入成功": "配置导入成功", + "导入配置失败: ": "导入配置失败: ", + "重置配置": "重置配置", + "将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?": "将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?", + "重置选项": "重置选项", + "是否同时重置对话消息?选择\"是\"将清空所有对话记录并恢复默认示例;选择\"否\"将保留当前对话记录。": "是否同时重置对话消息?选择\"是\"将清空所有对话记录并恢复默认示例;选择\"否\"将保留当前对话记录。", + "同时重置消息": "同时重置消息", + "仅重置配置": "仅重置配置", + "配置和消息已全部重置": "配置和消息已全部重置", + "配置已重置,对话消息已保留": "配置已重置,对话消息已保留", + "已有保存的配置": "已有保存的配置", + "暂无保存的配置": "暂无保存的配置", + "导出配置": "导出配置", + "导入配置": "导入配置", + "导出": "导出", + "导入": "导入", + "调试信息": "调试信息", + "预览请求体": "预览请求体", + "实际请求体": "实际请求体", + "预览更新": "预览更新", + "最后请求": "最后请求", + "操作暂时被禁用": "操作暂时被禁用", + "复制": "复制", + "编辑": "编辑", + "切换为System角色": "切换为System角色", + "切换为Assistant角色": "切换为Assistant角色", + "删除": "删除", + "请求发生错误": "请求发生错误", + "系统消息": "系统消息", + "请输入消息内容...": "请输入消息内容...", + "保存": "保存", + "模型配置": "模型配置", + "分组": "分组", + "请选择分组": "请选择分组", + "请选择模型": "请选择模型", + "思考中...": "思考中...", + "思考过程": "思考过程", + "选择同步渠道": "选择同步渠道", + "搜索渠道名称或地址": "搜索渠道名称或地址", + "暂无渠道": "暂无渠道", + "暂无选择": "暂无选择", + "无搜索结果": "无搜索结果", + "公告已更新": "公告已更新", + "公告更新失败": "公告更新失败", + "系统名称已更新": "系统名称已更新", + "系统名称更新失败": "系统名称更新失败", + "系统信息": "系统信息", + "当前版本": "当前版本", + "检查更新": "检查更新", + "启动时间": "启动时间", + "通用设置": "通用设置", + "设置公告": "设置公告", + "个性化设置": "个性化设置", + "系统名称": "系统名称", + "在此输入系统名称": "在此输入系统名称", + "设置系统名称": "设置系统名称", + "Logo 图片地址": "Logo 图片地址", + "在此输入 Logo 图片地址": "在此输入 Logo 图片地址", + "首页内容": "首页内容", + "设置首页内容": "设置首页内容", + "设置关于": "设置关于", + "页脚": "页脚", + "设置页脚": "设置页脚", + "详情": "详情", + "刷新失败": "刷新失败", + "令牌已重置并已复制到剪贴板": "令牌已重置并已复制到剪贴板", + "加载模型列表失败": "加载模型列表失败", + "系统令牌已复制到剪切板": "系统令牌已复制到剪切板", + "请输入你的账户名以确认删除!": "请输入你的账户名以确认删除!", + "账户已删除!": "账户已删除!", + "微信账户绑定成功!": "微信账户绑定成功!", + "请输入原密码!": "请输入原密码!", + "请输入新密码!": "请输入新密码!", + "新密码需要和原密码不一致!": "新密码需要和原密码不一致!", + "两次输入的密码不一致!": "两次输入的密码不一致!", + "密码修改成功!": "密码修改成功!", + "验证码发送成功,请检查邮箱!": "验证码发送成功,请检查邮箱!", + "请输入邮箱验证码!": "请输入邮箱验证码!", + "邮箱账户绑定成功!": "邮箱账户绑定成功!", + "无法复制到剪贴板,请手动复制": "无法复制到剪贴板,请手动复制", + "设置保存成功": "设置保存成功", + "设置保存失败": "设置保存失败", + "超级管理员": "超级管理员", + "普通用户": "普通用户", + "当前余额": "当前余额", + "历史消耗": "历史消耗", + "请求次数": "请求次数", + "默认": "默认", + "可用模型": "可用模型", + "模型列表": "模型列表", + "点击模型名称可复制": "点击模型名称可复制", + "没有可用模型": "没有可用模型", + "该分类下没有可用模型": "该分类下没有可用模型", + "更多": "更多", + "个模型": "个模型", + "账户绑定": "账户绑定", + "未绑定": "未绑定", + "修改绑定": "修改绑定", + "微信": "微信", + "已绑定": "已绑定", + "未启用": "未启用", + "绑定": "绑定", + "安全设置": "安全设置", + "系统访问令牌": "系统访问令牌", + "用于API调用的身份验证令牌,请妥善保管": "用于API调用的身份验证令牌,请妥善保管", + "生成令牌": "生成令牌", + "密码管理": "密码管理", + "定期更改密码可以提高账户安全性": "定期更改密码可以提高账户安全性", + "修改密码": "修改密码", + "此操作不可逆,所有数据将被永久删除": "此操作不可逆,所有数据将被永久删除", + "删除账户": "删除账户", + "其他设置": "其他设置", + "通知设置": "通知设置", + "邮件通知": "邮件通知", + "通过邮件接收通知": "通过邮件接收通知", + "Webhook通知": "Webhook通知", + "通过HTTP请求接收通知": "通过HTTP请求接收通知", + "请输入Webhook地址,例如: https://example.com/webhook": "请输入Webhook地址,例如: https://example.com/webhook", + "只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求": "只支持https,系统将以 POST 方式发送通知,请确保地址可以接收 POST 请求", + "接口凭证(可选)": "接口凭证(可选)", + "请输入密钥": "请输入密钥", + "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性": "密钥将以 Bearer 方式添加到请求头中,用于验证webhook请求的合法性", + "通知邮箱": "通知邮箱", + "留空则使用账号绑定的邮箱": "留空则使用账号绑定的邮箱", + "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱": "设置用于接收额度预警的邮箱地址,不填则使用账号绑定的邮箱", + "额度预警阈值": "额度预警阈值", + "请输入预警额度": "请输入预警额度", + "当剩余额度低于此数值时,系统将通过选择的方式发送通知": "当剩余额度低于此数值时,系统将通过选择的方式发送通知", + "接受未设置价格模型": "接受未设置价格模型", + "当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用": "当模型没有设置价格时仍接受调用,仅当您信任该网站时使用,可能会产生高额费用", + "IP记录": "IP记录", + "记录请求与错误日志 IP": "记录请求与错误日志 IP", + "开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址": "开启后,仅“消费”和“错误”日志将记录您的客户端 IP 地址", + "绑定邮箱地址": "绑定邮箱地址", + "重新发送": "重新发送", + "绑定微信账户": "绑定微信账户", + "删除账户确认": "删除账户确认", + "您正在删除自己的帐户,将清空所有数据且不可恢复": "您正在删除自己的帐户,将清空所有数据且不可恢复", + "请输入您的用户名以确认删除": "请输入您的用户名以确认删除", + "输入你的账户名{{username}}以确认删除": "输入你的账户名{{username}}以确认删除", + "原密码": "原密码", + "请输入原密码": "请输入原密码", + "请输入新密码": "请输入新密码", + "确认新密码": "确认新密码", + "请再次输入新密码": "请再次输入新密码", + "模型倍率设置": "模型倍率设置", + "可视化倍率设置": "可视化倍率设置", + "未设置倍率模型": "未设置倍率模型", + "上游倍率同步": "上游倍率同步", + "未知类型": "未知类型", + "标签聚合": "标签聚合", + "已启用": "已启用", + "自动禁用": "自动禁用", + "未知状态": "未知状态", + "未测试": "未测试", + "名称": "名称", + "类型": "类型", + "状态": "状态", + ",时间:": ",时间:", + "响应时间": "响应时间", + "已用/剩余": "已用/剩余", + "剩余额度$": "剩余额度$", + ",点击更新": ",点击更新", + "已用额度": "已用额度", + "修改子渠道优先级": "修改子渠道优先级", + "确定要修改所有子渠道优先级为 ": "确定要修改所有子渠道优先级为 ", + "权重": "权重", + "修改子渠道权重": "修改子渠道权重", + "确定要修改所有子渠道权重为 ": "确定要修改所有子渠道权重为 ", + "确定是否要删除此渠道?": "确定是否要删除此渠道?", + "此修改将不可逆": "此修改将不可逆", + "确定是否要复制此渠道?": "确定是否要复制此渠道?", + "复制渠道的所有信息": "复制渠道的所有信息", + "测试单个渠道操作项目组": "测试单个渠道操作项目组", + "禁用": "禁用", + "启用": "启用", + "启用全部": "启用全部", + "禁用全部": "禁用全部", + "重置": "重置", + "全选": "全选", + "_复制": "_复制", + "渠道未找到,请刷新页面后重试。": "渠道未找到,请刷新页面后重试。", + "渠道复制成功": "渠道复制成功", + "渠道复制失败: ": "渠道复制失败: ", + "操作成功完成!": "操作成功完成!", + "通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。": "通道 ${name} 测试成功,耗时 ${time.toFixed(2)} 秒。", + "已停止测试": "已停止测试", + "全部": "全部", + "请先选择要设置标签的渠道!": "请先选择要设置标签的渠道!", + "标签不能为空!": "标签不能为空!", + "已为 ${count} 个渠道设置标签!": "已为 ${count} 个渠道设置标签!", + "已成功开始测试所有已启用通道,请刷新页面查看结果。": "已成功开始测试所有已启用通道,请刷新页面查看结果。", + "已删除所有禁用渠道,共计 ${data} 个": "已删除所有禁用渠道,共计 ${data} 个", + "已更新完毕所有已启用通道余额!": "已更新完毕所有已启用通道余额!", + "通道 ${name} 余额更新成功!": "通道 ${name} 余额更新成功!", + "已删除 ${data} 个通道!": "已删除 ${data} 个通道!", + "已修复 ${data} 个通道!": "已修复 ${data} 个通道!", + "确定是否要删除所选通道?": "确定是否要删除所选通道?", + "删除所选通道": "删除所选通道", + "批量设置标签": "批量设置标签", + "确定要测试所有通道吗?": "确定要测试所有通道吗?", + "测试所有通道": "测试所有通道", + "确定要更新所有已启用通道余额吗?": "确定要更新所有已启用通道余额吗?", + "更新所有已启用通道余额": "更新所有已启用通道余额", + "确定是否要删除禁用通道?": "确定是否要删除禁用通道?", + "删除禁用通道": "删除禁用通道", + "确定是否要修复数据库一致性?": "确定是否要修复数据库一致性?", + "进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用": "进行该操作时,可能导致渠道访问错误,请仅在数据库出现问题时使用", + "批量操作": "批量操作", + "使用ID排序": "使用ID排序", + "开启批量操作": "开启批量操作", + "标签聚合模式": "标签聚合模式", + "刷新": "刷新", + "列设置": "列设置", + "搜索渠道的 ID,名称,密钥和API地址 ...": "搜索渠道的 ID,名称,密钥和API地址 ...", + "模型关键字": "模型关键字", + "选择分组": "选择分组", + "查询": "查询", + "第 {{start}} - {{end}} 条,共 {{total}} 条": "第 {{start}} - {{end}} 条,共 {{total}} 条", + "搜索无结果": "搜索无结果", + "请输入要设置的标签名称": "请输入要设置的标签名称", + "请输入标签名称": "请输入标签名称", + "已选择 ${count} 个渠道": "已选择 ${count} 个渠道", + "共": "共", + "停止测试": "停止测试", + "测试中...": "测试中...", + "批量测试${count}个模型": "批量测试${count}个模型", + "搜索模型...": "搜索模型...", + "模型名称": "模型名称", + "测试中": "测试中", + "未开始": "未开始", + "失败": "失败", + "请求时长: ${time}s": "请求时长: ${time}s", + "充值": "充值", + "消费": "消费", + "系统": "系统", + "错误": "错误", + "流": "流", + "非流": "非流", + "请求并计费模型": "请求并计费模型", + "实际模型": "实际模型", + "用户": "用户", + "用时/首字": "用时/首字", + "提示": "提示", + "花费": "花费", + "只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录": "只有当用户设置开启IP记录时,才会进行请求和错误类型日志的IP记录", + "确定": "确定", + "用户信息": "用户信息", + "渠道信息": "渠道信息", + "语音输入": "语音输入", + "文字输入": "文字输入", + "文字输出": "文字输出", + "缓存创建 Tokens": "缓存创建 Tokens", + "日志详情": "日志详情", + "消耗额度": "消耗额度", + "开始时间": "开始时间", + "结束时间": "结束时间", + "用户名称": "用户名称", + "日志类型": "日志类型", + "绘图": "绘图", + "放大": "放大", + "变换": "变换", + "强变换": "强变换", + "平移": "平移", + "图生文": "图生文", + "图混合": "图混合", + "重绘": "重绘", + "局部重绘-提交": "局部重绘-提交", + "自定义变焦-提交": "自定义变焦-提交", + "窗口处理": "窗口处理", + "未知": "未知", + "已提交": "已提交", + "等待中": "等待中", + "重复提交": "重复提交", + "成功": "成功", + "未启动": "未启动", + "执行中": "执行中", + "窗口等待": "窗口等待", + "秒": "秒", + "提交时间": "提交时间", + "花费时间": "花费时间", + "任务ID": "任务ID", + "提交结果": "提交结果", + "任务状态": "任务状态", + "结果图片": "结果图片", + "查看图片": "查看图片", + "无": "无", + "失败原因": "失败原因", + "已复制:": "已复制:", + "当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。": "当前未开启Midjourney回调,部分项目可能无法获得绘图结果,可在运营设置中开启。", + "Midjourney 任务记录": "Midjourney 任务记录", + "任务 ID": "任务 ID", + "按次计费": "按次计费", + "按量计费": "按量计费", + "您的分组可以使用该模型": "您的分组可以使用该模型", + "可用性": "可用性", + "计费类型": "计费类型", + "当前查看的分组为:{{group}},倍率为:{{ratio}}": "当前查看的分组为:{{group}},倍率为:{{ratio}}", + "倍率": "倍率", + "倍率是为了方便换算不同价格的模型": "倍率是为了方便换算不同价格的模型", + "模型倍率": "模型倍率", + "补全倍率": "补全倍率", + "分组倍率": "分组倍率", + "模型价格": "模型价格", + "补全": "补全", + "模糊搜索模型名称": "模糊搜索模型名称", + "复制选中模型": "复制选中模型", + "模型定价": "模型定价", + "当前分组": "当前分组", + "未登录,使用默认分组倍率": "未登录,使用默认分组倍率", + "按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)": "按量计费费用 = 分组倍率 × 模型倍率 × (提示token数 + 补全token数 × 补全倍率)/ 500000 (单位:美元)", + "已过期": "已过期", + "未使用": "未使用", + "已禁用": "已禁用", + "创建时间": "创建时间", + "过期时间": "过期时间", + "永不过期": "永不过期", + "确定是否要删除此兑换码?": "确定是否要删除此兑换码?", + "查看": "查看", + "已复制到剪贴板!": "已复制到剪贴板!", + "兑换码可以批量生成和分发,适合用于推广活动或批量充值。": "兑换码可以批量生成和分发,适合用于推广活动或批量充值。", + "添加兑换码": "添加兑换码", + "请至少选择一个兑换码!": "请至少选择一个兑换码!", + "复制所选兑换码到剪贴板": "复制所选兑换码到剪贴板", + "确定清除所有失效兑换码?": "确定清除所有失效兑换码?", + "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。": "将删除已使用、已禁用及过期的兑换码,此操作不可撤销。", + "已删除 {{count}} 条失效兑换码": "已删除 {{count}} 条失效兑换码", + "关键字(id或者名称)": "关键字(id或者名称)", + "生成音乐": "生成音乐", + "生成歌词": "生成歌词", + "生成视频": "生成视频", + "排队中": "排队中", + "正在提交": "正在提交", + "平台": "平台", + "点击预览视频": "点击预览视频", + "任务记录": "任务记录", + "渠道 ID": "渠道 ID", + "已启用:限制模型": "已启用:限制模型", + "已耗尽": "已耗尽", + "剩余额度": "剩余额度", + "聊天链接配置错误,请联系管理员": "聊天链接配置错误,请联系管理员", + "令牌详情": "令牌详情", + "确定是否要删除此令牌?": "确定是否要删除此令牌?", + "项目操作按钮组": "项目操作按钮组", + "请联系管理员配置聊天链接": "请联系管理员配置聊天链接", + "令牌用于API访问认证,可以设置额度限制和模型权限。": "令牌用于API访问认证,可以设置额度限制和模型权限。", + "添加令牌": "添加令牌", + "请至少选择一个令牌!": "请至少选择一个令牌!", + "复制所选令牌到剪贴板": "复制所选令牌到剪贴板", + "搜索关键字": "搜索关键字", + "未知身份": "未知身份", + "已封禁": "已封禁", + "统计信息": "统计信息", + "剩余": "剩余", + "调用": "调用", + "邀请信息": "邀请信息", + "收益": "收益", + "无邀请人": "无邀请人", + "已注销": "已注销", + "确定要提升此用户吗?": "确定要提升此用户吗?", + "此操作将提升用户的权限级别": "此操作将提升用户的权限级别", + "确定要降级此用户吗?": "确定要降级此用户吗?", + "此操作将降低用户的权限级别": "此操作将降低用户的权限级别", + "确定是否要注销此用户?": "确定是否要注销此用户?", + "相当于删除用户,此修改将不可逆": "相当于删除用户,此修改将不可逆", + "用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。": "用户管理页面,可以查看和管理所有注册用户的信息、权限和状态。", + "添加用户": "添加用户", + "支持搜索用户的 ID、用户名、显示名称和邮箱地址": "支持搜索用户的 ID、用户名、显示名称和邮箱地址", + "全部模型": "全部模型", + "智谱": "智谱", + "通义千问": "通义千问", + "文心一言": "文心一言", + "腾讯混元": "腾讯混元", + "360智脑": "360智脑", + "豆包": "豆包", + "用户分组": "用户分组", + "专属倍率": "专属倍率", + "输入价格:${{price}} / 1M tokens{{audioPrice}}": "输入价格:${{price}} / 1M tokens{{audioPrice}}", + "Web搜索价格:${{price}} / 1K 次": "Web搜索价格:${{price}} / 1K 次", + "文件搜索价格:${{price}} / 1K 次": "文件搜索价格:${{price}} / 1K 次", + "仅供参考,以实际扣费为准": "仅供参考,以实际扣费为准", + "价格:${{price}} * {{ratioType}}:{{ratio}}": "价格:${{price}} * {{ratioType}}:{{ratio}}", + "模型: {{ratio}} * {{ratioType}}:{{groupRatio}}": "模型: {{ratio}} * {{ratioType}}:{{groupRatio}}", + "提示价格:${{price}} / 1M tokens": "提示价格:${{price}} / 1M tokens", + "模型价格 ${{price}},{{ratioType}} {{ratio}}": "模型价格 ${{price}},{{ratioType}} {{ratio}}", + "模型: {{ratio}} * {{ratioType}}: {{groupRatio}}": "模型: {{ratio}} * {{ratioType}}: {{groupRatio}}", + "不是合法的 JSON 字符串": "不是合法的 JSON 字符串", + "请求发生错误: ": "请求发生错误: ", + "解析响应数据时发生错误": "解析响应数据时发生错误", + "连接已断开": "连接已断开", + "建立连接时发生错误": "建立连接时发生错误", + "加载模型失败": "加载模型失败", + "加载分组失败": "加载分组失败", + "消息已复制到剪贴板": "消息已复制到剪贴板", + "确认删除": "确认删除", + "确定要删除这条消息吗?": "确定要删除这条消息吗?", + "已删除消息及其回复": "已删除消息及其回复", + "消息已删除": "消息已删除", + "消息已编辑": "消息已编辑", + "检测到该消息后有AI回复,是否删除后续回复并重新生成?": "检测到该消息后有AI回复,是否删除后续回复并重新生成?", + "重新生成": "重新生成", + "消息已更新": "消息已更新", + "加载关于内容失败...": "加载关于内容失败...", + "可在设置页面设置关于内容,支持 HTML & Markdown": "可在设置页面设置关于内容,支持 HTML & Markdown", + "New API项目仓库地址:": "New API项目仓库地址:", + "| 基于": "| 基于", + "本项目根据": "本项目根据", + "MIT许可证": "MIT许可证", + "授权,需在遵守": "授权,需在遵守", + "Apache-2.0协议": "Apache-2.0协议", + "管理员暂时未设置任何关于内容": "管理员暂时未设置任何关于内容", + "仅支持 OpenAI 接口格式": "仅支持 OpenAI 接口格式", + "请填写密钥": "请填写密钥", + "获取模型列表成功": "获取模型列表成功", + "获取模型列表失败": "获取模型列表失败", + "请填写渠道名称和渠道密钥!": "请填写渠道名称和渠道密钥!", + "请至少选择一个模型!": "请至少选择一个模型!", + "模型映射必须是合法的 JSON 格式!": "模型映射必须是合法的 JSON 格式!", + "提交失败,请勿重复提交!": "提交失败,请勿重复提交!", + "渠道创建成功!": "渠道创建成功!", + "已新增 {{count}} 个模型:{{list}}": "已新增 {{count}} 个模型:{{list}}", + "未发现新增模型": "未发现新增模型", + "新建": "新建", + "更新渠道信息": "更新渠道信息", + "创建新的渠道": "创建新的渠道", + "基本信息": "基本信息", + "渠道的基本配置信息": "渠道的基本配置信息", + "请选择渠道类型": "请选择渠道类型", + "请为渠道命名": "请为渠道命名", + "请输入密钥,一行一个": "请输入密钥,一行一个", + "批量创建": "批量创建", + "API 配置": "API 配置", + "API 地址和相关配置": "API 地址和相关配置", + "2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的\".\"": "2025年5月10日后添加的渠道,不需要再在部署的时候移除模型名称中的\".\"", + "请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com": "请输入 AZURE_OPENAI_ENDPOINT,例如:https://docs-test-001.openai.azure.com", + "请输入默认 API 版本,例如:2025-04-01-preview": "请输入默认 API 版本,例如:2025-04-01-preview", + "如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。": "如果你对接的是上游One API或者New API等转发项目,请使用OpenAI类型,不要使用此类型,除非你知道你在做什么。", + "完整的 Base URL,支持变量{model}": "完整的 Base URL,支持变量{model}", + "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions": "请输入完整的URL,例如:https://api.openai.com/v1/chat/completions", + "Dify渠道只适配chatflow和agent,并且agent不支持图片!": "Dify渠道只适配chatflow和agent,并且agent不支持图片!", + "此项可选,用于通过自定义API地址来进行 API 调用,末尾不要带/v1和/": "此项可选,用于通过自定义API地址来进行 API 调用,末尾不要带/v1和/", + "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写": "对于官方渠道,new-api已经内置地址,除非是第三方代理站点或者Azure的特殊接入地址,否则不需要填写", + "私有部署地址": "私有部署地址", + "请输入私有部署地址,格式为:https://fastgpt.run/api/openapi": "请输入私有部署地址,格式为:https://fastgpt.run/api/openapi", + "注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用": "注意非Chat API,请务必填写正确的API地址,否则可能导致无法使用", + "请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com": "请输入到 /suno 前的路径,通常就是域名,例如:https://api.example.com", + "模型选择和映射设置": "模型选择和映射设置", + "模型": "模型", + "请选择该渠道所支持的模型": "请选择该渠道所支持的模型", + "填入相关模型": "填入相关模型", + "填入所有模型": "填入所有模型", + "获取模型列表": "获取模型列表", + "清除所有模型": "清除所有模型", + "输入自定义模型名称": "输入自定义模型名称", + "模型重定向": "模型重定向", + "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:": "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:", + "填入模板": "填入模板", + "默认测试模型": "默认测试模型", + "不填则为模型列表第一个": "不填则为模型列表第一个", + "渠道的高级配置选项": "渠道的高级配置选项", + "请选择可以使用该渠道的分组": "请选择可以使用该渠道的分组", + "请在系统设置页面编辑分组倍率以添加新的分组:": "请在系统设置页面编辑分组倍率以添加新的分组:", + "部署地区": "部署地区", + "知识库 ID": "知识库 ID", + "渠道标签": "渠道标签", + "渠道优先级": "渠道优先级", + "渠道权重": "渠道权重", + "渠道额外设置": "渠道额外设置", + "此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:": "此项可选,用于配置渠道特定设置,为一个 JSON 字符串,例如:", + "参数覆盖": "参数覆盖", + "此项可选,用于覆盖请求参数。不支持覆盖 stream 参数。为一个 JSON 字符串,例如:": "此项可选,用于覆盖请求参数。不支持覆盖 stream 参数。为一个 JSON 字符串,例如:", + "请输入组织org-xxx": "请输入组织org-xxx", + "组织,可选,不填则为默认组织": "组织,可选,不填则为默认组织", + "是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道": "是否自动禁用(仅当自动禁用开启时有效),关闭后不会自动禁用该渠道", + "状态码复写(仅影响本地判断,不修改返回到上游的状态码)": "状态码复写(仅影响本地判断,不修改返回到上游的状态码)", + "此项可选,用于复写返回的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:": "此项可选,用于复写返回的状态码,比如将claude渠道的400错误复写为500(用于重试),请勿滥用该功能,例如:", + "编辑标签": "编辑标签", + "标签信息": "标签信息", + "标签的基本配置": "标签的基本配置", + "所有编辑均为覆盖操作,留空则不更改": "所有编辑均为覆盖操作,留空则不更改", + "标签名称": "标签名称", + "请输入新标签,留空则解散标签": "请输入新标签,留空则解散标签", + "当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。": "当前模型列表为该标签下所有渠道模型列表最长的一个,并非所有渠道的并集,请注意可能导致某些渠道模型丢失。", + "请选择该渠道所支持的模型,留空则不更改": "请选择该渠道所支持的模型,留空则不更改", + "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改": "此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,留空则不更改", + "清空重定向": "清空重定向", + "分组设置": "分组设置", + "用户分组配置": "用户分组配置", + "请选择可以使用该渠道的分组,留空则不更改": "请选择可以使用该渠道的分组,留空则不更改", + "正在跳转...": "正在跳转...", + "小时": "小时", + "周": "周", + "模型调用次数占比": "模型调用次数占比", + "模型消耗分布": "模型消耗分布", + "总计": "总计", + "早上好": "早上好", + "中午好": "中午好", + "下午好": "下午好", + "账户数据": "账户数据", + "使用统计": "使用统计", + "统计次数": "统计次数", + "资源消耗": "资源消耗", + "统计额度": "统计额度", + "性能指标": "性能指标", + "平均RPM": "平均RPM", + "复制成功": "复制成功", + "进行中": "进行中", + "异常": "异常", + "正常": "正常", + "可用率": "可用率", + "有异常": "有异常", + "高延迟": "高延迟", + "维护中": "维护中", + "暂无监控数据": "暂无监控数据", + "搜索条件": "搜索条件", + "时间粒度": "时间粒度", + "模型数据分析": "模型数据分析", + "消耗分布": "消耗分布", + "调用次数分布": "调用次数分布", + "API信息": "API信息", + "暂无API信息": "暂无API信息", + "请联系管理员在系统设置中配置API信息": "请联系管理员在系统设置中配置API信息", + "显示最新20条": "显示最新20条", + "请联系管理员在系统设置中配置公告信息": "请联系管理员在系统设置中配置公告信息", + "暂无常见问答": "暂无常见问答", + "请联系管理员在系统设置中配置常见问答": "请联系管理员在系统设置中配置常见问答", + "服务可用性": "服务可用性", + "请联系管理员在系统设置中配置Uptime": "请联系管理员在系统设置中配置Uptime", + "加载首页内容失败...": "加载首页内容失败...", + "统一的大模型接口网关": "统一的大模型接口网关", + "更好的价格,更好的稳定性,无需订阅": "更好的价格,更好的稳定性,无需订阅", + "开始使用": "开始使用", + "支持众多的大模型供应商": "支持众多的大模型供应商", + "页面未找到,请检查您的浏览器地址是否正确": "页面未找到,请检查您的浏览器地址是否正确", + "登录过期,请重新登录!": "登录过期,请重新登录!", + "兑换码更新成功!": "兑换码更新成功!", + "兑换码创建成功!": "兑换码创建成功!", + "兑换码创建成功": "兑换码创建成功", + "兑换码创建成功,是否下载兑换码?": "兑换码创建成功,是否下载兑换码?", + "兑换码将以文本文件的形式下载,文件名为兑换码的名称。": "兑换码将以文本文件的形式下载,文件名为兑换码的名称。", + "更新兑换码信息": "更新兑换码信息", + "创建新的兑换码": "创建新的兑换码", + "设置兑换码的基本信息": "设置兑换码的基本信息", + "请输入名称": "请输入名称", + "选择过期时间(可选,留空为永久)": "选择过期时间(可选,留空为永久)", + "额度设置": "额度设置", + "设置兑换码的额度和数量": "设置兑换码的额度和数量", + "请输入额度": "请输入额度", + "生成数量": "生成数量", + "请输入生成数量": "请输入生成数量", + "你似乎并没有修改什么": "你似乎并没有修改什么", + "部分保存失败,请重试": "部分保存失败,请重试", + "保存成功": "保存成功", + "保存失败,请重试": "保存失败,请重试", + "请检查输入": "请检查输入", + "聊天配置": "聊天配置", + "为一个 JSON 文本": "为一个 JSON 文本", + "保存聊天设置": "保存聊天设置", + "设置已保存": "设置已保存", + "API地址": "API地址", + "说明": "说明", + "颜色": "颜色", + "API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)": "API信息管理,可以配置多个API地址用于状态展示和负载均衡(最多50个)", + "批量删除": "批量删除", + "保存设置": "保存设置", + "添加API": "添加API", + "请输入API地址": "请输入API地址", + "如:香港线路": "如:香港线路", + "请输入线路描述": "请输入线路描述", + "如:大带宽批量分析图片推荐": "如:大带宽批量分析图片推荐", + "请输入说明": "请输入说明", + "标识颜色": "标识颜色", + "确定要删除此API信息吗?": "确定要删除此API信息吗?", + "警告": "警告", + "发布时间": "发布时间", + "操作": "操作", + "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)", + "添加公告": "添加公告", + "编辑公告": "编辑公告", + "公告内容": "公告内容", + "请输入公告内容": "请输入公告内容", + "请选择发布日期": "请选择发布日期", + "公告类型": "公告类型", + "说明信息": "说明信息", + "可选,公告的补充说明": "可选,公告的补充说明", + "确定要删除此公告吗?": "确定要删除此公告吗?", + "数据看板设置": "数据看板设置", + "启用数据看板(实验性)": "启用数据看板(实验性)", + "数据看板更新间隔": "数据看板更新间隔", + "设置过短会影响数据库性能": "设置过短会影响数据库性能", + "数据看板默认时间粒度": "数据看板默认时间粒度", + "仅修改展示粒度,统计精确到小时": "仅修改展示粒度,统计精确到小时", + "保存数据看板设置": "保存数据看板设置", + "问题标题": "问题标题", + "回答内容": "回答内容", + "常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)", + "添加问答": "添加问答", + "编辑问答": "编辑问答", + "请输入问题标题": "请输入问题标题", + "请输入回答内容": "请输入回答内容", + "确定要删除此问答吗?": "确定要删除此问答吗?", + "分类名称": "分类名称", + "Uptime Kuma地址": "Uptime Kuma地址", + "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)": "Uptime Kuma监控分类管理,可以配置多个监控分类用于服务状态展示(最多20个)", + "编辑分类": "编辑分类", + "添加分类": "添加分类", + "请输入分类名称,如:OpenAI、Claude等": "请输入分类名称,如:OpenAI、Claude等", + "请输入分类名称": "请输入分类名称", + "请输入Uptime Kuma服务地址,如:https://status.example.com": "请输入Uptime Kuma服务地址,如:https://status.example.com", + "请输入Uptime Kuma地址": "请输入Uptime Kuma地址", + "请输入状态页面的Slug,如:my-status": "请输入状态页面的Slug,如:my-status", + "请输入状态页面Slug": "请输入状态页面Slug", + "确定要删除此分类吗?": "确定要删除此分类吗?", + "绘图设置": "绘图设置", + "启用绘图功能": "启用绘图功能", + "允许回调(会泄露服务器 IP 地址)": "允许回调(会泄露服务器 IP 地址)", + "允许 AccountFilter 参数": "允许 AccountFilter 参数", + "开启之后会清除用户提示词中的": "开启之后会清除用户提示词中的", + "以及": "以及", + "检测必须等待绘图成功才能进行放大等操作": "检测必须等待绘图成功才能进行放大等操作", + "保存绘图设置": "保存绘图设置", + "Claude设置": "Claude设置", + "Claude请求头覆盖": "Claude请求头覆盖", + "为一个 JSON 文本,例如:": "为一个 JSON 文本,例如:", + "缺省 MaxTokens": "缺省 MaxTokens", + "启用Claude思考适配(-thinking后缀)": "启用Claude思考适配(-thinking后缀)", + "思考适配 BudgetTokens 百分比": "思考适配 BudgetTokens 百分比", + "0.1-1之间的小数": "0.1-1之间的小数", + "Gemini设置": "Gemini设置", + "Gemini安全设置": "Gemini安全设置", + "default为默认设置,可单独设置每个模型的版本": "default为默认设置,可单独设置每个模型的版本", + "例如:": "例如:", + "Gemini思考适配设置": "Gemini思考适配设置", + "启用Gemini思考后缀适配": "启用Gemini思考后缀适配", + "适配 -thinking、-thinking-预算数字 和 -nothinking 后缀": "适配 -thinking、-thinking-预算数字 和 -nothinking 后缀", + "0.002-1之间的小数": "0.002-1之间的小数", + "全局设置": "全局设置", + "启用请求透传": "启用请求透传", + "连接保活设置": "连接保活设置", + "启用Ping间隔": "启用Ping间隔", + "Ping间隔(秒)": "Ping间隔(秒)", + "新用户初始额度": "新用户初始额度", + "请求预扣费额度": "请求预扣费额度", + "请求结束后多退少补": "请求结束后多退少补", + "邀请新用户奖励额度": "邀请新用户奖励额度", + "新用户使用邀请码奖励额度": "新用户使用邀请码奖励额度", + "例如:1000": "例如:1000", + "保存额度设置": "保存额度设置", + "例如发卡网站的购买链接": "例如发卡网站的购买链接", + "文档地址": "文档地址", + "单位美元额度": "单位美元额度", + "一单位货币能兑换的额度": "一单位货币能兑换的额度", + "失败重试次数": "失败重试次数", + "以货币形式显示额度": "以货币形式显示额度", + "额度查询接口返回令牌额度而非用户额度": "额度查询接口返回令牌额度而非用户额度", + "默认折叠侧边栏": "默认折叠侧边栏", + "开启后不限制:必须设置模型倍率": "开启后不限制:必须设置模型倍率", + "保存通用设置": "保存通用设置", + "请选择日志记录时间": "请选择日志记录时间", + "条日志已清理!": "条日志已清理!", + "日志清理失败:": "日志清理失败:", + "启用额度消费日志记录": "启用额度消费日志记录", + "日志记录时间": "日志记录时间", + "清除历史日志": "清除历史日志", + "保存日志设置": "保存日志设置", + "监控设置": "监控设置", + "测试所有渠道的最长响应时间": "测试所有渠道的最长响应时间", + "额度提醒阈值": "额度提醒阈值", + "低于此额度时将发送邮件提醒用户": "低于此额度时将发送邮件提醒用户", + "失败时自动禁用通道": "失败时自动禁用通道", + "成功时自动启用通道": "成功时自动启用通道", + "自动禁用关键词": "自动禁用关键词", + "一行一个,不区分大小写": "一行一个,不区分大小写", + "屏蔽词过滤设置": "屏蔽词过滤设置", + "启用屏蔽词过滤功能": "启用屏蔽词过滤功能", + "启用 Prompt 检查": "启用 Prompt 检查", + "一行一个屏蔽词,不需要符号分割": "一行一个屏蔽词,不需要符号分割", + "保存屏蔽词过滤设置": "保存屏蔽词过滤设置", + "更新成功": "更新成功", + "更新失败": "更新失败", + "服务器地址": "服务器地址", + "更新服务器地址": "更新服务器地址", + "请先填写服务器地址": "请先填写服务器地址", + "充值分组倍率不是合法的 JSON 字符串": "充值分组倍率不是合法的 JSON 字符串", + "充值方式设置不是合法的 JSON 字符串": "充值方式设置不是合法的 JSON 字符串", + "支付设置": "支付设置", + "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)": "(当前仅支持易支付接口,默认使用上方服务器地址作为回调地址!)", + "例如:https://yourdomain.com": "例如:https://yourdomain.com", + "易支付商户ID": "易支付商户ID", + "易支付商户密钥": "易支付商户密钥", + "敏感信息不会发送到前端显示": "敏感信息不会发送到前端显示", + "回调地址": "回调地址", + "充值价格(x元/美金)": "充值价格(x元/美金)", + "例如:7,就是7元/美金": "例如:7,就是7元/美金", + "最低充值美元数量": "最低充值美元数量", + "例如:2,就是最低充值2$": "例如:2,就是最低充值2$", + "为一个 JSON 文本,键为组名称,值为倍率": "为一个 JSON 文本,键为组名称,值为倍率", + "充值方式设置": "充值方式设置", + "更新支付设置": "更新支付设置", + "模型请求速率限制": "模型请求速率限制", + "启用用户模型请求速率限制(可能会影响高并发性能)": "启用用户模型请求速率限制(可能会影响高并发性能)", + "分钟": "分钟", + "频率限制的周期(分钟)": "频率限制的周期(分钟)", + "用户每周期最多请求次数": "用户每周期最多请求次数", + "包括失败请求的次数,0代表不限制": "包括失败请求的次数,0代表不限制", + "用户每周期最多请求完成次数": "用户每周期最多请求完成次数", + "只包括请求成功的次数": "只包括请求成功的次数", + "分组速率限制": "分组速率限制", + "使用 JSON 对象格式,格式为:{\"组名\": [最多请求次数, 最多请求完成次数]}": "使用 JSON 对象格式,格式为:{\"组名\": [最多请求次数, 最多请求完成次数]}", + "示例:{\"default\": [200, 100], \"vip\": [0, 1000]}。": "示例:{\"default\": [200, 100], \"vip\": [0, 1000]}。", + "[最多请求次数]必须大于等于0,[最多请求完成次数]必须大于等于1。": "[最多请求次数]必须大于等于0,[最多请求完成次数]必须大于等于1。", + "分组速率配置优先级高于全局速率限制。": "分组速率配置优先级高于全局速率限制。", + "限制周期统一使用上方配置的“限制周期”值。": "限制周期统一使用上方配置的“限制周期”值。", + "保存模型速率限制": "保存模型速率限制", + "保存失败": "保存失败", + "为一个 JSON 文本,键为分组名称,值为倍率": "为一个 JSON 文本,键为分组名称,值为倍率", + "用户可选分组": "用户可选分组", + "为一个 JSON 文本,键为分组名称,值为分组描述": "为一个 JSON 文本,键为分组名称,值为分组描述", + "自动分组auto,从第一个开始选择": "自动分组auto,从第一个开始选择", + "必须是有效的 JSON 字符串数组,例如:[\"g1\",\"g2\"]": "必须是有效的 JSON 字符串数组,例如:[\"g1\",\"g2\"]", + "模型固定价格": "模型固定价格", + "一次调用消耗多少刀,优先级大于模型倍率": "一次调用消耗多少刀,优先级大于模型倍率", + "为一个 JSON 文本,键为模型名称,值为倍率": "为一个 JSON 文本,键为模型名称,值为倍率", + "模型补全倍率(仅对自定义模型有效)": "模型补全倍率(仅对自定义模型有效)", + "仅对自定义模型有效": "仅对自定义模型有效", + "保存模型倍率设置": "保存模型倍率设置", + "确定重置模型倍率吗?": "确定重置模型倍率吗?", + "重置模型倍率": "重置模型倍率", + "获取启用模型失败:": "获取启用模型失败:", + "获取启用模型失败": "获取启用模型失败", + "JSON解析错误:": "JSON解析错误:", + "保存失败:": "保存失败:", + "输入模型倍率": "输入模型倍率", + "输入补全倍率": "输入补全倍率", + "请输入数字": "请输入数字", + "模型名称已存在": "模型名称已存在", + "请先选择需要批量设置的模型": "请先选择需要批量设置的模型", + "请输入模型倍率和补全倍率": "请输入模型倍率和补全倍率", + "请输入有效的数字": "请输入有效的数字", + "请输入填充值": "请输入填充值", + "批量设置成功": "批量设置成功", + "已为 {{count}} 个模型设置{{type}}": "已为 {{count}} 个模型设置{{type}}", + "模型倍率和补全倍率": "模型倍率和补全倍率", + "添加模型": "添加模型", + "批量设置": "批量设置", + "应用更改": "应用更改", + "搜索模型名称": "搜索模型名称", + "此页面仅显示未设置价格或倍率的模型,设置后将自动从列表中移除": "此页面仅显示未设置价格或倍率的模型,设置后将自动从列表中移除", + "定价模式": "定价模式", + "固定价格": "固定价格", + "固定价格(每次)": "固定价格(每次)", + "输入每次价格": "输入每次价格", + "输入补全价格": "输入补全价格", + "批量设置模型参数": "批量设置模型参数", + "设置类型": "设置类型", + "模型倍率和补全倍率同时设置": "模型倍率和补全倍率同时设置", + "模型倍率值": "模型倍率值", + "请输入模型倍率": "请输入模型倍率", + "补全倍率值": "补全倍率值", + "请输入补全倍率": "请输入补全倍率", + "请输入数值": "请输入数值", + "将为选中的 ": "将为选中的 ", + " 个模型设置相同的值": " 个模型设置相同的值", + "当前设置类型: ": "当前设置类型: ", + "默认补全倍率": "默认补全倍率", + "添加成功": "添加成功", + "价格设置方式": "价格设置方式", + "按倍率设置": "按倍率设置", + "按价格设置": "按价格设置", + "输入价格": "输入价格", + "输出价格": "输出价格", + "获取渠道失败:": "获取渠道失败:", + "请至少选择一个渠道": "请至少选择一个渠道", + "后端请求失败": "后端请求失败", + "部分渠道测试失败:": "部分渠道测试失败:", + "未找到差异化倍率,无需同步": "未找到差异化倍率,无需同步", + "请求后端接口失败:": "请求后端接口失败:", + "同步成功": "同步成功", + "部分保存失败": "部分保存失败", + "未找到匹配的模型": "未找到匹配的模型", + "暂无差异化倍率显示": "暂无差异化倍率显示", + "请先选择同步渠道": "请先选择同步渠道", + "倍率类型": "倍率类型", + "缓存倍率": "缓存倍率", + "当前值": "当前值", + "未设置": "未设置", + "与本地相同": "与本地相同", + "运营设置": "运营设置", + "聊天设置": "聊天设置", + "速率限制设置": "速率限制设置", + "模型相关设置": "模型相关设置", + "系统设置": "系统设置", + "仪表盘设置": "仪表盘设置", + "获取初始化状态失败": "获取初始化状态失败", + "表单引用错误,请刷新页面重试": "表单引用错误,请刷新页面重试", + "请输入管理员用户名": "请输入管理员用户名", + "密码长度至少为8个字符": "密码长度至少为8个字符", + "两次输入的密码不一致": "两次输入的密码不一致", + "系统初始化成功,正在跳转...": "系统初始化成功,正在跳转...", + "初始化失败,请重试": "初始化失败,请重试", + "系统初始化失败,请重试": "系统初始化失败,请重试", + "系统初始化": "系统初始化", + "欢迎使用,请完成以下设置以开始使用系统": "欢迎使用,请完成以下设置以开始使用系统", + "数据库信息": "数据库信息", + "管理员账号": "管理员账号", + "设置系统管理员的登录信息": "设置系统管理员的登录信息", + "管理员账号已经初始化过,请继续设置其他参数": "管理员账号已经初始化过,请继续设置其他参数", + "密码": "密码", + "请输入管理员密码": "请输入管理员密码", + "请确认管理员密码": "请确认管理员密码", + "选择适合您使用场景的模式": "选择适合您使用场景的模式", + "对外运营模式": "对外运营模式", + "适用于为多个用户提供服务的场景": "适用于为多个用户提供服务的场景", + "默认模式": "默认模式", + "适用于个人使用的场景,不需要设置模型价格": "适用于个人使用的场景,不需要设置模型价格", + "无需计费": "无需计费", + "演示站点模式": "演示站点模式", + "适用于展示系统功能的场景,提供基础功能演示": "适用于展示系统功能的场景,提供基础功能演示", + "初始化系统": "初始化系统", + "使用模式说明": "使用模式说明", + "我已了解": "我已了解", + "默认模式,适用于为多个用户提供服务的场景。": "默认模式,适用于为多个用户提供服务的场景。", + "此模式下,系统将计算每次调用的用量,您需要对每个模型都设置价格,如果没有设置价格,用户将无法使用该模型。": "此模式下,系统将计算每次调用的用量,您需要对每个模型都设置价格,如果没有设置价格,用户将无法使用该模型。", + "多用户支持": "多用户支持", + "适用于个人使用的场景。": "适用于个人使用的场景。", + "不需要设置模型价格,系统将弱化用量计算,您可专注于使用模型。": "不需要设置模型价格,系统将弱化用量计算,您可专注于使用模型。", + "个人使用": "个人使用", + "适用于展示系统功能的场景。": "适用于展示系统功能的场景。", + "提供基础功能演示,方便用户了解系统特性。": "提供基础功能演示,方便用户了解系统特性。", + "体验试用": "体验试用", + "自动选择": "自动选择", + "过期时间格式错误!": "过期时间格式错误!", + "令牌更新成功!": "令牌更新成功!", + "令牌创建成功,请在列表页面点击复制获取令牌!": "令牌创建成功,请在列表页面点击复制获取令牌!", + "更新令牌信息": "更新令牌信息", + "创建新的令牌": "创建新的令牌", + "设置令牌的基本信息": "设置令牌的基本信息", + "请选择过期时间": "请选择过期时间", + "一天": "一天", + "一个月": "一个月", + "设置令牌可用额度和数量": "设置令牌可用额度和数量", + "新建数量": "新建数量", + "请选择或输入创建令牌的数量": "请选择或输入创建令牌的数量", + "20个": "20个", + "100个": "100个", + "取消无限额度": "取消无限额度", + "设为无限额度": "设为无限额度", + "设置令牌的访问限制": "设置令牌的访问限制", + "IP白名单": "IP白名单", + "允许的IP,一行一个,不填写则不限制": "允许的IP,一行一个,不填写则不限制", + "请勿过度信任此功能,IP可能被伪造": "请勿过度信任此功能,IP可能被伪造", + "勾选启用模型限制后可选择": "勾选启用模型限制后可选择", + "非必要,不建议启用模型限制": "非必要,不建议启用模型限制", + "分组信息": "分组信息", + "设置令牌的分组": "设置令牌的分组", + "令牌分组,默认为用户的分组": "令牌分组,默认为用户的分组", + "管理员未设置用户可选分组": "管理员未设置用户可选分组", + "请输入兑换码!": "请输入兑换码!", + "兑换成功!": "兑换成功!", + "成功兑换额度:": "成功兑换额度:", + "请求失败": "请求失败", + "超级管理员未设置充值链接!": "超级管理员未设置充值链接!", + "管理员未开启在线充值!": "管理员未开启在线充值!", + "充值数量不能小于": "充值数量不能小于", + "支付请求失败": "支付请求失败", + "划转金额最低为": "划转金额最低为", + "邀请链接已复制到剪切板": "邀请链接已复制到剪切板", + "支付方式配置错误, 请联系管理员": "支付方式配置错误, 请联系管理员", + "划转邀请额度": "划转邀请额度", + "可用邀请额度": "可用邀请额度", + "划转额度": "划转额度", + "充值确认": "充值确认", + "充值数量": "充值数量", + "实付金额": "实付金额", + "支付方式": "支付方式", + "在线充值": "在线充值", + "快速方便的充值方式": "快速方便的充值方式", + "选择充值额度": "选择充值额度", + "实付": "实付", + "或输入自定义金额": "或输入自定义金额", + "充值数量,最低 ": "充值数量,最低 ", + "选择支付方式": "选择支付方式", + "处理中": "处理中", + "兑换码充值": "兑换码充值", + "使用兑换码快速充值": "使用兑换码快速充值", + "请输入兑换码": "请输入兑换码", + "兑换中...": "兑换中...", + "兑换": "兑换", + "邀请奖励": "邀请奖励", + "邀请好友获得额外奖励": "邀请好友获得额外奖励", + "待使用收益": "待使用收益", + "总收益": "总收益", + "邀请人数": "邀请人数", + "邀请链接": "邀请链接", + "邀请好友注册,好友充值后您可获得相应奖励": "邀请好友注册,好友充值后您可获得相应奖励", + "通过划转功能将奖励额度转入到您的账户余额中": "通过划转功能将奖励额度转入到您的账户余额中", + "邀请的好友越多,获得的奖励越多": "邀请的好友越多,获得的奖励越多", + "用户名和密码不能为空!": "用户名和密码不能为空!", + "用户账户创建成功!": "用户账户创建成功!", + "提交": "提交", + "创建新用户账户": "创建新用户账户", + "请输入显示名称": "请输入显示名称", + "请输入密码": "请输入密码", + "请输入备注(仅管理员可见)": "请输入备注(仅管理员可见)", + "编辑用户": "编辑用户", + "用户的基本账户信息": "用户的基本账户信息", + "请输入新的用户名": "请输入新的用户名", + "请输入新的密码,最短 8 位": "请输入新的密码,最短 8 位", + "显示名称": "显示名称", + "请输入新的显示名称": "请输入新的显示名称", + "权限设置": "权限设置", + "用户分组和额度管理": "用户分组和额度管理", + "请输入新的剩余额度": "请输入新的剩余额度", + "添加额度": "添加额度", + "第三方账户绑定状态(只读)": "第三方账户绑定状态(只读)", + "已绑定的 GitHub 账户": "已绑定的 GitHub 账户", + "已绑定的 OIDC 账户": "已绑定的 OIDC 账户", + "已绑定的微信账户": "已绑定的微信账户", + "已绑定的邮箱账户": "已绑定的邮箱账户", + "已绑定的 Telegram 账户": "已绑定的 Telegram 账户", + "新额度": "新额度", + "需要添加的额度(支持负数)": "需要添加的额度(支持负数)" +} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 00000000..ca3da601 --- /dev/null +++ b/main.go @@ -0,0 +1,210 @@ +package main + +import ( + "embed" + "fmt" + "log" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/controller" + "one-api/middleware" + "one-api/model" + "one-api/router" + "one-api/service" + "one-api/setting/ratio_setting" + "os" + "strconv" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + + _ "net/http/pprof" +) + +//go:embed web/dist +var buildFS embed.FS + +//go:embed web/dist/index.html +var indexPage []byte + +func main() { + + err := InitResources() + if err != nil { + common.FatalLog("failed to initialize resources: " + err.Error()) + return + } + + common.SysLog("New API " + common.Version + " started") + if os.Getenv("GIN_MODE") != "debug" { + gin.SetMode(gin.ReleaseMode) + } + if common.DebugEnabled { + common.SysLog("running in debug mode") + } + + defer func() { + err := model.CloseDB() + if err != nil { + common.FatalLog("failed to close database: " + err.Error()) + } + }() + + if common.RedisEnabled { + // for compatibility with old versions + common.MemoryCacheEnabled = true + } + if common.MemoryCacheEnabled { + common.SysLog("memory cache enabled") + common.SysError(fmt.Sprintf("sync frequency: %d seconds", common.SyncFrequency)) + + // Add panic recovery and retry for InitChannelCache + func() { + defer func() { + if r := recover(); r != nil { + common.SysError(fmt.Sprintf("InitChannelCache panic: %v, retrying once", r)) + // Retry once + _, _, fixErr := model.FixAbility() + if fixErr != nil { + common.FatalLog(fmt.Sprintf("InitChannelCache failed: %s", fixErr.Error())) + } + } + }() + model.InitChannelCache() + }() + + go model.SyncChannelCache(common.SyncFrequency) + } + + // 热更新配置 + go model.SyncOptions(common.SyncFrequency) + + // 数据看板 + go model.UpdateQuotaData() + + if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" { + frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY")) + if err != nil { + common.FatalLog("failed to parse CHANNEL_UPDATE_FREQUENCY: " + err.Error()) + } + go controller.AutomaticallyUpdateChannels(frequency) + } + if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" { + frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY")) + if err != nil { + common.FatalLog("failed to parse CHANNEL_TEST_FREQUENCY: " + err.Error()) + } + go controller.AutomaticallyTestChannels(frequency) + } + if common.IsMasterNode && constant.UpdateTask { + gopool.Go(func() { + controller.UpdateMidjourneyTaskBulk() + }) + gopool.Go(func() { + controller.UpdateTaskBulk() + }) + } + if os.Getenv("BATCH_UPDATE_ENABLED") == "true" { + common.BatchUpdateEnabled = true + common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s") + model.InitBatchUpdater() + } + + if os.Getenv("ENABLE_PPROF") == "true" { + gopool.Go(func() { + log.Println(http.ListenAndServe("0.0.0.0:8005", nil)) + }) + go common.Monitor() + common.SysLog("pprof enabled") + } + + // Initialize HTTP server + server := gin.New() + server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { + common.SysError(fmt.Sprintf("panic detected: %v", err)) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err), + "type": "new_api_panic", + }, + }) + })) + // This will cause SSE not to work!!! + //server.Use(gzip.Gzip(gzip.DefaultCompression)) + server.Use(middleware.RequestId()) + middleware.SetUpLogger(server) + // Initialize session store + store := cookie.NewStore([]byte(common.SessionSecret)) + store.Options(sessions.Options{ + Path: "/", + MaxAge: 2592000, // 30 days + HttpOnly: true, + Secure: false, + SameSite: http.SameSiteStrictMode, + }) + server.Use(sessions.Sessions("session", store)) + + router.SetRouter(server, buildFS, indexPage) + var port = os.Getenv("PORT") + if port == "" { + port = strconv.Itoa(*common.Port) + } + err = server.Run(":" + port) + if err != nil { + common.FatalLog("failed to start HTTP server: " + err.Error()) + } +} + +func InitResources() error { + // Initialize resources here if needed + // This is a placeholder function for future resource initialization + err := godotenv.Load(".env") + if err != nil { + common.SysLog("未找到 .env 文件,使用默认环境变量,如果需要,请创建 .env 文件并设置相关变量") + common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.") + } + + // 加载环境变量 + common.InitEnv() + + common.SetupLogger() + + // Initialize model settings + ratio_setting.InitRatioSettings() + + service.InitHttpClient() + + service.InitTokenEncoders() + + // Initialize SQL Database + err = model.InitDB() + if err != nil { + common.FatalLog("failed to initialize database: " + err.Error()) + return err + } + + model.CheckSetup() + + // Initialize options, should after model.InitDB() + model.InitOptionMap() + + // 初始化模型 + model.GetPricing() + + // Initialize SQL Database + err = model.InitLogDB() + if err != nil { + return err + } + + // Initialize Redis + err = common.InitRedisClient() + if err != nil { + return err + } + return nil +} diff --git a/makefile b/makefile new file mode 100644 index 00000000..cbc4ea6a --- /dev/null +++ b/makefile @@ -0,0 +1,14 @@ +FRONTEND_DIR = ./web +BACKEND_DIR = . + +.PHONY: all build-frontend start-backend + +all: build-frontend start-backend + +build-frontend: + @echo "Building frontend..." + @cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build + +start-backend: + @echo "Starting backend dev server..." + @cd $(BACKEND_DIR) && go run main.go & diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 00000000..a158318c --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,286 @@ +package middleware + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/model" + "strconv" + "strings" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +func validUserInfo(username string, role int) bool { + // check username is empty + if strings.TrimSpace(username) == "" { + return false + } + if !common.IsValidateRole(role) { + return false + } + return true +} + +func authHelper(c *gin.Context, minRole int) { + session := sessions.Default(c) + username := session.Get("username") + role := session.Get("role") + id := session.Get("id") + status := session.Get("status") + useAccessToken := false + if username == nil { + // Check access token + accessToken := c.Request.Header.Get("Authorization") + if accessToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,未登录且未提供 access token", + }) + c.Abort() + return + } + user := model.ValidateAccessToken(accessToken) + if user != nil && user.Username != "" { + if !validUserInfo(user.Username, user.Role) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权进行此操作,用户信息无效", + }) + c.Abort() + return + } + // Token is valid + username = user.Username + role = user.Role + id = user.Id + status = user.Status + useAccessToken = true + } else { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权进行此操作,access token 无效", + }) + c.Abort() + return + } + } + // get header New-Api-User + apiUserIdStr := c.Request.Header.Get("New-Api-User") + if apiUserIdStr == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,未提供 New-Api-User", + }) + c.Abort() + return + } + apiUserId, err := strconv.Atoi(apiUserIdStr) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,New-Api-User 格式错误", + }) + c.Abort() + return + + } + if id != apiUserId { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "无权进行此操作,New-Api-User 与登录用户不匹配", + }) + c.Abort() + return + } + if status.(int) == common.UserStatusDisabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已被封禁", + }) + c.Abort() + return + } + if role.(int) < minRole { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权进行此操作,权限不足", + }) + c.Abort() + return + } + if !validUserInfo(username.(string), role.(int)) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权进行此操作,用户信息无效", + }) + c.Abort() + return + } + c.Set("username", username) + c.Set("role", role) + c.Set("id", id) + c.Set("group", session.Get("group")) + c.Set("use_access_token", useAccessToken) + + //userCache, err := model.GetUserCache(id.(int)) + //if err != nil { + // c.JSON(http.StatusOK, gin.H{ + // "success": false, + // "message": err.Error(), + // }) + // c.Abort() + // return + //} + //userCache.WriteContext(c) + + c.Next() +} + +func TryUserAuth() func(c *gin.Context) { + return func(c *gin.Context) { + session := sessions.Default(c) + id := session.Get("id") + if id != nil { + c.Set("id", id) + } + c.Next() + } +} + +func UserAuth() func(c *gin.Context) { + return func(c *gin.Context) { + authHelper(c, common.RoleCommonUser) + } +} + +func AdminAuth() func(c *gin.Context) { + return func(c *gin.Context) { + authHelper(c, common.RoleAdminUser) + } +} + +func RootAuth() func(c *gin.Context) { + return func(c *gin.Context) { + authHelper(c, common.RoleRootUser) + } +} + +func WssAuth(c *gin.Context) { + +} + +func TokenAuth() func(c *gin.Context) { + return func(c *gin.Context) { + // 先检测是否为ws + if c.Request.Header.Get("Sec-WebSocket-Protocol") != "" { + // Sec-WebSocket-Protocol: realtime, openai-insecure-api-key.sk-xxx, openai-beta.realtime-v1 + // read sk from Sec-WebSocket-Protocol + key := c.Request.Header.Get("Sec-WebSocket-Protocol") + parts := strings.Split(key, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(part, "openai-insecure-api-key") { + key = strings.TrimPrefix(part, "openai-insecure-api-key.") + break + } + } + c.Request.Header.Set("Authorization", "Bearer "+key) + } + // 检查path包含/v1/messages + if strings.Contains(c.Request.URL.Path, "/v1/messages") { + // 从x-api-key中获取key + key := c.Request.Header.Get("x-api-key") + if key != "" { + c.Request.Header.Set("Authorization", "Bearer "+key) + } + } + // gemini api 从query中获取key + if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") { + skKey := c.Query("key") + if skKey != "" { + c.Request.Header.Set("Authorization", "Bearer "+skKey) + } + // 从x-goog-api-key header中获取key + xGoogKey := c.Request.Header.Get("x-goog-api-key") + if xGoogKey != "" { + c.Request.Header.Set("Authorization", "Bearer "+xGoogKey) + } + } + key := c.Request.Header.Get("Authorization") + parts := make([]string, 0) + key = strings.TrimPrefix(key, "Bearer ") + if key == "" || key == "midjourney-proxy" { + key = c.Request.Header.Get("mj-api-secret") + key = strings.TrimPrefix(key, "Bearer ") + key = strings.TrimPrefix(key, "sk-") + parts = strings.Split(key, "-") + key = parts[0] + } else { + key = strings.TrimPrefix(key, "sk-") + parts = strings.Split(key, "-") + key = parts[0] + } + token, err := model.ValidateUserToken(key) + if token != nil { + id := c.GetInt("id") + if id == 0 { + c.Set("id", token.UserId) + } + } + if err != nil { + abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error()) + return + } + userCache, err := model.GetUserCache(token.UserId) + if err != nil { + abortWithOpenAiMessage(c, http.StatusInternalServerError, err.Error()) + return + } + userEnabled := userCache.Status == common.UserStatusEnabled + if !userEnabled { + abortWithOpenAiMessage(c, http.StatusForbidden, "用户已被封禁") + return + } + + userCache.WriteContext(c) + + err = SetupContextForToken(c, token, parts...) + if err != nil { + return + } + c.Next() + } +} + +func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) error { + if token == nil { + return fmt.Errorf("token is nil") + } + c.Set("id", token.UserId) + c.Set("token_id", token.Id) + c.Set("token_key", token.Key) + c.Set("token_name", token.Name) + c.Set("token_unlimited_quota", token.UnlimitedQuota) + if !token.UnlimitedQuota { + c.Set("token_quota", token.RemainQuota) + } + if token.ModelLimitsEnabled { + c.Set("token_model_limit_enabled", true) + c.Set("token_model_limit", token.GetModelLimitsMap()) + } else { + c.Set("token_model_limit_enabled", false) + } + c.Set("allow_ips", token.GetIpLimitsMap()) + c.Set("token_group", token.Group) + if len(parts) > 1 { + if model.IsAdmin(token.UserId) { + c.Set("specific_channel_id", parts[1]) + } else { + abortWithOpenAiMessage(c, http.StatusForbidden, "普通用户不支持指定渠道") + return fmt.Errorf("普通用户不支持指定渠道") + } + } + return nil +} diff --git a/middleware/cache.go b/middleware/cache.go new file mode 100644 index 00000000..979734ab --- /dev/null +++ b/middleware/cache.go @@ -0,0 +1,16 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +func Cache() func(c *gin.Context) { + return func(c *gin.Context) { + if c.Request.RequestURI == "/" { + c.Header("Cache-Control", "no-cache") + } else { + c.Header("Cache-Control", "max-age=604800") // one week + } + c.Next() + } +} diff --git a/middleware/cors.go b/middleware/cors.go new file mode 100644 index 00000000..d2a109ab --- /dev/null +++ b/middleware/cors.go @@ -0,0 +1,15 @@ +package middleware + +import ( + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +func CORS() gin.HandlerFunc { + config := cors.DefaultConfig() + config.AllowAllOrigins = true + config.AllowCredentials = true + config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} + config.AllowHeaders = []string{"*"} + return cors.New(config) +} diff --git a/middleware/distributor.go b/middleware/distributor.go new file mode 100644 index 00000000..a6889e39 --- /dev/null +++ b/middleware/distributor.go @@ -0,0 +1,331 @@ +package middleware + +import ( + "errors" + "fmt" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/model" + relayconstant "one-api/relay/constant" + "one-api/service" + "one-api/setting" + "one-api/setting/ratio_setting" + "one-api/types" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +type ModelRequest struct { + Model string `json:"model"` + Group string `json:"group,omitempty"` +} + +func Distribute() func(c *gin.Context) { + return func(c *gin.Context) { + allowIpsMap := common.GetContextKeyStringMap(c, constant.ContextKeyTokenAllowIps) + if len(allowIpsMap) != 0 { + clientIp := c.ClientIP() + if _, ok := allowIpsMap[clientIp]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中") + return + } + } + var channel *model.Channel + channelId, ok := common.GetContextKey(c, constant.ContextKeyTokenSpecificChannelId) + modelRequest, shouldSelectChannel, err := getModelRequest(c) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request, "+err.Error()) + return + } + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) + if tokenGroup != "" { + // check common.UserUsableGroups[userGroup] + if _, ok := setting.GetUserUsableGroups(userGroup)[tokenGroup]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("令牌分组 %s 已被禁用", tokenGroup)) + return + } + // check group in common.GroupRatio + if !ratio_setting.ContainsGroupRatio(tokenGroup) { + if tokenGroup != "auto" { + abortWithOpenAiMessage(c, http.StatusForbidden, fmt.Sprintf("分组 %s 已被弃用", tokenGroup)) + return + } + } + userGroup = tokenGroup + } + common.SetContextKey(c, constant.ContextKeyUsingGroup, userGroup) + if ok { + id, err := strconv.Atoi(channelId.(string)) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的渠道 Id") + return + } + channel, err = model.GetChannelById(id, true) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的渠道 Id") + return + } + if channel.Status != common.ChannelStatusEnabled { + abortWithOpenAiMessage(c, http.StatusForbidden, "该渠道已被禁用") + return + } + } else { + // Select a channel for the user + // check token model mapping + modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) + if modelLimitEnable { + s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit) + var tokenModelLimit map[string]bool + if ok { + tokenModelLimit = s.(map[string]bool) + } else { + tokenModelLimit = map[string]bool{} + } + if tokenModelLimit != nil { + if _, ok := tokenModelLimit[modelRequest.Model]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问模型 "+modelRequest.Model) + return + } + } else { + // token model limit is empty, all models are not allowed + abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问任何模型") + return + } + } + + if shouldSelectChannel { + var selectGroup string + channel, selectGroup, err = model.CacheGetRandomSatisfiedChannel(c, userGroup, modelRequest.Model, 0) + if err != nil { + showGroup := userGroup + if userGroup == "auto" { + showGroup = fmt.Sprintf("auto(%s)", selectGroup) + } + message := fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道", showGroup, modelRequest.Model) + // 如果错误,但是渠道不为空,说明是数据库一致性问题 + if channel != nil { + common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) + message = "数据库一致性已被破坏,请联系管理员" + } + // 如果错误,而且渠道为空,说明是没有可用渠道 + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message) + return + } + if channel == nil { + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("当前分组 %s 下对于模型 %s 无可用渠道(数据库一致性已被破坏)", userGroup, modelRequest.Model)) + return + } + } + } + common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now()) + SetupContextForSelectedChannel(c, channel, modelRequest.Model) + c.Next() + } +} + +func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { + var modelRequest ModelRequest + shouldSelectChannel := true + var err error + if strings.Contains(c.Request.URL.Path, "/mj/") { + relayMode := relayconstant.Path2RelayModeMidjourney(c.Request.URL.Path) + if relayMode == relayconstant.RelayModeMidjourneyTaskFetch || + relayMode == relayconstant.RelayModeMidjourneyTaskFetchByCondition || + relayMode == relayconstant.RelayModeMidjourneyNotify || + relayMode == relayconstant.RelayModeMidjourneyTaskImageSeed { + shouldSelectChannel = false + } else { + midjourneyRequest := dto.MidjourneyRequest{} + err = common.UnmarshalBodyReusable(c, &midjourneyRequest) + if err != nil { + return nil, false, err + } + midjourneyModel, mjErr, success := service.GetMjRequestModel(relayMode, &midjourneyRequest) + if mjErr != nil { + return nil, false, fmt.Errorf(mjErr.Description) + } + if midjourneyModel == "" { + if !success { + return nil, false, fmt.Errorf("无效的请求, 无法解析模型") + } else { + // task fetch, task fetch by condition, notify + shouldSelectChannel = false + } + } + modelRequest.Model = midjourneyModel + } + c.Set("relay_mode", relayMode) + } else if strings.Contains(c.Request.URL.Path, "/suno/") { + relayMode := relayconstant.Path2RelaySuno(c.Request.Method, c.Request.URL.Path) + if relayMode == relayconstant.RelayModeSunoFetch || + relayMode == relayconstant.RelayModeSunoFetchByID { + shouldSelectChannel = false + } else { + modelName := service.CoverTaskActionToModelName(constant.TaskPlatformSuno, c.Param("action")) + modelRequest.Model = modelName + } + c.Set("platform", string(constant.TaskPlatformSuno)) + c.Set("relay_mode", relayMode) + } else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") { + err = common.UnmarshalBodyReusable(c, &modelRequest) + var platform string + var relayMode int + if strings.HasPrefix(modelRequest.Model, "jimeng") { + platform = string(constant.TaskPlatformJimeng) + relayMode = relayconstant.Path2RelayJimeng(c.Request.Method, c.Request.URL.Path) + if relayMode == relayconstant.RelayModeJimengFetchByID { + shouldSelectChannel = false + } + } else { + platform = string(constant.TaskPlatformKling) + relayMode = relayconstant.Path2RelayKling(c.Request.Method, c.Request.URL.Path) + if relayMode == relayconstant.RelayModeKlingFetchByID { + shouldSelectChannel = false + } + } + c.Set("platform", platform) + c.Set("relay_mode", relayMode) + } else if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models/") || strings.HasPrefix(c.Request.URL.Path, "/v1/models/") { + // Gemini API 路径处理: /v1beta/models/gemini-2.0-flash:generateContent + relayMode := relayconstant.RelayModeGemini + modelName := extractModelNameFromGeminiPath(c.Request.URL.Path) + if modelName != "" { + modelRequest.Model = modelName + } + c.Set("relay_mode", relayMode) + } else if !strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") && !strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") { + err = common.UnmarshalBodyReusable(c, &modelRequest) + } + if err != nil { + return nil, false, errors.New("无效的请求, " + err.Error()) + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/realtime") { + //wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-10-01 + modelRequest.Model = c.Query("model") + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/moderations") { + if modelRequest.Model == "" { + modelRequest.Model = "text-moderation-stable" + } + } + if strings.HasSuffix(c.Request.URL.Path, "embeddings") { + if modelRequest.Model == "" { + modelRequest.Model = c.Param("model") + } + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/images/generations") { + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "dall-e") + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/images/edits") { + modelRequest.Model = common.GetStringIfEmpty(c.PostForm("model"), "gpt-image-1") + } + if strings.HasPrefix(c.Request.URL.Path, "/v1/audio") { + relayMode := relayconstant.RelayModeAudioSpeech + if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/speech") { + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "tts-1") + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/translations") { + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, c.PostForm("model")) + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "whisper-1") + relayMode = relayconstant.RelayModeAudioTranslation + } else if strings.HasPrefix(c.Request.URL.Path, "/v1/audio/transcriptions") { + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, c.PostForm("model")) + modelRequest.Model = common.GetStringIfEmpty(modelRequest.Model, "whisper-1") + relayMode = relayconstant.RelayModeAudioTranscription + } + c.Set("relay_mode", relayMode) + } + if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { + // playground chat completions + err = common.UnmarshalBodyReusable(c, &modelRequest) + if err != nil { + return nil, false, errors.New("无效的请求, " + err.Error()) + } + common.SetContextKey(c, constant.ContextKeyTokenGroup, modelRequest.Group) + } + return &modelRequest, shouldSelectChannel, nil +} + +func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError { + c.Set("original_model", modelName) // for retry + if channel == nil { + return types.NewError(errors.New("channel is nil"), types.ErrorCodeGetChannelFailed) + } + common.SetContextKey(c, constant.ContextKeyChannelId, channel.Id) + common.SetContextKey(c, constant.ContextKeyChannelName, channel.Name) + common.SetContextKey(c, constant.ContextKeyChannelType, channel.Type) + common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime) + common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting()) + common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride()) + if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" { + common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization) + } + common.SetContextKey(c, constant.ContextKeyChannelAutoBan, channel.GetAutoBan()) + common.SetContextKey(c, constant.ContextKeyChannelModelMapping, channel.GetModelMapping()) + common.SetContextKey(c, constant.ContextKeyChannelStatusCodeMapping, channel.GetStatusCodeMapping()) + + key, index, newAPIError := channel.GetNextEnabledKey() + if newAPIError != nil { + return newAPIError + } + if channel.ChannelInfo.IsMultiKey { + common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, true) + common.SetContextKey(c, constant.ContextKeyChannelMultiKeyIndex, index) + } + // c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) + common.SetContextKey(c, constant.ContextKeyChannelKey, key) + common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL()) + + // TODO: api_version统一 + switch channel.Type { + case constant.ChannelTypeAzure: + c.Set("api_version", channel.Other) + case constant.ChannelTypeVertexAi: + c.Set("region", channel.Other) + case constant.ChannelTypeXunfei: + c.Set("api_version", channel.Other) + case constant.ChannelTypeGemini: + c.Set("api_version", channel.Other) + case constant.ChannelTypeAli: + c.Set("plugin", channel.Other) + case constant.ChannelCloudflare: + c.Set("api_version", channel.Other) + case constant.ChannelTypeMokaAI: + c.Set("api_version", channel.Other) + case constant.ChannelTypeCoze: + c.Set("bot_id", channel.Other) + } + return nil +} + +// extractModelNameFromGeminiPath 从 Gemini API URL 路径中提取模型名 +// 输入格式: /v1beta/models/gemini-2.0-flash:generateContent +// 输出: gemini-2.0-flash +func extractModelNameFromGeminiPath(path string) string { + // 查找 "/models/" 的位置 + modelsPrefix := "/models/" + modelsIndex := strings.Index(path, modelsPrefix) + if modelsIndex == -1 { + return "" + } + + // 从 "/models/" 之后开始提取 + startIndex := modelsIndex + len(modelsPrefix) + if startIndex >= len(path) { + return "" + } + + // 查找 ":" 的位置,模型名在 ":" 之前 + colonIndex := strings.Index(path[startIndex:], ":") + if colonIndex == -1 { + // 如果没有找到 ":",返回从 "/models/" 到路径结尾的部分 + return path[startIndex:] + } + + // 返回模型名部分 + return path[startIndex : startIndex+colonIndex] +} diff --git a/middleware/gzip.go b/middleware/gzip.go new file mode 100644 index 00000000..5b9d566a --- /dev/null +++ b/middleware/gzip.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "compress/gzip" + "github.com/andybalholm/brotli" + "github.com/gin-gonic/gin" + "io" + "net/http" +) + +func DecompressRequestMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.Body == nil || c.Request.Method == http.MethodGet { + c.Next() + return + } + switch c.GetHeader("Content-Encoding") { + case "gzip": + gzipReader, err := gzip.NewReader(c.Request.Body) + if err != nil { + c.AbortWithStatus(http.StatusBadRequest) + return + } + defer gzipReader.Close() + + // Replace the request body with the decompressed data + c.Request.Body = io.NopCloser(gzipReader) + c.Request.Header.Del("Content-Encoding") + case "br": + reader := brotli.NewReader(c.Request.Body) + c.Request.Body = io.NopCloser(reader) + c.Request.Header.Del("Content-Encoding") + } + + // Continue processing the request + c.Next() + } +} diff --git a/middleware/kling_adapter.go b/middleware/kling_adapter.go new file mode 100644 index 00000000..3d4943d2 --- /dev/null +++ b/middleware/kling_adapter.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "io" + "one-api/common" + "one-api/constant" + + "github.com/gin-gonic/gin" +) + +func KlingRequestConvert() func(c *gin.Context) { + return func(c *gin.Context) { + var originalReq map[string]interface{} + if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil { + c.Next() + return + } + + model, _ := originalReq["model_name"].(string) + prompt, _ := originalReq["prompt"].(string) + + unifiedReq := map[string]interface{}{ + "model": model, + "prompt": prompt, + "metadata": originalReq, + } + + jsonData, err := json.Marshal(unifiedReq) + if err != nil { + c.Next() + return + } + + // Rewrite request body and path + c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData)) + c.Request.URL.Path = "/v1/video/generations" + if image, ok := originalReq["image"]; !ok || image == "" { + c.Set("action", constant.TaskActionTextGenerate) + } + + // We have to reset the request body for the next handlers + c.Set(common.KeyRequestBody, jsonData) + c.Next() + } +} diff --git a/middleware/logger.go b/middleware/logger.go new file mode 100644 index 00000000..02f2e0a9 --- /dev/null +++ b/middleware/logger.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "fmt" + "github.com/gin-gonic/gin" + "one-api/common" +) + +func SetUpLogger(server *gin.Engine) { + server.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + var requestID string + if param.Keys != nil { + requestID = param.Keys[common.RequestIdKey].(string) + } + return fmt.Sprintf("[GIN] %s | %s | %3d | %13v | %15s | %7s %s\n", + param.TimeStamp.Format("2006/01/02 - 15:04:05"), + requestID, + param.StatusCode, + param.Latency, + param.ClientIP, + param.Method, + param.Path, + ) + })) +} diff --git a/middleware/model-rate-limit.go b/middleware/model-rate-limit.go new file mode 100644 index 00000000..14d9a737 --- /dev/null +++ b/middleware/model-rate-limit.go @@ -0,0 +1,199 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "one-api/common" + "one-api/common/limiter" + "one-api/constant" + "one-api/setting" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/go-redis/redis/v8" +) + +const ( + ModelRequestRateLimitCountMark = "MRRL" + ModelRequestRateLimitSuccessCountMark = "MRRLS" +) + +// 检查Redis中的请求限制 +func checkRedisRateLimit(ctx context.Context, rdb *redis.Client, key string, maxCount int, duration int64) (bool, error) { + // 如果maxCount为0,表示不限制 + if maxCount == 0 { + return true, nil + } + + // 获取当前计数 + length, err := rdb.LLen(ctx, key).Result() + if err != nil { + return false, err + } + + // 如果未达到限制,允许请求 + if length < int64(maxCount) { + return true, nil + } + + // 检查时间窗口 + oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result() + oldTime, err := time.Parse(timeFormat, oldTimeStr) + if err != nil { + return false, err + } + + nowTimeStr := time.Now().Format(timeFormat) + nowTime, err := time.Parse(timeFormat, nowTimeStr) + if err != nil { + return false, err + } + // 如果在时间窗口内已达到限制,拒绝请求 + subTime := nowTime.Sub(oldTime).Seconds() + if int64(subTime) < duration { + rdb.Expire(ctx, key, time.Duration(setting.ModelRequestRateLimitDurationMinutes)*time.Minute) + return false, nil + } + + return true, nil +} + +// 记录Redis请求 +func recordRedisRequest(ctx context.Context, rdb *redis.Client, key string, maxCount int) { + // 如果maxCount为0,不记录请求 + if maxCount == 0 { + return + } + + now := time.Now().Format(timeFormat) + rdb.LPush(ctx, key, now) + rdb.LTrim(ctx, key, 0, int64(maxCount-1)) + rdb.Expire(ctx, key, time.Duration(setting.ModelRequestRateLimitDurationMinutes)*time.Minute) +} + +// Redis限流处理器 +func redisRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) gin.HandlerFunc { + return func(c *gin.Context) { + userId := strconv.Itoa(c.GetInt("id")) + ctx := context.Background() + rdb := common.RDB + + // 1. 检查成功请求数限制 + successKey := fmt.Sprintf("rateLimit:%s:%s", ModelRequestRateLimitSuccessCountMark, userId) + allowed, err := checkRedisRateLimit(ctx, rdb, successKey, successMaxCount, duration) + if err != nil { + fmt.Println("检查成功请求数限制失败:", err.Error()) + abortWithOpenAiMessage(c, http.StatusInternalServerError, "rate_limit_check_failed") + return + } + if !allowed { + abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("您已达到请求数限制:%d分钟内最多请求%d次", setting.ModelRequestRateLimitDurationMinutes, successMaxCount)) + return + } + + //2.检查总请求数限制并记录总请求(当totalMaxCount为0时会自动跳过,使用令牌桶限流器 + if totalMaxCount > 0 { + totalKey := fmt.Sprintf("rateLimit:%s", userId) + // 初始化 + tb := limiter.New(ctx, rdb) + allowed, err = tb.Allow( + ctx, + totalKey, + limiter.WithCapacity(int64(totalMaxCount)*duration), + limiter.WithRate(int64(totalMaxCount)), + limiter.WithRequested(duration), + ) + + if err != nil { + fmt.Println("检查总请求数限制失败:", err.Error()) + abortWithOpenAiMessage(c, http.StatusInternalServerError, "rate_limit_check_failed") + return + } + + if !allowed { + abortWithOpenAiMessage(c, http.StatusTooManyRequests, fmt.Sprintf("您已达到总请求数限制:%d分钟内最多请求%d次,包括失败次数,请检查您的请求是否正确", setting.ModelRequestRateLimitDurationMinutes, totalMaxCount)) + } + } + + // 4. 处理请求 + c.Next() + + // 5. 如果请求成功,记录成功请求 + if c.Writer.Status() < 400 { + recordRedisRequest(ctx, rdb, successKey, successMaxCount) + } + } +} + +// 内存限流处理器 +func memoryRateLimitHandler(duration int64, totalMaxCount, successMaxCount int) gin.HandlerFunc { + inMemoryRateLimiter.Init(time.Duration(setting.ModelRequestRateLimitDurationMinutes) * time.Minute) + + return func(c *gin.Context) { + userId := strconv.Itoa(c.GetInt("id")) + totalKey := ModelRequestRateLimitCountMark + userId + successKey := ModelRequestRateLimitSuccessCountMark + userId + + // 1. 检查总请求数限制(当totalMaxCount为0时跳过) + if totalMaxCount > 0 && !inMemoryRateLimiter.Request(totalKey, totalMaxCount, duration) { + c.Status(http.StatusTooManyRequests) + c.Abort() + return + } + + // 2. 检查成功请求数限制 + // 使用一个临时key来检查限制,这样可以避免实际记录 + checkKey := successKey + "_check" + if !inMemoryRateLimiter.Request(checkKey, successMaxCount, duration) { + c.Status(http.StatusTooManyRequests) + c.Abort() + return + } + + // 3. 处理请求 + c.Next() + + // 4. 如果请求成功,记录到实际的成功请求计数中 + if c.Writer.Status() < 400 { + inMemoryRateLimiter.Request(successKey, successMaxCount, duration) + } + } +} + +// ModelRequestRateLimit 模型请求限流中间件 +func ModelRequestRateLimit() func(c *gin.Context) { + return func(c *gin.Context) { + // 在每个请求时检查是否启用限流 + if !setting.ModelRequestRateLimitEnabled { + c.Next() + return + } + + // 计算限流参数 + duration := int64(setting.ModelRequestRateLimitDurationMinutes * 60) + totalMaxCount := setting.ModelRequestRateLimitCount + successMaxCount := setting.ModelRequestRateLimitSuccessCount + + // 获取分组 + group := common.GetContextKeyString(c, constant.ContextKeyTokenGroup) + if group == "" { + group = common.GetContextKeyString(c, constant.ContextKeyUserGroup) + } + + //获取分组的限流配置 + groupTotalCount, groupSuccessCount, found := setting.GetGroupRateLimit(group) + if found { + totalMaxCount = groupTotalCount + successMaxCount = groupSuccessCount + } + + // 根据存储类型选择并执行限流处理器 + if common.RedisEnabled { + redisRateLimitHandler(duration, totalMaxCount, successMaxCount)(c) + } else { + memoryRateLimitHandler(duration, totalMaxCount, successMaxCount)(c) + } + } +} diff --git a/middleware/rate-limit.go b/middleware/rate-limit.go new file mode 100644 index 00000000..e38fb8f6 --- /dev/null +++ b/middleware/rate-limit.go @@ -0,0 +1,113 @@ +package middleware + +import ( + "context" + "fmt" + "github.com/gin-gonic/gin" + "net/http" + "one-api/common" + "time" +) + +var timeFormat = "2006-01-02T15:04:05.000Z" + +var inMemoryRateLimiter common.InMemoryRateLimiter + +var defNext = func(c *gin.Context) { + c.Next() +} + +func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) { + ctx := context.Background() + rdb := common.RDB + key := "rateLimit:" + mark + c.ClientIP() + listLength, err := rdb.LLen(ctx, key).Result() + if err != nil { + fmt.Println(err.Error()) + c.Status(http.StatusInternalServerError) + c.Abort() + return + } + if listLength < int64(maxRequestNum) { + rdb.LPush(ctx, key, time.Now().Format(timeFormat)) + rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) + } else { + oldTimeStr, _ := rdb.LIndex(ctx, key, -1).Result() + oldTime, err := time.Parse(timeFormat, oldTimeStr) + if err != nil { + fmt.Println(err) + c.Status(http.StatusInternalServerError) + c.Abort() + return + } + nowTimeStr := time.Now().Format(timeFormat) + nowTime, err := time.Parse(timeFormat, nowTimeStr) + if err != nil { + fmt.Println(err) + c.Status(http.StatusInternalServerError) + c.Abort() + return + } + // time.Since will return negative number! + // See: https://stackoverflow.com/questions/50970900/why-is-time-since-returning-negative-durations-on-windows + if int64(nowTime.Sub(oldTime).Seconds()) < duration { + rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) + c.Status(http.StatusTooManyRequests) + c.Abort() + return + } else { + rdb.LPush(ctx, key, time.Now().Format(timeFormat)) + rdb.LTrim(ctx, key, 0, int64(maxRequestNum-1)) + rdb.Expire(ctx, key, common.RateLimitKeyExpirationDuration) + } + } +} + +func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) { + key := mark + c.ClientIP() + if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) { + c.Status(http.StatusTooManyRequests) + c.Abort() + return + } +} + +func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) { + if common.RedisEnabled { + return func(c *gin.Context) { + redisRateLimiter(c, maxRequestNum, duration, mark) + } + } else { + // It's safe to call multi times. + inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) + return func(c *gin.Context) { + memoryRateLimiter(c, maxRequestNum, duration, mark) + } + } +} + +func GlobalWebRateLimit() func(c *gin.Context) { + if common.GlobalWebRateLimitEnable { + return rateLimitFactory(common.GlobalWebRateLimitNum, common.GlobalWebRateLimitDuration, "GW") + } + return defNext +} + +func GlobalAPIRateLimit() func(c *gin.Context) { + if common.GlobalApiRateLimitEnable { + return rateLimitFactory(common.GlobalApiRateLimitNum, common.GlobalApiRateLimitDuration, "GA") + } + return defNext +} + +func CriticalRateLimit() func(c *gin.Context) { + return rateLimitFactory(common.CriticalRateLimitNum, common.CriticalRateLimitDuration, "CT") +} + +func DownloadRateLimit() func(c *gin.Context) { + return rateLimitFactory(common.DownloadRateLimitNum, common.DownloadRateLimitDuration, "DW") +} + +func UploadRateLimit() func(c *gin.Context) { + return rateLimitFactory(common.UploadRateLimitNum, common.UploadRateLimitDuration, "UP") +} diff --git a/middleware/recover.go b/middleware/recover.go new file mode 100644 index 00000000..51fc7190 --- /dev/null +++ b/middleware/recover.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "fmt" + "github.com/gin-gonic/gin" + "net/http" + "one-api/common" + "runtime/debug" +) + +func RelayPanicRecover() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + common.SysError(fmt.Sprintf("panic detected: %v", err)) + common.SysError(fmt.Sprintf("stacktrace from panic: %s", string(debug.Stack()))) + c.JSON(http.StatusInternalServerError, gin.H{ + "error": gin.H{ + "message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err), + "type": "new_api_panic", + }, + }) + c.Abort() + } + }() + c.Next() + } +} diff --git a/middleware/request-id.go b/middleware/request-id.go new file mode 100644 index 00000000..e623be7a --- /dev/null +++ b/middleware/request-id.go @@ -0,0 +1,18 @@ +package middleware + +import ( + "context" + "github.com/gin-gonic/gin" + "one-api/common" +) + +func RequestId() func(c *gin.Context) { + return func(c *gin.Context) { + id := common.GetTimeString() + common.GetRandomString(8) + c.Set(common.RequestIdKey, id) + ctx := context.WithValue(c.Request.Context(), common.RequestIdKey, id) + c.Request = c.Request.WithContext(ctx) + c.Header(common.RequestIdKey, id) + c.Next() + } +} diff --git a/middleware/stats.go b/middleware/stats.go new file mode 100644 index 00000000..1c97983f --- /dev/null +++ b/middleware/stats.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "sync/atomic" + + "github.com/gin-gonic/gin" +) + +// HTTPStats 存储HTTP统计信息 +type HTTPStats struct { + activeConnections int64 +} + +var globalStats = &HTTPStats{} + +// StatsMiddleware 统计中间件 +func StatsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 增加活跃连接数 + atomic.AddInt64(&globalStats.activeConnections, 1) + + // 确保在请求结束时减少连接数 + defer func() { + atomic.AddInt64(&globalStats.activeConnections, -1) + }() + + c.Next() + } +} + +// StatsInfo 统计信息结构 +type StatsInfo struct { + ActiveConnections int64 `json:"active_connections"` +} + +// GetStats 获取统计信息 +func GetStats() StatsInfo { + return StatsInfo{ + ActiveConnections: atomic.LoadInt64(&globalStats.activeConnections), + } +} \ No newline at end of file diff --git a/middleware/turnstile-check.go b/middleware/turnstile-check.go new file mode 100644 index 00000000..26688810 --- /dev/null +++ b/middleware/turnstile-check.go @@ -0,0 +1,80 @@ +package middleware + +import ( + "encoding/json" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "net/http" + "net/url" + "one-api/common" +) + +type turnstileCheckResponse struct { + Success bool `json:"success"` +} + +func TurnstileCheck() gin.HandlerFunc { + return func(c *gin.Context) { + if common.TurnstileCheckEnabled { + session := sessions.Default(c) + turnstileChecked := session.Get("turnstile") + if turnstileChecked != nil { + c.Next() + return + } + response := c.Query("turnstile") + if response == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Turnstile token 为空", + }) + c.Abort() + return + } + rawRes, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{ + "secret": {common.TurnstileSecretKey}, + "response": {response}, + "remoteip": {c.ClientIP()}, + }) + if err != nil { + common.SysError(err.Error()) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + c.Abort() + return + } + defer rawRes.Body.Close() + var res turnstileCheckResponse + err = json.NewDecoder(rawRes.Body).Decode(&res) + if err != nil { + common.SysError(err.Error()) + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + c.Abort() + return + } + if !res.Success { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Turnstile 校验失败,请刷新重试!", + }) + c.Abort() + return + } + session.Set("turnstile", true) + err = session.Save() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "无法保存会话信息,请重试", + "success": false, + }) + return + } + } + c.Next() + } +} diff --git a/middleware/utils.go b/middleware/utils.go new file mode 100644 index 00000000..082f5657 --- /dev/null +++ b/middleware/utils.go @@ -0,0 +1,29 @@ +package middleware + +import ( + "fmt" + "github.com/gin-gonic/gin" + "one-api/common" +) + +func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string) { + userId := c.GetInt("id") + c.JSON(statusCode, gin.H{ + "error": gin.H{ + "message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)), + "type": "new_api_error", + }, + }) + c.Abort() + common.LogError(c.Request.Context(), fmt.Sprintf("user %d | %s", userId, message)) +} + +func abortWithMidjourneyMessage(c *gin.Context, statusCode int, code int, description string) { + c.JSON(statusCode, gin.H{ + "description": description, + "type": "new_api_error", + "code": code, + }) + c.Abort() + common.LogError(c.Request.Context(), description) +} diff --git a/model/ability.go b/model/ability.go new file mode 100644 index 00000000..f36ff764 --- /dev/null +++ b/model/ability.go @@ -0,0 +1,320 @@ +package model + +import ( + "errors" + "fmt" + "one-api/common" + "strings" + "sync" + + "github.com/samber/lo" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type Ability struct { + Group string `json:"group" gorm:"type:varchar(64);primaryKey;autoIncrement:false"` + Model string `json:"model" gorm:"type:varchar(255);primaryKey;autoIncrement:false"` + ChannelId int `json:"channel_id" gorm:"primaryKey;autoIncrement:false;index"` + Enabled bool `json:"enabled"` + Priority *int64 `json:"priority" gorm:"bigint;default:0;index"` + Weight uint `json:"weight" gorm:"default:0;index"` + Tag *string `json:"tag" gorm:"index"` +} + +type AbilityWithChannel struct { + Ability + ChannelType int `json:"channel_type"` +} + +func GetAllEnableAbilityWithChannels() ([]AbilityWithChannel, error) { + var abilities []AbilityWithChannel + err := DB.Table("abilities"). + Select("abilities.*, channels.type as channel_type"). + Joins("left join channels on abilities.channel_id = channels.id"). + Where("abilities.enabled = ?", true). + Scan(&abilities).Error + return abilities, err +} + +func GetGroupEnabledModels(group string) []string { + var models []string + // Find distinct models + DB.Table("abilities").Where(commonGroupCol+" = ? and enabled = ?", group, true).Distinct("model").Pluck("model", &models) + return models +} + +func GetEnabledModels() []string { + var models []string + // Find distinct models + DB.Table("abilities").Where("enabled = ?", true).Distinct("model").Pluck("model", &models) + return models +} + +func GetAllEnableAbilities() []Ability { + var abilities []Ability + DB.Find(&abilities, "enabled = ?", true) + return abilities +} + +func getPriority(group string, model string, retry int) (int, error) { + + var priorities []int + err := DB.Model(&Ability{}). + Select("DISTINCT(priority)"). + Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true). + Order("priority DESC"). // 按优先级降序排序 + Pluck("priority", &priorities).Error // Pluck用于将查询的结果直接扫描到一个切片中 + + if err != nil { + // 处理错误 + return 0, err + } + + if len(priorities) == 0 { + // 如果没有查询到优先级,则返回错误 + return 0, errors.New("数据库一致性被破坏") + } + + // 确定要使用的优先级 + var priorityToUse int + if retry >= len(priorities) { + // 如果重试次数大于优先级数,则使用最小的优先级 + priorityToUse = priorities[len(priorities)-1] + } else { + priorityToUse = priorities[retry] + } + return priorityToUse, nil +} + +func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) { + maxPrioritySubQuery := DB.Model(&Ability{}).Select("MAX(priority)").Where(commonGroupCol+" = ? and model = ? and enabled = ?", group, model, true) + channelQuery := DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = (?)", group, model, true, maxPrioritySubQuery) + if retry != 0 { + priority, err := getPriority(group, model, retry) + if err != nil { + return nil, err + } else { + channelQuery = DB.Where(commonGroupCol+" = ? and model = ? and enabled = ? and priority = ?", group, model, true, priority) + } + } + + return channelQuery, nil +} + +func GetRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { + var abilities []Ability + + var err error = nil + channelQuery, err := getChannelQuery(group, model, retry) + if err != nil { + return nil, err + } + if common.UsingSQLite || common.UsingPostgreSQL { + err = channelQuery.Order("weight DESC").Find(&abilities).Error + } else { + err = channelQuery.Order("weight DESC").Find(&abilities).Error + } + if err != nil { + return nil, err + } + channel := Channel{} + if len(abilities) > 0 { + // Randomly choose one + weightSum := uint(0) + for _, ability_ := range abilities { + weightSum += ability_.Weight + 10 + } + // Randomly choose one + weight := common.GetRandomInt(int(weightSum)) + for _, ability_ := range abilities { + weight -= int(ability_.Weight) + 10 + //log.Printf("weight: %d, ability weight: %d", weight, *ability_.Weight) + if weight <= 0 { + channel.Id = ability_.ChannelId + break + } + } + } else { + return nil, errors.New("channel not found") + } + err = DB.First(&channel, "id = ?", channel.Id).Error + return &channel, err +} + +func (channel *Channel) AddAbilities() error { + models_ := strings.Split(channel.Models, ",") + groups_ := strings.Split(channel.Group, ",") + abilitySet := make(map[string]struct{}) + abilities := make([]Ability, 0, len(models_)) + for _, model := range models_ { + for _, group := range groups_ { + key := group + "|" + model + if _, exists := abilitySet[key]; exists { + continue + } + abilitySet[key] = struct{}{} + ability := Ability{ + Group: group, + Model: model, + ChannelId: channel.Id, + Enabled: channel.Status == common.ChannelStatusEnabled, + Priority: channel.Priority, + Weight: uint(channel.GetWeight()), + Tag: channel.Tag, + } + abilities = append(abilities, ability) + } + } + if len(abilities) == 0 { + return nil + } + for _, chunk := range lo.Chunk(abilities, 50) { + err := DB.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error + if err != nil { + return err + } + } + return nil +} + +func (channel *Channel) DeleteAbilities() error { + return DB.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error +} + +// UpdateAbilities updates abilities of this channel. +// Make sure the channel is completed before calling this function. +func (channel *Channel) UpdateAbilities(tx *gorm.DB) error { + isNewTx := false + // 如果没有传入事务,创建新的事务 + if tx == nil { + tx = DB.Begin() + if tx.Error != nil { + return tx.Error + } + isNewTx = true + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + } + + // First delete all abilities of this channel + err := tx.Where("channel_id = ?", channel.Id).Delete(&Ability{}).Error + if err != nil { + if isNewTx { + tx.Rollback() + } + return err + } + + // Then add new abilities + models_ := strings.Split(channel.Models, ",") + groups_ := strings.Split(channel.Group, ",") + abilitySet := make(map[string]struct{}) + abilities := make([]Ability, 0, len(models_)) + for _, model := range models_ { + for _, group := range groups_ { + key := group + "|" + model + if _, exists := abilitySet[key]; exists { + continue + } + abilitySet[key] = struct{}{} + ability := Ability{ + Group: group, + Model: model, + ChannelId: channel.Id, + Enabled: channel.Status == common.ChannelStatusEnabled, + Priority: channel.Priority, + Weight: uint(channel.GetWeight()), + Tag: channel.Tag, + } + abilities = append(abilities, ability) + } + } + + if len(abilities) > 0 { + for _, chunk := range lo.Chunk(abilities, 50) { + err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&chunk).Error + if err != nil { + if isNewTx { + tx.Rollback() + } + return err + } + } + } + + // 如果是新创建的事务,需要提交 + if isNewTx { + return tx.Commit().Error + } + + return nil +} + +func UpdateAbilityStatus(channelId int, status bool) error { + return DB.Model(&Ability{}).Where("channel_id = ?", channelId).Select("enabled").Update("enabled", status).Error +} + +func UpdateAbilityStatusByTag(tag string, status bool) error { + return DB.Model(&Ability{}).Where("tag = ?", tag).Select("enabled").Update("enabled", status).Error +} + +func UpdateAbilityByTag(tag string, newTag *string, priority *int64, weight *uint) error { + ability := Ability{} + if newTag != nil { + ability.Tag = newTag + } + if priority != nil { + ability.Priority = priority + } + if weight != nil { + ability.Weight = *weight + } + return DB.Model(&Ability{}).Where("tag = ?", tag).Updates(ability).Error +} + +var fixLock = sync.Mutex{} + +func FixAbility() (int, int, error) { + lock := fixLock.TryLock() + if !lock { + return 0, 0, errors.New("已经有一个修复任务在运行中,请稍后再试") + } + defer fixLock.Unlock() + var channels []*Channel + // Find all channels + err := DB.Model(&Channel{}).Find(&channels).Error + if err != nil { + return 0, 0, err + } + if len(channels) == 0 { + return 0, 0, nil + } + successCount := 0 + failCount := 0 + for _, chunk := range lo.Chunk(channels, 50) { + ids := lo.Map(chunk, func(c *Channel, _ int) int { return c.Id }) + // Delete all abilities of this channel + err = DB.Where("channel_id IN ?", ids).Delete(&Ability{}).Error + if err != nil { + common.SysError(fmt.Sprintf("Delete abilities failed: %s", err.Error())) + failCount += len(chunk) + continue + } + // Then add new abilities + for _, channel := range chunk { + err = channel.AddAbilities() + if err != nil { + common.SysError(fmt.Sprintf("Add abilities for channel %d failed: %s", channel.Id, err.Error())) + failCount++ + } else { + successCount++ + } + } + } + InitChannelCache() + return successCount, failCount, nil +} diff --git a/model/channel.go b/model/channel.go new file mode 100644 index 00000000..6277fcda --- /dev/null +++ b/model/channel.go @@ -0,0 +1,909 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "math/rand" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/types" + "strings" + "sync" + + "gorm.io/gorm" +) + +type Channel struct { + Id int `json:"id"` + Type int `json:"type" gorm:"default:0"` + Key string `json:"key" gorm:"not null"` + OpenAIOrganization *string `json:"openai_organization"` + TestModel *string `json:"test_model"` + Status int `json:"status" gorm:"default:1"` + Name string `json:"name" gorm:"index"` + Weight *uint `json:"weight" gorm:"default:0"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + TestTime int64 `json:"test_time" gorm:"bigint"` + ResponseTime int `json:"response_time"` // in milliseconds + BaseURL *string `json:"base_url" gorm:"column:base_url;default:''"` + Other string `json:"other"` + Balance float64 `json:"balance"` // in USD + BalanceUpdatedTime int64 `json:"balance_updated_time" gorm:"bigint"` + Models string `json:"models"` + Group string `json:"group" gorm:"type:varchar(64);default:'default'"` + UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"` + ModelMapping *string `json:"model_mapping" gorm:"type:text"` + //MaxInputTokens *int `json:"max_input_tokens" gorm:"default:0"` + StatusCodeMapping *string `json:"status_code_mapping" gorm:"type:varchar(1024);default:''"` + Priority *int64 `json:"priority" gorm:"bigint;default:0"` + AutoBan *int `json:"auto_ban" gorm:"default:1"` + OtherInfo string `json:"other_info"` + Tag *string `json:"tag" gorm:"index"` + Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 + ParamOverride *string `json:"param_override" gorm:"type:text"` + // add after v0.8.5 + ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` +} + +type ChannelInfo struct { + IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式 + MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量 + MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status + MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引 + MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` +} + +// Value implements driver.Valuer interface +func (c ChannelInfo) Value() (driver.Value, error) { + return common.Marshal(&c) +} + +// Scan implements sql.Scanner interface +func (c *ChannelInfo) Scan(value interface{}) error { + bytesValue, _ := value.([]byte) + return common.Unmarshal(bytesValue, c) +} + +func (channel *Channel) getKeys() []string { + if channel.Key == "" { + return []string{} + } + trimmed := strings.TrimSpace(channel.Key) + // If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios) + if strings.HasPrefix(trimmed, "[") { + var arr []json.RawMessage + if err := json.Unmarshal([]byte(trimmed), &arr); err == nil { + res := make([]string, len(arr)) + for i, v := range arr { + res[i] = string(v) + } + return res + } + } + // Otherwise, fall back to splitting by newline + keys := strings.Split(strings.Trim(channel.Key, "\n"), "\n") + return keys +} + +func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) { + // If not in multi-key mode, return the original key string directly. + if !channel.ChannelInfo.IsMultiKey { + return channel.Key, 0, nil + } + + // Obtain all keys (split by \n) + keys := channel.getKeys() + if len(keys) == 0 { + // No keys available, return error, should disable the channel + return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey) + } + + statusList := channel.ChannelInfo.MultiKeyStatusList + // helper to get key status, default to enabled when missing + getStatus := func(idx int) int { + if statusList == nil { + return common.ChannelStatusEnabled + } + if status, ok := statusList[idx]; ok { + return status + } + return common.ChannelStatusEnabled + } + + // Collect indexes of enabled keys + enabledIdx := make([]int, 0, len(keys)) + for i := range keys { + if getStatus(i) == common.ChannelStatusEnabled { + enabledIdx = append(enabledIdx, i) + } + } + // If no specific status list or none enabled, fall back to first key + if len(enabledIdx) == 0 { + return keys[0], 0, nil + } + + switch channel.ChannelInfo.MultiKeyMode { + case constant.MultiKeyModeRandom: + // Randomly pick one enabled key + selectedIdx := enabledIdx[rand.Intn(len(enabledIdx))] + return keys[selectedIdx], selectedIdx, nil + case constant.MultiKeyModePolling: + // Use channel-specific lock to ensure thread-safe polling + lock := getChannelPollingLock(channel.Id) + lock.Lock() + defer lock.Unlock() + + channelInfo, err := CacheGetChannelInfo(channel.Id) + if err != nil { + return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed) + } + //println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex) + defer func() { + if common.DebugEnabled { + println(fmt.Sprintf("channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex)) + } + if !common.MemoryCacheEnabled { + _ = channel.SaveChannelInfo() + } else { + // CacheUpdateChannel(channel) + } + }() + // Start from the saved polling index and look for the next enabled key + start := channelInfo.MultiKeyPollingIndex + if start < 0 || start >= len(keys) { + start = 0 + } + for i := 0; i < len(keys); i++ { + idx := (start + i) % len(keys) + if getStatus(idx) == common.ChannelStatusEnabled { + // update polling index for next call (point to the next position) + channel.ChannelInfo.MultiKeyPollingIndex = (idx + 1) % len(keys) + return keys[idx], idx, nil + } + } + // Fallback – should not happen, but return first enabled key + return keys[enabledIdx[0]], enabledIdx[0], nil + default: + // Unknown mode, default to first enabled key (or original key string) + return keys[enabledIdx[0]], enabledIdx[0], nil + } +} + +func (channel *Channel) SaveChannelInfo() error { + return DB.Model(channel).Update("channel_info", channel.ChannelInfo).Error +} + +func (channel *Channel) GetModels() []string { + if channel.Models == "" { + return []string{} + } + return strings.Split(strings.Trim(channel.Models, ","), ",") +} + +func (channel *Channel) GetGroups() []string { + if channel.Group == "" { + return []string{} + } + groups := strings.Split(strings.Trim(channel.Group, ","), ",") + for i, group := range groups { + groups[i] = strings.TrimSpace(group) + } + return groups +} + +func (channel *Channel) GetOtherInfo() map[string]interface{} { + otherInfo := make(map[string]interface{}) + if channel.OtherInfo != "" { + err := json.Unmarshal([]byte(channel.OtherInfo), &otherInfo) + if err != nil { + common.SysError("failed to unmarshal other info: " + err.Error()) + } + } + return otherInfo +} + +func (channel *Channel) SetOtherInfo(otherInfo map[string]interface{}) { + otherInfoBytes, err := json.Marshal(otherInfo) + if err != nil { + common.SysError("failed to marshal other info: " + err.Error()) + return + } + channel.OtherInfo = string(otherInfoBytes) +} + +func (channel *Channel) GetTag() string { + if channel.Tag == nil { + return "" + } + return *channel.Tag +} + +func (channel *Channel) SetTag(tag string) { + channel.Tag = &tag +} + +func (channel *Channel) GetAutoBan() bool { + if channel.AutoBan == nil { + return false + } + return *channel.AutoBan == 1 +} + +func (channel *Channel) Save() error { + return DB.Save(channel).Error +} + +func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Channel, error) { + var channels []*Channel + var err error + order := "priority desc" + if idSort { + order = "id desc" + } + if selectAll { + err = DB.Order(order).Find(&channels).Error + } else { + err = DB.Order(order).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error + } + return channels, err +} + +func GetChannelsByTag(tag string, idSort bool) ([]*Channel, error) { + var channels []*Channel + order := "priority desc" + if idSort { + order = "id desc" + } + err := DB.Where("tag = ?", tag).Order(order).Find(&channels).Error + return channels, err +} + +func SearchChannels(keyword string, group string, model string, idSort bool) ([]*Channel, error) { + var channels []*Channel + modelsCol := "`models`" + + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + modelsCol = `"models"` + } + + baseURLCol := "`base_url`" + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + baseURLCol = `"base_url"` + } + + order := "priority desc" + if idSort { + order = "id desc" + } + + // 构造基础查询 + baseQuery := DB.Model(&Channel{}).Omit("key") + + // 构造WHERE子句 + var whereClause string + var args []interface{} + if group != "" && group != "null" { + var groupCondition string + if common.UsingMySQL { + groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?` + } else { + // sqlite, PostgreSQL + groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?` + } + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%") + } else { + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%") + } + + // 执行查询 + err := baseQuery.Where(whereClause, args...).Order(order).Find(&channels).Error + if err != nil { + return nil, err + } + return channels, nil +} + +func GetChannelById(id int, selectAll bool) (*Channel, error) { + channel := &Channel{Id: id} + var err error = nil + if selectAll { + err = DB.First(channel, "id = ?", id).Error + } else { + err = DB.Omit("key").First(channel, "id = ?", id).Error + } + if err != nil { + return nil, err + } + if channel == nil { + return nil, errors.New("channel not found") + } + return channel, nil +} + +func BatchInsertChannels(channels []Channel) error { + var err error + err = DB.Create(&channels).Error + if err != nil { + return err + } + for _, channel_ := range channels { + err = channel_.AddAbilities() + if err != nil { + return err + } + } + return nil +} + +func BatchDeleteChannels(ids []int) error { + //使用事务 删除channel表和channel_ability表 + tx := DB.Begin() + err := tx.Where("id in (?)", ids).Delete(&Channel{}).Error + if err != nil { + // 回滚事务 + tx.Rollback() + return err + } + err = tx.Where("channel_id in (?)", ids).Delete(&Ability{}).Error + if err != nil { + // 回滚事务 + tx.Rollback() + return err + } + // 提交事务 + tx.Commit() + return err +} + +func (channel *Channel) GetPriority() int64 { + if channel.Priority == nil { + return 0 + } + return *channel.Priority +} + +func (channel *Channel) GetWeight() int { + if channel.Weight == nil { + return 0 + } + return int(*channel.Weight) +} + +func (channel *Channel) GetBaseURL() string { + if channel.BaseURL == nil { + return "" + } + return *channel.BaseURL +} + +func (channel *Channel) GetModelMapping() string { + if channel.ModelMapping == nil { + return "" + } + return *channel.ModelMapping +} + +func (channel *Channel) GetStatusCodeMapping() string { + if channel.StatusCodeMapping == nil { + return "" + } + return *channel.StatusCodeMapping +} + +func (channel *Channel) Insert() error { + var err error + err = DB.Create(channel).Error + if err != nil { + return err + } + err = channel.AddAbilities() + return err +} + +func (channel *Channel) Update() error { + // If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys + if channel.ChannelInfo.IsMultiKey { + var keyStr string + if channel.Key != "" { + keyStr = channel.Key + } else { + // If key is not provided, read the existing key from the database + if existing, err := GetChannelById(channel.Id, true); err == nil { + keyStr = existing.Key + } + } + // Parse the key list (supports newline separation or JSON array) + keys := []string{} + if keyStr != "" { + trimmed := strings.TrimSpace(keyStr) + if strings.HasPrefix(trimmed, "[") { + var arr []json.RawMessage + if err := json.Unmarshal([]byte(trimmed), &arr); err == nil { + keys = make([]string, len(arr)) + for i, v := range arr { + keys[i] = string(v) + } + } + } + if len(keys) == 0 { // fallback to newline split + keys = strings.Split(strings.Trim(keyStr, "\n"), "\n") + } + } + channel.ChannelInfo.MultiKeySize = len(keys) + // Clean up status data that exceeds the new key count to prevent index out of range + if channel.ChannelInfo.MultiKeyStatusList != nil { + for idx := range channel.ChannelInfo.MultiKeyStatusList { + if idx >= channel.ChannelInfo.MultiKeySize { + delete(channel.ChannelInfo.MultiKeyStatusList, idx) + } + } + } + } + var err error + err = DB.Model(channel).Updates(channel).Error + if err != nil { + return err + } + DB.Model(channel).First(channel, "id = ?", channel.Id) + err = channel.UpdateAbilities(nil) + return err +} + +func (channel *Channel) UpdateResponseTime(responseTime int64) { + err := DB.Model(channel).Select("response_time", "test_time").Updates(Channel{ + TestTime: common.GetTimestamp(), + ResponseTime: int(responseTime), + }).Error + if err != nil { + common.SysError("failed to update response time: " + err.Error()) + } +} + +func (channel *Channel) UpdateBalance(balance float64) { + err := DB.Model(channel).Select("balance_updated_time", "balance").Updates(Channel{ + BalanceUpdatedTime: common.GetTimestamp(), + Balance: balance, + }).Error + if err != nil { + common.SysError("failed to update balance: " + err.Error()) + } +} + +func (channel *Channel) Delete() error { + var err error + err = DB.Delete(channel).Error + if err != nil { + return err + } + err = channel.DeleteAbilities() + return err +} + +var channelStatusLock sync.Mutex + +// channelPollingLocks stores locks for each channel.id to ensure thread-safe polling +var channelPollingLocks sync.Map + +// getChannelPollingLock returns or creates a mutex for the given channel ID +func getChannelPollingLock(channelId int) *sync.Mutex { + if lock, exists := channelPollingLocks.Load(channelId); exists { + return lock.(*sync.Mutex) + } + // Create new lock for this channel + newLock := &sync.Mutex{} + actual, _ := channelPollingLocks.LoadOrStore(channelId, newLock) + return actual.(*sync.Mutex) +} + +// CleanupChannelPollingLocks removes locks for channels that no longer exist +// This is optional and can be called periodically to prevent memory leaks +func CleanupChannelPollingLocks() { + var activeChannelIds []int + DB.Model(&Channel{}).Pluck("id", &activeChannelIds) + + activeChannelSet := make(map[int]bool) + for _, id := range activeChannelIds { + activeChannelSet[id] = true + } + + channelPollingLocks.Range(func(key, value interface{}) bool { + channelId := key.(int) + if !activeChannelSet[channelId] { + channelPollingLocks.Delete(channelId) + } + return true + }) +} + +func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int) { + keys := channel.getKeys() + if len(keys) == 0 { + channel.Status = status + } else { + var keyIndex int + for i, key := range keys { + if key == usingKey { + keyIndex = i + break + } + } + if channel.ChannelInfo.MultiKeyStatusList == nil { + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + } + if status == common.ChannelStatusEnabled { + delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex) + } else { + channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status + } + if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize { + channel.Status = common.ChannelStatusAutoDisabled + info := channel.GetOtherInfo() + info["status_reason"] = "All keys are disabled" + info["status_time"] = common.GetTimestamp() + channel.SetOtherInfo(info) + } + } +} + +func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool { + if common.MemoryCacheEnabled { + channelStatusLock.Lock() + defer channelStatusLock.Unlock() + + channelCache, _ := CacheGetChannel(channelId) + if channelCache == nil { + return false + } + if channelCache.ChannelInfo.IsMultiKey { + // 如果是多Key模式,更新缓存中的状态 + handlerMultiKeyUpdate(channelCache, usingKey, status) + //CacheUpdateChannel(channelCache) + //return true + } else { + // 如果缓存渠道存在,且状态已是目标状态,直接返回 + if channelCache.Status == status { + return false + } + // 如果缓存渠道不存在(说明已经被禁用),且要设置的状态不为启用,直接返回 + if status != common.ChannelStatusEnabled { + return false + } + CacheUpdateChannelStatus(channelId, status) + } + } + + shouldUpdateAbilities := false + defer func() { + if shouldUpdateAbilities { + err := UpdateAbilityStatus(channelId, status == common.ChannelStatusEnabled) + if err != nil { + common.SysError("failed to update ability status: " + err.Error()) + } + } + }() + channel, err := GetChannelById(channelId, true) + if err != nil { + return false + } else { + if channel.Status == status { + return false + } + + if channel.ChannelInfo.IsMultiKey { + beforeStatus := channel.Status + handlerMultiKeyUpdate(channel, usingKey, status) + if beforeStatus != channel.Status { + shouldUpdateAbilities = true + } + } else { + info := channel.GetOtherInfo() + info["status_reason"] = reason + info["status_time"] = common.GetTimestamp() + channel.SetOtherInfo(info) + channel.Status = status + shouldUpdateAbilities = true + } + err = channel.Save() + if err != nil { + common.SysError("failed to update channel status: " + err.Error()) + return false + } + } + return true +} + +func EnableChannelByTag(tag string) error { + err := DB.Model(&Channel{}).Where("tag = ?", tag).Update("status", common.ChannelStatusEnabled).Error + if err != nil { + return err + } + err = UpdateAbilityStatusByTag(tag, true) + return err +} + +func DisableChannelByTag(tag string) error { + err := DB.Model(&Channel{}).Where("tag = ?", tag).Update("status", common.ChannelStatusManuallyDisabled).Error + if err != nil { + return err + } + err = UpdateAbilityStatusByTag(tag, false) + return err +} + +func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint) error { + updateData := Channel{} + shouldReCreateAbilities := false + updatedTag := tag + // 如果 newTag 不为空且不等于 tag,则更新 tag + if newTag != nil && *newTag != tag { + updateData.Tag = newTag + updatedTag = *newTag + } + if modelMapping != nil && *modelMapping != "" { + updateData.ModelMapping = modelMapping + } + if models != nil && *models != "" { + shouldReCreateAbilities = true + updateData.Models = *models + } + if group != nil && *group != "" { + shouldReCreateAbilities = true + updateData.Group = *group + } + if priority != nil { + updateData.Priority = priority + } + if weight != nil { + updateData.Weight = weight + } + + err := DB.Model(&Channel{}).Where("tag = ?", tag).Updates(updateData).Error + if err != nil { + return err + } + if shouldReCreateAbilities { + channels, err := GetChannelsByTag(updatedTag, false) + if err == nil { + for _, channel := range channels { + err = channel.UpdateAbilities(nil) + if err != nil { + common.SysError("failed to update abilities: " + err.Error()) + } + } + } + } else { + err := UpdateAbilityByTag(tag, newTag, priority, weight) + if err != nil { + return err + } + } + return nil +} + +func UpdateChannelUsedQuota(id int, quota int) { + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeChannelUsedQuota, id, quota) + return + } + updateChannelUsedQuota(id, quota) +} + +func updateChannelUsedQuota(id int, quota int) { + err := DB.Model(&Channel{}).Where("id = ?", id).Update("used_quota", gorm.Expr("used_quota + ?", quota)).Error + if err != nil { + common.SysError("failed to update channel used quota: " + err.Error()) + } +} + +func DeleteChannelByStatus(status int64) (int64, error) { + result := DB.Where("status = ?", status).Delete(&Channel{}) + return result.RowsAffected, result.Error +} + +func DeleteDisabledChannel() (int64, error) { + result := DB.Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled).Delete(&Channel{}) + return result.RowsAffected, result.Error +} + +func GetPaginatedTags(offset int, limit int) ([]*string, error) { + var tags []*string + err := DB.Model(&Channel{}).Select("DISTINCT tag").Where("tag != ''").Offset(offset).Limit(limit).Find(&tags).Error + return tags, err +} + +func SearchTags(keyword string, group string, model string, idSort bool) ([]*string, error) { + var tags []*string + modelsCol := "`models`" + + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + modelsCol = `"models"` + } + + baseURLCol := "`base_url`" + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + baseURLCol = `"base_url"` + } + + order := "priority desc" + if idSort { + order = "id desc" + } + + // 构造基础查询 + baseQuery := DB.Model(&Channel{}).Omit("key") + + // 构造WHERE子句 + var whereClause string + var args []interface{} + if group != "" && group != "null" { + var groupCondition string + if common.UsingMySQL { + groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?` + } else { + // sqlite, PostgreSQL + groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?` + } + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%") + } else { + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%") + } + + subQuery := baseQuery.Where(whereClause, args...). + Select("tag"). + Where("tag != ''"). + Order(order) + + err := DB.Table("(?) as sub", subQuery). + Select("DISTINCT tag"). + Find(&tags).Error + + if err != nil { + return nil, err + } + + return tags, nil +} + +func (channel *Channel) ValidateSettings() error { + channelParams := &dto.ChannelSettings{} + if channel.Setting != nil && *channel.Setting != "" { + err := json.Unmarshal([]byte(*channel.Setting), channelParams) + if err != nil { + return err + } + } + return nil +} + +func (channel *Channel) GetSetting() dto.ChannelSettings { + setting := dto.ChannelSettings{} + if channel.Setting != nil && *channel.Setting != "" { + err := json.Unmarshal([]byte(*channel.Setting), &setting) + if err != nil { + common.SysError("failed to unmarshal setting: " + err.Error()) + channel.Setting = nil // 清空设置以避免后续错误 + _ = channel.Save() // 保存修改 + } + } + return setting +} + +func (channel *Channel) SetSetting(setting dto.ChannelSettings) { + settingBytes, err := json.Marshal(setting) + if err != nil { + common.SysError("failed to marshal setting: " + err.Error()) + return + } + channel.Setting = common.GetPointer[string](string(settingBytes)) +} + +func (channel *Channel) GetParamOverride() map[string]interface{} { + paramOverride := make(map[string]interface{}) + if channel.ParamOverride != nil && *channel.ParamOverride != "" { + err := json.Unmarshal([]byte(*channel.ParamOverride), ¶mOverride) + if err != nil { + common.SysError("failed to unmarshal param override: " + err.Error()) + } + } + return paramOverride +} + +func GetChannelsByIds(ids []int) ([]*Channel, error) { + var channels []*Channel + err := DB.Where("id in (?)", ids).Find(&channels).Error + return channels, err +} + +func BatchSetChannelTag(ids []int, tag *string) error { + // 开启事务 + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + + // 更新标签 + err := tx.Model(&Channel{}).Where("id in (?)", ids).Update("tag", tag).Error + if err != nil { + tx.Rollback() + return err + } + + // update ability status + channels, err := GetChannelsByIds(ids) + if err != nil { + tx.Rollback() + return err + } + + for _, channel := range channels { + err = channel.UpdateAbilities(tx) + if err != nil { + tx.Rollback() + return err + } + } + + // 提交事务 + return tx.Commit().Error +} + +// CountAllChannels returns total channels in DB +func CountAllChannels() (int64, error) { + var total int64 + err := DB.Model(&Channel{}).Count(&total).Error + return total, err +} + +// CountAllTags returns number of non-empty distinct tags +func CountAllTags() (int64, error) { + var total int64 + err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error + return total, err +} + +// Get channels of specified type with pagination +func GetChannelsByType(startIdx int, num int, idSort bool, channelType int) ([]*Channel, error) { + var channels []*Channel + order := "priority desc" + if idSort { + order = "id desc" + } + err := DB.Where("type = ?", channelType).Order(order).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error + return channels, err +} + +// Count channels of specific type +func CountChannelsByType(channelType int) (int64, error) { + var count int64 + err := DB.Model(&Channel{}).Where("type = ?", channelType).Count(&count).Error + return count, err +} + +// Return map[type]count for all channels +func CountChannelsGroupByType() (map[int64]int64, error) { + type result struct { + Type int64 `gorm:"column:type"` + Count int64 `gorm:"column:count"` + } + var results []result + err := DB.Model(&Channel{}).Select("type, count(*) as count").Group("type").Find(&results).Error + if err != nil { + return nil, err + } + counts := make(map[int64]int64) + for _, r := range results { + counts[r.Type] = r.Count + } + return counts, nil +} diff --git a/model/channel_cache.go b/model/channel_cache.go new file mode 100644 index 00000000..b2451248 --- /dev/null +++ b/model/channel_cache.go @@ -0,0 +1,262 @@ +package model + +import ( + "errors" + "fmt" + "math/rand" + "one-api/common" + "one-api/setting" + "sort" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +var group2model2channels map[string]map[string][]int // enabled channel +var channelsIDM map[int]*Channel // all channels include disabled +var channelSyncLock sync.RWMutex + +func InitChannelCache() { + if !common.MemoryCacheEnabled { + return + } + newChannelId2channel := make(map[int]*Channel) + var channels []*Channel + DB.Find(&channels) + for _, channel := range channels { + newChannelId2channel[channel.Id] = channel + } + var abilities []*Ability + DB.Find(&abilities) + groups := make(map[string]bool) + for _, ability := range abilities { + groups[ability.Group] = true + } + newGroup2model2channels := make(map[string]map[string][]int) + for group := range groups { + newGroup2model2channels[group] = make(map[string][]int) + } + for _, channel := range channels { + if channel.Status != common.ChannelStatusEnabled { + continue // skip disabled channels + } + groups := strings.Split(channel.Group, ",") + for _, group := range groups { + models := strings.Split(channel.Models, ",") + for _, model := range models { + if _, ok := newGroup2model2channels[group][model]; !ok { + newGroup2model2channels[group][model] = make([]int, 0) + } + newGroup2model2channels[group][model] = append(newGroup2model2channels[group][model], channel.Id) + } + } + } + + // sort by priority + for group, model2channels := range newGroup2model2channels { + for model, channels := range model2channels { + sort.Slice(channels, func(i, j int) bool { + return newChannelId2channel[channels[i]].GetPriority() > newChannelId2channel[channels[j]].GetPriority() + }) + newGroup2model2channels[group][model] = channels + } + } + + channelSyncLock.Lock() + group2model2channels = newGroup2model2channels + channelsIDM = newChannelId2channel + channelSyncLock.Unlock() + common.SysLog("channels synced from database") +} + +func SyncChannelCache(frequency int) { + for { + time.Sleep(time.Duration(frequency) * time.Second) + common.SysLog("syncing channels from database") + InitChannelCache() + } +} + +func CacheGetRandomSatisfiedChannel(c *gin.Context, group string, model string, retry int) (*Channel, string, error) { + var channel *Channel + var err error + selectGroup := group + if group == "auto" { + if len(setting.AutoGroups) == 0 { + return nil, selectGroup, errors.New("auto groups is not enabled") + } + for _, autoGroup := range setting.AutoGroups { + if common.DebugEnabled { + println("autoGroup:", autoGroup) + } + channel, _ = getRandomSatisfiedChannel(autoGroup, model, retry) + if channel == nil { + continue + } else { + c.Set("auto_group", autoGroup) + selectGroup = autoGroup + if common.DebugEnabled { + println("selectGroup:", selectGroup) + } + break + } + } + } else { + channel, err = getRandomSatisfiedChannel(group, model, retry) + if err != nil { + return nil, group, err + } + } + if channel == nil { + return nil, group, errors.New("channel not found") + } + return channel, selectGroup, nil +} + +func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { + if strings.HasPrefix(model, "gpt-4-gizmo") { + model = "gpt-4-gizmo-*" + } + if strings.HasPrefix(model, "gpt-4o-gizmo") { + model = "gpt-4o-gizmo-*" + } + + // if memory cache is disabled, get channel directly from database + if !common.MemoryCacheEnabled { + return GetRandomSatisfiedChannel(group, model, retry) + } + + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + channels := group2model2channels[group][model] + + if len(channels) == 0 { + return nil, errors.New("channel not found") + } + + if len(channels) == 1 { + if channel, ok := channelsIDM[channels[0]]; ok { + return channel, nil + } + return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channels[0]) + } + + uniquePriorities := make(map[int]bool) + for _, channelId := range channels { + if channel, ok := channelsIDM[channelId]; ok { + uniquePriorities[int(channel.GetPriority())] = true + } else { + return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) + } + } + var sortedUniquePriorities []int + for priority := range uniquePriorities { + sortedUniquePriorities = append(sortedUniquePriorities, priority) + } + sort.Sort(sort.Reverse(sort.IntSlice(sortedUniquePriorities))) + + if retry >= len(uniquePriorities) { + retry = len(uniquePriorities) - 1 + } + targetPriority := int64(sortedUniquePriorities[retry]) + + // get the priority for the given retry number + var targetChannels []*Channel + for _, channelId := range channels { + if channel, ok := channelsIDM[channelId]; ok { + if channel.GetPriority() == targetPriority { + targetChannels = append(targetChannels, channel) + } + } else { + return nil, fmt.Errorf("数据库一致性错误,渠道# %d 不存在,请联系管理员修复", channelId) + } + } + + // 平滑系数 + smoothingFactor := 10 + // Calculate the total weight of all channels up to endIdx + totalWeight := 0 + for _, channel := range targetChannels { + totalWeight += channel.GetWeight() + smoothingFactor + } + // Generate a random value in the range [0, totalWeight) + randomWeight := rand.Intn(totalWeight) + + // Find a channel based on its weight + for _, channel := range targetChannels { + randomWeight -= channel.GetWeight() + smoothingFactor + if randomWeight < 0 { + return channel, nil + } + } + // return null if no channel is not found + return nil, errors.New("channel not found") +} + +func CacheGetChannel(id int) (*Channel, error) { + if !common.MemoryCacheEnabled { + return GetChannelById(id, true) + } + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + + c, ok := channelsIDM[id] + if !ok { + return nil, fmt.Errorf("渠道# %d,已不存在", id) + } + if c.Status != common.ChannelStatusEnabled { + return nil, fmt.Errorf("渠道# %d,已被禁用", id) + } + return c, nil +} + +func CacheGetChannelInfo(id int) (*ChannelInfo, error) { + if !common.MemoryCacheEnabled { + channel, err := GetChannelById(id, true) + if err != nil { + return nil, err + } + return &channel.ChannelInfo, nil + } + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + + c, ok := channelsIDM[id] + if !ok { + return nil, fmt.Errorf("渠道# %d,已不存在", id) + } + if c.Status != common.ChannelStatusEnabled { + return nil, fmt.Errorf("渠道# %d,已被禁用", id) + } + return &c.ChannelInfo, nil +} + +func CacheUpdateChannelStatus(id int, status int) { + if !common.MemoryCacheEnabled { + return + } + channelSyncLock.Lock() + defer channelSyncLock.Unlock() + if channel, ok := channelsIDM[id]; ok { + channel.Status = status + } +} + +func CacheUpdateChannel(channel *Channel) { + if !common.MemoryCacheEnabled { + return + } + channelSyncLock.Lock() + defer channelSyncLock.Unlock() + if channel == nil { + return + } + + println("CacheUpdateChannel:", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex) + + println("before:", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex) + channelsIDM[channel.Id] = channel + println("after :", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex) +} diff --git a/model/log.go b/model/log.go new file mode 100644 index 00000000..2070cd6f --- /dev/null +++ b/model/log.go @@ -0,0 +1,411 @@ +package model + +import ( + "context" + "fmt" + "one-api/common" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/bytedance/gopkg/util/gopool" + "gorm.io/gorm" +) + +type Log struct { + Id int `json:"id" gorm:"index:idx_created_at_id,priority:1"` + UserId int `json:"user_id" gorm:"index"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"` + Type int `json:"type" gorm:"index:idx_created_at_type"` + Content string `json:"content"` + Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"` + TokenName string `json:"token_name" gorm:"index;default:''"` + ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"` + Quota int `json:"quota" gorm:"default:0"` + PromptTokens int `json:"prompt_tokens" gorm:"default:0"` + CompletionTokens int `json:"completion_tokens" gorm:"default:0"` + UseTime int `json:"use_time" gorm:"default:0"` + IsStream bool `json:"is_stream"` + ChannelId int `json:"channel" gorm:"index"` + ChannelName string `json:"channel_name" gorm:"->"` + TokenId int `json:"token_id" gorm:"default:0;index"` + Group string `json:"group" gorm:"index"` + Ip string `json:"ip" gorm:"index;default:''"` + Other string `json:"other"` +} + +const ( + LogTypeUnknown = iota + LogTypeTopup + LogTypeConsume + LogTypeManage + LogTypeSystem + LogTypeError +) + +func formatUserLogs(logs []*Log) { + for i := range logs { + logs[i].ChannelName = "" + var otherMap map[string]interface{} + otherMap, _ = common.StrToMap(logs[i].Other) + if otherMap != nil { + // delete admin + delete(otherMap, "admin_info") + } + logs[i].Other = common.MapToJsonStr(otherMap) + logs[i].Id = logs[i].Id % 1024 + } +} + +func GetLogByKey(key string) (logs []*Log, err error) { + if os.Getenv("LOG_SQL_DSN") != "" { + var tk Token + if err = DB.Model(&Token{}).Where(logKeyCol+"=?", strings.TrimPrefix(key, "sk-")).First(&tk).Error; err != nil { + return nil, err + } + err = LOG_DB.Model(&Log{}).Where("token_id=?", tk.Id).Find(&logs).Error + } else { + err = LOG_DB.Joins("left join tokens on tokens.id = logs.token_id").Where("tokens.key = ?", strings.TrimPrefix(key, "sk-")).Find(&logs).Error + } + formatUserLogs(logs) + return logs, err +} + +func RecordLog(userId int, logType int, content string) { + if logType == LogTypeConsume && !common.LogConsumeEnabled { + return + } + username, _ := GetUsernameById(userId, false) + log := &Log{ + UserId: userId, + Username: username, + CreatedAt: common.GetTimestamp(), + Type: logType, + Content: content, + } + err := LOG_DB.Create(log).Error + if err != nil { + common.SysError("failed to record log: " + err.Error()) + } +} + +func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int, + isStream bool, group string, other map[string]interface{}) { + common.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content)) + username := c.GetString("username") + otherStr := common.MapToJsonStr(other) + // 判断是否需要记录 IP + needRecordIp := false + if settingMap, err := GetUserSetting(userId, false); err == nil { + if settingMap.RecordIpLog { + needRecordIp = true + } + } + log := &Log{ + UserId: userId, + Username: username, + CreatedAt: common.GetTimestamp(), + Type: LogTypeError, + Content: content, + PromptTokens: 0, + CompletionTokens: 0, + TokenName: tokenName, + ModelName: modelName, + Quota: 0, + ChannelId: channelId, + TokenId: tokenId, + UseTime: useTimeSeconds, + IsStream: isStream, + Group: group, + Ip: func() string { + if needRecordIp { + return c.ClientIP() + } + return "" + }(), + Other: otherStr, + } + err := LOG_DB.Create(log).Error + if err != nil { + common.LogError(c, "failed to record log: "+err.Error()) + } +} + +type RecordConsumeLogParams struct { + ChannelId int `json:"channel_id"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + ModelName string `json:"model_name"` + TokenName string `json:"token_name"` + Quota int `json:"quota"` + Content string `json:"content"` + TokenId int `json:"token_id"` + UserQuota int `json:"user_quota"` + UseTimeSeconds int `json:"use_time_seconds"` + IsStream bool `json:"is_stream"` + Group string `json:"group"` + Other map[string]interface{} `json:"other"` +} + +func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) { + common.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params))) + if !common.LogConsumeEnabled { + return + } + username := c.GetString("username") + otherStr := common.MapToJsonStr(params.Other) + // 判断是否需要记录 IP + needRecordIp := false + if settingMap, err := GetUserSetting(userId, false); err == nil { + if settingMap.RecordIpLog { + needRecordIp = true + } + } + log := &Log{ + UserId: userId, + Username: username, + CreatedAt: common.GetTimestamp(), + Type: LogTypeConsume, + Content: params.Content, + PromptTokens: params.PromptTokens, + CompletionTokens: params.CompletionTokens, + TokenName: params.TokenName, + ModelName: params.ModelName, + Quota: params.Quota, + ChannelId: params.ChannelId, + TokenId: params.TokenId, + UseTime: params.UseTimeSeconds, + IsStream: params.IsStream, + Group: params.Group, + Ip: func() string { + if needRecordIp { + return c.ClientIP() + } + return "" + }(), + Other: otherStr, + } + err := LOG_DB.Create(log).Error + if err != nil { + common.LogError(c, "failed to record log: "+err.Error()) + } + if common.DataExportEnabled { + gopool.Go(func() { + LogQuotaData(userId, username, params.ModelName, params.Quota, common.GetTimestamp(), params.PromptTokens+params.CompletionTokens) + }) + } +} + +func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string) (logs []*Log, total int64, err error) { + var tx *gorm.DB + if logType == LogTypeUnknown { + tx = LOG_DB + } else { + tx = LOG_DB.Where("logs.type = ?", logType) + } + + if modelName != "" { + tx = tx.Where("logs.model_name like ?", modelName) + } + if username != "" { + tx = tx.Where("logs.username = ?", username) + } + if tokenName != "" { + tx = tx.Where("logs.token_name = ?", tokenName) + } + if startTimestamp != 0 { + tx = tx.Where("logs.created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("logs.created_at <= ?", endTimestamp) + } + if channel != 0 { + tx = tx.Where("logs.channel_id = ?", channel) + } + if group != "" { + tx = tx.Where("logs."+logGroupCol+" = ?", group) + } + err = tx.Model(&Log{}).Count(&total).Error + if err != nil { + return nil, 0, err + } + err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error + if err != nil { + return nil, 0, err + } + + channelIdsMap := make(map[int]struct{}) + channelMap := make(map[int]string) + for _, log := range logs { + if log.ChannelId != 0 { + channelIdsMap[log.ChannelId] = struct{}{} + } + } + + channelIds := make([]int, 0, len(channelIdsMap)) + for channelId := range channelIdsMap { + channelIds = append(channelIds, channelId) + } + if len(channelIds) > 0 { + var channels []struct { + Id int `gorm:"column:id"` + Name string `gorm:"column:name"` + } + if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds).Find(&channels).Error; err != nil { + return logs, total, err + } + for _, channel := range channels { + channelMap[channel.Id] = channel.Name + } + for i := range logs { + logs[i].ChannelName = channelMap[logs[i].ChannelId] + } + } + + return logs, total, err +} + +func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string) (logs []*Log, total int64, err error) { + var tx *gorm.DB + if logType == LogTypeUnknown { + tx = LOG_DB.Where("logs.user_id = ?", userId) + } else { + tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType) + } + + if modelName != "" { + tx = tx.Where("logs.model_name like ?", modelName) + } + if tokenName != "" { + tx = tx.Where("logs.token_name = ?", tokenName) + } + if startTimestamp != 0 { + tx = tx.Where("logs.created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("logs.created_at <= ?", endTimestamp) + } + if group != "" { + tx = tx.Where("logs."+logGroupCol+" = ?", group) + } + err = tx.Model(&Log{}).Count(&total).Error + if err != nil { + return nil, 0, err + } + err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error + if err != nil { + return nil, 0, err + } + + formatUserLogs(logs) + return logs, total, err +} + +func SearchAllLogs(keyword string) (logs []*Log, err error) { + err = LOG_DB.Where("type = ? or content LIKE ?", keyword, keyword+"%").Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error + return logs, err +} + +func SearchUserLogs(userId int, keyword string) (logs []*Log, err error) { + err = LOG_DB.Where("user_id = ? and type = ?", userId, keyword).Order("id desc").Limit(common.MaxRecentItems).Find(&logs).Error + formatUserLogs(logs) + return logs, err +} + +type Stat struct { + Quota int `json:"quota"` + Rpm int `json:"rpm"` + Tpm int `json:"tpm"` +} + +func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat) { + tx := LOG_DB.Table("logs").Select("sum(quota) quota") + + // 为rpm和tpm创建单独的查询 + rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, sum(prompt_tokens) + sum(completion_tokens) tpm") + + if username != "" { + tx = tx.Where("username = ?", username) + rpmTpmQuery = rpmTpmQuery.Where("username = ?", username) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + rpmTpmQuery = rpmTpmQuery.Where("token_name = ?", tokenName) + } + if startTimestamp != 0 { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if modelName != "" { + tx = tx.Where("model_name like ?", modelName) + rpmTpmQuery = rpmTpmQuery.Where("model_name like ?", modelName) + } + if channel != 0 { + tx = tx.Where("channel_id = ?", channel) + rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel) + } + if group != "" { + tx = tx.Where(logGroupCol+" = ?", group) + rpmTpmQuery = rpmTpmQuery.Where(logGroupCol+" = ?", group) + } + + tx = tx.Where("type = ?", LogTypeConsume) + rpmTpmQuery = rpmTpmQuery.Where("type = ?", LogTypeConsume) + + // 只统计最近60秒的rpm和tpm + rpmTpmQuery = rpmTpmQuery.Where("created_at >= ?", time.Now().Add(-60*time.Second).Unix()) + + // 执行查询 + tx.Scan(&stat) + rpmTpmQuery.Scan(&stat) + + return stat +} + +func SumUsedToken(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string) (token int) { + tx := LOG_DB.Table("logs").Select("ifnull(sum(prompt_tokens),0) + ifnull(sum(completion_tokens),0)") + if username != "" { + tx = tx.Where("username = ?", username) + } + if tokenName != "" { + tx = tx.Where("token_name = ?", tokenName) + } + if startTimestamp != 0 { + tx = tx.Where("created_at >= ?", startTimestamp) + } + if endTimestamp != 0 { + tx = tx.Where("created_at <= ?", endTimestamp) + } + if modelName != "" { + tx = tx.Where("model_name = ?", modelName) + } + tx.Where("type = ?", LogTypeConsume).Scan(&token) + return token +} + +func DeleteOldLog(ctx context.Context, targetTimestamp int64, limit int) (int64, error) { + var total int64 = 0 + + for { + if nil != ctx.Err() { + return total, ctx.Err() + } + + result := LOG_DB.Where("created_at < ?", targetTimestamp).Limit(limit).Delete(&Log{}) + if nil != result.Error { + return total, result.Error + } + + total += result.RowsAffected + + if result.RowsAffected < int64(limit) { + break + } + } + + return total, nil +} diff --git a/model/main.go b/model/main.go new file mode 100644 index 00000000..013beacd --- /dev/null +++ b/model/main.go @@ -0,0 +1,363 @@ +package model + +import ( + "fmt" + "log" + "one-api/common" + "one-api/constant" + "os" + "strings" + "sync" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/driver/mysql" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +var commonGroupCol string +var commonKeyCol string +var commonTrueVal string +var commonFalseVal string + +var logKeyCol string +var logGroupCol string + +func initCol() { + // init common column names + if common.UsingPostgreSQL { + commonGroupCol = `"group"` + commonKeyCol = `"key"` + commonTrueVal = "true" + commonFalseVal = "false" + } else { + commonGroupCol = "`group`" + commonKeyCol = "`key`" + commonTrueVal = "1" + commonFalseVal = "0" + } + if os.Getenv("LOG_SQL_DSN") != "" { + switch common.LogSqlType { + case common.DatabaseTypePostgreSQL: + logGroupCol = `"group"` + logKeyCol = `"key"` + default: + logGroupCol = commonGroupCol + logKeyCol = commonKeyCol + } + } else { + // LOG_SQL_DSN 为空时,日志数据库与主数据库相同 + if common.UsingPostgreSQL { + logGroupCol = `"group"` + logKeyCol = `"key"` + } else { + logGroupCol = commonGroupCol + logKeyCol = commonKeyCol + } + } + // log sql type and database type + //common.SysLog("Using Log SQL Type: " + common.LogSqlType) +} + +var DB *gorm.DB + +var LOG_DB *gorm.DB + +func createRootAccountIfNeed() error { + var user User + //if user.Status != common.UserStatusEnabled { + if err := DB.First(&user).Error; err != nil { + common.SysLog("no user exists, create a root user for you: username is root, password is 123456") + hashedPassword, err := common.Password2Hash("123456") + if err != nil { + return err + } + rootUser := User{ + Username: "root", + Password: hashedPassword, + Role: common.RoleRootUser, + Status: common.UserStatusEnabled, + DisplayName: "Root User", + AccessToken: nil, + Quota: 100000000, + } + DB.Create(&rootUser) + } + return nil +} + +func CheckSetup() { + setup := GetSetup() + if setup == nil { + // No setup record exists, check if we have a root user + if RootUserExists() { + common.SysLog("system is not initialized, but root user exists") + // Create setup record + newSetup := Setup{ + Version: common.Version, + InitializedAt: time.Now().Unix(), + } + err := DB.Create(&newSetup).Error + if err != nil { + common.SysLog("failed to create setup record: " + err.Error()) + } + constant.Setup = true + } else { + common.SysLog("system is not initialized and no root user exists") + constant.Setup = false + } + } else { + // Setup record exists, system is initialized + common.SysLog("system is already initialized at: " + time.Unix(setup.InitializedAt, 0).String()) + constant.Setup = true + } +} + +func chooseDB(envName string, isLog bool) (*gorm.DB, error) { + defer func() { + initCol() + }() + dsn := os.Getenv(envName) + if dsn != "" { + if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") { + // Use PostgreSQL + common.SysLog("using PostgreSQL as database") + if !isLog { + common.UsingPostgreSQL = true + } else { + common.LogSqlType = common.DatabaseTypePostgreSQL + } + return gorm.Open(postgres.New(postgres.Config{ + DSN: dsn, + PreferSimpleProtocol: true, // disables implicit prepared statement usage + }), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) + } + if strings.HasPrefix(dsn, "local") { + common.SysLog("SQL_DSN not set, using SQLite as database") + if !isLog { + common.UsingSQLite = true + } else { + common.LogSqlType = common.DatabaseTypeSQLite + } + return gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) + } + // Use MySQL + common.SysLog("using MySQL as database") + // check parseTime + if !strings.Contains(dsn, "parseTime") { + if strings.Contains(dsn, "?") { + dsn += "&parseTime=true" + } else { + dsn += "?parseTime=true" + } + } + if !isLog { + common.UsingMySQL = true + } else { + common.LogSqlType = common.DatabaseTypeMySQL + } + return gorm.Open(mysql.Open(dsn), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) + } + // Use SQLite + common.SysLog("SQL_DSN not set, using SQLite as database") + common.UsingSQLite = true + return gorm.Open(sqlite.Open(common.SQLitePath), &gorm.Config{ + PrepareStmt: true, // precompile SQL + }) +} + +func InitDB() (err error) { + db, err := chooseDB("SQL_DSN", false) + if err == nil { + if common.DebugEnabled { + db = db.Debug() + } + DB = db + sqlDB, err := DB.DB() + if err != nil { + return err + } + sqlDB.SetMaxIdleConns(common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100)) + sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000)) + sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60))) + + if !common.IsMasterNode { + return nil + } + if common.UsingMySQL { + //_, _ = sqlDB.Exec("ALTER TABLE channels MODIFY model_mapping TEXT;") // TODO: delete this line when most users have upgraded + } + common.SysLog("database migration started") + err = migrateDB() + return err + } else { + common.FatalLog(err) + } + return err +} + +func InitLogDB() (err error) { + if os.Getenv("LOG_SQL_DSN") == "" { + LOG_DB = DB + return + } + db, err := chooseDB("LOG_SQL_DSN", true) + if err == nil { + if common.DebugEnabled { + db = db.Debug() + } + LOG_DB = db + sqlDB, err := LOG_DB.DB() + if err != nil { + return err + } + sqlDB.SetMaxIdleConns(common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100)) + sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000)) + sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60))) + + if !common.IsMasterNode { + return nil + } + common.SysLog("database migration started") + err = migrateLOGDB() + return err + } else { + common.FatalLog(err) + } + return err +} + +func migrateDB() error { + if !common.UsingPostgreSQL { + return migrateDBFast() + } + err := DB.AutoMigrate( + &Channel{}, + &Token{}, + &User{}, + &Option{}, + &Redemption{}, + &Ability{}, + &Log{}, + &Midjourney{}, + &TopUp{}, + &QuotaData{}, + &Task{}, + &Setup{}, + ) + if err != nil { + return err + } + return nil +} + +func migrateDBFast() error { + var wg sync.WaitGroup + + migrations := []struct { + model interface{} + name string + }{ + {&Channel{}, "Channel"}, + {&Token{}, "Token"}, + {&User{}, "User"}, + {&Option{}, "Option"}, + {&Redemption{}, "Redemption"}, + {&Ability{}, "Ability"}, + {&Log{}, "Log"}, + {&Midjourney{}, "Midjourney"}, + {&TopUp{}, "TopUp"}, + {&QuotaData{}, "QuotaData"}, + {&Task{}, "Task"}, + {&Setup{}, "Setup"}, + } + // 动态计算migration数量,确保errChan缓冲区足够大 + errChan := make(chan error, len(migrations)) + + for _, m := range migrations { + wg.Add(1) + go func(model interface{}, name string) { + defer wg.Done() + if err := DB.AutoMigrate(model); err != nil { + errChan <- fmt.Errorf("failed to migrate %s: %v", name, err) + } + }(m.model, m.name) + } + + // Wait for all migrations to complete + wg.Wait() + close(errChan) + + // Check for any errors + for err := range errChan { + if err != nil { + return err + } + } + common.SysLog("database migrated") + return nil +} + +func migrateLOGDB() error { + var err error + if err = LOG_DB.AutoMigrate(&Log{}); err != nil { + return err + } + return nil +} + +func closeDB(db *gorm.DB) error { + sqlDB, err := db.DB() + if err != nil { + return err + } + err = sqlDB.Close() + return err +} + +func CloseDB() error { + if LOG_DB != DB { + err := closeDB(LOG_DB) + if err != nil { + return err + } + } + return closeDB(DB) +} + +var ( + lastPingTime time.Time + pingMutex sync.Mutex +) + +func PingDB() error { + pingMutex.Lock() + defer pingMutex.Unlock() + + if time.Since(lastPingTime) < time.Second*10 { + return nil + } + + sqlDB, err := DB.DB() + if err != nil { + log.Printf("Error getting sql.DB from GORM: %v", err) + return err + } + + err = sqlDB.Ping() + if err != nil { + log.Printf("Error pinging DB: %v", err) + return err + } + + lastPingTime = time.Now() + common.SysLog("Database pinged successfully") + return nil +} diff --git a/model/midjourney.go b/model/midjourney.go new file mode 100644 index 00000000..c6ef5de5 --- /dev/null +++ b/model/midjourney.go @@ -0,0 +1,207 @@ +package model + +type Midjourney struct { + Id int `json:"id"` + Code int `json:"code"` + UserId int `json:"user_id" gorm:"index"` + Action string `json:"action" gorm:"type:varchar(40);index"` + MjId string `json:"mj_id" gorm:"index"` + Prompt string `json:"prompt"` + PromptEn string `json:"prompt_en"` + Description string `json:"description"` + State string `json:"state"` + SubmitTime int64 `json:"submit_time" gorm:"index"` + StartTime int64 `json:"start_time" gorm:"index"` + FinishTime int64 `json:"finish_time" gorm:"index"` + ImageUrl string `json:"image_url"` + VideoUrl string `json:"video_url"` + VideoUrls string `json:"video_urls"` + Status string `json:"status" gorm:"type:varchar(20);index"` + Progress string `json:"progress" gorm:"type:varchar(30);index"` + FailReason string `json:"fail_reason"` + ChannelId int `json:"channel_id"` + Quota int `json:"quota"` + Buttons string `json:"buttons"` + Properties string `json:"properties"` +} + +// TaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段 +type TaskQueryParams struct { + ChannelID string + MjID string + StartTimestamp string + EndTimestamp string +} + +func GetAllUserTask(userId int, startIdx int, num int, queryParams TaskQueryParams) []*Midjourney { + var tasks []*Midjourney + var err error + + // 初始化查询构建器 + query := DB.Where("user_id = ?", userId) + + if queryParams.MjID != "" { + query = query.Where("mj_id = ?", queryParams.MjID) + } + if queryParams.StartTimestamp != "" { + // 假设您已将前端传来的时间戳转换为数据库所需的时间格式,并处理了时间戳的验证和解析 + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != "" { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + + // 获取数据 + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error + if err != nil { + return nil + } + + return tasks +} + +func GetAllTasks(startIdx int, num int, queryParams TaskQueryParams) []*Midjourney { + var tasks []*Midjourney + var err error + + // 初始化查询构建器 + query := DB + + // 添加过滤条件 + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.MjID != "" { + query = query.Where("mj_id = ?", queryParams.MjID) + } + if queryParams.StartTimestamp != "" { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != "" { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + + // 获取数据 + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error + if err != nil { + return nil + } + + return tasks +} + +func GetAllUnFinishTasks() []*Midjourney { + var tasks []*Midjourney + var err error + // get all tasks progress is not 100% + err = DB.Where("progress != ?", "100%").Find(&tasks).Error + if err != nil { + return nil + } + return tasks +} + +func GetByOnlyMJId(mjId string) *Midjourney { + var mj *Midjourney + var err error + err = DB.Where("mj_id = ?", mjId).First(&mj).Error + if err != nil { + return nil + } + return mj +} + +func GetByMJId(userId int, mjId string) *Midjourney { + var mj *Midjourney + var err error + err = DB.Where("user_id = ? and mj_id = ?", userId, mjId).First(&mj).Error + if err != nil { + return nil + } + return mj +} + +func GetByMJIds(userId int, mjIds []string) []*Midjourney { + var mj []*Midjourney + var err error + err = DB.Where("user_id = ? and mj_id in (?)", userId, mjIds).Find(&mj).Error + if err != nil { + return nil + } + return mj +} + +func GetMjByuId(id int) *Midjourney { + var mj *Midjourney + var err error + err = DB.Where("id = ?", id).First(&mj).Error + if err != nil { + return nil + } + return mj +} + +func UpdateProgress(id int, progress string) error { + return DB.Model(&Midjourney{}).Where("id = ?", id).Update("progress", progress).Error +} + +func (midjourney *Midjourney) Insert() error { + var err error + err = DB.Create(midjourney).Error + return err +} + +func (midjourney *Midjourney) Update() error { + var err error + err = DB.Save(midjourney).Error + return err +} + +func MjBulkUpdate(mjIds []string, params map[string]any) error { + return DB.Model(&Midjourney{}). + Where("mj_id in (?)", mjIds). + Updates(params).Error +} + +func MjBulkUpdateByTaskIds(taskIDs []int, params map[string]any) error { + return DB.Model(&Midjourney{}). + Where("id in (?)", taskIDs). + Updates(params).Error +} + +// CountAllTasks returns total midjourney tasks for admin query +func CountAllTasks(queryParams TaskQueryParams) int64 { + var total int64 + query := DB.Model(&Midjourney{}) + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.MjID != "" { + query = query.Where("mj_id = ?", queryParams.MjID) + } + if queryParams.StartTimestamp != "" { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != "" { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} + +// CountAllUserTask returns total midjourney tasks for user +func CountAllUserTask(userId int, queryParams TaskQueryParams) int64 { + var total int64 + query := DB.Model(&Midjourney{}).Where("user_id = ?", userId) + if queryParams.MjID != "" { + query = query.Where("mj_id = ?", queryParams.MjID) + } + if queryParams.StartTimestamp != "" { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != "" { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} diff --git a/model/option.go b/model/option.go new file mode 100644 index 00000000..05b99b41 --- /dev/null +++ b/model/option.go @@ -0,0 +1,442 @@ +package model + +import ( + "one-api/common" + "one-api/setting" + "one-api/setting/config" + "one-api/setting/operation_setting" + "one-api/setting/ratio_setting" + "strconv" + "strings" + "time" +) + +type Option struct { + Key string `json:"key" gorm:"primaryKey"` + Value string `json:"value"` +} + +func AllOption() ([]*Option, error) { + var options []*Option + var err error + err = DB.Find(&options).Error + return options, err +} + +func InitOptionMap() { + common.OptionMapRWMutex.Lock() + common.OptionMap = make(map[string]string) + + // 添加原有的系统配置 + common.OptionMap["FileUploadPermission"] = strconv.Itoa(common.FileUploadPermission) + common.OptionMap["FileDownloadPermission"] = strconv.Itoa(common.FileDownloadPermission) + common.OptionMap["ImageUploadPermission"] = strconv.Itoa(common.ImageUploadPermission) + common.OptionMap["ImageDownloadPermission"] = strconv.Itoa(common.ImageDownloadPermission) + common.OptionMap["PasswordLoginEnabled"] = strconv.FormatBool(common.PasswordLoginEnabled) + common.OptionMap["PasswordRegisterEnabled"] = strconv.FormatBool(common.PasswordRegisterEnabled) + common.OptionMap["EmailVerificationEnabled"] = strconv.FormatBool(common.EmailVerificationEnabled) + common.OptionMap["GitHubOAuthEnabled"] = strconv.FormatBool(common.GitHubOAuthEnabled) + common.OptionMap["LinuxDOOAuthEnabled"] = strconv.FormatBool(common.LinuxDOOAuthEnabled) + common.OptionMap["TelegramOAuthEnabled"] = strconv.FormatBool(common.TelegramOAuthEnabled) + common.OptionMap["WeChatAuthEnabled"] = strconv.FormatBool(common.WeChatAuthEnabled) + common.OptionMap["TurnstileCheckEnabled"] = strconv.FormatBool(common.TurnstileCheckEnabled) + common.OptionMap["RegisterEnabled"] = strconv.FormatBool(common.RegisterEnabled) + common.OptionMap["AutomaticDisableChannelEnabled"] = strconv.FormatBool(common.AutomaticDisableChannelEnabled) + common.OptionMap["AutomaticEnableChannelEnabled"] = strconv.FormatBool(common.AutomaticEnableChannelEnabled) + common.OptionMap["LogConsumeEnabled"] = strconv.FormatBool(common.LogConsumeEnabled) + common.OptionMap["DisplayInCurrencyEnabled"] = strconv.FormatBool(common.DisplayInCurrencyEnabled) + common.OptionMap["DisplayTokenStatEnabled"] = strconv.FormatBool(common.DisplayTokenStatEnabled) + common.OptionMap["DrawingEnabled"] = strconv.FormatBool(common.DrawingEnabled) + common.OptionMap["TaskEnabled"] = strconv.FormatBool(common.TaskEnabled) + common.OptionMap["DataExportEnabled"] = strconv.FormatBool(common.DataExportEnabled) + common.OptionMap["ChannelDisableThreshold"] = strconv.FormatFloat(common.ChannelDisableThreshold, 'f', -1, 64) + common.OptionMap["EmailDomainRestrictionEnabled"] = strconv.FormatBool(common.EmailDomainRestrictionEnabled) + common.OptionMap["EmailAliasRestrictionEnabled"] = strconv.FormatBool(common.EmailAliasRestrictionEnabled) + common.OptionMap["EmailDomainWhitelist"] = strings.Join(common.EmailDomainWhitelist, ",") + common.OptionMap["SMTPServer"] = "" + common.OptionMap["SMTPFrom"] = "" + common.OptionMap["SMTPPort"] = strconv.Itoa(common.SMTPPort) + common.OptionMap["SMTPAccount"] = "" + common.OptionMap["SMTPToken"] = "" + common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled) + common.OptionMap["Notice"] = "" + common.OptionMap["About"] = "" + common.OptionMap["HomePageContent"] = "" + common.OptionMap["Footer"] = common.Footer + common.OptionMap["SystemName"] = common.SystemName + common.OptionMap["Logo"] = common.Logo + common.OptionMap["ServerAddress"] = "" + common.OptionMap["WorkerUrl"] = setting.WorkerUrl + common.OptionMap["WorkerValidKey"] = setting.WorkerValidKey + common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(setting.WorkerAllowHttpImageRequestEnabled) + common.OptionMap["PayAddress"] = "" + common.OptionMap["CustomCallbackAddress"] = "" + common.OptionMap["EpayId"] = "" + common.OptionMap["EpayKey"] = "" + common.OptionMap["Price"] = strconv.FormatFloat(setting.Price, 'f', -1, 64) + common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(setting.USDExchangeRate, 'f', -1, 64) + common.OptionMap["MinTopUp"] = strconv.Itoa(setting.MinTopUp) + common.OptionMap["StripeMinTopUp"] = strconv.Itoa(setting.StripeMinTopUp) + common.OptionMap["StripeApiSecret"] = setting.StripeApiSecret + common.OptionMap["StripeWebhookSecret"] = setting.StripeWebhookSecret + common.OptionMap["StripePriceId"] = setting.StripePriceId + common.OptionMap["StripeUnitPrice"] = strconv.FormatFloat(setting.StripeUnitPrice, 'f', -1, 64) + common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() + common.OptionMap["Chats"] = setting.Chats2JsonString() + common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() + common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup) + common.OptionMap["PayMethods"] = setting.PayMethods2JsonString() + common.OptionMap["GitHubClientId"] = "" + common.OptionMap["GitHubClientSecret"] = "" + common.OptionMap["TelegramBotToken"] = "" + common.OptionMap["TelegramBotName"] = "" + common.OptionMap["WeChatServerAddress"] = "" + common.OptionMap["WeChatServerToken"] = "" + common.OptionMap["WeChatAccountQRCodeImageURL"] = "" + common.OptionMap["TurnstileSiteKey"] = "" + common.OptionMap["TurnstileSecretKey"] = "" + common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser) + common.OptionMap["QuotaForInviter"] = strconv.Itoa(common.QuotaForInviter) + common.OptionMap["QuotaForInvitee"] = strconv.Itoa(common.QuotaForInvitee) + common.OptionMap["QuotaRemindThreshold"] = strconv.Itoa(common.QuotaRemindThreshold) + common.OptionMap["PreConsumedQuota"] = strconv.Itoa(common.PreConsumedQuota) + common.OptionMap["ModelRequestRateLimitCount"] = strconv.Itoa(setting.ModelRequestRateLimitCount) + common.OptionMap["ModelRequestRateLimitDurationMinutes"] = strconv.Itoa(setting.ModelRequestRateLimitDurationMinutes) + common.OptionMap["ModelRequestRateLimitSuccessCount"] = strconv.Itoa(setting.ModelRequestRateLimitSuccessCount) + common.OptionMap["ModelRequestRateLimitGroup"] = setting.ModelRequestRateLimitGroup2JSONString() + common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString() + common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString() + common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString() + common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString() + common.OptionMap["GroupGroupRatio"] = ratio_setting.GroupGroupRatio2JSONString() + common.OptionMap["UserUsableGroups"] = setting.UserUsableGroups2JSONString() + common.OptionMap["CompletionRatio"] = ratio_setting.CompletionRatio2JSONString() + common.OptionMap["TopUpLink"] = common.TopUpLink + //common.OptionMap["ChatLink"] = common.ChatLink + //common.OptionMap["ChatLink2"] = common.ChatLink2 + common.OptionMap["QuotaPerUnit"] = strconv.FormatFloat(common.QuotaPerUnit, 'f', -1, 64) + common.OptionMap["RetryTimes"] = strconv.Itoa(common.RetryTimes) + common.OptionMap["DataExportInterval"] = strconv.Itoa(common.DataExportInterval) + common.OptionMap["DataExportDefaultTime"] = common.DataExportDefaultTime + common.OptionMap["DefaultCollapseSidebar"] = strconv.FormatBool(common.DefaultCollapseSidebar) + common.OptionMap["MjNotifyEnabled"] = strconv.FormatBool(setting.MjNotifyEnabled) + common.OptionMap["MjAccountFilterEnabled"] = strconv.FormatBool(setting.MjAccountFilterEnabled) + common.OptionMap["MjModeClearEnabled"] = strconv.FormatBool(setting.MjModeClearEnabled) + common.OptionMap["MjForwardUrlEnabled"] = strconv.FormatBool(setting.MjForwardUrlEnabled) + common.OptionMap["MjActionCheckSuccessEnabled"] = strconv.FormatBool(setting.MjActionCheckSuccessEnabled) + common.OptionMap["CheckSensitiveEnabled"] = strconv.FormatBool(setting.CheckSensitiveEnabled) + common.OptionMap["DemoSiteEnabled"] = strconv.FormatBool(operation_setting.DemoSiteEnabled) + common.OptionMap["SelfUseModeEnabled"] = strconv.FormatBool(operation_setting.SelfUseModeEnabled) + common.OptionMap["ModelRequestRateLimitEnabled"] = strconv.FormatBool(setting.ModelRequestRateLimitEnabled) + common.OptionMap["CheckSensitiveOnPromptEnabled"] = strconv.FormatBool(setting.CheckSensitiveOnPromptEnabled) + common.OptionMap["StopOnSensitiveEnabled"] = strconv.FormatBool(setting.StopOnSensitiveEnabled) + common.OptionMap["SensitiveWords"] = setting.SensitiveWordsToString() + common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) + common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() + common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled()) + + // 自动添加所有注册的模型配置 + modelConfigs := config.GlobalConfig.ExportAllConfigs() + for k, v := range modelConfigs { + common.OptionMap[k] = v + } + + common.OptionMapRWMutex.Unlock() + loadOptionsFromDatabase() +} + +func loadOptionsFromDatabase() { + options, _ := AllOption() + for _, option := range options { + err := updateOptionMap(option.Key, option.Value) + if err != nil { + common.SysError("failed to update option map: " + err.Error()) + } + } +} + +func SyncOptions(frequency int) { + for { + time.Sleep(time.Duration(frequency) * time.Second) + common.SysLog("syncing options from database") + loadOptionsFromDatabase() + } +} + +func UpdateOption(key string, value string) error { + // Save to database first + option := Option{ + Key: key, + } + // https://gorm.io/docs/update.html#Save-All-Fields + DB.FirstOrCreate(&option, Option{Key: key}) + option.Value = value + // Save is a combination function. + // If save value does not contain primary key, it will execute Create, + // otherwise it will execute Update (with all fields). + DB.Save(&option) + // Update OptionMap + return updateOptionMap(key, value) +} + +func updateOptionMap(key string, value string) (err error) { + common.OptionMapRWMutex.Lock() + defer common.OptionMapRWMutex.Unlock() + common.OptionMap[key] = value + + // 检查是否是模型配置 - 使用更规范的方式处理 + if handleConfigUpdate(key, value) { + return nil // 已由配置系统处理 + } + + // 处理传统配置项... + if strings.HasSuffix(key, "Permission") { + intValue, _ := strconv.Atoi(value) + switch key { + case "FileUploadPermission": + common.FileUploadPermission = intValue + case "FileDownloadPermission": + common.FileDownloadPermission = intValue + case "ImageUploadPermission": + common.ImageUploadPermission = intValue + case "ImageDownloadPermission": + common.ImageDownloadPermission = intValue + } + } + if strings.HasSuffix(key, "Enabled") || key == "DefaultCollapseSidebar" || key == "DefaultUseAutoGroup" { + boolValue := value == "true" + switch key { + case "PasswordRegisterEnabled": + common.PasswordRegisterEnabled = boolValue + case "PasswordLoginEnabled": + common.PasswordLoginEnabled = boolValue + case "EmailVerificationEnabled": + common.EmailVerificationEnabled = boolValue + case "GitHubOAuthEnabled": + common.GitHubOAuthEnabled = boolValue + case "LinuxDOOAuthEnabled": + common.LinuxDOOAuthEnabled = boolValue + case "WeChatAuthEnabled": + common.WeChatAuthEnabled = boolValue + case "TelegramOAuthEnabled": + common.TelegramOAuthEnabled = boolValue + case "TurnstileCheckEnabled": + common.TurnstileCheckEnabled = boolValue + case "RegisterEnabled": + common.RegisterEnabled = boolValue + case "EmailDomainRestrictionEnabled": + common.EmailDomainRestrictionEnabled = boolValue + case "EmailAliasRestrictionEnabled": + common.EmailAliasRestrictionEnabled = boolValue + case "AutomaticDisableChannelEnabled": + common.AutomaticDisableChannelEnabled = boolValue + case "AutomaticEnableChannelEnabled": + common.AutomaticEnableChannelEnabled = boolValue + case "LogConsumeEnabled": + common.LogConsumeEnabled = boolValue + case "DisplayInCurrencyEnabled": + common.DisplayInCurrencyEnabled = boolValue + case "DisplayTokenStatEnabled": + common.DisplayTokenStatEnabled = boolValue + case "DrawingEnabled": + common.DrawingEnabled = boolValue + case "TaskEnabled": + common.TaskEnabled = boolValue + case "DataExportEnabled": + common.DataExportEnabled = boolValue + case "DefaultCollapseSidebar": + common.DefaultCollapseSidebar = boolValue + case "MjNotifyEnabled": + setting.MjNotifyEnabled = boolValue + case "MjAccountFilterEnabled": + setting.MjAccountFilterEnabled = boolValue + case "MjModeClearEnabled": + setting.MjModeClearEnabled = boolValue + case "MjForwardUrlEnabled": + setting.MjForwardUrlEnabled = boolValue + case "MjActionCheckSuccessEnabled": + setting.MjActionCheckSuccessEnabled = boolValue + case "CheckSensitiveEnabled": + setting.CheckSensitiveEnabled = boolValue + case "DemoSiteEnabled": + operation_setting.DemoSiteEnabled = boolValue + case "SelfUseModeEnabled": + operation_setting.SelfUseModeEnabled = boolValue + case "CheckSensitiveOnPromptEnabled": + setting.CheckSensitiveOnPromptEnabled = boolValue + case "ModelRequestRateLimitEnabled": + setting.ModelRequestRateLimitEnabled = boolValue + case "StopOnSensitiveEnabled": + setting.StopOnSensitiveEnabled = boolValue + case "SMTPSSLEnabled": + common.SMTPSSLEnabled = boolValue + case "WorkerAllowHttpImageRequestEnabled": + setting.WorkerAllowHttpImageRequestEnabled = boolValue + case "DefaultUseAutoGroup": + setting.DefaultUseAutoGroup = boolValue + case "ExposeRatioEnabled": + ratio_setting.SetExposeRatioEnabled(boolValue) + } + } + switch key { + case "EmailDomainWhitelist": + common.EmailDomainWhitelist = strings.Split(value, ",") + case "SMTPServer": + common.SMTPServer = value + case "SMTPPort": + intValue, _ := strconv.Atoi(value) + common.SMTPPort = intValue + case "SMTPAccount": + common.SMTPAccount = value + case "SMTPFrom": + common.SMTPFrom = value + case "SMTPToken": + common.SMTPToken = value + case "ServerAddress": + setting.ServerAddress = value + case "WorkerUrl": + setting.WorkerUrl = value + case "WorkerValidKey": + setting.WorkerValidKey = value + case "PayAddress": + setting.PayAddress = value + case "Chats": + err = setting.UpdateChatsByJsonString(value) + case "AutoGroups": + err = setting.UpdateAutoGroupsByJsonString(value) + case "CustomCallbackAddress": + setting.CustomCallbackAddress = value + case "EpayId": + setting.EpayId = value + case "EpayKey": + setting.EpayKey = value + case "Price": + setting.Price, _ = strconv.ParseFloat(value, 64) + case "USDExchangeRate": + setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64) + case "MinTopUp": + setting.MinTopUp, _ = strconv.Atoi(value) + case "StripeApiSecret": + setting.StripeApiSecret = value + case "StripeWebhookSecret": + setting.StripeWebhookSecret = value + case "StripePriceId": + setting.StripePriceId = value + case "StripeUnitPrice": + setting.StripeUnitPrice, _ = strconv.ParseFloat(value, 64) + case "StripeMinTopUp": + setting.StripeMinTopUp, _ = strconv.Atoi(value) + case "TopupGroupRatio": + err = common.UpdateTopupGroupRatioByJSONString(value) + case "GitHubClientId": + common.GitHubClientId = value + case "GitHubClientSecret": + common.GitHubClientSecret = value + case "LinuxDOClientId": + common.LinuxDOClientId = value + case "LinuxDOClientSecret": + common.LinuxDOClientSecret = value + case "Footer": + common.Footer = value + case "SystemName": + common.SystemName = value + case "Logo": + common.Logo = value + case "WeChatServerAddress": + common.WeChatServerAddress = value + case "WeChatServerToken": + common.WeChatServerToken = value + case "WeChatAccountQRCodeImageURL": + common.WeChatAccountQRCodeImageURL = value + case "TelegramBotToken": + common.TelegramBotToken = value + case "TelegramBotName": + common.TelegramBotName = value + case "TurnstileSiteKey": + common.TurnstileSiteKey = value + case "TurnstileSecretKey": + common.TurnstileSecretKey = value + case "QuotaForNewUser": + common.QuotaForNewUser, _ = strconv.Atoi(value) + case "QuotaForInviter": + common.QuotaForInviter, _ = strconv.Atoi(value) + case "QuotaForInvitee": + common.QuotaForInvitee, _ = strconv.Atoi(value) + case "QuotaRemindThreshold": + common.QuotaRemindThreshold, _ = strconv.Atoi(value) + case "PreConsumedQuota": + common.PreConsumedQuota, _ = strconv.Atoi(value) + case "ModelRequestRateLimitCount": + setting.ModelRequestRateLimitCount, _ = strconv.Atoi(value) + case "ModelRequestRateLimitDurationMinutes": + setting.ModelRequestRateLimitDurationMinutes, _ = strconv.Atoi(value) + case "ModelRequestRateLimitSuccessCount": + setting.ModelRequestRateLimitSuccessCount, _ = strconv.Atoi(value) + case "ModelRequestRateLimitGroup": + err = setting.UpdateModelRequestRateLimitGroupByJSONString(value) + case "RetryTimes": + common.RetryTimes, _ = strconv.Atoi(value) + case "DataExportInterval": + common.DataExportInterval, _ = strconv.Atoi(value) + case "DataExportDefaultTime": + common.DataExportDefaultTime = value + case "ModelRatio": + err = ratio_setting.UpdateModelRatioByJSONString(value) + case "GroupRatio": + err = ratio_setting.UpdateGroupRatioByJSONString(value) + case "GroupGroupRatio": + err = ratio_setting.UpdateGroupGroupRatioByJSONString(value) + case "UserUsableGroups": + err = setting.UpdateUserUsableGroupsByJSONString(value) + case "CompletionRatio": + err = ratio_setting.UpdateCompletionRatioByJSONString(value) + case "ModelPrice": + err = ratio_setting.UpdateModelPriceByJSONString(value) + case "CacheRatio": + err = ratio_setting.UpdateCacheRatioByJSONString(value) + case "TopUpLink": + common.TopUpLink = value + //case "ChatLink": + // common.ChatLink = value + //case "ChatLink2": + // common.ChatLink2 = value + case "ChannelDisableThreshold": + common.ChannelDisableThreshold, _ = strconv.ParseFloat(value, 64) + case "QuotaPerUnit": + common.QuotaPerUnit, _ = strconv.ParseFloat(value, 64) + case "SensitiveWords": + setting.SensitiveWordsFromString(value) + case "AutomaticDisableKeywords": + operation_setting.AutomaticDisableKeywordsFromString(value) + case "StreamCacheQueueLength": + setting.StreamCacheQueueLength, _ = strconv.Atoi(value) + case "PayMethods": + err = setting.UpdatePayMethodsByJsonString(value) + } + return err +} + +// handleConfigUpdate 处理分层配置更新,返回是否已处理 +func handleConfigUpdate(key, value string) bool { + parts := strings.SplitN(key, ".", 2) + if len(parts) != 2 { + return false // 不是分层配置 + } + + configName := parts[0] + configKey := parts[1] + + // 获取配置对象 + cfg := config.GlobalConfig.Get(configName) + if cfg == nil { + return false // 未注册的配置 + } + + // 更新配置 + configMap := map[string]string{ + configKey: value, + } + config.UpdateConfigFromMap(cfg, configMap) + + return true // 已处理 +} diff --git a/model/pricing.go b/model/pricing.go new file mode 100644 index 00000000..a280b524 --- /dev/null +++ b/model/pricing.go @@ -0,0 +1,127 @@ +package model + +import ( + "fmt" + "one-api/common" + "one-api/constant" + "one-api/setting/ratio_setting" + "one-api/types" + "sync" + "time" +) + +type Pricing struct { + ModelName string `json:"model_name"` + QuotaType int `json:"quota_type"` + ModelRatio float64 `json:"model_ratio"` + ModelPrice float64 `json:"model_price"` + OwnerBy string `json:"owner_by"` + CompletionRatio float64 `json:"completion_ratio"` + EnableGroup []string `json:"enable_groups"` + SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` +} + +var ( + pricingMap []Pricing + lastGetPricingTime time.Time + updatePricingLock sync.Mutex +) + +var ( + modelSupportEndpointTypes = make(map[string][]constant.EndpointType) + modelSupportEndpointsLock = sync.RWMutex{} +) + +func GetPricing() []Pricing { + if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { + updatePricingLock.Lock() + defer updatePricingLock.Unlock() + // Double check after acquiring the lock + if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { + modelSupportEndpointsLock.Lock() + defer modelSupportEndpointsLock.Unlock() + updatePricing() + } + } + return pricingMap +} + +func GetModelSupportEndpointTypes(model string) []constant.EndpointType { + if model == "" { + return make([]constant.EndpointType, 0) + } + modelSupportEndpointsLock.RLock() + defer modelSupportEndpointsLock.RUnlock() + if endpoints, ok := modelSupportEndpointTypes[model]; ok { + return endpoints + } + return make([]constant.EndpointType, 0) +} + +func updatePricing() { + //modelRatios := common.GetModelRatios() + enableAbilities, err := GetAllEnableAbilityWithChannels() + if err != nil { + common.SysError(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err)) + return + } + modelGroupsMap := make(map[string]*types.Set[string]) + + for _, ability := range enableAbilities { + groups, ok := modelGroupsMap[ability.Model] + if !ok { + groups = types.NewSet[string]() + modelGroupsMap[ability.Model] = groups + } + groups.Add(ability.Group) + } + + //这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点 + modelSupportEndpointsStr := make(map[string][]string) + + for _, ability := range enableAbilities { + endpoints, ok := modelSupportEndpointsStr[ability.Model] + if !ok { + endpoints = make([]string, 0) + modelSupportEndpointsStr[ability.Model] = endpoints + } + channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) + for _, channelType := range channelTypes { + if !common.StringsContains(endpoints, string(channelType)) { + endpoints = append(endpoints, string(channelType)) + } + } + modelSupportEndpointsStr[ability.Model] = endpoints + } + + modelSupportEndpointTypes = make(map[string][]constant.EndpointType) + for model, endpoints := range modelSupportEndpointsStr { + supportedEndpoints := make([]constant.EndpointType, 0) + for _, endpointStr := range endpoints { + endpointType := constant.EndpointType(endpointStr) + supportedEndpoints = append(supportedEndpoints, endpointType) + } + modelSupportEndpointTypes[model] = supportedEndpoints + } + + pricingMap = make([]Pricing, 0) + for model, groups := range modelGroupsMap { + pricing := Pricing{ + ModelName: model, + EnableGroup: groups.Items(), + SupportedEndpointTypes: modelSupportEndpointTypes[model], + } + modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) + if findPrice { + pricing.ModelPrice = modelPrice + pricing.QuotaType = 1 + } else { + modelRatio, _, _ := ratio_setting.GetModelRatio(model) + pricing.ModelRatio = modelRatio + pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model) + pricing.QuotaType = 0 + } + pricingMap = append(pricingMap, pricing) + } + lastGetPricingTime = time.Now() +} diff --git a/model/redemption.go b/model/redemption.go new file mode 100644 index 00000000..bf237668 --- /dev/null +++ b/model/redemption.go @@ -0,0 +1,195 @@ +package model + +import ( + "errors" + "fmt" + "one-api/common" + "strconv" + + "gorm.io/gorm" +) + +type Redemption struct { + Id int `json:"id"` + UserId int `json:"user_id"` + Key string `json:"key" gorm:"type:char(32);uniqueIndex"` + Status int `json:"status" gorm:"default:1"` + Name string `json:"name" gorm:"index"` + Quota int `json:"quota" gorm:"default:100"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"` + Count int `json:"count" gorm:"-:all"` // only for api request + UsedUserId int `json:"used_user_id"` + DeletedAt gorm.DeletedAt `gorm:"index"` + ExpiredTime int64 `json:"expired_time" gorm:"bigint"` // 过期时间,0 表示不过期 +} + +func GetAllRedemptions(startIdx int, num int) (redemptions []*Redemption, total int64, err error) { + // 开始事务 + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 获取总数 + err = tx.Model(&Redemption{}).Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // 获取分页数据 + err = tx.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // 提交事务 + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return redemptions, total, nil +} + +func SearchRedemptions(keyword string, startIdx int, num int) (redemptions []*Redemption, total int64, err error) { + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // Build query based on keyword type + query := tx.Model(&Redemption{}) + + // Only try to convert to ID if the string represents a valid integer + if id, err := strconv.Atoi(keyword); err == nil { + query = query.Where("id = ? OR name LIKE ?", id, keyword+"%") + } else { + query = query.Where("name LIKE ?", keyword+"%") + } + + // Get total count + err = query.Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // Get paginated data + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return redemptions, total, nil +} + +func GetRedemptionById(id int) (*Redemption, error) { + if id == 0 { + return nil, errors.New("id 为空!") + } + redemption := Redemption{Id: id} + var err error = nil + err = DB.First(&redemption, "id = ?", id).Error + return &redemption, err +} + +func Redeem(key string, userId int) (quota int, err error) { + if key == "" { + return 0, errors.New("未提供兑换码") + } + if userId == 0 { + return 0, errors.New("无效的 user id") + } + redemption := &Redemption{} + + keyCol := "`key`" + if common.UsingPostgreSQL { + keyCol = `"key"` + } + common.RandomSleep() + err = DB.Transaction(func(tx *gorm.DB) error { + err := tx.Set("gorm:query_option", "FOR UPDATE").Where(keyCol+" = ?", key).First(redemption).Error + if err != nil { + return errors.New("无效的兑换码") + } + if redemption.Status != common.RedemptionCodeStatusEnabled { + return errors.New("该兑换码已被使用") + } + if redemption.ExpiredTime != 0 && redemption.ExpiredTime < common.GetTimestamp() { + return errors.New("该兑换码已过期") + } + err = tx.Model(&User{}).Where("id = ?", userId).Update("quota", gorm.Expr("quota + ?", redemption.Quota)).Error + if err != nil { + return err + } + redemption.RedeemedTime = common.GetTimestamp() + redemption.Status = common.RedemptionCodeStatusUsed + redemption.UsedUserId = userId + err = tx.Save(redemption).Error + return err + }) + if err != nil { + return 0, errors.New("兑换失败," + err.Error()) + } + RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s,兑换码ID %d", common.LogQuota(redemption.Quota), redemption.Id)) + return redemption.Quota, nil +} + +func (redemption *Redemption) Insert() error { + var err error + err = DB.Create(redemption).Error + return err +} + +func (redemption *Redemption) SelectUpdate() error { + // This can update zero values + return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error +} + +// Update Make sure your token's fields is completed, because this will update non-zero values +func (redemption *Redemption) Update() error { + var err error + err = DB.Model(redemption).Select("name", "status", "quota", "redeemed_time", "expired_time").Updates(redemption).Error + return err +} + +func (redemption *Redemption) Delete() error { + var err error + err = DB.Delete(redemption).Error + return err +} + +func DeleteRedemptionById(id int) (err error) { + if id == 0 { + return errors.New("id 为空!") + } + redemption := Redemption{Id: id} + err = DB.Where(redemption).First(&redemption).Error + if err != nil { + return err + } + return redemption.Delete() +} + +func DeleteInvalidRedemptions() (int64, error) { + now := common.GetTimestamp() + result := DB.Where("status IN ? OR (status = ? AND expired_time != 0 AND expired_time < ?)", []int{common.RedemptionCodeStatusUsed, common.RedemptionCodeStatusDisabled}, common.RedemptionCodeStatusEnabled, now).Delete(&Redemption{}) + return result.RowsAffected, result.Error +} diff --git a/model/setup.go b/model/setup.go new file mode 100644 index 00000000..c4d7997f --- /dev/null +++ b/model/setup.go @@ -0,0 +1,16 @@ +package model + +type Setup struct { + ID uint `json:"id" gorm:"primaryKey"` + Version string `json:"version" gorm:"type:varchar(50);not null"` + InitializedAt int64 `json:"initialized_at" gorm:"type:bigint;not null"` +} + +func GetSetup() *Setup { + var setup Setup + err := DB.First(&setup).Error + if err != nil { + return nil + } + return &setup +} diff --git a/model/task.go b/model/task.go new file mode 100644 index 00000000..9e4177ba --- /dev/null +++ b/model/task.go @@ -0,0 +1,365 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "one-api/constant" + commonRelay "one-api/relay/common" + "time" +) + +type TaskStatus string + +const ( + TaskStatusNotStart TaskStatus = "NOT_START" + TaskStatusSubmitted = "SUBMITTED" + TaskStatusQueued = "QUEUED" + TaskStatusInProgress = "IN_PROGRESS" + TaskStatusFailure = "FAILURE" + TaskStatusSuccess = "SUCCESS" + TaskStatusUnknown = "UNKNOWN" +) + +type Task struct { + ID int64 `json:"id" gorm:"primary_key;AUTO_INCREMENT"` + CreatedAt int64 `json:"created_at" gorm:"index"` + UpdatedAt int64 `json:"updated_at"` + TaskID string `json:"task_id" gorm:"type:varchar(50);index"` // 第三方id,不一定有/ song id\ Task id + Platform constant.TaskPlatform `json:"platform" gorm:"type:varchar(30);index"` // 平台 + UserId int `json:"user_id" gorm:"index"` + ChannelId int `json:"channel_id" gorm:"index"` + Quota int `json:"quota"` + Action string `json:"action" gorm:"type:varchar(40);index"` // 任务类型, song, lyrics, description-mode + Status TaskStatus `json:"status" gorm:"type:varchar(20);index"` // 任务状态 + FailReason string `json:"fail_reason"` + SubmitTime int64 `json:"submit_time" gorm:"index"` + StartTime int64 `json:"start_time" gorm:"index"` + FinishTime int64 `json:"finish_time" gorm:"index"` + Progress string `json:"progress" gorm:"type:varchar(20);index"` + Properties Properties `json:"properties" gorm:"type:json"` + + Data json.RawMessage `json:"data" gorm:"type:json"` +} + +func (t *Task) SetData(data any) { + b, _ := json.Marshal(data) + t.Data = json.RawMessage(b) +} + +func (t *Task) GetData(v any) error { + err := json.Unmarshal(t.Data, &v) + return err +} + +type Properties struct { + Input string `json:"input"` +} + +func (m *Properties) Scan(val interface{}) error { + bytesValue, _ := val.([]byte) + return json.Unmarshal(bytesValue, m) +} + +func (m Properties) Value() (driver.Value, error) { + return json.Marshal(m) +} + +// SyncTaskQueryParams 用于包含所有搜索条件的结构体,可以根据需求添加更多字段 +type SyncTaskQueryParams struct { + Platform constant.TaskPlatform + ChannelID string + TaskID string + UserID string + Action string + Status string + StartTimestamp int64 + EndTimestamp int64 + UserIDs []int +} + +func InitTask(platform constant.TaskPlatform, relayInfo *commonRelay.TaskRelayInfo) *Task { + t := &Task{ + UserId: relayInfo.UserId, + SubmitTime: time.Now().Unix(), + Status: TaskStatusNotStart, + Progress: "0%", + ChannelId: relayInfo.ChannelId, + Platform: platform, + } + return t +} + +func TaskGetAllUserTask(userId int, startIdx int, num int, queryParams SyncTaskQueryParams) []*Task { + var tasks []*Task + var err error + + // 初始化查询构建器 + query := DB.Where("user_id = ?", userId) + + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.Platform != "" { + query = query.Where("platform = ?", queryParams.Platform) + } + if queryParams.StartTimestamp != 0 { + // 假设您已将前端传来的时间戳转换为数据库所需的时间格式,并处理了时间戳的验证和解析 + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + + // 获取数据 + err = query.Omit("channel_id").Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error + if err != nil { + return nil + } + + return tasks +} + +func TaskGetAllTasks(startIdx int, num int, queryParams SyncTaskQueryParams) []*Task { + var tasks []*Task + var err error + + // 初始化查询构建器 + query := DB + + // 添加过滤条件 + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.Platform != "" { + query = query.Where("platform = ?", queryParams.Platform) + } + if queryParams.UserID != "" { + query = query.Where("user_id = ?", queryParams.UserID) + } + if len(queryParams.UserIDs) != 0 { + query = query.Where("user_id in (?)", queryParams.UserIDs) + } + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.StartTimestamp != 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + + // 获取数据 + err = query.Order("id desc").Limit(num).Offset(startIdx).Find(&tasks).Error + if err != nil { + return nil + } + + return tasks +} + +func GetAllUnFinishSyncTasks(limit int) []*Task { + var tasks []*Task + var err error + // get all tasks progress is not 100% + err = DB.Where("progress != ?", "100%").Limit(limit).Order("id").Find(&tasks).Error + if err != nil { + return nil + } + return tasks +} + +func GetByOnlyTaskId(taskId string) (*Task, bool, error) { + if taskId == "" { + return nil, false, nil + } + var task *Task + var err error + err = DB.Where("task_id = ?", taskId).First(&task).Error + exist, err := RecordExist(err) + if err != nil { + return nil, false, err + } + return task, exist, err +} + +func GetByTaskId(userId int, taskId string) (*Task, bool, error) { + if taskId == "" { + return nil, false, nil + } + var task *Task + var err error + err = DB.Where("user_id = ? and task_id = ?", userId, taskId). + First(&task).Error + exist, err := RecordExist(err) + if err != nil { + return nil, false, err + } + return task, exist, err +} + +func GetByTaskIds(userId int, taskIds []any) ([]*Task, error) { + if len(taskIds) == 0 { + return nil, nil + } + var task []*Task + var err error + err = DB.Where("user_id = ? and task_id in (?)", userId, taskIds). + Find(&task).Error + if err != nil { + return nil, err + } + return task, nil +} + +func TaskUpdateProgress(id int64, progress string) error { + return DB.Model(&Task{}).Where("id = ?", id).Update("progress", progress).Error +} + +func (Task *Task) Insert() error { + var err error + err = DB.Create(Task).Error + return err +} + +func (Task *Task) Update() error { + var err error + err = DB.Save(Task).Error + return err +} + +func TaskBulkUpdate(TaskIds []string, params map[string]any) error { + if len(TaskIds) == 0 { + return nil + } + return DB.Model(&Task{}). + Where("task_id in (?)", TaskIds). + Updates(params).Error +} + +func TaskBulkUpdateByTaskIds(taskIDs []int64, params map[string]any) error { + if len(taskIDs) == 0 { + return nil + } + return DB.Model(&Task{}). + Where("id in (?)", taskIDs). + Updates(params).Error +} + +func TaskBulkUpdateByID(ids []int64, params map[string]any) error { + if len(ids) == 0 { + return nil + } + return DB.Model(&Task{}). + Where("id in (?)", ids). + Updates(params).Error +} + +type TaskQuotaUsage struct { + Mode string `json:"mode"` + Count float64 `json:"count"` +} + +func SumUsedTaskQuota(queryParams SyncTaskQueryParams) (stat []TaskQuotaUsage, err error) { + query := DB.Model(Task{}) + // 添加过滤条件 + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.UserID != "" { + query = query.Where("user_id = ?", queryParams.UserID) + } + if len(queryParams.UserIDs) != 0 { + query = query.Where("user_id in (?)", queryParams.UserIDs) + } + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.StartTimestamp != 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + err = query.Select("mode, sum(quota) as count").Group("mode").Find(&stat).Error + return stat, err +} + +// TaskCountAllTasks returns total tasks that match the given query params (admin usage) +func TaskCountAllTasks(queryParams SyncTaskQueryParams) int64 { + var total int64 + query := DB.Model(&Task{}) + if queryParams.ChannelID != "" { + query = query.Where("channel_id = ?", queryParams.ChannelID) + } + if queryParams.Platform != "" { + query = query.Where("platform = ?", queryParams.Platform) + } + if queryParams.UserID != "" { + query = query.Where("user_id = ?", queryParams.UserID) + } + if len(queryParams.UserIDs) != 0 { + query = query.Where("user_id in (?)", queryParams.UserIDs) + } + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.StartTimestamp != 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} + +// TaskCountAllUserTask returns total tasks for given user +func TaskCountAllUserTask(userId int, queryParams SyncTaskQueryParams) int64 { + var total int64 + query := DB.Model(&Task{}).Where("user_id = ?", userId) + if queryParams.TaskID != "" { + query = query.Where("task_id = ?", queryParams.TaskID) + } + if queryParams.Action != "" { + query = query.Where("action = ?", queryParams.Action) + } + if queryParams.Status != "" { + query = query.Where("status = ?", queryParams.Status) + } + if queryParams.Platform != "" { + query = query.Where("platform = ?", queryParams.Platform) + } + if queryParams.StartTimestamp != 0 { + query = query.Where("submit_time >= ?", queryParams.StartTimestamp) + } + if queryParams.EndTimestamp != 0 { + query = query.Where("submit_time <= ?", queryParams.EndTimestamp) + } + _ = query.Count(&total).Error + return total +} diff --git a/model/token.go b/model/token.go new file mode 100644 index 00000000..e85a445e --- /dev/null +++ b/model/token.go @@ -0,0 +1,363 @@ +package model + +import ( + "errors" + "fmt" + "one-api/common" + "strings" + + "github.com/bytedance/gopkg/util/gopool" + "gorm.io/gorm" +) + +type Token struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Key string `json:"key" gorm:"type:char(48);uniqueIndex"` + Status int `json:"status" gorm:"default:1"` + Name string `json:"name" gorm:"index" ` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + AccessedTime int64 `json:"accessed_time" gorm:"bigint"` + ExpiredTime int64 `json:"expired_time" gorm:"bigint;default:-1"` // -1 means never expired + RemainQuota int `json:"remain_quota" gorm:"default:0"` + UnlimitedQuota bool `json:"unlimited_quota"` + ModelLimitsEnabled bool `json:"model_limits_enabled"` + ModelLimits string `json:"model_limits" gorm:"type:varchar(1024);default:''"` + AllowIps *string `json:"allow_ips" gorm:"default:''"` + UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota + Group string `json:"group" gorm:"default:''"` + DeletedAt gorm.DeletedAt `gorm:"index"` +} + +func (token *Token) Clean() { + token.Key = "" +} + +func (token *Token) GetIpLimitsMap() map[string]any { + // delete empty spaces + //split with \n + ipLimitsMap := make(map[string]any) + if token.AllowIps == nil { + return ipLimitsMap + } + cleanIps := strings.ReplaceAll(*token.AllowIps, " ", "") + if cleanIps == "" { + return ipLimitsMap + } + ips := strings.Split(cleanIps, "\n") + for _, ip := range ips { + ip = strings.TrimSpace(ip) + ip = strings.ReplaceAll(ip, ",", "") + if common.IsIP(ip) { + ipLimitsMap[ip] = true + } + } + return ipLimitsMap +} + +func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) { + var tokens []*Token + var err error + err = DB.Where("user_id = ?", userId).Order("id desc").Limit(num).Offset(startIdx).Find(&tokens).Error + return tokens, err +} + +func SearchUserTokens(userId int, keyword string, token string) (tokens []*Token, err error) { + if token != "" { + token = strings.Trim(token, "sk-") + } + err = DB.Where("user_id = ?", userId).Where("name LIKE ?", "%"+keyword+"%").Where(commonKeyCol+" LIKE ?", "%"+token+"%").Find(&tokens).Error + return tokens, err +} + +func ValidateUserToken(key string) (token *Token, err error) { + if key == "" { + return nil, errors.New("未提供令牌") + } + token, err = GetTokenByKey(key, false) + if err == nil { + if token.Status == common.TokenStatusExhausted { + keyPrefix := key[:3] + keySuffix := key[len(key)-3:] + return token, errors.New("该令牌额度已用尽 TokenStatusExhausted[sk-" + keyPrefix + "***" + keySuffix + "]") + } else if token.Status == common.TokenStatusExpired { + return token, errors.New("该令牌已过期") + } + if token.Status != common.TokenStatusEnabled { + return token, errors.New("该令牌状态不可用") + } + if token.ExpiredTime != -1 && token.ExpiredTime < common.GetTimestamp() { + if !common.RedisEnabled { + token.Status = common.TokenStatusExpired + err := token.SelectUpdate() + if err != nil { + common.SysError("failed to update token status" + err.Error()) + } + } + return token, errors.New("该令牌已过期") + } + if !token.UnlimitedQuota && token.RemainQuota <= 0 { + if !common.RedisEnabled { + // in this case, we can make sure the token is exhausted + token.Status = common.TokenStatusExhausted + err := token.SelectUpdate() + if err != nil { + common.SysError("failed to update token status" + err.Error()) + } + } + keyPrefix := key[:3] + keySuffix := key[len(key)-3:] + return token, errors.New(fmt.Sprintf("[sk-%s***%s] 该令牌额度已用尽 !token.UnlimitedQuota && token.RemainQuota = %d", keyPrefix, keySuffix, token.RemainQuota)) + } + return token, nil + } + return nil, errors.New("无效的令牌") +} + +func GetTokenByIds(id int, userId int) (*Token, error) { + if id == 0 || userId == 0 { + return nil, errors.New("id 或 userId 为空!") + } + token := Token{Id: id, UserId: userId} + var err error = nil + err = DB.First(&token, "id = ? and user_id = ?", id, userId).Error + return &token, err +} + +func GetTokenById(id int) (*Token, error) { + if id == 0 { + return nil, errors.New("id 为空!") + } + token := Token{Id: id} + var err error = nil + err = DB.First(&token, "id = ?", id).Error + if shouldUpdateRedis(true, err) { + gopool.Go(func() { + if err := cacheSetToken(token); err != nil { + common.SysError("failed to update user status cache: " + err.Error()) + } + }) + } + return &token, err +} + +func GetTokenByKey(key string, fromDB bool) (token *Token, err error) { + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) && token != nil { + gopool.Go(func() { + if err := cacheSetToken(*token); err != nil { + common.SysError("failed to update user status cache: " + err.Error()) + } + }) + } + }() + if !fromDB && common.RedisEnabled { + // Try Redis first + token, err := cacheGetTokenByKey(key) + if err == nil { + return token, nil + } + // Don't return error - fall through to DB + } + fromDB = true + err = DB.Where(commonKeyCol+" = ?", key).First(&token).Error + return token, err +} + +func (token *Token) Insert() error { + var err error + err = DB.Create(token).Error + return err +} + +// Update Make sure your token's fields is completed, because this will update non-zero values +func (token *Token) Update() (err error) { + defer func() { + if shouldUpdateRedis(true, err) { + gopool.Go(func() { + err := cacheSetToken(*token) + if err != nil { + common.SysError("failed to update token cache: " + err.Error()) + } + }) + } + }() + err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota", + "model_limits_enabled", "model_limits", "allow_ips", "group").Updates(token).Error + return err +} + +func (token *Token) SelectUpdate() (err error) { + defer func() { + if shouldUpdateRedis(true, err) { + gopool.Go(func() { + err := cacheSetToken(*token) + if err != nil { + common.SysError("failed to update token cache: " + err.Error()) + } + }) + } + }() + // This can update zero values + return DB.Model(token).Select("accessed_time", "status").Updates(token).Error +} + +func (token *Token) Delete() (err error) { + defer func() { + if shouldUpdateRedis(true, err) { + gopool.Go(func() { + err := cacheDeleteToken(token.Key) + if err != nil { + common.SysError("failed to delete token cache: " + err.Error()) + } + }) + } + }() + err = DB.Delete(token).Error + return err +} + +func (token *Token) IsModelLimitsEnabled() bool { + return token.ModelLimitsEnabled +} + +func (token *Token) GetModelLimits() []string { + if token.ModelLimits == "" { + return []string{} + } + return strings.Split(token.ModelLimits, ",") +} + +func (token *Token) GetModelLimitsMap() map[string]bool { + limits := token.GetModelLimits() + limitsMap := make(map[string]bool) + for _, limit := range limits { + limitsMap[limit] = true + } + return limitsMap +} + +func DisableModelLimits(tokenId int) error { + token, err := GetTokenById(tokenId) + if err != nil { + return err + } + token.ModelLimitsEnabled = false + token.ModelLimits = "" + return token.Update() +} + +func DeleteTokenById(id int, userId int) (err error) { + // Why we need userId here? In case user want to delete other's token. + if id == 0 || userId == 0 { + return errors.New("id 或 userId 为空!") + } + token := Token{Id: id, UserId: userId} + err = DB.Where(token).First(&token).Error + if err != nil { + return err + } + return token.Delete() +} + +func IncreaseTokenQuota(id int, key string, quota int) (err error) { + if quota < 0 { + return errors.New("quota 不能为负数!") + } + if common.RedisEnabled { + gopool.Go(func() { + err := cacheIncrTokenQuota(key, int64(quota)) + if err != nil { + common.SysError("failed to increase token quota: " + err.Error()) + } + }) + } + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeTokenQuota, id, quota) + return nil + } + return increaseTokenQuota(id, quota) +} + +func increaseTokenQuota(id int, quota int) (err error) { + err = DB.Model(&Token{}).Where("id = ?", id).Updates( + map[string]interface{}{ + "remain_quota": gorm.Expr("remain_quota + ?", quota), + "used_quota": gorm.Expr("used_quota - ?", quota), + "accessed_time": common.GetTimestamp(), + }, + ).Error + return err +} + +func DecreaseTokenQuota(id int, key string, quota int) (err error) { + if quota < 0 { + return errors.New("quota 不能为负数!") + } + if common.RedisEnabled { + gopool.Go(func() { + err := cacheDecrTokenQuota(key, int64(quota)) + if err != nil { + common.SysError("failed to decrease token quota: " + err.Error()) + } + }) + } + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeTokenQuota, id, -quota) + return nil + } + return decreaseTokenQuota(id, quota) +} + +func decreaseTokenQuota(id int, quota int) (err error) { + err = DB.Model(&Token{}).Where("id = ?", id).Updates( + map[string]interface{}{ + "remain_quota": gorm.Expr("remain_quota - ?", quota), + "used_quota": gorm.Expr("used_quota + ?", quota), + "accessed_time": common.GetTimestamp(), + }, + ).Error + return err +} + +// CountUserTokens returns total number of tokens for the given user, used for pagination +func CountUserTokens(userId int) (int64, error) { + var total int64 + err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error + return total, err +} + +// BatchDeleteTokens 删除指定用户的一组令牌,返回成功删除数量 +func BatchDeleteTokens(ids []int, userId int) (int, error) { + if len(ids) == 0 { + return 0, errors.New("ids 不能为空!") + } + + tx := DB.Begin() + + var tokens []Token + if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Find(&tokens).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Delete(&Token{}).Error; err != nil { + tx.Rollback() + return 0, err + } + + if err := tx.Commit().Error; err != nil { + return 0, err + } + + if common.RedisEnabled { + gopool.Go(func() { + for _, t := range tokens { + _ = cacheDeleteToken(t.Key) + } + }) + } + + return len(tokens), nil +} diff --git a/model/token_cache.go b/model/token_cache.go new file mode 100644 index 00000000..5399dbc8 --- /dev/null +++ b/model/token_cache.go @@ -0,0 +1,64 @@ +package model + +import ( + "fmt" + "one-api/common" + "one-api/constant" + "time" +) + +func cacheSetToken(token Token) error { + key := common.GenerateHMAC(token.Key) + token.Clean() + err := common.RedisHSetObj(fmt.Sprintf("token:%s", key), &token, time.Duration(common.RedisKeyCacheSeconds())*time.Second) + if err != nil { + return err + } + return nil +} + +func cacheDeleteToken(key string) error { + key = common.GenerateHMAC(key) + err := common.RedisDelKey(fmt.Sprintf("token:%s", key)) + if err != nil { + return err + } + return nil +} + +func cacheIncrTokenQuota(key string, increment int64) error { + key = common.GenerateHMAC(key) + err := common.RedisHIncrBy(fmt.Sprintf("token:%s", key), constant.TokenFiledRemainQuota, increment) + if err != nil { + return err + } + return nil +} + +func cacheDecrTokenQuota(key string, decrement int64) error { + return cacheIncrTokenQuota(key, -decrement) +} + +func cacheSetTokenField(key string, field string, value string) error { + key = common.GenerateHMAC(key) + err := common.RedisHSetField(fmt.Sprintf("token:%s", key), field, value) + if err != nil { + return err + } + return nil +} + +// CacheGetTokenByKey 从缓存中获取 token,如果缓存中不存在,则从数据库中获取 +func cacheGetTokenByKey(key string) (*Token, error) { + hmacKey := common.GenerateHMAC(key) + if !common.RedisEnabled { + return nil, fmt.Errorf("redis is not enabled") + } + var token Token + err := common.RedisHGetObj(fmt.Sprintf("token:%s", hmacKey), &token) + if err != nil { + return nil, err + } + token.Key = key + return &token, nil +} diff --git a/model/topup.go b/model/topup.go new file mode 100644 index 00000000..c34c0ce6 --- /dev/null +++ b/model/topup.go @@ -0,0 +1,100 @@ +package model + +import ( + "errors" + "fmt" + "one-api/common" + + "gorm.io/gorm" +) + +type TopUp struct { + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Amount int64 `json:"amount"` + Money float64 `json:"money"` + TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` + CreateTime int64 `json:"create_time"` + CompleteTime int64 `json:"complete_time"` + Status string `json:"status"` +} + +func (topUp *TopUp) Insert() error { + var err error + err = DB.Create(topUp).Error + return err +} + +func (topUp *TopUp) Update() error { + var err error + err = DB.Save(topUp).Error + return err +} + +func GetTopUpById(id int) *TopUp { + var topUp *TopUp + var err error + err = DB.Where("id = ?", id).First(&topUp).Error + if err != nil { + return nil + } + return topUp +} + +func GetTopUpByTradeNo(tradeNo string) *TopUp { + var topUp *TopUp + var err error + err = DB.Where("trade_no = ?", tradeNo).First(&topUp).Error + if err != nil { + return nil + } + return topUp +} + +func Recharge(referenceId string, customerId string) (err error) { + if referenceId == "" { + return errors.New("未提供支付单号") + } + + var quota float64 + topUp := &TopUp{} + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", referenceId).First(topUp).Error + if err != nil { + return errors.New("充值订单不存在") + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + err = tx.Save(topUp).Error + if err != nil { + return err + } + + quota = topUp.Money * common.QuotaPerUnit + err = tx.Model(&User{}).Where("id = ?", topUp.UserId).Updates(map[string]interface{}{"stripe_customer": customerId, "quota": gorm.Expr("quota + ?", quota)}).Error + if err != nil { + return err + } + + return nil + }) + + if err != nil { + return errors.New("充值失败," + err.Error()) + } + + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v,支付金额:%d", common.FormatQuota(int(quota)), topUp.Amount)) + + return nil +} diff --git a/model/usedata.go b/model/usedata.go new file mode 100644 index 00000000..1255b0be --- /dev/null +++ b/model/usedata.go @@ -0,0 +1,133 @@ +package model + +import ( + "fmt" + "gorm.io/gorm" + "one-api/common" + "sync" + "time" +) + +// QuotaData 柱状图数据 +type QuotaData struct { + Id int `json:"id"` + UserID int `json:"user_id" gorm:"index"` + Username string `json:"username" gorm:"index:idx_qdt_model_user_name,priority:2;size:64;default:''"` + ModelName string `json:"model_name" gorm:"index:idx_qdt_model_user_name,priority:1;size:64;default:''"` + CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_qdt_created_at,priority:2"` + TokenUsed int `json:"token_used" gorm:"default:0"` + Count int `json:"count" gorm:"default:0"` + Quota int `json:"quota" gorm:"default:0"` +} + +func UpdateQuotaData() { + // recover + defer func() { + if r := recover(); r != nil { + common.SysLog(fmt.Sprintf("UpdateQuotaData panic: %s", r)) + } + }() + for { + if common.DataExportEnabled { + common.SysLog("正在更新数据看板数据...") + SaveQuotaDataCache() + } + time.Sleep(time.Duration(common.DataExportInterval) * time.Minute) + } +} + +var CacheQuotaData = make(map[string]*QuotaData) +var CacheQuotaDataLock = sync.Mutex{} + +func logQuotaDataCache(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) { + key := fmt.Sprintf("%d-%s-%s-%d", userId, username, modelName, createdAt) + quotaData, ok := CacheQuotaData[key] + if ok { + quotaData.Count += 1 + quotaData.Quota += quota + quotaData.TokenUsed += tokenUsed + } else { + quotaData = &QuotaData{ + UserID: userId, + Username: username, + ModelName: modelName, + CreatedAt: createdAt, + Count: 1, + Quota: quota, + TokenUsed: tokenUsed, + } + } + CacheQuotaData[key] = quotaData +} + +func LogQuotaData(userId int, username string, modelName string, quota int, createdAt int64, tokenUsed int) { + // 只精确到小时 + createdAt = createdAt - (createdAt % 3600) + + CacheQuotaDataLock.Lock() + defer CacheQuotaDataLock.Unlock() + logQuotaDataCache(userId, username, modelName, quota, createdAt, tokenUsed) +} + +func SaveQuotaDataCache() { + CacheQuotaDataLock.Lock() + defer CacheQuotaDataLock.Unlock() + size := len(CacheQuotaData) + // 如果缓存中有数据,就保存到数据库中 + // 1. 先查询数据库中是否有数据 + // 2. 如果有数据,就更新数据 + // 3. 如果没有数据,就插入数据 + for _, quotaData := range CacheQuotaData { + quotaDataDB := &QuotaData{} + DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?", + quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.CreatedAt).First(quotaDataDB) + if quotaDataDB.Id > 0 { + //quotaDataDB.Count += quotaData.Count + //quotaDataDB.Quota += quotaData.Quota + //DB.Table("quota_data").Save(quotaDataDB) + increaseQuotaData(quotaData.UserID, quotaData.Username, quotaData.ModelName, quotaData.Count, quotaData.Quota, quotaData.CreatedAt, quotaData.TokenUsed) + } else { + DB.Table("quota_data").Create(quotaData) + } + } + CacheQuotaData = make(map[string]*QuotaData) + common.SysLog(fmt.Sprintf("保存数据看板数据成功,共保存%d条数据", size)) +} + +func increaseQuotaData(userId int, username string, modelName string, count int, quota int, createdAt int64, tokenUsed int) { + err := DB.Table("quota_data").Where("user_id = ? and username = ? and model_name = ? and created_at = ?", + userId, username, modelName, createdAt).Updates(map[string]interface{}{ + "count": gorm.Expr("count + ?", count), + "quota": gorm.Expr("quota + ?", quota), + "token_used": gorm.Expr("token_used + ?", tokenUsed), + }).Error + if err != nil { + common.SysLog(fmt.Sprintf("increaseQuotaData error: %s", err)) + } +} + +func GetQuotaDataByUsername(username string, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { + var quotaDatas []*QuotaData + // 从quota_data表中查询数据 + err = DB.Table("quota_data").Where("username = ? and created_at >= ? and created_at <= ?", username, startTime, endTime).Find("aDatas).Error + return quotaDatas, err +} + +func GetQuotaDataByUserId(userId int, startTime int64, endTime int64) (quotaData []*QuotaData, err error) { + var quotaDatas []*QuotaData + // 从quota_data表中查询数据 + err = DB.Table("quota_data").Where("user_id = ? and created_at >= ? and created_at <= ?", userId, startTime, endTime).Find("aDatas).Error + return quotaDatas, err +} + +func GetAllQuotaDates(startTime int64, endTime int64, username string) (quotaData []*QuotaData, err error) { + if username != "" { + return GetQuotaDataByUsername(username, startTime, endTime) + } + var quotaDatas []*QuotaData + // 从quota_data表中查询数据 + // only select model_name, sum(count) as count, sum(quota) as quota, model_name, created_at from quota_data group by model_name, created_at; + //err = DB.Table("quota_data").Where("created_at >= ? and created_at <= ?", startTime, endTime).Find("aDatas).Error + err = DB.Table("quota_data").Select("model_name, sum(count) as count, sum(quota) as quota, sum(token_used) as token_used, created_at").Where("created_at >= ? and created_at <= ?", startTime, endTime).Group("model_name, created_at").Find("aDatas).Error + return quotaDatas, err +} diff --git a/model/user.go b/model/user.go new file mode 100644 index 00000000..6021f495 --- /dev/null +++ b/model/user.go @@ -0,0 +1,830 @@ +package model + +import ( + "encoding/json" + "errors" + "fmt" + "one-api/common" + "one-api/dto" + "strconv" + "strings" + + "github.com/bytedance/gopkg/util/gopool" + "gorm.io/gorm" +) + +// User if you add sensitive fields, don't forget to clean them in setupLogin function. +// Otherwise, the sensitive information will be saved on local storage in plain text! +type User struct { + Id int `json:"id"` + Username string `json:"username" gorm:"unique;index" validate:"max=12"` + Password string `json:"password" gorm:"not null;" validate:"min=8,max=20"` + OriginalPassword string `json:"original_password" gorm:"-:all"` // this field is only for Password change verification, don't save it to database! + DisplayName string `json:"display_name" gorm:"index" validate:"max=20"` + Role int `json:"role" gorm:"type:int;default:1"` // admin, common + Status int `json:"status" gorm:"type:int;default:1"` // enabled, disabled + Email string `json:"email" gorm:"index" validate:"max=50"` + GitHubId string `json:"github_id" gorm:"column:github_id;index"` + OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"` + WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"` + TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"` + VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database! + AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management + Quota int `json:"quota" gorm:"type:int;default:0"` + UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota + RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number + Group string `json:"group" gorm:"type:varchar(64);default:'default'"` + AffCode string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"` + AffCount int `json:"aff_count" gorm:"type:int;default:0;column:aff_count"` + AffQuota int `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"` // 邀请剩余额度 + AffHistoryQuota int `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度 + InviterId int `json:"inviter_id" gorm:"type:int;column:inviter_id;index"` + DeletedAt gorm.DeletedAt `gorm:"index"` + LinuxDOId string `json:"linux_do_id" gorm:"column:linux_do_id;index"` + Setting string `json:"setting" gorm:"type:text;column:setting"` + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` + StripeCustomer string `json:"stripe_customer" gorm:"type:varchar(64);column:stripe_customer;index"` +} + +func (user *User) ToBaseUser() *UserBase { + cache := &UserBase{ + Id: user.Id, + Group: user.Group, + Quota: user.Quota, + Status: user.Status, + Username: user.Username, + Setting: user.Setting, + Email: user.Email, + } + return cache +} + +func (user *User) GetAccessToken() string { + if user.AccessToken == nil { + return "" + } + return *user.AccessToken +} + +func (user *User) SetAccessToken(token string) { + user.AccessToken = &token +} + +func (user *User) GetSetting() dto.UserSetting { + setting := dto.UserSetting{} + if user.Setting != "" { + err := json.Unmarshal([]byte(user.Setting), &setting) + if err != nil { + common.SysError("failed to unmarshal setting: " + err.Error()) + } + } + return setting +} + +func (user *User) SetSetting(setting dto.UserSetting) { + settingBytes, err := json.Marshal(setting) + if err != nil { + common.SysError("failed to marshal setting: " + err.Error()) + return + } + user.Setting = string(settingBytes) +} + +// CheckUserExistOrDeleted check if user exist or deleted, if not exist, return false, nil, if deleted or exist, return true, nil +func CheckUserExistOrDeleted(username string, email string) (bool, error) { + var user User + + // err := DB.Unscoped().First(&user, "username = ? or email = ?", username, email).Error + // check email if empty + var err error + if email == "" { + err = DB.Unscoped().First(&user, "username = ?", username).Error + } else { + err = DB.Unscoped().First(&user, "username = ? or email = ?", username, email).Error + } + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // not exist, return false, nil + return false, nil + } + // other error, return false, err + return false, err + } + // exist, return true, nil + return true, nil +} + +func GetMaxUserId() int { + var user User + DB.Unscoped().Last(&user) + return user.Id +} + +func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err error) { + // Start transaction + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // Get total count within transaction + err = tx.Unscoped().Model(&User{}).Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // Get paginated users within same transaction + err = tx.Unscoped().Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("password").Find(&users).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // Commit transaction + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} + +func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, int64, error) { + var users []*User + var total int64 + var err error + + // 开始事务 + tx := DB.Begin() + if tx.Error != nil { + return nil, 0, tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + // 构建基础查询 + query := tx.Unscoped().Model(&User{}) + + // 构建搜索条件 + likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?" + + // 尝试将关键字转换为整数ID + keywordInt, err := strconv.Atoi(keyword) + if err == nil { + // 如果是数字,同时搜索ID和其他字段 + likeCondition = "id = ? OR " + likeCondition + if group != "" { + query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", + keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) + } else { + query = query.Where(likeCondition, + keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + } else { + // 非数字关键字,只搜索字符串字段 + if group != "" { + query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group) + } else { + query = query.Where(likeCondition, + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + } + } + + // 获取总数 + err = query.Count(&total).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // 获取分页数据 + err = query.Omit("password").Order("id desc").Limit(num).Offset(startIdx).Find(&users).Error + if err != nil { + tx.Rollback() + return nil, 0, err + } + + // 提交事务 + if err = tx.Commit().Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} + +func GetUserById(id int, selectAll bool) (*User, error) { + if id == 0 { + return nil, errors.New("id 为空!") + } + user := User{Id: id} + var err error = nil + if selectAll { + err = DB.First(&user, "id = ?", id).Error + } else { + err = DB.Omit("password").First(&user, "id = ?", id).Error + } + return &user, err +} + +func GetUserIdByAffCode(affCode string) (int, error) { + if affCode == "" { + return 0, errors.New("affCode 为空!") + } + var user User + err := DB.Select("id").First(&user, "aff_code = ?", affCode).Error + return user.Id, err +} + +func DeleteUserById(id int) (err error) { + if id == 0 { + return errors.New("id 为空!") + } + user := User{Id: id} + return user.Delete() +} + +func HardDeleteUserById(id int) error { + if id == 0 { + return errors.New("id 为空!") + } + err := DB.Unscoped().Delete(&User{}, "id = ?", id).Error + return err +} + +func inviteUser(inviterId int) (err error) { + user, err := GetUserById(inviterId, true) + if err != nil { + return err + } + user.AffCount++ + user.AffQuota += common.QuotaForInviter + user.AffHistoryQuota += common.QuotaForInviter + return DB.Save(user).Error +} + +func (user *User) TransferAffQuotaToQuota(quota int) error { + // 检查quota是否小于最小额度 + if float64(quota) < common.QuotaPerUnit { + return fmt.Errorf("转移额度最小为%s!", common.LogQuota(int(common.QuotaPerUnit))) + } + + // 开始数据库事务 + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + defer tx.Rollback() // 确保在函数退出时事务能回滚 + + // 加锁查询用户以确保数据一致性 + err := tx.Set("gorm:query_option", "FOR UPDATE").First(&user, user.Id).Error + if err != nil { + return err + } + + // 再次检查用户的AffQuota是否足够 + if user.AffQuota < quota { + return errors.New("邀请额度不足!") + } + + // 更新用户额度 + user.AffQuota -= quota + user.Quota += quota + + // 保存用户状态 + if err := tx.Save(user).Error; err != nil { + return err + } + + // 提交事务 + return tx.Commit().Error +} + +func (user *User) Insert(inviterId int) error { + var err error + if user.Password != "" { + user.Password, err = common.Password2Hash(user.Password) + if err != nil { + return err + } + } + user.Quota = common.QuotaForNewUser + //user.SetAccessToken(common.GetUUID()) + user.AffCode = common.GetRandomString(4) + result := DB.Create(user) + if result.Error != nil { + return result.Error + } + if common.QuotaForNewUser > 0 { + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", common.LogQuota(common.QuotaForNewUser))) + } + if inviterId != 0 { + if common.QuotaForInvitee > 0 { + _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", common.LogQuota(common.QuotaForInvitee))) + } + if common.QuotaForInviter > 0 { + //_ = IncreaseUserQuota(inviterId, common.QuotaForInviter) + RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", common.LogQuota(common.QuotaForInviter))) + _ = inviteUser(inviterId) + } + } + return nil +} + +func (user *User) Update(updatePassword bool) error { + var err error + if updatePassword { + user.Password, err = common.Password2Hash(user.Password) + if err != nil { + return err + } + } + newUser := *user + DB.First(&user, user.Id) + if err = DB.Model(user).Updates(newUser).Error; err != nil { + return err + } + + // Update cache + return updateUserCache(*user) +} + +func (user *User) Edit(updatePassword bool) error { + var err error + if updatePassword { + user.Password, err = common.Password2Hash(user.Password) + if err != nil { + return err + } + } + + newUser := *user + updates := map[string]interface{}{ + "username": newUser.Username, + "display_name": newUser.DisplayName, + "group": newUser.Group, + "quota": newUser.Quota, + "remark": newUser.Remark, + } + if updatePassword { + updates["password"] = newUser.Password + } + + DB.First(&user, user.Id) + if err = DB.Model(user).Updates(updates).Error; err != nil { + return err + } + + // Update cache + return updateUserCache(*user) +} + +func (user *User) Delete() error { + if user.Id == 0 { + return errors.New("id 为空!") + } + if err := DB.Delete(user).Error; err != nil { + return err + } + + // 清除缓存 + return invalidateUserCache(user.Id) +} + +func (user *User) HardDelete() error { + if user.Id == 0 { + return errors.New("id 为空!") + } + err := DB.Unscoped().Delete(user).Error + return err +} + +// ValidateAndFill check password & user status +func (user *User) ValidateAndFill() (err error) { + // When querying with struct, GORM will only query with non-zero fields, + // that means if your field's value is 0, '', false or other zero values, + // it won't be used to build query conditions + password := user.Password + username := strings.TrimSpace(user.Username) + if username == "" || password == "" { + return errors.New("用户名或密码为空") + } + // find buy username or email + DB.Where("username = ? OR email = ?", username, username).First(user) + okay := common.ValidatePasswordAndHash(password, user.Password) + if !okay || user.Status != common.UserStatusEnabled { + return errors.New("用户名或密码错误,或用户已被封禁") + } + return nil +} + +func (user *User) FillUserById() error { + if user.Id == 0 { + return errors.New("id 为空!") + } + DB.Where(User{Id: user.Id}).First(user) + return nil +} + +func (user *User) FillUserByEmail() error { + if user.Email == "" { + return errors.New("email 为空!") + } + DB.Where(User{Email: user.Email}).First(user) + return nil +} + +func (user *User) FillUserByGitHubId() error { + if user.GitHubId == "" { + return errors.New("GitHub id 为空!") + } + DB.Where(User{GitHubId: user.GitHubId}).First(user) + return nil +} + +func (user *User) FillUserByOidcId() error { + if user.OidcId == "" { + return errors.New("oidc id 为空!") + } + DB.Where(User{OidcId: user.OidcId}).First(user) + return nil +} + +func (user *User) FillUserByWeChatId() error { + if user.WeChatId == "" { + return errors.New("WeChat id 为空!") + } + DB.Where(User{WeChatId: user.WeChatId}).First(user) + return nil +} + +func (user *User) FillUserByTelegramId() error { + if user.TelegramId == "" { + return errors.New("Telegram id 为空!") + } + err := DB.Where(User{TelegramId: user.TelegramId}).First(user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("该 Telegram 账户未绑定") + } + return nil +} + +func IsEmailAlreadyTaken(email string) bool { + return DB.Unscoped().Where("email = ?", email).Find(&User{}).RowsAffected == 1 +} + +func IsWeChatIdAlreadyTaken(wechatId string) bool { + return DB.Unscoped().Where("wechat_id = ?", wechatId).Find(&User{}).RowsAffected == 1 +} + +func IsGitHubIdAlreadyTaken(githubId string) bool { + return DB.Unscoped().Where("github_id = ?", githubId).Find(&User{}).RowsAffected == 1 +} + +func IsOidcIdAlreadyTaken(oidcId string) bool { + return DB.Where("oidc_id = ?", oidcId).Find(&User{}).RowsAffected == 1 +} + +func IsTelegramIdAlreadyTaken(telegramId string) bool { + return DB.Unscoped().Where("telegram_id = ?", telegramId).Find(&User{}).RowsAffected == 1 +} + +func ResetUserPasswordByEmail(email string, password string) error { + if email == "" || password == "" { + return errors.New("邮箱地址或密码为空!") + } + hashedPassword, err := common.Password2Hash(password) + if err != nil { + return err + } + err = DB.Model(&User{}).Where("email = ?", email).Update("password", hashedPassword).Error + return err +} + +func IsAdmin(userId int) bool { + if userId == 0 { + return false + } + var user User + err := DB.Where("id = ?", userId).Select("role").Find(&user).Error + if err != nil { + common.SysError("no such user " + err.Error()) + return false + } + return user.Role >= common.RoleAdminUser +} + +//// IsUserEnabled checks user status from Redis first, falls back to DB if needed +//func IsUserEnabled(id int, fromDB bool) (status bool, err error) { +// defer func() { +// // Update Redis cache asynchronously on successful DB read +// if shouldUpdateRedis(fromDB, err) { +// gopool.Go(func() { +// if err := updateUserStatusCache(id, status); err != nil { +// common.SysError("failed to update user status cache: " + err.Error()) +// } +// }) +// } +// }() +// if !fromDB && common.RedisEnabled { +// // Try Redis first +// status, err := getUserStatusCache(id) +// if err == nil { +// return status == common.UserStatusEnabled, nil +// } +// // Don't return error - fall through to DB +// } +// fromDB = true +// var user User +// err = DB.Where("id = ?", id).Select("status").Find(&user).Error +// if err != nil { +// return false, err +// } +// +// return user.Status == common.UserStatusEnabled, nil +//} + +func ValidateAccessToken(token string) (user *User) { + if token == "" { + return nil + } + token = strings.Replace(token, "Bearer ", "", 1) + user = &User{} + if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 { + return user + } + return nil +} + +// GetUserQuota gets quota from Redis first, falls back to DB if needed +func GetUserQuota(id int, fromDB bool) (quota int, err error) { + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) { + gopool.Go(func() { + if err := updateUserQuotaCache(id, quota); err != nil { + common.SysError("failed to update user quota cache: " + err.Error()) + } + }) + } + }() + if !fromDB && common.RedisEnabled { + quota, err := getUserQuotaCache(id) + if err == nil { + return quota, nil + } + // Don't return error - fall through to DB + } + fromDB = true + err = DB.Model(&User{}).Where("id = ?", id).Select("quota").Find("a).Error + if err != nil { + return 0, err + } + + return quota, nil +} + +func GetUserUsedQuota(id int) (quota int, err error) { + err = DB.Model(&User{}).Where("id = ?", id).Select("used_quota").Find("a).Error + return quota, err +} + +func GetUserEmail(id int) (email string, err error) { + err = DB.Model(&User{}).Where("id = ?", id).Select("email").Find(&email).Error + return email, err +} + +// GetUserGroup gets group from Redis first, falls back to DB if needed +func GetUserGroup(id int, fromDB bool) (group string, err error) { + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) { + gopool.Go(func() { + if err := updateUserGroupCache(id, group); err != nil { + common.SysError("failed to update user group cache: " + err.Error()) + } + }) + } + }() + if !fromDB && common.RedisEnabled { + group, err := getUserGroupCache(id) + if err == nil { + return group, nil + } + // Don't return error - fall through to DB + } + fromDB = true + err = DB.Model(&User{}).Where("id = ?", id).Select(commonGroupCol).Find(&group).Error + if err != nil { + return "", err + } + + return group, nil +} + +// GetUserSetting gets setting from Redis first, falls back to DB if needed +func GetUserSetting(id int, fromDB bool) (settingMap dto.UserSetting, err error) { + var setting string + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) { + gopool.Go(func() { + if err := updateUserSettingCache(id, setting); err != nil { + common.SysError("failed to update user setting cache: " + err.Error()) + } + }) + } + }() + if !fromDB && common.RedisEnabled { + setting, err := getUserSettingCache(id) + if err == nil { + return setting, nil + } + // Don't return error - fall through to DB + } + fromDB = true + err = DB.Model(&User{}).Where("id = ?", id).Select("setting").Find(&setting).Error + if err != nil { + return settingMap, err + } + userBase := &UserBase{ + Setting: setting, + } + return userBase.GetSetting(), nil +} + +func IncreaseUserQuota(id int, quota int, db bool) (err error) { + if quota < 0 { + return errors.New("quota 不能为负数!") + } + gopool.Go(func() { + err := cacheIncrUserQuota(id, int64(quota)) + if err != nil { + common.SysError("failed to increase user quota: " + err.Error()) + } + }) + if !db && common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeUserQuota, id, quota) + return nil + } + return increaseUserQuota(id, quota) +} + +func increaseUserQuota(id int, quota int) (err error) { + err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota + ?", quota)).Error + if err != nil { + return err + } + return err +} + +func DecreaseUserQuota(id int, quota int) (err error) { + if quota < 0 { + return errors.New("quota 不能为负数!") + } + gopool.Go(func() { + err := cacheDecrUserQuota(id, int64(quota)) + if err != nil { + common.SysError("failed to decrease user quota: " + err.Error()) + } + }) + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeUserQuota, id, -quota) + return nil + } + return decreaseUserQuota(id, quota) +} + +func decreaseUserQuota(id int, quota int) (err error) { + err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error + if err != nil { + return err + } + return err +} + +func DeltaUpdateUserQuota(id int, delta int) (err error) { + if delta == 0 { + return nil + } + if delta > 0 { + return IncreaseUserQuota(id, delta, false) + } else { + return DecreaseUserQuota(id, -delta) + } +} + +//func GetRootUserEmail() (email string) { +// DB.Model(&User{}).Where("role = ?", common.RoleRootUser).Select("email").Find(&email) +// return email +//} + +func GetRootUser() (user *User) { + DB.Where("role = ?", common.RoleRootUser).First(&user) + return user +} + +func UpdateUserUsedQuotaAndRequestCount(id int, quota int) { + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeUsedQuota, id, quota) + addNewRecord(BatchUpdateTypeRequestCount, id, 1) + return + } + updateUserUsedQuotaAndRequestCount(id, quota, 1) +} + +func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) { + err := DB.Model(&User{}).Where("id = ?", id).Updates( + map[string]interface{}{ + "used_quota": gorm.Expr("used_quota + ?", quota), + "request_count": gorm.Expr("request_count + ?", count), + }, + ).Error + if err != nil { + common.SysError("failed to update user used quota and request count: " + err.Error()) + return + } + + //// 更新缓存 + //if err := invalidateUserCache(id); err != nil { + // common.SysError("failed to invalidate user cache: " + err.Error()) + //} +} + +func updateUserUsedQuota(id int, quota int) { + err := DB.Model(&User{}).Where("id = ?", id).Updates( + map[string]interface{}{ + "used_quota": gorm.Expr("used_quota + ?", quota), + }, + ).Error + if err != nil { + common.SysError("failed to update user used quota: " + err.Error()) + } +} + +func updateUserRequestCount(id int, count int) { + err := DB.Model(&User{}).Where("id = ?", id).Update("request_count", gorm.Expr("request_count + ?", count)).Error + if err != nil { + common.SysError("failed to update user request count: " + err.Error()) + } +} + +// GetUsernameById gets username from Redis first, falls back to DB if needed +func GetUsernameById(id int, fromDB bool) (username string, err error) { + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) { + gopool.Go(func() { + if err := updateUserNameCache(id, username); err != nil { + common.SysError("failed to update user name cache: " + err.Error()) + } + }) + } + }() + if !fromDB && common.RedisEnabled { + username, err := getUserNameCache(id) + if err == nil { + return username, nil + } + // Don't return error - fall through to DB + } + fromDB = true + err = DB.Model(&User{}).Where("id = ?", id).Select("username").Find(&username).Error + if err != nil { + return "", err + } + + return username, nil +} + +func IsLinuxDOIdAlreadyTaken(linuxDOId string) bool { + var user User + err := DB.Unscoped().Where("linux_do_id = ?", linuxDOId).First(&user).Error + return !errors.Is(err, gorm.ErrRecordNotFound) +} + +func (user *User) FillUserByLinuxDOId() error { + if user.LinuxDOId == "" { + return errors.New("linux do id is empty") + } + err := DB.Where("linux_do_id = ?", user.LinuxDOId).First(user).Error + return err +} + +func RootUserExists() bool { + var user User + err := DB.Where("role = ?", common.RoleRootUser).First(&user).Error + if err != nil { + return false + } + return true +} diff --git a/model/user_cache.go b/model/user_cache.go new file mode 100644 index 00000000..a631457c --- /dev/null +++ b/model/user_cache.go @@ -0,0 +1,218 @@ +package model + +import ( + "fmt" + "one-api/common" + "one-api/constant" + "one-api/dto" + "time" + + "github.com/gin-gonic/gin" + + "github.com/bytedance/gopkg/util/gopool" +) + +// UserBase struct remains the same as it represents the cached data structure +type UserBase struct { + Id int `json:"id"` + Group string `json:"group"` + Email string `json:"email"` + Quota int `json:"quota"` + Status int `json:"status"` + Username string `json:"username"` + Setting string `json:"setting"` +} + +func (user *UserBase) WriteContext(c *gin.Context) { + common.SetContextKey(c, constant.ContextKeyUserGroup, user.Group) + common.SetContextKey(c, constant.ContextKeyUserQuota, user.Quota) + common.SetContextKey(c, constant.ContextKeyUserStatus, user.Status) + common.SetContextKey(c, constant.ContextKeyUserEmail, user.Email) + common.SetContextKey(c, constant.ContextKeyUserName, user.Username) + common.SetContextKey(c, constant.ContextKeyUserSetting, user.GetSetting()) +} + +func (user *UserBase) GetSetting() dto.UserSetting { + setting := dto.UserSetting{} + if user.Setting != "" { + err := common.Unmarshal([]byte(user.Setting), &setting) + if err != nil { + common.SysError("failed to unmarshal setting: " + err.Error()) + } + } + return setting +} + +// getUserCacheKey returns the key for user cache +func getUserCacheKey(userId int) string { + return fmt.Sprintf("user:%d", userId) +} + +// invalidateUserCache clears user cache +func invalidateUserCache(userId int) error { + if !common.RedisEnabled { + return nil + } + return common.RedisDelKey(getUserCacheKey(userId)) +} + +// updateUserCache updates all user cache fields using hash +func updateUserCache(user User) error { + if !common.RedisEnabled { + return nil + } + + return common.RedisHSetObj( + getUserCacheKey(user.Id), + user.ToBaseUser(), + time.Duration(common.RedisKeyCacheSeconds())*time.Second, + ) +} + +// GetUserCache gets complete user cache from hash +func GetUserCache(userId int) (userCache *UserBase, err error) { + var user *User + var fromDB bool + defer func() { + // Update Redis cache asynchronously on successful DB read + if shouldUpdateRedis(fromDB, err) && user != nil { + gopool.Go(func() { + if err := updateUserCache(*user); err != nil { + common.SysError("failed to update user status cache: " + err.Error()) + } + }) + } + }() + + // Try getting from Redis first + userCache, err = cacheGetUserBase(userId) + if err == nil { + return userCache, nil + } + + // If Redis fails, get from DB + fromDB = true + user, err = GetUserById(userId, false) + if err != nil { + return nil, err // Return nil and error if DB lookup fails + } + + // Create cache object from user data + userCache = &UserBase{ + Id: user.Id, + Group: user.Group, + Quota: user.Quota, + Status: user.Status, + Username: user.Username, + Setting: user.Setting, + Email: user.Email, + } + + return userCache, nil +} + +func cacheGetUserBase(userId int) (*UserBase, error) { + if !common.RedisEnabled { + return nil, fmt.Errorf("redis is not enabled") + } + var userCache UserBase + // Try getting from Redis first + err := common.RedisHGetObj(getUserCacheKey(userId), &userCache) + if err != nil { + return nil, err + } + return &userCache, nil +} + +// Add atomic quota operations using hash fields +func cacheIncrUserQuota(userId int, delta int64) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHIncrBy(getUserCacheKey(userId), "Quota", delta) +} + +func cacheDecrUserQuota(userId int, delta int64) error { + return cacheIncrUserQuota(userId, -delta) +} + +// Helper functions to get individual fields if needed +func getUserGroupCache(userId int) (string, error) { + cache, err := GetUserCache(userId) + if err != nil { + return "", err + } + return cache.Group, nil +} + +func getUserQuotaCache(userId int) (int, error) { + cache, err := GetUserCache(userId) + if err != nil { + return 0, err + } + return cache.Quota, nil +} + +func getUserStatusCache(userId int) (int, error) { + cache, err := GetUserCache(userId) + if err != nil { + return 0, err + } + return cache.Status, nil +} + +func getUserNameCache(userId int) (string, error) { + cache, err := GetUserCache(userId) + if err != nil { + return "", err + } + return cache.Username, nil +} + +func getUserSettingCache(userId int) (dto.UserSetting, error) { + cache, err := GetUserCache(userId) + if err != nil { + return dto.UserSetting{}, err + } + return cache.GetSetting(), nil +} + +// New functions for individual field updates +func updateUserStatusCache(userId int, status bool) error { + if !common.RedisEnabled { + return nil + } + statusInt := common.UserStatusEnabled + if !status { + statusInt = common.UserStatusDisabled + } + return common.RedisHSetField(getUserCacheKey(userId), "Status", fmt.Sprintf("%d", statusInt)) +} + +func updateUserQuotaCache(userId int, quota int) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHSetField(getUserCacheKey(userId), "Quota", fmt.Sprintf("%d", quota)) +} + +func updateUserGroupCache(userId int, group string) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHSetField(getUserCacheKey(userId), "Group", group) +} + +func updateUserNameCache(userId int, username string) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHSetField(getUserCacheKey(userId), "Username", username) +} + +func updateUserSettingCache(userId int, setting string) error { + if !common.RedisEnabled { + return nil + } + return common.RedisHSetField(getUserCacheKey(userId), "Setting", setting) +} diff --git a/model/utils.go b/model/utils.go new file mode 100644 index 00000000..1f8a0963 --- /dev/null +++ b/model/utils.go @@ -0,0 +1,111 @@ +package model + +import ( + "errors" + "one-api/common" + "sync" + "time" + + "github.com/bytedance/gopkg/util/gopool" + "gorm.io/gorm" +) + +const ( + BatchUpdateTypeUserQuota = iota + BatchUpdateTypeTokenQuota + BatchUpdateTypeUsedQuota + BatchUpdateTypeChannelUsedQuota + BatchUpdateTypeRequestCount + BatchUpdateTypeCount // if you add a new type, you need to add a new map and a new lock +) + +var batchUpdateStores []map[int]int +var batchUpdateLocks []sync.Mutex + +func init() { + for i := 0; i < BatchUpdateTypeCount; i++ { + batchUpdateStores = append(batchUpdateStores, make(map[int]int)) + batchUpdateLocks = append(batchUpdateLocks, sync.Mutex{}) + } +} + +func InitBatchUpdater() { + gopool.Go(func() { + for { + time.Sleep(time.Duration(common.BatchUpdateInterval) * time.Second) + batchUpdate() + } + }) +} + +func addNewRecord(type_ int, id int, value int) { + batchUpdateLocks[type_].Lock() + defer batchUpdateLocks[type_].Unlock() + if _, ok := batchUpdateStores[type_][id]; !ok { + batchUpdateStores[type_][id] = value + } else { + batchUpdateStores[type_][id] += value + } +} + +func batchUpdate() { + // check if there's any data to update + hasData := false + for i := 0; i < BatchUpdateTypeCount; i++ { + batchUpdateLocks[i].Lock() + if len(batchUpdateStores[i]) > 0 { + hasData = true + batchUpdateLocks[i].Unlock() + break + } + batchUpdateLocks[i].Unlock() + } + + if !hasData { + return + } + + common.SysLog("batch update started") + for i := 0; i < BatchUpdateTypeCount; i++ { + batchUpdateLocks[i].Lock() + store := batchUpdateStores[i] + batchUpdateStores[i] = make(map[int]int) + batchUpdateLocks[i].Unlock() + // TODO: maybe we can combine updates with same key? + for key, value := range store { + switch i { + case BatchUpdateTypeUserQuota: + err := increaseUserQuota(key, value) + if err != nil { + common.SysError("failed to batch update user quota: " + err.Error()) + } + case BatchUpdateTypeTokenQuota: + err := increaseTokenQuota(key, value) + if err != nil { + common.SysError("failed to batch update token quota: " + err.Error()) + } + case BatchUpdateTypeUsedQuota: + updateUserUsedQuota(key, value) + case BatchUpdateTypeRequestCount: + updateUserRequestCount(key, value) + case BatchUpdateTypeChannelUsedQuota: + updateChannelUsedQuota(key, value) + } + } + } + common.SysLog("batch update finished") +} + +func RecordExist(err error) (bool, error) { + if err == nil { + return true, nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return false, err +} + +func shouldUpdateRedis(fromDB bool, err error) bool { + return common.RedisEnabled && fromDB && err == nil +} diff --git a/one-api.service b/one-api.service new file mode 100644 index 00000000..17e236bc --- /dev/null +++ b/one-api.service @@ -0,0 +1,18 @@ +# File path: /etc/systemd/system/one-api.service +# sudo systemctl daemon-reload +# sudo systemctl start one-api +# sudo systemctl enable one-api +# sudo systemctl status one-api +[Unit] +Description=One API Service +After=network.target + +[Service] +User=ubuntu # 注意修改用户名 +WorkingDirectory=/path/to/one-api # 注意修改路径 +ExecStart=/path/to/one-api/one-api --port 3000 --log-dir /path/to/one-api/logs # 注意修改路径和端口号 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/relay/audio_handler.go b/relay/audio_handler.go new file mode 100644 index 00000000..f39dbd82 --- /dev/null +++ b/relay/audio_handler.go @@ -0,0 +1,134 @@ +package relay + +import ( + "errors" + "fmt" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "one-api/setting" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func getAndValidAudioRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.AudioRequest, error) { + audioRequest := &dto.AudioRequest{} + err := common.UnmarshalBodyReusable(c, audioRequest) + if err != nil { + return nil, err + } + switch info.RelayMode { + case relayconstant.RelayModeAudioSpeech: + if audioRequest.Model == "" { + return nil, errors.New("model is required") + } + if setting.ShouldCheckPromptSensitive() { + words, err := service.CheckSensitiveInput(audioRequest.Input) + if err != nil { + common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ","))) + return nil, err + } + } + default: + err = c.Request.ParseForm() + if err != nil { + return nil, err + } + formData := c.Request.PostForm + if audioRequest.Model == "" { + audioRequest.Model = formData.Get("model") + } + + if audioRequest.Model == "" { + return nil, errors.New("model is required") + } + audioRequest.ResponseFormat = formData.Get("response_format") + if audioRequest.ResponseFormat == "" { + audioRequest.ResponseFormat = "json" + } + } + return audioRequest, nil +} + +func AudioHelper(c *gin.Context) (newAPIError *types.NewAPIError) { + relayInfo := relaycommon.GenRelayInfoOpenAIAudio(c) + audioRequest, err := getAndValidAudioRequest(c, relayInfo) + + if err != nil { + common.LogError(c, fmt.Sprintf("getAndValidAudioRequest failed: %s", err.Error())) + return types.NewError(err, types.ErrorCodeInvalidRequest) + } + + promptTokens := 0 + preConsumedTokens := common.PreConsumedQuota + if relayInfo.RelayMode == relayconstant.RelayModeAudioSpeech { + promptTokens = service.CountTTSToken(audioRequest.Input, audioRequest.Model) + preConsumedTokens = promptTokens + relayInfo.PromptTokens = promptTokens + } + + priceData, err := helper.ModelPriceHelper(c, relayInfo, preConsumedTokens, 0) + if err != nil { + return types.NewError(err, types.ErrorCodeModelPriceError) + } + + preConsumedQuota, userQuota, openaiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + if openaiErr != nil { + return openaiErr + } + defer func() { + if openaiErr != nil { + returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) + } + }() + + err = helper.ModelMappedHelper(c, relayInfo, audioRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError) + } + + adaptor := GetAdaptor(relayInfo.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + } + adaptor.Init(relayInfo) + + ioReader, err := adaptor.ConvertAudioRequest(c, relayInfo, *audioRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + + resp, err := adaptor.DoRequest(c, relayInfo, ioReader) + if err != nil { + return types.NewError(err, types.ErrorCodeDoRequestFailed) + } + statusCodeMappingStr := c.GetString("status_code_mapping") + + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, relayInfo) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "") + + return nil +} diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go new file mode 100644 index 00000000..ab8836ba --- /dev/null +++ b/relay/channel/adapter.go @@ -0,0 +1,50 @@ +package channel + +import ( + "io" + "net/http" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor interface { + // Init IsStream bool + Init(info *relaycommon.RelayInfo) + GetRequestURL(info *relaycommon.RelayInfo) (string, error) + SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error + ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) + ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) + ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) + ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) + ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) + ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) + DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) + DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) + GetModelList() []string + GetChannelName() string + ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) +} + +type TaskAdaptor interface { + Init(info *relaycommon.TaskRelayInfo) + + ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) *dto.TaskError + + BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) + BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error + BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) + + DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) + DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, err *dto.TaskError) + + GetModelList() []string + GetChannelName() string + + // FetchTask + FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) + + ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) +} diff --git a/relay/channel/ai360/constants.go b/relay/channel/ai360/constants.go new file mode 100644 index 00000000..4b09dd56 --- /dev/null +++ b/relay/channel/ai360/constants.go @@ -0,0 +1,14 @@ +package ai360 + +var ModelList = []string{ + "360gpt-turbo", + "360gpt-turbo-responsibility-8k", + "360gpt-pro", + "360gpt2-pro", + "360GPT_S2_V9", + "embedding-bert-512-v1", + "embedding_s1_v1", + "semantic_similarity_s1_v1", +} + +var ChannelName = "ai360" diff --git a/relay/channel/ali/adaptor.go b/relay/channel/ali/adaptor.go new file mode 100644 index 00000000..d941a1bc --- /dev/null +++ b/relay/channel/ali/adaptor.go @@ -0,0 +1,127 @@ +package ali + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + var fullRequestURL string + switch info.RelayMode { + case constant.RelayModeEmbeddings: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.BaseUrl) + case constant.RelayModeRerank: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.BaseUrl) + case constant.RelayModeImagesGenerations: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.BaseUrl) + case constant.RelayModeCompletions: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.BaseUrl) + default: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.BaseUrl) + } + return fullRequestURL, nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + if info.IsStream { + req.Set("X-DashScope-SSE", "enable") + } + if c.GetString("plugin") != "" { + req.Set("X-DashScope-Plugin", c.GetString("plugin")) + } + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + // fix: ali parameter.enable_thinking must be set to false for non-streaming calls + if !info.IsStream { + request.EnableThinking = false + } + + switch info.RelayMode { + default: + aliReq := requestOpenAI2Ali(*request) + return aliReq, nil + } +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + aliRequest := oaiImage2Ali(request) + return aliRequest, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return ConvertRerankRequest(request), nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case constant.RelayModeImagesGenerations: + err, usage = aliImageHandler(c, resp, info) + case constant.RelayModeEmbeddings: + err, usage = aliEmbeddingHandler(c, resp) + case constant.RelayModeRerank: + err, usage = RerankHandler(c, resp, info) + default: + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/ali/constants.go b/relay/channel/ali/constants.go new file mode 100644 index 00000000..df64439b --- /dev/null +++ b/relay/channel/ali/constants.go @@ -0,0 +1,14 @@ +package ali + +var ModelList = []string{ + "qwen-turbo", + "qwen-plus", + "qwen-max", + "qwen-max-longcontext", + "qwq-32b", + "qwen3-235b-a22b", + "text-embedding-v1", + "gte-rerank-v2", +} + +var ChannelName = "ali" diff --git a/relay/channel/ali/dto.go b/relay/channel/ali/dto.go new file mode 100644 index 00000000..dbd18968 --- /dev/null +++ b/relay/channel/ali/dto.go @@ -0,0 +1,126 @@ +package ali + +import "one-api/dto" + +type AliMessage struct { + Content string `json:"content"` + Role string `json:"role"` +} + +type AliInput struct { + Prompt string `json:"prompt,omitempty"` + //History []AliMessage `json:"history,omitempty"` + Messages []AliMessage `json:"messages"` +} + +type AliParameters struct { + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Seed uint64 `json:"seed,omitempty"` + EnableSearch bool `json:"enable_search,omitempty"` + IncrementalOutput bool `json:"incremental_output,omitempty"` +} + +type AliChatRequest struct { + Model string `json:"model"` + Input AliInput `json:"input,omitempty"` + Parameters AliParameters `json:"parameters,omitempty"` +} + +type AliEmbeddingRequest struct { + Model string `json:"model"` + Input struct { + Texts []string `json:"texts"` + } `json:"input"` + Parameters *struct { + TextType string `json:"text_type,omitempty"` + } `json:"parameters,omitempty"` +} + +type AliEmbedding struct { + Embedding []float64 `json:"embedding"` + TextIndex int `json:"text_index"` +} + +type AliEmbeddingResponse struct { + Output struct { + Embeddings []AliEmbedding `json:"embeddings"` + } `json:"output"` + Usage AliUsage `json:"usage"` + AliError +} + +type AliError struct { + Code string `json:"code"` + Message string `json:"message"` + RequestId string `json:"request_id"` +} + +type AliUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type TaskResult struct { + B64Image string `json:"b64_image,omitempty"` + Url string `json:"url,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +type AliOutput struct { + TaskId string `json:"task_id,omitempty"` + TaskStatus string `json:"task_status,omitempty"` + Text string `json:"text"` + FinishReason string `json:"finish_reason"` + Message string `json:"message,omitempty"` + Code string `json:"code,omitempty"` + Results []TaskResult `json:"results,omitempty"` +} + +type AliResponse struct { + Output AliOutput `json:"output"` + Usage AliUsage `json:"usage"` + AliError +} + +type AliImageRequest struct { + Model string `json:"model"` + Input struct { + Prompt string `json:"prompt"` + NegativePrompt string `json:"negative_prompt,omitempty"` + } `json:"input"` + Parameters struct { + Size string `json:"size,omitempty"` + N int `json:"n,omitempty"` + Steps string `json:"steps,omitempty"` + Scale string `json:"scale,omitempty"` + } `json:"parameters,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` +} + +type AliRerankParameters struct { + TopN *int `json:"top_n,omitempty"` + ReturnDocuments *bool `json:"return_documents,omitempty"` +} + +type AliRerankInput struct { + Query string `json:"query"` + Documents []any `json:"documents"` +} + +type AliRerankRequest struct { + Model string `json:"model"` + Input AliRerankInput `json:"input"` + Parameters AliRerankParameters `json:"parameters,omitempty"` +} + +type AliRerankResponse struct { + Output struct { + Results []dto.RerankResponseResult `json:"results"` + } `json:"output"` + Usage AliUsage `json:"usage"` + RequestId string `json:"request_id"` + AliError +} diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go new file mode 100644 index 00000000..0d430c62 --- /dev/null +++ b/relay/channel/ali/image.go @@ -0,0 +1,171 @@ +package ali + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/service" + "one-api/types" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +func oaiImage2Ali(request dto.ImageRequest) *AliImageRequest { + var imageRequest AliImageRequest + imageRequest.Input.Prompt = request.Prompt + imageRequest.Model = request.Model + imageRequest.Parameters.Size = strings.Replace(request.Size, "x", "*", -1) + imageRequest.Parameters.N = request.N + imageRequest.ResponseFormat = request.ResponseFormat + + return &imageRequest +} + +func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) { + url := fmt.Sprintf("%s/api/v1/tasks/%s", info.BaseUrl, taskID) + + var aliResponse AliResponse + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return &aliResponse, err, nil + } + + req.Header.Set("Authorization", "Bearer "+info.ApiKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + common.SysError("updateTask client.Do err: " + err.Error()) + return &aliResponse, err, nil + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + + var response AliResponse + err = json.Unmarshal(responseBody, &response) + if err != nil { + common.SysError("updateTask NewDecoder err: " + err.Error()) + return &aliResponse, err, nil + } + + return &response, nil, responseBody +} + +func asyncTaskWait(info *relaycommon.RelayInfo, taskID string) (*AliResponse, []byte, error) { + waitSeconds := 3 + step := 0 + maxStep := 20 + + var taskResponse AliResponse + var responseBody []byte + + for { + step++ + rsp, err, body := updateTask(info, taskID) + responseBody = body + if err != nil { + return &taskResponse, responseBody, err + } + + if rsp.Output.TaskStatus == "" { + return &taskResponse, responseBody, nil + } + + switch rsp.Output.TaskStatus { + case "FAILED": + fallthrough + case "CANCELED": + fallthrough + case "SUCCEEDED": + fallthrough + case "UNKNOWN": + return rsp, responseBody, nil + } + if step >= maxStep { + break + } + time.Sleep(time.Duration(waitSeconds) * time.Second) + } + + return nil, nil, fmt.Errorf("aliAsyncTaskWait timeout") +} + +func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, info *relaycommon.RelayInfo, responseFormat string) *dto.ImageResponse { + imageResponse := dto.ImageResponse{ + Created: info.StartTime.Unix(), + } + + for _, data := range response.Output.Results { + var b64Json string + if responseFormat == "b64_json" { + _, b64, err := service.GetImageFromUrl(data.Url) + if err != nil { + common.LogError(c, "get_image_data_failed: "+err.Error()) + continue + } + b64Json = b64 + } else { + b64Json = data.B64Image + } + + imageResponse.Data = append(imageResponse.Data, dto.ImageData{ + Url: data.Url, + B64Json: b64Json, + RevisedPrompt: "", + }) + } + return &imageResponse +} + +func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { + responseFormat := c.GetString("response_format") + + var aliTaskResponse AliResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil + } + common.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &aliTaskResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + + if aliTaskResponse.Message != "" { + common.LogError(c, "ali_async_task_failed: "+aliTaskResponse.Message) + return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil + } + + aliResponse, _, err := asyncTaskWait(info, aliTaskResponse.Output.TaskId) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponse), nil + } + + if aliResponse.Output.TaskStatus != "SUCCEEDED" { + return types.WithOpenAIError(types.OpenAIError{ + Message: aliResponse.Output.Message, + Type: "ali_error", + Param: "", + Code: aliResponse.Output.Code, + }, resp.StatusCode), nil + } + + fullTextResponse := responseAli2OpenAIImage(c, aliResponse, info, responseFormat) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + c.Writer.Write(jsonResponse) + return nil, &dto.Usage{} +} diff --git a/relay/channel/ali/rerank.go b/relay/channel/ali/rerank.go new file mode 100644 index 00000000..59cb0a11 --- /dev/null +++ b/relay/channel/ali/rerank.go @@ -0,0 +1,74 @@ +package ali + +import ( + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +func ConvertRerankRequest(request dto.RerankRequest) *AliRerankRequest { + returnDocuments := request.ReturnDocuments + if returnDocuments == nil { + t := true + returnDocuments = &t + } + return &AliRerankRequest{ + Model: request.Model, + Input: AliRerankInput{ + Query: request.Query, + Documents: request.Documents, + }, + Parameters: AliRerankParameters{ + TopN: &request.TopN, + ReturnDocuments: returnDocuments, + }, + } +} + +func RerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil + } + common.CloseResponseBodyGracefully(resp) + + var aliResponse AliRerankResponse + err = json.Unmarshal(responseBody, &aliResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + + if aliResponse.Code != "" { + return types.WithOpenAIError(types.OpenAIError{ + Message: aliResponse.Message, + Type: aliResponse.Code, + Param: aliResponse.RequestId, + Code: aliResponse.Code, + }, resp.StatusCode), nil + } + + usage := dto.Usage{ + PromptTokens: aliResponse.Usage.TotalTokens, + CompletionTokens: 0, + TotalTokens: aliResponse.Usage.TotalTokens, + } + rerankResponse := dto.RerankResponse{ + Results: aliResponse.Output.Results, + Usage: usage, + } + + jsonResponse, err := json.Marshal(rerankResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + c.Writer.Write(jsonResponse) + return nil, &usage +} diff --git a/relay/channel/ali/text.go b/relay/channel/ali/text.go new file mode 100644 index 00000000..6d90fa71 --- /dev/null +++ b/relay/channel/ali/text.go @@ -0,0 +1,206 @@ +package ali + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/relay/helper" + "strings" + + "one-api/types" + + "github.com/gin-gonic/gin" +) + +// https://help.aliyun.com/document_detail/613695.html?spm=a2c4g.2399480.0.0.1adb778fAdzP9w#341800c0f8w0r + +const EnableSearchModelSuffix = "-internet" + +func requestOpenAI2Ali(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { + if request.TopP >= 1 { + request.TopP = 0.999 + } else if request.TopP <= 0 { + request.TopP = 0.001 + } + return &request +} + +func embeddingRequestOpenAI2Ali(request dto.EmbeddingRequest) *AliEmbeddingRequest { + return &AliEmbeddingRequest{ + Model: request.Model, + Input: struct { + Texts []string `json:"texts"` + }{ + Texts: request.ParseInput(), + }, + } +} + +func aliEmbeddingHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + var fullTextResponse dto.FlexibleEmbeddingResponse + err := json.NewDecoder(resp.Body).Decode(&fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + + common.CloseResponseBodyGracefully(resp) + + model := c.GetString("model") + if model == "" { + model = "text-embedding-v4" + } + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func embeddingResponseAli2OpenAI(response *AliEmbeddingResponse, model string) *dto.OpenAIEmbeddingResponse { + openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{ + Object: "list", + Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Output.Embeddings)), + Model: model, + Usage: dto.Usage{TotalTokens: response.Usage.TotalTokens}, + } + + for _, item := range response.Output.Embeddings { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, dto.OpenAIEmbeddingResponseItem{ + Object: `embedding`, + Index: item.TextIndex, + Embedding: item.Embedding, + }) + } + return &openAIEmbeddingResponse +} + +func responseAli2OpenAI(response *AliResponse) *dto.OpenAITextResponse { + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: response.Output.Text, + }, + FinishReason: response.Output.FinishReason, + } + fullTextResponse := dto.OpenAITextResponse{ + Id: response.RequestId, + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: []dto.OpenAITextResponseChoice{choice}, + Usage: dto.Usage{ + PromptTokens: response.Usage.InputTokens, + CompletionTokens: response.Usage.OutputTokens, + TotalTokens: response.Usage.InputTokens + response.Usage.OutputTokens, + }, + } + return &fullTextResponse +} + +func streamResponseAli2OpenAI(aliResponse *AliResponse) *dto.ChatCompletionsStreamResponse { + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString(aliResponse.Output.Text) + if aliResponse.Output.FinishReason != "null" { + finishReason := aliResponse.Output.FinishReason + choice.FinishReason = &finishReason + } + response := dto.ChatCompletionsStreamResponse{ + Id: aliResponse.RequestId, + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "ernie-bot", + Choices: []dto.ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func aliStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + var usage dto.Usage + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + dataChan := make(chan string) + stopChan := make(chan bool) + go func() { + for scanner.Scan() { + data := scanner.Text() + if len(data) < 5 { // ignore blank line or wrong format + continue + } + if data[:5] != "data:" { + continue + } + data = data[5:] + dataChan <- data + } + stopChan <- true + }() + helper.SetEventStreamHeaders(c) + lastResponseText := "" + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + var aliResponse AliResponse + err := json.Unmarshal([]byte(data), &aliResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + if aliResponse.Usage.OutputTokens != 0 { + usage.PromptTokens = aliResponse.Usage.InputTokens + usage.CompletionTokens = aliResponse.Usage.OutputTokens + usage.TotalTokens = aliResponse.Usage.InputTokens + aliResponse.Usage.OutputTokens + } + response := streamResponseAli2OpenAI(&aliResponse) + response.Choices[0].Delta.SetContentString(strings.TrimPrefix(response.Choices[0].Delta.GetContentString(), lastResponseText)) + lastResponseText = aliResponse.Output.Text + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + common.CloseResponseBodyGracefully(resp) + return nil, &usage +} + +func aliHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + var aliResponse AliResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil + } + common.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &aliResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + if aliResponse.Code != "" { + return types.WithOpenAIError(types.OpenAIError{ + Message: aliResponse.Message, + Type: "ali_error", + Param: aliResponse.RequestId, + Code: aliResponse.Code, + }, resp.StatusCode), nil + } + fullTextResponse := responseAli2OpenAI(&aliResponse) + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go new file mode 100644 index 00000000..ff7c63fa --- /dev/null +++ b/relay/channel/api_request.go @@ -0,0 +1,277 @@ +package channel + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + common2 "one-api/common" + "one-api/relay/common" + "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "one-api/setting/operation_setting" + "sync" + "time" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Header) { + if info.RelayMode == constant.RelayModeAudioTranscription || info.RelayMode == constant.RelayModeAudioTranslation { + // multipart/form-data + } else if info.RelayMode == constant.RelayModeRealtime { + // websocket + } else { + req.Set("Content-Type", c.Request.Header.Get("Content-Type")) + req.Set("Accept", c.Request.Header.Get("Accept")) + if info.IsStream && c.Request.Header.Get("Accept") == "" { + req.Set("Accept", "text/event-stream") + } + } +} + +func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + fullRequestURL, err := a.GetRequestURL(info) + if err != nil { + return nil, fmt.Errorf("get request url failed: %w", err) + } + if common2.DebugEnabled { + println("fullRequestURL:", fullRequestURL) + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + err = a.SetupRequestHeader(c, &req.Header, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := doRequest(c, req, info) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} + +func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) { + fullRequestURL, err := a.GetRequestURL(info) + if err != nil { + return nil, fmt.Errorf("get request url failed: %w", err) + } + if common2.DebugEnabled { + println("fullRequestURL:", fullRequestURL) + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + // set form data + req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) + + err = a.SetupRequestHeader(c, &req.Header, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := doRequest(c, req, info) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} + +func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*websocket.Conn, error) { + fullRequestURL, err := a.GetRequestURL(info) + if err != nil { + return nil, fmt.Errorf("get request url failed: %w", err) + } + targetHeader := http.Header{} + err = a.SetupRequestHeader(c, &targetHeader, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + targetHeader.Set("Content-Type", c.Request.Header.Get("Content-Type")) + targetConn, _, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader) + if err != nil { + return nil, fmt.Errorf("dial failed to %s: %w", fullRequestURL, err) + } + // send request body + //all, err := io.ReadAll(requestBody) + //err = service.WssString(c, targetConn, string(all)) + return targetConn, nil +} + +func startPingKeepAlive(c *gin.Context, pingInterval time.Duration) context.CancelFunc { + pingerCtx, stopPinger := context.WithCancel(context.Background()) + + gopool.Go(func() { + defer func() { + // 增加panic恢复处理 + if r := recover(); r != nil { + if common2.DebugEnabled { + println("SSE ping goroutine panic recovered:", fmt.Sprintf("%v", r)) + } + } + if common2.DebugEnabled { + println("SSE ping goroutine stopped.") + } + }() + + if pingInterval <= 0 { + pingInterval = helper.DefaultPingInterval + } + + ticker := time.NewTicker(pingInterval) + // 确保在任何情况下都清理ticker + defer func() { + ticker.Stop() + if common2.DebugEnabled { + println("SSE ping ticker stopped") + } + }() + + var pingMutex sync.Mutex + if common2.DebugEnabled { + println("SSE ping goroutine started") + } + + // 增加超时控制,防止goroutine长时间运行 + maxPingDuration := 120 * time.Minute // 最大ping持续时间 + pingTimeout := time.NewTimer(maxPingDuration) + defer pingTimeout.Stop() + + for { + select { + // 发送 ping 数据 + case <-ticker.C: + if err := sendPingData(c, &pingMutex); err != nil { + if common2.DebugEnabled { + println("SSE ping error, stopping goroutine:", err.Error()) + } + return + } + // 收到退出信号 + case <-pingerCtx.Done(): + return + // request 结束 + case <-c.Request.Context().Done(): + return + // 超时保护,防止goroutine无限运行 + case <-pingTimeout.C: + if common2.DebugEnabled { + println("SSE ping goroutine timeout, stopping") + } + return + } + } + }) + + return stopPinger +} + +func sendPingData(c *gin.Context, mutex *sync.Mutex) error { + // 增加超时控制,防止锁死等待 + done := make(chan error, 1) + go func() { + mutex.Lock() + defer mutex.Unlock() + + err := helper.PingData(c) + if err != nil { + common2.LogError(c, "SSE ping error: "+err.Error()) + done <- err + return + } + + if common2.DebugEnabled { + println("SSE ping data sent.") + } + done <- nil + }() + + // 设置发送ping数据的超时时间 + select { + case err := <-done: + return err + case <-time.After(10 * time.Second): + return errors.New("SSE ping data send timeout") + case <-c.Request.Context().Done(): + return errors.New("request context cancelled during ping") + } +} + +func DoRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { + return doRequest(c, req, info) +} +func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) { + var client *http.Client + var err error + if info.ChannelSetting.Proxy != "" { + client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + } else { + client = service.GetHttpClient() + } + + var stopPinger context.CancelFunc + if info.IsStream { + helper.SetEventStreamHeaders(c) + // 处理流式请求的 ping 保活 + generalSettings := operation_setting.GetGeneralSetting() + if generalSettings.PingIntervalEnabled { + pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second + stopPinger = startPingKeepAlive(c, pingInterval) + // 使用defer确保在任何情况下都能停止ping goroutine + defer func() { + if stopPinger != nil { + stopPinger() + if common2.DebugEnabled { + println("SSE ping goroutine stopped by defer") + } + } + }() + } + } + + resp, err := client.Do(req) + + if err != nil { + return nil, err + } + if resp == nil { + return nil, errors.New("resp is nil") + } + + _ = req.Body.Close() + _ = c.Request.Body.Close() + return resp, nil +} + +func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { + fullRequestURL, err := a.BuildRequestURL(info) + if err != nil { + return nil, err + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + req.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(requestBody), nil + } + + err = a.BuildRequestHeader(c, req, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := doRequest(c, req, info.RelayInfo) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} diff --git a/relay/channel/aws/adaptor.go b/relay/channel/aws/adaptor.go new file mode 100644 index 00000000..d3354f00 --- /dev/null +++ b/relay/channel/aws/adaptor.go @@ -0,0 +1,107 @@ +package aws + +import ( + "errors" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel/claude" + relaycommon "one-api/relay/common" + "one-api/setting/model_setting" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +const ( + RequestModeCompletion = 1 + RequestModeMessage = 2 +) + +type Adaptor struct { + RequestMode int +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + c.Set("request_model", request.Model) + c.Set("converted_request", request) + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + a.RequestMode = RequestModeMessage +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return "", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + var claudeReq *dto.ClaudeRequest + var err error + claudeReq, err = claude.RequestOpenAI2ClaudeMessage(*request) + if err != nil { + return nil, err + } + c.Set("request_model", claudeReq.Model) + c.Set("converted_request", claudeReq) + return claudeReq, err +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return nil, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + err, usage = awsStreamHandler(c, resp, info, a.RequestMode) + } else { + err, usage = awsHandler(c, info, a.RequestMode) + } + return +} + +func (a *Adaptor) GetModelList() (models []string) { + for n := range awsModelIDMap { + models = append(models, n) + } + + return +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/aws/constants.go b/relay/channel/aws/constants.go new file mode 100644 index 00000000..64c7b747 --- /dev/null +++ b/relay/channel/aws/constants.go @@ -0,0 +1,65 @@ +package aws + +var awsModelIDMap = map[string]string{ + "claude-instant-1.2": "anthropic.claude-instant-v1", + "claude-2.0": "anthropic.claude-v2", + "claude-2.1": "anthropic.claude-v2:1", + "claude-3-sonnet-20240229": "anthropic.claude-3-sonnet-20240229-v1:0", + "claude-3-opus-20240229": "anthropic.claude-3-opus-20240229-v1:0", + "claude-3-haiku-20240307": "anthropic.claude-3-haiku-20240307-v1:0", + "claude-3-5-sonnet-20240620": "anthropic.claude-3-5-sonnet-20240620-v1:0", + "claude-3-5-sonnet-20241022": "anthropic.claude-3-5-sonnet-20241022-v2:0", + "claude-3-5-haiku-20241022": "anthropic.claude-3-5-haiku-20241022-v1:0", + "claude-3-7-sonnet-20250219": "anthropic.claude-3-7-sonnet-20250219-v1:0", + "claude-sonnet-4-20250514": "anthropic.claude-sonnet-4-20250514-v1:0", + "claude-opus-4-20250514": "anthropic.claude-opus-4-20250514-v1:0", +} + +var awsModelCanCrossRegionMap = map[string]map[string]bool{ + "anthropic.claude-3-sonnet-20240229-v1:0": { + "us": true, + "eu": true, + "ap": true, + }, + "anthropic.claude-3-opus-20240229-v1:0": { + "us": true, + }, + "anthropic.claude-3-haiku-20240307-v1:0": { + "us": true, + "eu": true, + "ap": true, + }, + "anthropic.claude-3-5-sonnet-20240620-v1:0": { + "us": true, + "eu": true, + "ap": true, + }, + "anthropic.claude-3-5-sonnet-20241022-v2:0": { + "us": true, + "ap": true, + }, + "anthropic.claude-3-5-haiku-20241022-v1:0": { + "us": true, + }, + "anthropic.claude-3-7-sonnet-20250219-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, + "anthropic.claude-sonnet-4-20250514-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, + "anthropic.claude-opus-4-20250514-v1:0": { + "us": true, + }, +} + +var awsRegionCrossModelPrefixMap = map[string]string{ + "us": "us", + "eu": "eu", + "ap": "apac", +} + +var ChannelName = "aws" diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go new file mode 100644 index 00000000..0188c30a --- /dev/null +++ b/relay/channel/aws/dto.go @@ -0,0 +1,36 @@ +package aws + +import ( + "one-api/dto" +) + +type AwsClaudeRequest struct { + // AnthropicVersion should be "bedrock-2023-05-31" + AnthropicVersion string `json:"anthropic_version"` + System any `json:"system,omitempty"` + Messages []dto.ClaudeMessage `json:"messages"` + MaxTokens uint `json:"max_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Tools any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Thinking *dto.Thinking `json:"thinking,omitempty"` +} + +func copyRequest(req *dto.ClaudeRequest) *AwsClaudeRequest { + return &AwsClaudeRequest{ + AnthropicVersion: "bedrock-2023-05-31", + System: req.System, + Messages: req.Messages, + MaxTokens: req.MaxTokens, + Temperature: req.Temperature, + TopP: req.TopP, + TopK: req.TopK, + StopSequences: req.StopSequences, + Tools: req.Tools, + ToolChoice: req.ToolChoice, + Thinking: req.Thinking, + } +} diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go new file mode 100644 index 00000000..0df19e07 --- /dev/null +++ b/relay/channel/aws/relay-aws.go @@ -0,0 +1,196 @@ +package aws + +import ( + "encoding/json" + "fmt" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/relay/channel/claude" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/bedrockruntime" + bedrockruntimeTypes "github.com/aws/aws-sdk-go-v2/service/bedrockruntime/types" +) + +func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) { + awsSecret := strings.Split(info.ApiKey, "|") + if len(awsSecret) != 3 { + return nil, errors.New("invalid aws secret key") + } + ak := awsSecret[0] + sk := awsSecret[1] + region := awsSecret[2] + client := bedrockruntime.New(bedrockruntime.Options{ + Region: region, + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(ak, sk, "")), + }) + + return client, nil +} + +func wrapErr(err error) *dto.OpenAIErrorWithStatusCode { + return &dto.OpenAIErrorWithStatusCode{ + StatusCode: http.StatusInternalServerError, + Error: dto.OpenAIError{ + Message: fmt.Sprintf("%s", err.Error()), + }, + } +} + +func awsRegionPrefix(awsRegionId string) string { + parts := strings.Split(awsRegionId, "-") + regionPrefix := "" + if len(parts) > 0 { + regionPrefix = parts[0] + } + return regionPrefix +} + +func awsModelCanCrossRegion(awsModelId, awsRegionPrefix string) bool { + regionSet, exists := awsModelCanCrossRegionMap[awsModelId] + return exists && regionSet[awsRegionPrefix] +} + +func awsModelCrossRegion(awsModelId, awsRegionPrefix string) string { + modelPrefix, find := awsRegionCrossModelPrefixMap[awsRegionPrefix] + if !find { + return awsModelId + } + return modelPrefix + "." + awsModelId +} + +func awsModelID(requestModel string) string { + if awsModelID, ok := awsModelIDMap[requestModel]; ok { + return awsModelID + } + + return requestModel +} + +func awsHandler(c *gin.Context, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) { + awsCli, err := newAwsClient(c, info) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelAwsClientError), nil + } + + awsModelId := awsModelID(c.GetString("request_model")) + + awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region) + canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix) + if canCrossRegion { + awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix) + } + + awsReq := &bedrockruntime.InvokeModelInput{ + ModelId: aws.String(awsModelId), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + + claudeReq_, ok := c.Get("converted_request") + if !ok { + return types.NewError(errors.New("aws claude request not found"), types.ErrorCodeInvalidRequest), nil + } + claudeReq := claudeReq_.(*dto.ClaudeRequest) + awsClaudeReq := copyRequest(claudeReq) + awsReq.Body, err = json.Marshal(awsClaudeReq) + if err != nil { + return types.NewError(errors.Wrap(err, "marshal request"), types.ErrorCodeBadResponseBody), nil + } + + awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq) + if err != nil { + return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil + } + + claudeInfo := &claude.ClaudeResponseInfo{ + ResponseId: helper.GetResponseID(c), + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + ResponseText: strings.Builder{}, + Usage: &dto.Usage{}, + } + + handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, awsResp.Body, RequestModeMessage) + if handlerErr != nil { + return handlerErr, nil + } + return nil, claudeInfo.Usage +} + +func awsStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) { + awsCli, err := newAwsClient(c, info) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelAwsClientError), nil + } + + awsModelId := awsModelID(c.GetString("request_model")) + + awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region) + canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix) + if canCrossRegion { + awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix) + } + + awsReq := &bedrockruntime.InvokeModelWithResponseStreamInput{ + ModelId: aws.String(awsModelId), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + + claudeReq_, ok := c.Get("converted_request") + if !ok { + return types.NewError(errors.New("aws claude request not found"), types.ErrorCodeInvalidRequest), nil + } + claudeReq := claudeReq_.(*dto.ClaudeRequest) + + awsClaudeReq := copyRequest(claudeReq) + awsReq.Body, err = json.Marshal(awsClaudeReq) + if err != nil { + return types.NewError(errors.Wrap(err, "marshal request"), types.ErrorCodeBadResponseBody), nil + } + + awsResp, err := awsCli.InvokeModelWithResponseStream(c.Request.Context(), awsReq) + if err != nil { + return types.NewError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeChannelAwsClientError), nil + } + stream := awsResp.GetStream() + defer stream.Close() + + claudeInfo := &claude.ClaudeResponseInfo{ + ResponseId: helper.GetResponseID(c), + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + ResponseText: strings.Builder{}, + Usage: &dto.Usage{}, + } + + for event := range stream.Events() { + switch v := event.(type) { + case *bedrockruntimeTypes.ResponseStreamMemberChunk: + info.SetFirstResponseTime() + respErr := claude.HandleStreamResponseData(c, info, claudeInfo, string(v.Value.Bytes), RequestModeMessage) + if respErr != nil { + return respErr, nil + } + case *bedrockruntimeTypes.UnknownUnionMember: + fmt.Println("unknown tag:", v.Tag) + return types.NewError(errors.New("unknown response type"), types.ErrorCodeInvalidRequest), nil + default: + fmt.Println("union is nil or unknown type") + return types.NewError(errors.New("nil or unknown response type"), types.ErrorCodeInvalidRequest), nil + } + } + + claude.HandleStreamFinalResponse(c, info, claudeInfo, RequestModeMessage) + return nil, claudeInfo.Usage +} diff --git a/relay/channel/baidu/adaptor.go b/relay/channel/baidu/adaptor.go new file mode 100644 index 00000000..22443354 --- /dev/null +++ b/relay/channel/baidu/adaptor.go @@ -0,0 +1,164 @@ +package baidu + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + // https://cloud.baidu.com/doc/WENXINWORKSHOP/s/clntwmv7t + suffix := "chat/" + if strings.HasPrefix(info.UpstreamModelName, "Embedding") { + suffix = "embeddings/" + } + if strings.HasPrefix(info.UpstreamModelName, "bge-large") { + suffix = "embeddings/" + } + if strings.HasPrefix(info.UpstreamModelName, "tao-8k") { + suffix = "embeddings/" + } + switch info.UpstreamModelName { + case "ERNIE-4.0": + suffix += "completions_pro" + case "ERNIE-Bot-4": + suffix += "completions_pro" + case "ERNIE-Bot": + suffix += "completions" + case "ERNIE-Bot-turbo": + suffix += "eb-instant" + case "ERNIE-Speed": + suffix += "ernie_speed" + case "ERNIE-4.0-8K": + suffix += "completions_pro" + case "ERNIE-3.5-8K": + suffix += "completions" + case "ERNIE-3.5-8K-0205": + suffix += "ernie-3.5-8k-0205" + case "ERNIE-3.5-8K-1222": + suffix += "ernie-3.5-8k-1222" + case "ERNIE-Bot-8K": + suffix += "ernie_bot_8k" + case "ERNIE-3.5-4K-0205": + suffix += "ernie-3.5-4k-0205" + case "ERNIE-Speed-8K": + suffix += "ernie_speed" + case "ERNIE-Speed-128K": + suffix += "ernie-speed-128k" + case "ERNIE-Lite-8K-0922": + suffix += "eb-instant" + case "ERNIE-Lite-8K-0308": + suffix += "ernie-lite-8k" + case "ERNIE-Tiny-8K": + suffix += "ernie-tiny-8k" + case "BLOOMZ-7B": + suffix += "bloomz_7b1" + case "Embedding-V1": + suffix += "embedding-v1" + case "bge-large-zh": + suffix += "bge_large_zh" + case "bge-large-en": + suffix += "bge_large_en" + case "tao-8k": + suffix += "tao_8k" + default: + suffix += strings.ToLower(info.UpstreamModelName) + } + fullRequestURL := fmt.Sprintf("%s/rpc/2.0/ai_custom/v1/wenxinworkshop/%s", info.BaseUrl, suffix) + var accessToken string + var err error + if accessToken, err = getBaiduAccessToken(info.ApiKey); err != nil { + return "", err + } + fullRequestURL += "?access_token=" + accessToken + return fullRequestURL, nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch info.RelayMode { + default: + baiduRequest := requestOpenAI2Baidu(*request) + return baiduRequest, nil + } +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + baiduEmbeddingRequest := embeddingRequestOpenAI2Baidu(request) + return baiduEmbeddingRequest, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + err, usage = baiduStreamHandler(c, info, resp) + } else { + switch info.RelayMode { + case constant.RelayModeEmbeddings: + err, usage = baiduEmbeddingHandler(c, info, resp) + default: + err, usage = baiduHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/baidu/constants.go b/relay/channel/baidu/constants.go new file mode 100644 index 00000000..46914330 --- /dev/null +++ b/relay/channel/baidu/constants.go @@ -0,0 +1,22 @@ +package baidu + +var ModelList = []string{ + "ERNIE-4.0-8K", + "ERNIE-3.5-8K", + "ERNIE-3.5-8K-0205", + "ERNIE-3.5-8K-1222", + "ERNIE-Bot-8K", + "ERNIE-3.5-4K-0205", + "ERNIE-Speed-8K", + "ERNIE-Speed-128K", + "ERNIE-Lite-8K-0922", + "ERNIE-Lite-8K-0308", + "ERNIE-Tiny-8K", + "BLOOMZ-7B", + "Embedding-V1", + "bge-large-zh", + "bge-large-en", + "tao-8k", +} + +var ChannelName = "baidu" diff --git a/relay/channel/baidu/dto.go b/relay/channel/baidu/dto.go new file mode 100644 index 00000000..a486de5a --- /dev/null +++ b/relay/channel/baidu/dto.go @@ -0,0 +1,78 @@ +package baidu + +import ( + "one-api/dto" + "time" +) + +type BaiduMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type BaiduChatRequest struct { + Messages []BaiduMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + PenaltyScore float64 `json:"penalty_score,omitempty"` + Stream bool `json:"stream,omitempty"` + System string `json:"system,omitempty"` + DisableSearch bool `json:"disable_search,omitempty"` + EnableCitation bool `json:"enable_citation,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` + UserId string `json:"user_id,omitempty"` +} + +type Error struct { + ErrorCode int `json:"error_code"` + ErrorMsg string `json:"error_msg"` +} + +type BaiduChatResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Result string `json:"result"` + IsTruncated bool `json:"is_truncated"` + NeedClearHistory bool `json:"need_clear_history"` + Usage dto.Usage `json:"usage"` + Error +} + +type BaiduChatStreamResponse struct { + BaiduChatResponse + SentenceId int `json:"sentence_id"` + IsEnd bool `json:"is_end"` +} + +type BaiduEmbeddingRequest struct { + Input []string `json:"input"` +} + +type BaiduEmbeddingData struct { + Object string `json:"object"` + Embedding []float64 `json:"embedding"` + Index int `json:"index"` +} + +type BaiduEmbeddingResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Data []BaiduEmbeddingData `json:"data"` + Usage dto.Usage `json:"usage"` + Error +} + +type BaiduAccessToken struct { + AccessToken string `json:"access_token"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + ExpiresAt time.Time `json:"-"` +} + +type BaiduTokenResponse struct { + ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` +} diff --git a/relay/channel/baidu/relay-baidu.go b/relay/channel/baidu/relay-baidu.go new file mode 100644 index 00000000..06b48c20 --- /dev/null +++ b/relay/channel/baidu/relay-baidu.go @@ -0,0 +1,245 @@ +package baidu + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/flfmc9do2 + +var baiduTokenStore sync.Map + +func requestOpenAI2Baidu(request dto.GeneralOpenAIRequest) *BaiduChatRequest { + baiduRequest := BaiduChatRequest{ + Temperature: request.Temperature, + TopP: request.TopP, + PenaltyScore: request.FrequencyPenalty, + Stream: request.Stream, + DisableSearch: false, + EnableCitation: false, + UserId: request.User, + } + if request.MaxTokens != 0 { + maxTokens := int(request.MaxTokens) + if request.MaxTokens == 1 { + maxTokens = 2 + } + baiduRequest.MaxOutputTokens = &maxTokens + } + for _, message := range request.Messages { + if message.Role == "system" { + baiduRequest.System = message.StringContent() + } else { + baiduRequest.Messages = append(baiduRequest.Messages, BaiduMessage{ + Role: message.Role, + Content: message.StringContent(), + }) + } + } + return &baiduRequest +} + +func responseBaidu2OpenAI(response *BaiduChatResponse) *dto.OpenAITextResponse { + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: response.Result, + }, + FinishReason: "stop", + } + fullTextResponse := dto.OpenAITextResponse{ + Id: response.Id, + Object: "chat.completion", + Created: response.Created, + Choices: []dto.OpenAITextResponseChoice{choice}, + Usage: response.Usage, + } + return &fullTextResponse +} + +func streamResponseBaidu2OpenAI(baiduResponse *BaiduChatStreamResponse) *dto.ChatCompletionsStreamResponse { + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString(baiduResponse.Result) + if baiduResponse.IsEnd { + choice.FinishReason = &constant.FinishReasonStop + } + response := dto.ChatCompletionsStreamResponse{ + Id: baiduResponse.Id, + Object: "chat.completion.chunk", + Created: baiduResponse.Created, + Model: "ernie-bot", + Choices: []dto.ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func embeddingRequestOpenAI2Baidu(request dto.EmbeddingRequest) *BaiduEmbeddingRequest { + return &BaiduEmbeddingRequest{ + Input: request.ParseInput(), + } +} + +func embeddingResponseBaidu2OpenAI(response *BaiduEmbeddingResponse) *dto.OpenAIEmbeddingResponse { + openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{ + Object: "list", + Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Data)), + Model: "baidu-embedding", + Usage: response.Usage, + } + for _, item := range response.Data { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, dto.OpenAIEmbeddingResponseItem{ + Object: item.Object, + Index: item.Index, + Embedding: item.Embedding, + }) + } + return &openAIEmbeddingResponse +} + +func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + usage := &dto.Usage{} + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var baiduResponse BaiduChatStreamResponse + err := common.Unmarshal([]byte(data), &baiduResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + if baiduResponse.Usage.TotalTokens != 0 { + usage.TotalTokens = baiduResponse.Usage.TotalTokens + usage.PromptTokens = baiduResponse.Usage.PromptTokens + usage.CompletionTokens = baiduResponse.Usage.TotalTokens - baiduResponse.Usage.PromptTokens + } + response := streamResponseBaidu2OpenAI(&baiduResponse) + err = helper.ObjectData(c, response) + if err != nil { + common.SysError("error sending stream response: " + err.Error()) + } + return true + }) + common.CloseResponseBodyGracefully(resp) + return nil, usage +} + +func baiduHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + var baiduResponse BaiduChatResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + common.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &baiduResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + if baiduResponse.ErrorMsg != "" { + return types.NewError(fmt.Errorf(baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil + } + fullTextResponse := responseBaidu2OpenAI(&baiduResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func baiduEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + var baiduResponse BaiduEmbeddingResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + common.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &baiduResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + if baiduResponse.ErrorMsg != "" { + return types.NewError(fmt.Errorf(baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil + } + fullTextResponse := embeddingResponseBaidu2OpenAI(&baiduResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return nil, &fullTextResponse.Usage +} + +func getBaiduAccessToken(apiKey string) (string, error) { + if val, ok := baiduTokenStore.Load(apiKey); ok { + var accessToken BaiduAccessToken + if accessToken, ok = val.(BaiduAccessToken); ok { + // soon this will expire + if time.Now().Add(time.Hour).After(accessToken.ExpiresAt) { + go func() { + _, _ = getBaiduAccessTokenHelper(apiKey) + }() + } + return accessToken.AccessToken, nil + } + } + accessToken, err := getBaiduAccessTokenHelper(apiKey) + if err != nil { + return "", err + } + if accessToken == nil { + return "", errors.New("getBaiduAccessToken return a nil token") + } + return (*accessToken).AccessToken, nil +} + +func getBaiduAccessTokenHelper(apiKey string) (*BaiduAccessToken, error) { + parts := strings.Split(apiKey, "|") + if len(parts) != 2 { + return nil, errors.New("invalid baidu apikey") + } + req, err := http.NewRequest("POST", fmt.Sprintf("https://aip.baidubce.com/oauth/2.0/token?grant_type=client_credentials&client_id=%s&client_secret=%s", + parts[0], parts[1]), nil) + if err != nil { + return nil, err + } + req.Header.Add("Content-Type", "application/json") + req.Header.Add("Accept", "application/json") + res, err := service.GetHttpClient().Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var accessToken BaiduAccessToken + err = json.NewDecoder(res.Body).Decode(&accessToken) + if err != nil { + return nil, err + } + if accessToken.Error != "" { + return nil, errors.New(accessToken.Error + ": " + accessToken.ErrorDescription) + } + if accessToken.AccessToken == "" { + return nil, errors.New("getBaiduAccessTokenHelper get empty access token") + } + accessToken.ExpiresAt = time.Now().Add(time.Duration(accessToken.ExpiresIn) * time.Second) + baiduTokenStore.Store(apiKey, accessToken) + return &accessToken, nil +} diff --git a/relay/channel/baidu_v2/adaptor.go b/relay/channel/baidu_v2/adaptor.go new file mode 100644 index 00000000..375fd531 --- /dev/null +++ b/relay/channel/baidu_v2/adaptor.go @@ -0,0 +1,111 @@ +package baidu_v2 + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/v2/chat/completions", info.BaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + keyParts := strings.Split(info.ApiKey, "|") + if len(keyParts) == 0 || keyParts[0] == "" { + return errors.New("invalid API key: authorization token is required") + } + if len(keyParts) > 1 { + if keyParts[1] != "" { + req.Set("appid", keyParts[1]) + } + } + req.Set("Authorization", "Bearer "+keyParts[0]) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if strings.HasSuffix(info.UpstreamModelName, "-search") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search") + request.Model = info.UpstreamModelName + toMap := request.ToMap() + toMap["web_search"] = map[string]any{ + "enable": true, + "enable_citation": true, + "enable_trace": true, + "enable_status": false, + } + return toMap, nil + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/baidu_v2/constants.go b/relay/channel/baidu_v2/constants.go new file mode 100644 index 00000000..a7cee248 --- /dev/null +++ b/relay/channel/baidu_v2/constants.go @@ -0,0 +1,29 @@ +package baidu_v2 + +var ModelList = []string{ + "ernie-4.0-8k-latest", + "ernie-4.0-8k-preview", + "ernie-4.0-8k", + "ernie-4.0-turbo-8k-latest", + "ernie-4.0-turbo-8k-preview", + "ernie-4.0-turbo-8k", + "ernie-4.0-turbo-128k", + "ernie-3.5-8k-preview", + "ernie-3.5-8k", + "ernie-3.5-128k", + "ernie-speed-8k", + "ernie-speed-128k", + "ernie-speed-pro-128k", + "ernie-lite-8k", + "ernie-lite-pro-128k", + "ernie-tiny-8k", + "ernie-char-8k", + "ernie-char-fiction-8k", + "ernie-novel-8k", + "deepseek-v3", + "deepseek-r1", + "deepseek-r1-distill-qwen-32b", + "deepseek-r1-distill-qwen-14b", +} + +var ChannelName = "volcengine" diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go new file mode 100644 index 00000000..540742d6 --- /dev/null +++ b/relay/channel/claude/adaptor.go @@ -0,0 +1,113 @@ +package claude + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/setting/model_setting" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +const ( + RequestModeCompletion = 1 + RequestModeMessage = 2 +) + +type Adaptor struct { + RequestMode int +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + if strings.HasPrefix(info.UpstreamModelName, "claude-2") || strings.HasPrefix(info.UpstreamModelName, "claude-instant") { + a.RequestMode = RequestModeCompletion + } else { + a.RequestMode = RequestModeMessage + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if a.RequestMode == RequestModeMessage { + return fmt.Sprintf("%s/v1/messages", info.BaseUrl), nil + } else { + return fmt.Sprintf("%s/v1/complete", info.BaseUrl), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("x-api-key", info.ApiKey) + anthropicVersion := c.Request.Header.Get("anthropic-version") + if anthropicVersion == "" { + anthropicVersion = "2023-06-01" + } + req.Set("anthropic-version", anthropicVersion) + model_setting.GetClaudeSettings().WriteHeaders(info.OriginModelName, req) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if a.RequestMode == RequestModeCompletion { + return RequestOpenAI2ClaudeComplete(*request), nil + } else { + return RequestOpenAI2ClaudeMessage(*request) + } +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + err, usage = ClaudeStreamHandler(c, resp, info, a.RequestMode) + } else { + err, usage = ClaudeHandler(c, resp, a.RequestMode, info) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go new file mode 100644 index 00000000..e0e3c421 --- /dev/null +++ b/relay/channel/claude/constants.go @@ -0,0 +1,22 @@ +package claude + +var ModelList = []string{ + "claude-instant-1.2", + "claude-2", + "claude-2.0", + "claude-2.1", + "claude-3-sonnet-20240229", + "claude-3-opus-20240229", + "claude-3-haiku-20240307", + "claude-3-5-haiku-20241022", + "claude-3-5-sonnet-20240620", + "claude-3-5-sonnet-20241022", + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-20250219-thinking", + "claude-sonnet-4-20250514", + "claude-sonnet-4-20250514-thinking", + "claude-opus-4-20250514", + "claude-opus-4-20250514-thinking", +} + +var ChannelName = "claude" diff --git a/relay/channel/claude/dto.go b/relay/channel/claude/dto.go new file mode 100644 index 00000000..89415868 --- /dev/null +++ b/relay/channel/claude/dto.go @@ -0,0 +1,95 @@ +package claude + +// +//type ClaudeMetadata struct { +// UserId string `json:"user_id"` +//} +// +//type ClaudeMediaMessage struct { +// Type string `json:"type"` +// Text string `json:"text,omitempty"` +// Source *ClaudeMessageSource `json:"source,omitempty"` +// Usage *ClaudeUsage `json:"usage,omitempty"` +// StopReason *string `json:"stop_reason,omitempty"` +// PartialJson string `json:"partial_json,omitempty"` +// Thinking string `json:"thinking,omitempty"` +// Signature string `json:"signature,omitempty"` +// Delta string `json:"delta,omitempty"` +// // tool_calls +// Id string `json:"id,omitempty"` +// Name string `json:"name,omitempty"` +// Input any `json:"input,omitempty"` +// Content string `json:"content,omitempty"` +// ToolUseId string `json:"tool_use_id,omitempty"` +//} +// +//type ClaudeMessageSource struct { +// Type string `json:"type"` +// MediaType string `json:"media_type"` +// Data string `json:"data"` +//} +// +//type ClaudeMessage struct { +// Role string `json:"role"` +// Content any `json:"content"` +//} +// +//type Tool struct { +// Name string `json:"name"` +// Description string `json:"description,omitempty"` +// InputSchema map[string]interface{} `json:"input_schema"` +//} +// +//type InputSchema struct { +// Type string `json:"type"` +// Properties any `json:"properties,omitempty"` +// Required any `json:"required,omitempty"` +//} +// +//type ClaudeRequest struct { +// Model string `json:"model"` +// Prompt string `json:"prompt,omitempty"` +// System string `json:"system,omitempty"` +// Messages []ClaudeMessage `json:"messages,omitempty"` +// MaxTokens uint `json:"max_tokens,omitempty"` +// MaxTokensToSample uint `json:"max_tokens_to_sample,omitempty"` +// StopSequences []string `json:"stop_sequences,omitempty"` +// Temperature *float64 `json:"temperature,omitempty"` +// TopP float64 `json:"top_p,omitempty"` +// TopK int `json:"top_k,omitempty"` +// //ClaudeMetadata `json:"metadata,omitempty"` +// Stream bool `json:"stream,omitempty"` +// Tools any `json:"tools,omitempty"` +// ToolChoice any `json:"tool_choice,omitempty"` +// Thinking *Thinking `json:"thinking,omitempty"` +//} +// +//type Thinking struct { +// Type string `json:"type"` +// BudgetTokens int `json:"budget_tokens"` +//} +// +//type ClaudeError struct { +// Type string `json:"type"` +// Message string `json:"message"` +//} +// +//type ClaudeResponse struct { +// Id string `json:"id"` +// Type string `json:"type"` +// Content []ClaudeMediaMessage `json:"content"` +// Completion string `json:"completion"` +// StopReason string `json:"stop_reason"` +// Model string `json:"model"` +// Error ClaudeError `json:"error"` +// Usage ClaudeUsage `json:"usage"` +// Index int `json:"index"` // stream only +// ContentBlock *ClaudeMediaMessage `json:"content_block"` +// Delta *ClaudeMediaMessage `json:"delta"` // stream only +// Message *ClaudeResponse `json:"message"` // stream only: message_start +//} +// +//type ClaudeUsage struct { +// InputTokens int `json:"input_tokens"` +// OutputTokens int `json:"output_tokens"` +//} diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go new file mode 100644 index 00000000..f20b573d --- /dev/null +++ b/relay/channel/claude/relay-claude.go @@ -0,0 +1,813 @@ +package claude + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/relay/channel/openrouter" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/setting/model_setting" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +const ( + WebSearchMaxUsesLow = 1 + WebSearchMaxUsesMedium = 5 + WebSearchMaxUsesHigh = 10 +) + +func stopReasonClaude2OpenAI(reason string) string { + switch reason { + case "stop_sequence": + return "stop" + case "end_turn": + return "stop" + case "max_tokens": + return "max_tokens" + case "tool_use": + return "tool_calls" + default: + return reason + } +} + +func RequestOpenAI2ClaudeComplete(textRequest dto.GeneralOpenAIRequest) *dto.ClaudeRequest { + + claudeRequest := dto.ClaudeRequest{ + Model: textRequest.Model, + Prompt: "", + StopSequences: nil, + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + TopK: textRequest.TopK, + Stream: textRequest.Stream, + } + if claudeRequest.MaxTokensToSample == 0 { + claudeRequest.MaxTokensToSample = 4096 + } + prompt := "" + for _, message := range textRequest.Messages { + if message.Role == "user" { + prompt += fmt.Sprintf("\n\nHuman: %s", message.StringContent()) + } else if message.Role == "assistant" { + prompt += fmt.Sprintf("\n\nAssistant: %s", message.StringContent()) + } else if message.Role == "system" { + if prompt == "" { + prompt = message.StringContent() + } + } + } + prompt += "\n\nAssistant:" + claudeRequest.Prompt = prompt + return &claudeRequest +} + +func RequestOpenAI2ClaudeMessage(textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) { + claudeTools := make([]any, 0, len(textRequest.Tools)) + + for _, tool := range textRequest.Tools { + if params, ok := tool.Function.Parameters.(map[string]any); ok { + claudeTool := dto.Tool{ + Name: tool.Function.Name, + Description: tool.Function.Description, + } + claudeTool.InputSchema = make(map[string]interface{}) + if params["type"] != nil { + claudeTool.InputSchema["type"] = params["type"].(string) + } + claudeTool.InputSchema["properties"] = params["properties"] + claudeTool.InputSchema["required"] = params["required"] + for s, a := range params { + if s == "type" || s == "properties" || s == "required" { + continue + } + claudeTool.InputSchema[s] = a + } + claudeTools = append(claudeTools, &claudeTool) + } + } + + // Web search tool + // https://docs.anthropic.com/en/docs/agents-and-tools/tool-use/web-search-tool + if textRequest.WebSearchOptions != nil { + webSearchTool := dto.ClaudeWebSearchTool{ + Type: "web_search_20250305", + Name: "web_search", + } + + // 处理 user_location + if textRequest.WebSearchOptions.UserLocation != nil { + anthropicUserLocation := &dto.ClaudeWebSearchUserLocation{ + Type: "approximate", // 固定为 "approximate" + } + + // 解析 UserLocation JSON + var userLocationMap map[string]interface{} + if err := json.Unmarshal(textRequest.WebSearchOptions.UserLocation, &userLocationMap); err == nil { + // 检查是否有 approximate 字段 + if approximateData, ok := userLocationMap["approximate"].(map[string]interface{}); ok { + if timezone, ok := approximateData["timezone"].(string); ok && timezone != "" { + anthropicUserLocation.Timezone = timezone + } + if country, ok := approximateData["country"].(string); ok && country != "" { + anthropicUserLocation.Country = country + } + if region, ok := approximateData["region"].(string); ok && region != "" { + anthropicUserLocation.Region = region + } + if city, ok := approximateData["city"].(string); ok && city != "" { + anthropicUserLocation.City = city + } + } + } + + webSearchTool.UserLocation = anthropicUserLocation + } + + // 处理 search_context_size 转换为 max_uses + if textRequest.WebSearchOptions.SearchContextSize != "" { + switch textRequest.WebSearchOptions.SearchContextSize { + case "low": + webSearchTool.MaxUses = WebSearchMaxUsesLow + case "medium": + webSearchTool.MaxUses = WebSearchMaxUsesMedium + case "high": + webSearchTool.MaxUses = WebSearchMaxUsesHigh + } + } + + claudeTools = append(claudeTools, &webSearchTool) + } + + claudeRequest := dto.ClaudeRequest{ + Model: textRequest.Model, + MaxTokens: textRequest.MaxTokens, + StopSequences: nil, + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + TopK: textRequest.TopK, + Stream: textRequest.Stream, + Tools: claudeTools, + } + + // 处理 tool_choice 和 parallel_tool_calls + if textRequest.ToolChoice != nil || textRequest.ParallelTooCalls != nil { + claudeToolChoice := mapToolChoice(textRequest.ToolChoice, textRequest.ParallelTooCalls) + if claudeToolChoice != nil { + claudeRequest.ToolChoice = claudeToolChoice + } + } + + if claudeRequest.MaxTokens == 0 { + claudeRequest.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model)) + } + + if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && + strings.HasSuffix(textRequest.Model, "-thinking") { + + // 因为BudgetTokens 必须大于1024 + if claudeRequest.MaxTokens < 1280 { + claudeRequest.MaxTokens = 1280 + } + + // BudgetTokens 为 max_tokens 的 80% + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](int(float64(claudeRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), + } + // TODO: 临时处理 + // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking + claudeRequest.TopP = 0 + claudeRequest.Temperature = common.GetPointer[float64](1.0) + claudeRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking") + } + + if textRequest.ReasoningEffort != "" { + switch textRequest.ReasoningEffort { + case "low": + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](1280), + } + case "medium": + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](2048), + } + case "high": + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](4096), + } + } + } + + // 指定了 reasoning 参数,覆盖 budgetTokens + if textRequest.Reasoning != nil { + var reasoning openrouter.RequestReasoning + if err := common.Unmarshal(textRequest.Reasoning, &reasoning); err != nil { + return nil, err + } + + budgetTokens := reasoning.MaxTokens + if budgetTokens > 0 { + claudeRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: &budgetTokens, + } + } + } + + if textRequest.Stop != nil { + // stop maybe string/array string, convert to array string + switch textRequest.Stop.(type) { + case string: + claudeRequest.StopSequences = []string{textRequest.Stop.(string)} + case []interface{}: + stopSequences := make([]string, 0) + for _, stop := range textRequest.Stop.([]interface{}) { + stopSequences = append(stopSequences, stop.(string)) + } + claudeRequest.StopSequences = stopSequences + } + } + formatMessages := make([]dto.Message, 0) + lastMessage := dto.Message{ + Role: "tool", + } + for i, message := range textRequest.Messages { + if message.Role == "" { + textRequest.Messages[i].Role = "user" + } + fmtMessage := dto.Message{ + Role: message.Role, + Content: message.Content, + } + if message.Role == "tool" { + fmtMessage.ToolCallId = message.ToolCallId + } + if message.Role == "assistant" && message.ToolCalls != nil { + fmtMessage.ToolCalls = message.ToolCalls + } + if lastMessage.Role == message.Role && lastMessage.Role != "tool" { + if lastMessage.IsStringContent() && message.IsStringContent() { + fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\"")) + // delete last message + formatMessages = formatMessages[:len(formatMessages)-1] + } + } + if fmtMessage.Content == nil { + fmtMessage.SetStringContent("...") + } + formatMessages = append(formatMessages, fmtMessage) + lastMessage = fmtMessage + } + + claudeMessages := make([]dto.ClaudeMessage, 0) + isFirstMessage := true + for _, message := range formatMessages { + if message.Role == "system" { + if message.IsStringContent() { + claudeRequest.System = message.StringContent() + } else { + contents := message.ParseContent() + content := "" + for _, ctx := range contents { + if ctx.Type == "text" { + content += ctx.Text + } + } + claudeRequest.System = content + } + } else { + if isFirstMessage { + isFirstMessage = false + if message.Role != "user" { + // fix: first message is assistant, add user message + claudeMessage := dto.ClaudeMessage{ + Role: "user", + Content: []dto.ClaudeMediaMessage{ + { + Type: "text", + Text: common.GetPointer[string]("..."), + }, + }, + } + claudeMessages = append(claudeMessages, claudeMessage) + } + } + claudeMessage := dto.ClaudeMessage{ + Role: message.Role, + } + if message.Role == "tool" { + if len(claudeMessages) > 0 && claudeMessages[len(claudeMessages)-1].Role == "user" { + lastMessage := claudeMessages[len(claudeMessages)-1] + if content, ok := lastMessage.Content.(string); ok { + lastMessage.Content = []dto.ClaudeMediaMessage{ + { + Type: "text", + Text: common.GetPointer[string](content), + }, + } + } + lastMessage.Content = append(lastMessage.Content.([]dto.ClaudeMediaMessage), dto.ClaudeMediaMessage{ + Type: "tool_result", + ToolUseId: message.ToolCallId, + Content: message.Content, + }) + claudeMessages[len(claudeMessages)-1] = lastMessage + continue + } else { + claudeMessage.Role = "user" + claudeMessage.Content = []dto.ClaudeMediaMessage{ + { + Type: "tool_result", + ToolUseId: message.ToolCallId, + Content: message.Content, + }, + } + } + } else if message.IsStringContent() && message.ToolCalls == nil { + claudeMessage.Content = message.StringContent() + } else { + claudeMediaMessages := make([]dto.ClaudeMediaMessage, 0) + for _, mediaMessage := range message.ParseContent() { + claudeMediaMessage := dto.ClaudeMediaMessage{ + Type: mediaMessage.Type, + } + if mediaMessage.Type == "text" { + claudeMediaMessage.Text = common.GetPointer[string](mediaMessage.Text) + } else { + imageUrl := mediaMessage.GetImageMedia() + claudeMediaMessage.Type = "image" + claudeMediaMessage.Source = &dto.ClaudeMessageSource{ + Type: "base64", + } + // 判断是否是url + if strings.HasPrefix(imageUrl.Url, "http") { + // 是url,获取图片的类型和base64编码的数据 + fileData, err := service.GetFileBase64FromUrl(imageUrl.Url) + if err != nil { + return nil, fmt.Errorf("get file base64 from url failed: %s", err.Error()) + } + claudeMediaMessage.Source.MediaType = fileData.MimeType + claudeMediaMessage.Source.Data = fileData.Base64Data + } else { + _, format, base64String, err := service.DecodeBase64ImageData(imageUrl.Url) + if err != nil { + return nil, err + } + claudeMediaMessage.Source.MediaType = "image/" + format + claudeMediaMessage.Source.Data = base64String + } + } + claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage) + } + if message.ToolCalls != nil { + for _, toolCall := range message.ParseToolCalls() { + inputObj := make(map[string]any) + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &inputObj); err != nil { + common.SysError("tool call function arguments is not a map[string]any: " + fmt.Sprintf("%v", toolCall.Function.Arguments)) + continue + } + claudeMediaMessages = append(claudeMediaMessages, dto.ClaudeMediaMessage{ + Type: "tool_use", + Id: toolCall.ID, + Name: toolCall.Function.Name, + Input: inputObj, + }) + } + } + claudeMessage.Content = claudeMediaMessages + } + claudeMessages = append(claudeMessages, claudeMessage) + } + } + claudeRequest.Prompt = "" + claudeRequest.Messages = claudeMessages + return &claudeRequest, nil +} + +func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto.ChatCompletionsStreamResponse { + var response dto.ChatCompletionsStreamResponse + response.Object = "chat.completion.chunk" + response.Model = claudeResponse.Model + response.Choices = make([]dto.ChatCompletionsStreamResponseChoice, 0) + tools := make([]dto.ToolCallResponse, 0) + fcIdx := 0 + if claudeResponse.Index != nil { + fcIdx = *claudeResponse.Index - 1 + if fcIdx < 0 { + fcIdx = 0 + } + } + var choice dto.ChatCompletionsStreamResponseChoice + if reqMode == RequestModeCompletion { + choice.Delta.SetContentString(claudeResponse.Completion) + finishReason := stopReasonClaude2OpenAI(claudeResponse.StopReason) + if finishReason != "null" { + choice.FinishReason = &finishReason + } + } else { + if claudeResponse.Type == "message_start" { + response.Id = claudeResponse.Message.Id + response.Model = claudeResponse.Message.Model + //claudeUsage = &claudeResponse.Message.Usage + choice.Delta.SetContentString("") + choice.Delta.Role = "assistant" + } else if claudeResponse.Type == "content_block_start" { + if claudeResponse.ContentBlock != nil { + //choice.Delta.SetContentString(claudeResponse.ContentBlock.Text) + if claudeResponse.ContentBlock.Type == "tool_use" { + tools = append(tools, dto.ToolCallResponse{ + Index: common.GetPointer(fcIdx), + ID: claudeResponse.ContentBlock.Id, + Type: "function", + Function: dto.FunctionResponse{ + Name: claudeResponse.ContentBlock.Name, + Arguments: "", + }, + }) + } + } else { + return nil + } + } else if claudeResponse.Type == "content_block_delta" { + if claudeResponse.Delta != nil { + choice.Delta.Content = claudeResponse.Delta.Text + switch claudeResponse.Delta.Type { + case "input_json_delta": + tools = append(tools, dto.ToolCallResponse{ + Type: "function", + Index: common.GetPointer(fcIdx), + Function: dto.FunctionResponse{ + Arguments: *claudeResponse.Delta.PartialJson, + }, + }) + case "signature_delta": + // 加密的不处理 + signatureContent := "\n" + choice.Delta.ReasoningContent = &signatureContent + case "thinking_delta": + thinkingContent := claudeResponse.Delta.Thinking + choice.Delta.ReasoningContent = &thinkingContent + } + } + } else if claudeResponse.Type == "message_delta" { + finishReason := stopReasonClaude2OpenAI(*claudeResponse.Delta.StopReason) + if finishReason != "null" { + choice.FinishReason = &finishReason + } + //claudeUsage = &claudeResponse.Usage + } else if claudeResponse.Type == "message_stop" { + return nil + } else { + return nil + } + } + if len(tools) > 0 { + choice.Delta.Content = nil // compatible with other OpenAI derivative applications, like LobeOpenAICompatibleFactory ... + choice.Delta.ToolCalls = tools + } + response.Choices = append(response.Choices, choice) + + return &response +} + +func ResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse { + choices := make([]dto.OpenAITextResponseChoice, 0) + fullTextResponse := dto.OpenAITextResponse{ + Id: fmt.Sprintf("chatcmpl-%s", common.GetUUID()), + Object: "chat.completion", + Created: common.GetTimestamp(), + } + var responseText string + var responseThinking string + if len(claudeResponse.Content) > 0 { + responseText = claudeResponse.Content[0].GetText() + responseThinking = claudeResponse.Content[0].Thinking + } + tools := make([]dto.ToolCallResponse, 0) + thinkingContent := "" + + if reqMode == RequestModeCompletion { + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: strings.TrimPrefix(claudeResponse.Completion, " "), + Name: nil, + }, + FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), + } + choices = append(choices, choice) + } else { + fullTextResponse.Id = claudeResponse.Id + for _, message := range claudeResponse.Content { + switch message.Type { + case "tool_use": + args, _ := json.Marshal(message.Input) + tools = append(tools, dto.ToolCallResponse{ + ID: message.Id, + Type: "function", // compatible with other OpenAI derivative applications + Function: dto.FunctionResponse{ + Name: message.Name, + Arguments: string(args), + }, + }) + case "thinking": + // 加密的不管, 只输出明文的推理过程 + thinkingContent = message.Thinking + case "text": + responseText = message.GetText() + } + } + } + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + }, + FinishReason: stopReasonClaude2OpenAI(claudeResponse.StopReason), + } + choice.SetStringContent(responseText) + if len(responseThinking) > 0 { + choice.ReasoningContent = responseThinking + } + if len(tools) > 0 { + choice.Message.SetToolCalls(tools) + } + choice.Message.ReasoningContent = thinkingContent + fullTextResponse.Model = claudeResponse.Model + choices = append(choices, choice) + fullTextResponse.Choices = choices + return &fullTextResponse +} + +type ClaudeResponseInfo struct { + ResponseId string + Created int64 + Model string + ResponseText strings.Builder + Usage *dto.Usage + Done bool +} + +func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool { + if requestMode == RequestModeCompletion { + claudeInfo.ResponseText.WriteString(claudeResponse.Completion) + } else { + if claudeResponse.Type == "message_start" { + claudeInfo.ResponseId = claudeResponse.Message.Id + claudeInfo.Model = claudeResponse.Message.Model + + // message_start, 获取usage + claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens + claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens + claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens + claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens + } else if claudeResponse.Type == "content_block_delta" { + if claudeResponse.Delta.Text != nil { + claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text) + } + if claudeResponse.Delta.Thinking != "" { + claudeInfo.ResponseText.WriteString(claudeResponse.Delta.Thinking) + } + } else if claudeResponse.Type == "message_delta" { + // 最终的usage获取 + if claudeResponse.Usage.InputTokens > 0 { + // 不叠加,只取最新的 + claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens + } + claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens + claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens + + // 判断是否完整 + claudeInfo.Done = true + } else if claudeResponse.Type == "content_block_start" { + } else { + return false + } + } + if oaiResponse != nil { + oaiResponse.Id = claudeInfo.ResponseId + oaiResponse.Created = claudeInfo.Created + oaiResponse.Model = claudeInfo.Model + } + return true +} + +func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data string, requestMode int) *types.NewAPIError { + var claudeResponse dto.ClaudeResponse + err := common.UnmarshalJsonStr(data, &claudeResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return types.NewError(err, types.ErrorCodeBadResponseBody) + } + if claudeResponse.Error != nil && claudeResponse.Error.Type != "" { + return types.WithClaudeError(*claudeResponse.Error, http.StatusInternalServerError) + } + if info.RelayFormat == relaycommon.RelayFormatClaude { + FormatClaudeResponseInfo(requestMode, &claudeResponse, nil, claudeInfo) + + if requestMode == RequestModeCompletion { + } else { + if claudeResponse.Type == "message_start" { + // message_start, 获取usage + info.UpstreamModelName = claudeResponse.Message.Model + } else if claudeResponse.Type == "content_block_delta" { + } else if claudeResponse.Type == "message_delta" { + } + } + helper.ClaudeChunkData(c, claudeResponse, data) + } else if info.RelayFormat == relaycommon.RelayFormatOpenAI { + response := StreamResponseClaude2OpenAI(requestMode, &claudeResponse) + + if !FormatClaudeResponseInfo(requestMode, &claudeResponse, response, claudeInfo) { + return nil + } + + err = helper.ObjectData(c, response) + if err != nil { + common.LogError(c, "send_stream_response_failed: "+err.Error()) + } + } + return nil +} + +func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, requestMode int) { + + if requestMode == RequestModeCompletion { + claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, info.PromptTokens) + } else { + if claudeInfo.Usage.PromptTokens == 0 { + //上游出错 + } + if claudeInfo.Usage.CompletionTokens == 0 || !claudeInfo.Done { + if common.DebugEnabled { + common.SysError("claude response usage is not complete, maybe upstream error") + } + claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) + } + } + + if info.RelayFormat == relaycommon.RelayFormatClaude { + // + } else if info.RelayFormat == relaycommon.RelayFormatOpenAI { + + if info.ShouldIncludeUsage { + response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, *claudeInfo.Usage) + err := helper.ObjectData(c, response) + if err != nil { + common.SysError("send final response failed: " + err.Error()) + } + } + helper.Done(c) + } +} + +func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*types.NewAPIError, *dto.Usage) { + claudeInfo := &ClaudeResponseInfo{ + ResponseId: helper.GetResponseID(c), + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + ResponseText: strings.Builder{}, + Usage: &dto.Usage{}, + } + var err *types.NewAPIError + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + err = HandleStreamResponseData(c, info, claudeInfo, data, requestMode) + if err != nil { + return false + } + return true + }) + if err != nil { + return err, nil + } + + HandleStreamFinalResponse(c, info, claudeInfo, requestMode) + return nil, claudeInfo.Usage +} + +func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, data []byte, requestMode int) *types.NewAPIError { + var claudeResponse dto.ClaudeResponse + err := common.Unmarshal(data, &claudeResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody) + } + if claudeResponse.Error != nil && claudeResponse.Error.Type != "" { + return types.WithClaudeError(*claudeResponse.Error, http.StatusInternalServerError) + } + if requestMode == RequestModeCompletion { + completionTokens := service.CountTextToken(claudeResponse.Completion, info.OriginModelName) + claudeInfo.Usage.PromptTokens = info.PromptTokens + claudeInfo.Usage.CompletionTokens = completionTokens + claudeInfo.Usage.TotalTokens = info.PromptTokens + completionTokens + } else { + claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens + claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens + claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens + claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens + claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens + } + var responseData []byte + switch info.RelayFormat { + case relaycommon.RelayFormatOpenAI: + openaiResponse := ResponseClaude2OpenAI(requestMode, &claudeResponse) + openaiResponse.Usage = *claudeInfo.Usage + responseData, err = json.Marshal(openaiResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody) + } + case relaycommon.RelayFormatClaude: + responseData = data + } + + if claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 { + c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests) + } + + common.IOCopyBytesGracefully(c, nil, responseData) + return nil +} + +func ClaudeHandler(c *gin.Context, resp *http.Response, requestMode int, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { + defer common.CloseResponseBodyGracefully(resp) + + claudeInfo := &ClaudeResponseInfo{ + ResponseId: helper.GetResponseID(c), + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + ResponseText: strings.Builder{}, + Usage: &dto.Usage{}, + } + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + if common.DebugEnabled { + println("responseBody: ", string(responseBody)) + } + handleErr := HandleClaudeResponseData(c, info, claudeInfo, responseBody, requestMode) + if handleErr != nil { + return handleErr, nil + } + return nil, claudeInfo.Usage +} + +func mapToolChoice(toolChoice any, parallelToolCalls *bool) *dto.ClaudeToolChoice { + var claudeToolChoice *dto.ClaudeToolChoice + + // 处理 tool_choice 字符串值 + if toolChoiceStr, ok := toolChoice.(string); ok { + switch toolChoiceStr { + case "auto": + claudeToolChoice = &dto.ClaudeToolChoice{ + Type: "auto", + } + case "required": + claudeToolChoice = &dto.ClaudeToolChoice{ + Type: "any", + } + case "none": + claudeToolChoice = &dto.ClaudeToolChoice{ + Type: "none", + } + } + } else if toolChoiceMap, ok := toolChoice.(map[string]interface{}); ok { + // 处理 tool_choice 对象值 + if function, ok := toolChoiceMap["function"].(map[string]interface{}); ok { + if toolName, ok := function["name"].(string); ok { + claudeToolChoice = &dto.ClaudeToolChoice{ + Type: "tool", + Name: toolName, + } + } + } + } + + // 处理 parallel_tool_calls + if parallelToolCalls != nil { + if claudeToolChoice == nil { + // 如果没有 tool_choice,但有 parallel_tool_calls,创建默认的 auto 类型 + claudeToolChoice = &dto.ClaudeToolChoice{ + Type: "auto", + } + } + + // 设置 disable_parallel_tool_use + // 如果 parallel_tool_calls 为 true,则 disable_parallel_tool_use 为 false + claudeToolChoice.DisableParallelToolUse = !*parallelToolCalls + } + + return claudeToolChoice +} diff --git a/relay/channel/cloudflare/adaptor.go b/relay/channel/cloudflare/adaptor.go new file mode 100644 index 00000000..6e59ad71 --- /dev/null +++ b/relay/channel/cloudflare/adaptor.go @@ -0,0 +1,122 @@ +package cloudflare + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch info.RelayMode { + case constant.RelayModeChatCompletions: + return fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1/chat/completions", info.BaseUrl, info.ApiVersion), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1/embeddings", info.BaseUrl, info.ApiVersion), nil + default: + return fmt.Sprintf("%s/client/v4/accounts/%s/ai/run/%s", info.BaseUrl, info.ApiVersion, info.UpstreamModelName), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch info.RelayMode { + case constant.RelayModeCompletions: + return convertCf2CompletionsRequest(*request), nil + default: + return request, nil + } +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + // 添加文件字段 + file, _, err := c.Request.FormFile("file") + if err != nil { + return nil, errors.New("file is required") + } + defer file.Close() + // 打开临时文件用于保存上传的文件内容 + requestBody := &bytes.Buffer{} + + // 将上传的文件内容复制到临时文件 + if _, err := io.Copy(requestBody, file); err != nil { + return nil, err + } + return requestBody, nil +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case constant.RelayModeEmbeddings: + fallthrough + case constant.RelayModeChatCompletions: + if info.IsStream { + err, usage = cfStreamHandler(c, info, resp) + } else { + err, usage = cfHandler(c, info, resp) + } + case constant.RelayModeAudioTranslation: + fallthrough + case constant.RelayModeAudioTranscription: + err, usage = cfSTTHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/cloudflare/constant.go b/relay/channel/cloudflare/constant.go new file mode 100644 index 00000000..0e2aec2b --- /dev/null +++ b/relay/channel/cloudflare/constant.go @@ -0,0 +1,39 @@ +package cloudflare + +var ModelList = []string{ + "@cf/meta/llama-3.1-8b-instruct", + "@cf/meta/llama-2-7b-chat-fp16", + "@cf/meta/llama-2-7b-chat-int8", + "@cf/mistral/mistral-7b-instruct-v0.1", + "@hf/thebloke/deepseek-coder-6.7b-base-awq", + "@hf/thebloke/deepseek-coder-6.7b-instruct-awq", + "@cf/deepseek-ai/deepseek-math-7b-base", + "@cf/deepseek-ai/deepseek-math-7b-instruct", + "@cf/thebloke/discolm-german-7b-v1-awq", + "@cf/tiiuae/falcon-7b-instruct", + "@cf/google/gemma-2b-it-lora", + "@hf/google/gemma-7b-it", + "@cf/google/gemma-7b-it-lora", + "@hf/nousresearch/hermes-2-pro-mistral-7b", + "@hf/thebloke/llama-2-13b-chat-awq", + "@cf/meta-llama/llama-2-7b-chat-hf-lora", + "@cf/meta/llama-3-8b-instruct", + "@hf/thebloke/llamaguard-7b-awq", + "@hf/thebloke/mistral-7b-instruct-v0.1-awq", + "@hf/mistralai/mistral-7b-instruct-v0.2", + "@cf/mistral/mistral-7b-instruct-v0.2-lora", + "@hf/thebloke/neural-chat-7b-v3-1-awq", + "@cf/openchat/openchat-3.5-0106", + "@hf/thebloke/openhermes-2.5-mistral-7b-awq", + "@cf/microsoft/phi-2", + "@cf/qwen/qwen1.5-0.5b-chat", + "@cf/qwen/qwen1.5-1.8b-chat", + "@cf/qwen/qwen1.5-14b-chat-awq", + "@cf/qwen/qwen1.5-7b-chat-awq", + "@cf/defog/sqlcoder-7b-2", + "@hf/nexusflow/starling-lm-7b-beta", + "@cf/tinyllama/tinyllama-1.1b-chat-v1.0", + "@hf/thebloke/zephyr-7b-beta-awq", +} + +var ChannelName = "cloudflare" diff --git a/relay/channel/cloudflare/dto.go b/relay/channel/cloudflare/dto.go new file mode 100644 index 00000000..62a45c40 --- /dev/null +++ b/relay/channel/cloudflare/dto.go @@ -0,0 +1,21 @@ +package cloudflare + +import "one-api/dto" + +type CfRequest struct { + Messages []dto.Message `json:"messages,omitempty"` + Lora string `json:"lora,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Prompt string `json:"prompt,omitempty"` + Raw bool `json:"raw,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` +} + +type CfAudioResponse struct { + Result CfSTTResult `json:"result"` +} + +type CfSTTResult struct { + Text string `json:"text"` +} diff --git a/relay/channel/cloudflare/relay_cloudflare.go b/relay/channel/cloudflare/relay_cloudflare.go new file mode 100644 index 00000000..5e8fe7f9 --- /dev/null +++ b/relay/channel/cloudflare/relay_cloudflare.go @@ -0,0 +1,150 @@ +package cloudflare + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +func convertCf2CompletionsRequest(textRequest dto.GeneralOpenAIRequest) *CfRequest { + p, _ := textRequest.Prompt.(string) + return &CfRequest{ + Prompt: p, + MaxTokens: textRequest.GetMaxTokens(), + Stream: textRequest.Stream, + Temperature: textRequest.Temperature, + } +} + +func cfStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + helper.SetEventStreamHeaders(c) + id := helper.GetResponseID(c) + var responseText string + isFirst := true + + for scanner.Scan() { + data := scanner.Text() + if len(data) < len("data: ") { + continue + } + data = strings.TrimPrefix(data, "data: ") + data = strings.TrimSuffix(data, "\r") + + if data == "[DONE]" { + break + } + + var response dto.ChatCompletionsStreamResponse + err := json.Unmarshal([]byte(data), &response) + if err != nil { + common.LogError(c, "error_unmarshalling_stream_response: "+err.Error()) + continue + } + for _, choice := range response.Choices { + choice.Delta.Role = "assistant" + responseText += choice.Delta.GetContentString() + } + response.Id = id + response.Model = info.UpstreamModelName + err = helper.ObjectData(c, response) + if isFirst { + isFirst = false + info.FirstResponseTime = time.Now() + } + if err != nil { + common.LogError(c, "error_rendering_stream_response: "+err.Error()) + } + } + + if err := scanner.Err(); err != nil { + common.LogError(c, "error_scanning_stream_response: "+err.Error()) + } + usage := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) + if info.ShouldIncludeUsage { + response := helper.GenerateFinalUsageResponse(id, info.StartTime.Unix(), info.UpstreamModelName, *usage) + err := helper.ObjectData(c, response) + if err != nil { + common.LogError(c, "error_rendering_final_usage_response: "+err.Error()) + } + } + helper.Done(c) + + common.CloseResponseBodyGracefully(resp) + + return nil, usage +} + +func cfHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + common.CloseResponseBodyGracefully(resp) + var response dto.TextResponse + err = json.Unmarshal(responseBody, &response) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + response.Model = info.UpstreamModelName + var responseText string + for _, choice := range response.Choices { + responseText += choice.Message.StringContent() + } + usage := service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) + response.Usage = *usage + response.Id = helper.GetResponseID(c) + jsonResponse, err := json.Marshal(response) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return nil, usage +} + +func cfSTTHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { + var cfResp CfAudioResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + common.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &cfResp) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + + audioResp := &dto.AudioResponse{ + Text: cfResp.Result.Text, + } + + jsonResponse, err := json.Marshal(audioResp) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + + usage := &dto.Usage{} + usage.PromptTokens = info.PromptTokens + usage.CompletionTokens = service.CountTextToken(cfResp.Result.Text, info.UpstreamModelName) + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + + return nil, usage +} diff --git a/relay/channel/cohere/adaptor.go b/relay/channel/cohere/adaptor.go new file mode 100644 index 00000000..4f3a96c3 --- /dev/null +++ b/relay/channel/cohere/adaptor.go @@ -0,0 +1,94 @@ +package cohere + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode == constant.RelayModeRerank { + return fmt.Sprintf("%s/v1/rerank", info.BaseUrl), nil + } else { + return fmt.Sprintf("%s/v1/chat", info.BaseUrl), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + return requestOpenAI2Cohere(*request), nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return requestConvertRerank2Cohere(request), nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == constant.RelayModeRerank { + usage, err = cohereRerankHandler(c, resp, info) + } else { + if info.IsStream { + usage, err = cohereStreamHandler(c, info, resp) // TODO: fix this + } else { + usage, err = cohereHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/cohere/constant.go b/relay/channel/cohere/constant.go new file mode 100644 index 00000000..f2d2e559 --- /dev/null +++ b/relay/channel/cohere/constant.go @@ -0,0 +1,12 @@ +package cohere + +var ModelList = []string{ + "command-a-03-2025", + "command-r", "command-r-plus", + "command-r-08-2024", "command-r-plus-08-2024", + "c4ai-aya-23-35b", "c4ai-aya-23-8b", + "command-light", "command-light-nightly", "command", "command-nightly", + "rerank-english-v3.0", "rerank-multilingual-v3.0", "rerank-english-v2.0", "rerank-multilingual-v2.0", +} + +var ChannelName = "cohere" diff --git a/relay/channel/cohere/dto.go b/relay/channel/cohere/dto.go new file mode 100644 index 00000000..410540c0 --- /dev/null +++ b/relay/channel/cohere/dto.go @@ -0,0 +1,60 @@ +package cohere + +import "one-api/dto" + +type CohereRequest struct { + Model string `json:"model"` + ChatHistory []ChatHistory `json:"chat_history"` + Message string `json:"message"` + Stream bool `json:"stream"` + MaxTokens int `json:"max_tokens"` + SafetyMode string `json:"safety_mode,omitempty"` +} + +type ChatHistory struct { + Role string `json:"role"` + Message string `json:"message"` +} + +type CohereResponse struct { + IsFinished bool `json:"is_finished"` + EventType string `json:"event_type"` + Text string `json:"text,omitempty"` + FinishReason string `json:"finish_reason,omitempty"` + Response *CohereResponseResult `json:"response"` +} + +type CohereResponseResult struct { + ResponseId string `json:"response_id"` + FinishReason string `json:"finish_reason,omitempty"` + Text string `json:"text"` + Meta CohereMeta `json:"meta"` +} + +type CohereRerankRequest struct { + Documents []any `json:"documents"` + Query string `json:"query"` + Model string `json:"model"` + TopN int `json:"top_n"` + ReturnDocuments bool `json:"return_documents"` +} + +type CohereRerankResponseResult struct { + Results []dto.RerankResponseResult `json:"results"` + Meta CohereMeta `json:"meta"` +} + +type CohereMeta struct { + //Tokens CohereTokens `json:"tokens"` + BilledUnits CohereBilledUnits `json:"billed_units"` +} + +type CohereBilledUnits struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type CohereTokens struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} diff --git a/relay/channel/cohere/relay-cohere.go b/relay/channel/cohere/relay-cohere.go new file mode 100644 index 00000000..fcfb12b7 --- /dev/null +++ b/relay/channel/cohere/relay-cohere.go @@ -0,0 +1,248 @@ +package cohere + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +func requestOpenAI2Cohere(textRequest dto.GeneralOpenAIRequest) *CohereRequest { + cohereReq := CohereRequest{ + Model: textRequest.Model, + ChatHistory: []ChatHistory{}, + Message: "", + Stream: textRequest.Stream, + MaxTokens: textRequest.GetMaxTokens(), + } + if common.CohereSafetySetting != "NONE" { + cohereReq.SafetyMode = common.CohereSafetySetting + } + if cohereReq.MaxTokens == 0 { + cohereReq.MaxTokens = 4000 + } + for _, msg := range textRequest.Messages { + if msg.Role == "user" { + cohereReq.Message = msg.StringContent() + } else { + var role string + if msg.Role == "assistant" { + role = "CHATBOT" + } else if msg.Role == "system" { + role = "SYSTEM" + } else { + role = "USER" + } + cohereReq.ChatHistory = append(cohereReq.ChatHistory, ChatHistory{ + Role: role, + Message: msg.StringContent(), + }) + } + } + + return &cohereReq +} + +func requestConvertRerank2Cohere(rerankRequest dto.RerankRequest) *CohereRerankRequest { + if rerankRequest.TopN == 0 { + rerankRequest.TopN = 1 + } + cohereReq := CohereRerankRequest{ + Query: rerankRequest.Query, + Documents: rerankRequest.Documents, + Model: rerankRequest.Model, + TopN: rerankRequest.TopN, + ReturnDocuments: true, + } + return &cohereReq +} + +func stopReasonCohere2OpenAI(reason string) string { + switch reason { + case "COMPLETE": + return "stop" + case "MAX_TOKENS": + return "max_tokens" + default: + return reason + } +} + +func cohereStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseId := helper.GetResponseID(c) + createdTime := common.GetTimestamp() + usage := &dto.Usage{} + responseText := "" + scanner := bufio.NewScanner(resp.Body) + scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { + if atEOF && len(data) == 0 { + return 0, nil, nil + } + if i := strings.Index(string(data), "\n"); i >= 0 { + return i + 1, data[0:i], nil + } + if atEOF { + return len(data), data, nil + } + return 0, nil, nil + }) + dataChan := make(chan string) + stopChan := make(chan bool) + go func() { + for scanner.Scan() { + data := scanner.Text() + dataChan <- data + } + stopChan <- true + }() + helper.SetEventStreamHeaders(c) + isFirst := true + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + if isFirst { + isFirst = false + info.FirstResponseTime = time.Now() + } + data = strings.TrimSuffix(data, "\r") + var cohereResp CohereResponse + err := json.Unmarshal([]byte(data), &cohereResp) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + var openaiResp dto.ChatCompletionsStreamResponse + openaiResp.Id = responseId + openaiResp.Created = createdTime + openaiResp.Object = "chat.completion.chunk" + openaiResp.Model = info.UpstreamModelName + if cohereResp.IsFinished { + finishReason := stopReasonCohere2OpenAI(cohereResp.FinishReason) + openaiResp.Choices = []dto.ChatCompletionsStreamResponseChoice{ + { + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{}, + Index: 0, + FinishReason: &finishReason, + }, + } + if cohereResp.Response != nil { + usage.PromptTokens = cohereResp.Response.Meta.BilledUnits.InputTokens + usage.CompletionTokens = cohereResp.Response.Meta.BilledUnits.OutputTokens + } + } else { + openaiResp.Choices = []dto.ChatCompletionsStreamResponseChoice{ + { + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + Content: &cohereResp.Text, + }, + Index: 0, + }, + } + responseText += cohereResp.Text + } + jsonStr, err := json.Marshal(openaiResp) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonStr)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + if usage.PromptTokens == 0 { + usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) + } + return usage, nil +} + +func cohereHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + createdTime := common.GetTimestamp() + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + common.CloseResponseBodyGracefully(resp) + var cohereResp CohereResponseResult + err = json.Unmarshal(responseBody, &cohereResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + usage := dto.Usage{} + usage.PromptTokens = cohereResp.Meta.BilledUnits.InputTokens + usage.CompletionTokens = cohereResp.Meta.BilledUnits.OutputTokens + usage.TotalTokens = cohereResp.Meta.BilledUnits.InputTokens + cohereResp.Meta.BilledUnits.OutputTokens + + var openaiResp dto.TextResponse + openaiResp.Id = cohereResp.ResponseId + openaiResp.Created = createdTime + openaiResp.Object = "chat.completion" + openaiResp.Model = info.UpstreamModelName + openaiResp.Usage = usage + + openaiResp.Choices = []dto.OpenAITextResponseChoice{ + { + Index: 0, + Message: dto.Message{Content: cohereResp.Text, Role: "assistant"}, + FinishReason: stopReasonCohere2OpenAI(cohereResp.FinishReason), + }, + } + + jsonResponse, err := json.Marshal(openaiResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + return &usage, nil +} + +func cohereRerankHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + common.CloseResponseBodyGracefully(resp) + var cohereResp CohereRerankResponseResult + err = json.Unmarshal(responseBody, &cohereResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + usage := dto.Usage{} + if cohereResp.Meta.BilledUnits.InputTokens == 0 { + usage.PromptTokens = info.PromptTokens + usage.CompletionTokens = 0 + usage.TotalTokens = info.PromptTokens + } else { + usage.PromptTokens = cohereResp.Meta.BilledUnits.InputTokens + usage.CompletionTokens = cohereResp.Meta.BilledUnits.OutputTokens + usage.TotalTokens = cohereResp.Meta.BilledUnits.InputTokens + cohereResp.Meta.BilledUnits.OutputTokens + } + + var rerankResp dto.RerankResponse + rerankResp.Results = cohereResp.Results + rerankResp.Usage = usage + + jsonResponse, err := json.Marshal(rerankResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return &usage, nil +} diff --git a/relay/channel/coze/adaptor.go b/relay/channel/coze/adaptor.go new file mode 100644 index 00000000..fe5f5f00 --- /dev/null +++ b/relay/channel/coze/adaptor.go @@ -0,0 +1,133 @@ +package coze + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/common" + "one-api/types" + "time" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +// ConvertAudioRequest implements channel.Adaptor. +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *common.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("not implemented") +} + +// ConvertClaudeRequest implements channel.Adaptor. +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *common.RelayInfo, request *dto.ClaudeRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// ConvertEmbeddingRequest implements channel.Adaptor. +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *common.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// ConvertImageRequest implements channel.Adaptor. +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *common.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// ConvertOpenAIRequest implements channel.Adaptor. +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *common.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return convertCozeChatRequest(c, *request), nil +} + +// ConvertOpenAIResponsesRequest implements channel.Adaptor. +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *common.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// ConvertRerankRequest implements channel.Adaptor. +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("not implemented") +} + +// DoRequest implements channel.Adaptor. +func (a *Adaptor) DoRequest(c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (any, error) { + if info.IsStream { + return channel.DoApiRequest(a, c, info, requestBody) + } + // 首先发送创建消息请求,成功后再发送获取消息请求 + // 发送创建消息请求 + resp, err := channel.DoApiRequest(a, c, info, requestBody) + if err != nil { + return nil, err + } + // 解析 resp + var cozeResponse CozeChatResponse + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + err = json.Unmarshal(respBody, &cozeResponse) + if cozeResponse.Code != 0 { + return nil, errors.New(cozeResponse.Msg) + } + c.Set("coze_conversation_id", cozeResponse.Data.ConversationId) + c.Set("coze_chat_id", cozeResponse.Data.Id) + // 轮询检查消息是否完成 + for { + err, isComplete := checkIfChatComplete(a, c, info) + if err != nil { + return nil, err + } else { + if isComplete { + break + } + } + time.Sleep(time.Second * 1) + } + // 发送获取消息请求 + return getChatDetail(a, c, info) +} + +// DoResponse implements channel.Adaptor. +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *common.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = cozeChatStreamHandler(c, info, resp) + } else { + usage, err = cozeChatHandler(c, info, resp) + } + return +} + +// GetChannelName implements channel.Adaptor. +func (a *Adaptor) GetChannelName() string { + return ChannelName +} + +// GetModelList implements channel.Adaptor. +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +// GetRequestURL implements channel.Adaptor. +func (a *Adaptor) GetRequestURL(info *common.RelayInfo) (string, error) { + return fmt.Sprintf("%s/v3/chat", info.BaseUrl), nil +} + +// Init implements channel.Adaptor. +func (a *Adaptor) Init(info *common.RelayInfo) { + +} + +// SetupRequestHeader implements channel.Adaptor. +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *common.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} diff --git a/relay/channel/coze/constants.go b/relay/channel/coze/constants.go new file mode 100644 index 00000000..873ffe24 --- /dev/null +++ b/relay/channel/coze/constants.go @@ -0,0 +1,30 @@ +package coze + +var ModelList = []string{ + "moonshot-v1-8k", + "moonshot-v1-32k", + "moonshot-v1-128k", + "Baichuan4", + "abab6.5s-chat-pro", + "glm-4-0520", + "qwen-max", + "deepseek-r1", + "deepseek-v3", + "deepseek-r1-distill-qwen-32b", + "deepseek-r1-distill-qwen-7b", + "step-1v-8k", + "step-1.5v-mini", + "Doubao-pro-32k", + "Doubao-pro-256k", + "Doubao-lite-128k", + "Doubao-lite-32k", + "Doubao-vision-lite-32k", + "Doubao-vision-pro-32k", + "Doubao-1.5-pro-vision-32k", + "Doubao-1.5-lite-32k", + "Doubao-1.5-pro-32k", + "Doubao-1.5-thinking-pro", + "Doubao-1.5-pro-256k", +} + +var ChannelName = "coze" diff --git a/relay/channel/coze/dto.go b/relay/channel/coze/dto.go new file mode 100644 index 00000000..d5dc9a81 --- /dev/null +++ b/relay/channel/coze/dto.go @@ -0,0 +1,78 @@ +package coze + +import "encoding/json" + +type CozeError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type CozeEnterMessage struct { + Role string `json:"role"` + Type string `json:"type,omitempty"` + Content any `json:"content,omitempty"` + MetaData json.RawMessage `json:"meta_data,omitempty"` + ContentType string `json:"content_type,omitempty"` +} + +type CozeChatRequest struct { + BotId string `json:"bot_id"` + UserId string `json:"user_id"` + AdditionalMessages []CozeEnterMessage `json:"additional_messages,omitempty"` + Stream bool `json:"stream,omitempty"` + CustomVariables json.RawMessage `json:"custom_variables,omitempty"` + AutoSaveHistory bool `json:"auto_save_history,omitempty"` + MetaData json.RawMessage `json:"meta_data,omitempty"` + ExtraParams json.RawMessage `json:"extra_params,omitempty"` + ShortcutCommand json.RawMessage `json:"shortcut_command,omitempty"` + Parameters json.RawMessage `json:"parameters,omitempty"` +} + +type CozeChatResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data CozeChatResponseData `json:"data"` +} + +type CozeChatResponseData struct { + Id string `json:"id"` + ConversationId string `json:"conversation_id"` + BotId string `json:"bot_id"` + CreatedAt int64 `json:"created_at"` + LastError CozeError `json:"last_error"` + Status string `json:"status"` + Usage CozeChatUsage `json:"usage"` +} + +type CozeChatUsage struct { + TokenCount int `json:"token_count"` + OutputCount int `json:"output_count"` + InputCount int `json:"input_count"` +} + +type CozeChatDetailResponse struct { + Data []CozeChatV3MessageDetail `json:"data"` + Code int `json:"code"` + Msg string `json:"msg"` + Detail CozeResponseDetail `json:"detail"` +} + +type CozeChatV3MessageDetail struct { + Id string `json:"id"` + Role string `json:"role"` + Type string `json:"type"` + BotId string `json:"bot_id"` + ChatId string `json:"chat_id"` + Content json.RawMessage `json:"content"` + MetaData json.RawMessage `json:"meta_data"` + CreatedAt int64 `json:"created_at"` + SectionId string `json:"section_id"` + UpdatedAt int64 `json:"updated_at"` + ContentType string `json:"content_type"` + ConversationId string `json:"conversation_id"` + ReasoningContent string `json:"reasoning_content"` +} + +type CozeResponseDetail struct { + Logid string `json:"logid"` +} diff --git a/relay/channel/coze/relay-coze.go b/relay/channel/coze/relay-coze.go new file mode 100644 index 00000000..32cc6937 --- /dev/null +++ b/relay/channel/coze/relay-coze.go @@ -0,0 +1,296 @@ +package coze + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func convertCozeChatRequest(c *gin.Context, request dto.GeneralOpenAIRequest) *CozeChatRequest { + var messages []CozeEnterMessage + // 将 request的messages的role为user的content转换为CozeMessage + for _, message := range request.Messages { + if message.Role == "user" { + messages = append(messages, CozeEnterMessage{ + Role: "user", + Content: message.Content, + // TODO: support more content type + ContentType: "text", + }) + } + } + user := request.User + if user == "" { + user = helper.GetResponseID(c) + } + cozeRequest := &CozeChatRequest{ + BotId: c.GetString("bot_id"), + UserId: user, + AdditionalMessages: messages, + Stream: request.Stream, + } + return cozeRequest +} + +func cozeChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + common.CloseResponseBodyGracefully(resp) + // convert coze response to openai response + var response dto.TextResponse + var cozeResponse CozeChatDetailResponse + response.Model = info.UpstreamModelName + err = json.Unmarshal(responseBody, &cozeResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if cozeResponse.Code != 0 { + return nil, types.NewError(errors.New(cozeResponse.Msg), types.ErrorCodeBadResponseBody) + } + // 从上下文获取 usage + var usage dto.Usage + usage.PromptTokens = c.GetInt("coze_input_count") + usage.CompletionTokens = c.GetInt("coze_output_count") + usage.TotalTokens = c.GetInt("coze_token_count") + response.Usage = usage + response.Id = helper.GetResponseID(c) + + var responseContent json.RawMessage + for _, data := range cozeResponse.Data { + if data.Type == "answer" { + responseContent = data.Content + response.Created = data.CreatedAt + } + } + // 添加 response.Choices + response.Choices = []dto.OpenAITextResponseChoice{ + { + Index: 0, + Message: dto.Message{Role: "assistant", Content: responseContent}, + FinishReason: "stop", + }, + } + jsonResponse, err := json.Marshal(response) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + + return &usage, nil +} + +func cozeChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + helper.SetEventStreamHeaders(c) + id := helper.GetResponseID(c) + var responseText string + + var currentEvent string + var currentData string + var usage = &dto.Usage{} + + for scanner.Scan() { + line := scanner.Text() + + if line == "" { + if currentEvent != "" && currentData != "" { + // handle last event + handleCozeEvent(c, currentEvent, currentData, &responseText, usage, id, info) + currentEvent = "" + currentData = "" + } + continue + } + + if strings.HasPrefix(line, "event:") { + currentEvent = strings.TrimSpace(line[6:]) + continue + } + + if strings.HasPrefix(line, "data:") { + currentData = strings.TrimSpace(line[5:]) + continue + } + } + + // Last event + if currentEvent != "" && currentData != "" { + handleCozeEvent(c, currentEvent, currentData, &responseText, usage, id, info) + } + + if err := scanner.Err(); err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + helper.Done(c) + + if usage.TotalTokens == 0 { + usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, c.GetInt("coze_input_count")) + } + + return usage, nil +} + +func handleCozeEvent(c *gin.Context, event string, data string, responseText *string, usage *dto.Usage, id string, info *relaycommon.RelayInfo) { + switch event { + case "conversation.chat.completed": + // 将 data 解析为 CozeChatResponseData + var chatData CozeChatResponseData + err := json.Unmarshal([]byte(data), &chatData) + if err != nil { + common.SysError("error_unmarshalling_stream_response: " + err.Error()) + return + } + + usage.PromptTokens = chatData.Usage.InputCount + usage.CompletionTokens = chatData.Usage.OutputCount + usage.TotalTokens = chatData.Usage.TokenCount + + finishReason := "stop" + stopResponse := helper.GenerateStopResponse(id, common.GetTimestamp(), info.UpstreamModelName, finishReason) + helper.ObjectData(c, stopResponse) + + case "conversation.message.delta": + // 将 data 解析为 CozeChatV3MessageDetail + var messageData CozeChatV3MessageDetail + err := json.Unmarshal([]byte(data), &messageData) + if err != nil { + common.SysError("error_unmarshalling_stream_response: " + err.Error()) + return + } + + var content string + err = json.Unmarshal(messageData.Content, &content) + if err != nil { + common.SysError("error_unmarshalling_stream_response: " + err.Error()) + return + } + + *responseText += content + + openaiResponse := dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + } + + choice := dto.ChatCompletionsStreamResponseChoice{ + Index: 0, + } + choice.Delta.SetContentString(content) + openaiResponse.Choices = append(openaiResponse.Choices, choice) + + helper.ObjectData(c, openaiResponse) + + case "error": + var errorData CozeError + err := json.Unmarshal([]byte(data), &errorData) + if err != nil { + common.SysError("error_unmarshalling_stream_response: " + err.Error()) + return + } + + common.SysError(fmt.Sprintf("stream event error: ", errorData.Code, errorData.Message)) + } +} + +func checkIfChatComplete(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (error, bool) { + requestURL := fmt.Sprintf("%s/v3/chat/retrieve", info.BaseUrl) + + requestURL = requestURL + "?conversation_id=" + c.GetString("coze_conversation_id") + "&chat_id=" + c.GetString("coze_chat_id") + // 将 conversationId和chatId作为参数发送get请求 + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return err, false + } + err = a.SetupRequestHeader(c, &req.Header, info) + if err != nil { + return err, false + } + + resp, err := doRequest(req, info) // 调用 doRequest + if err != nil { + return err, false + } + if resp == nil { // 确保在 doRequest 失败时 resp 不为 nil 导致 panic + return fmt.Errorf("resp is nil"), false + } + defer resp.Body.Close() // 确保响应体被关闭 + + // 解析 resp 到 CozeChatResponse + var cozeResponse CozeChatResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response body failed: %w", err), false + } + err = json.Unmarshal(responseBody, &cozeResponse) + if err != nil { + return fmt.Errorf("unmarshal response body failed: %w", err), false + } + if cozeResponse.Data.Status == "completed" { + // 在上下文设置 usage + c.Set("coze_token_count", cozeResponse.Data.Usage.TokenCount) + c.Set("coze_output_count", cozeResponse.Data.Usage.OutputCount) + c.Set("coze_input_count", cozeResponse.Data.Usage.InputCount) + return nil, true + } else if cozeResponse.Data.Status == "failed" || cozeResponse.Data.Status == "canceled" || cozeResponse.Data.Status == "requires_action" { + return fmt.Errorf("chat status: %s", cozeResponse.Data.Status), false + } else { + return nil, false + } +} + +func getChatDetail(a *Adaptor, c *gin.Context, info *relaycommon.RelayInfo) (*http.Response, error) { + requestURL := fmt.Sprintf("%s/v3/chat/message/list", info.BaseUrl) + + requestURL = requestURL + "?conversation_id=" + c.GetString("coze_conversation_id") + "&chat_id=" + c.GetString("coze_chat_id") + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + err = a.SetupRequestHeader(c, &req.Header, info) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := doRequest(req, info) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} + +func doRequest(req *http.Request, info *relaycommon.RelayInfo) (*http.Response, error) { + var client *http.Client + var err error // 声明 err 变量 + if info.ChannelSetting.Proxy != "" { + client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + if err != nil { + return nil, fmt.Errorf("new proxy http client failed: %w", err) + } + } else { + client = service.GetHttpClient() + } + resp, err := client.Do(req) + if err != nil { // 增加对 client.Do(req) 返回错误的检查 + return nil, fmt.Errorf("client.Do failed: %w", err) + } + // _ = resp.Body.Close() + return resp, nil +} diff --git a/relay/channel/deepseek/adaptor.go b/relay/channel/deepseek/adaptor.go new file mode 100644 index 00000000..edfc7fd3 --- /dev/null +++ b/relay/channel/deepseek/adaptor.go @@ -0,0 +1,100 @@ +package deepseek + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + fimBaseUrl := info.BaseUrl + if !strings.HasSuffix(info.BaseUrl, "/beta") { + fimBaseUrl += "/beta" + } + switch info.RelayMode { + case constant.RelayModeCompletions: + return fmt.Sprintf("%s/completions", fimBaseUrl), nil + default: + return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/deepseek/constants.go b/relay/channel/deepseek/constants.go new file mode 100644 index 00000000..1d7b1e32 --- /dev/null +++ b/relay/channel/deepseek/constants.go @@ -0,0 +1,7 @@ +package deepseek + +var ModelList = []string{ + "deepseek-chat", "deepseek-reasoner", +} + +var ChannelName = "deepseek" diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go new file mode 100644 index 00000000..4ad16766 --- /dev/null +++ b/relay/channel/dify/adaptor.go @@ -0,0 +1,115 @@ +package dify + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +const ( + BotTypeChatFlow = 1 // chatflow default + BotTypeAgent = 2 + BotTypeWorkFlow = 3 + BotTypeCompletion = 4 +) + +type Adaptor struct { + BotType int +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + //if strings.HasPrefix(info.UpstreamModelName, "agent") { + // a.BotType = BotTypeAgent + //} else if strings.HasPrefix(info.UpstreamModelName, "workflow") { + // a.BotType = BotTypeWorkFlow + //} else if strings.HasPrefix(info.UpstreamModelName, "chat") { + // a.BotType = BotTypeCompletion + //} else { + //} + a.BotType = BotTypeChatFlow + +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch a.BotType { + case BotTypeWorkFlow: + return fmt.Sprintf("%s/v1/workflows/run", info.BaseUrl), nil + case BotTypeCompletion: + return fmt.Sprintf("%s/v1/completion-messages", info.BaseUrl), nil + case BotTypeAgent: + fallthrough + default: + return fmt.Sprintf("%s/v1/chat-messages", info.BaseUrl), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return requestOpenAI2Dify(c, info, *request), nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + return difyStreamHandler(c, info, resp) + } else { + return difyHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/dify/constants.go b/relay/channel/dify/constants.go new file mode 100644 index 00000000..db3e67c7 --- /dev/null +++ b/relay/channel/dify/constants.go @@ -0,0 +1,5 @@ +package dify + +var ModelList []string + +var ChannelName = "dify" diff --git a/relay/channel/dify/dto.go b/relay/channel/dify/dto.go new file mode 100644 index 00000000..7c6f39b6 --- /dev/null +++ b/relay/channel/dify/dto.go @@ -0,0 +1,45 @@ +package dify + +import "one-api/dto" + +type DifyChatRequest struct { + Inputs map[string]interface{} `json:"inputs"` + Query string `json:"query"` + ResponseMode string `json:"response_mode"` + User string `json:"user"` + AutoGenerateName bool `json:"auto_generate_name"` + Files []DifyFile `json:"files"` +} + +type DifyFile struct { + Type string `json:"type"` + TransferMode string `json:"transfer_mode"` + URL string `json:"url,omitempty"` + UploadFileId string `json:"upload_file_id,omitempty"` +} + +type DifyMetaData struct { + Usage dto.Usage `json:"usage"` +} + +type DifyData struct { + WorkflowId string `json:"workflow_id"` + NodeId string `json:"node_id"` + NodeType string `json:"node_type"` + Status string `json:"status"` +} + +type DifyChatCompletionResponse struct { + ConversationId string `json:"conversation_id"` + Answer string `json:"answer"` + CreateAt int64 `json:"create_at"` + MetaData DifyMetaData `json:"metadata"` +} + +type DifyChunkChatCompletionResponse struct { + Event string `json:"event"` + ConversationId string `json:"conversation_id"` + Answer string `json:"answer"` + Data DifyData `json:"data"` + MetaData DifyMetaData `json:"metadata"` +} diff --git a/relay/channel/dify/relay-dify.go b/relay/channel/dify/relay-dify.go new file mode 100644 index 00000000..47337127 --- /dev/null +++ b/relay/channel/dify/relay-dify.go @@ -0,0 +1,289 @@ +package dify + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +func uploadDifyFile(c *gin.Context, info *relaycommon.RelayInfo, user string, media dto.MediaContent) *DifyFile { + uploadUrl := fmt.Sprintf("%s/v1/files/upload", info.BaseUrl) + switch media.Type { + case dto.ContentTypeImageURL: + // Decode base64 data + imageMedia := media.GetImageMedia() + base64Data := imageMedia.Url + // Remove base64 prefix if exists (e.g., "data:image/jpeg;base64,") + if idx := strings.Index(base64Data, ","); idx != -1 { + base64Data = base64Data[idx+1:] + } + + // Decode base64 string + decodedData, err := base64.StdEncoding.DecodeString(base64Data) + if err != nil { + common.SysError("failed to decode base64: " + err.Error()) + return nil + } + + // Create temporary file + tempFile, err := os.CreateTemp("", "dify-upload-*") + if err != nil { + common.SysError("failed to create temp file: " + err.Error()) + return nil + } + defer tempFile.Close() + defer os.Remove(tempFile.Name()) + + // Write decoded data to temp file + if _, err := tempFile.Write(decodedData); err != nil { + common.SysError("failed to write to temp file: " + err.Error()) + return nil + } + + // Create multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // Add user field + if err := writer.WriteField("user", user); err != nil { + common.SysError("failed to add user field: " + err.Error()) + return nil + } + + // Create form file with proper mime type + mimeType := imageMedia.MimeType + if mimeType == "" { + mimeType = "image/jpeg" // default mime type + } + + // Create form file + part, err := writer.CreateFormFile("file", fmt.Sprintf("image.%s", strings.TrimPrefix(mimeType, "image/"))) + if err != nil { + common.SysError("failed to create form file: " + err.Error()) + return nil + } + + // Copy file content to form + if _, err = io.Copy(part, bytes.NewReader(decodedData)); err != nil { + common.SysError("failed to copy file content: " + err.Error()) + return nil + } + writer.Close() + + // Create HTTP request + req, err := http.NewRequest("POST", uploadUrl, body) + if err != nil { + common.SysError("failed to create request: " + err.Error()) + return nil + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + + // Send request + client := service.GetHttpClient() + resp, err := client.Do(req) + if err != nil { + common.SysError("failed to send request: " + err.Error()) + return nil + } + defer resp.Body.Close() + + // Parse response + var result struct { + Id string `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + common.SysError("failed to decode response: " + err.Error()) + return nil + } + + return &DifyFile{ + UploadFileId: result.Id, + Type: "image", + TransferMode: "local_file", + } + } + return nil +} + +func requestOpenAI2Dify(c *gin.Context, info *relaycommon.RelayInfo, request dto.GeneralOpenAIRequest) *DifyChatRequest { + difyReq := DifyChatRequest{ + Inputs: make(map[string]interface{}), + AutoGenerateName: false, + } + + user := request.User + if user == "" { + user = helper.GetResponseID(c) + } + difyReq.User = user + + files := make([]DifyFile, 0) + var content strings.Builder + for _, message := range request.Messages { + if message.Role == "system" { + content.WriteString("SYSTEM: \n" + message.StringContent() + "\n") + } else if message.Role == "assistant" { + content.WriteString("ASSISTANT: \n" + message.StringContent() + "\n") + } else { + parseContent := message.ParseContent() + for _, mediaContent := range parseContent { + switch mediaContent.Type { + case dto.ContentTypeText: + content.WriteString("USER: \n" + mediaContent.Text + "\n") + case dto.ContentTypeImageURL: + media := mediaContent.GetImageMedia() + var file *DifyFile + if media.IsRemoteImage() { + file.Type = media.MimeType + file.TransferMode = "remote_url" + file.URL = media.Url + } else { + file = uploadDifyFile(c, info, difyReq.User, mediaContent) + } + if file != nil { + files = append(files, *file) + } + } + } + } + } + difyReq.Query = content.String() + difyReq.Files = files + mode := "blocking" + if request.Stream { + mode = "streaming" + } + difyReq.ResponseMode = mode + return &difyReq +} + +func streamResponseDify2OpenAI(difyResponse DifyChunkChatCompletionResponse) *dto.ChatCompletionsStreamResponse { + response := dto.ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "dify", + } + var choice dto.ChatCompletionsStreamResponseChoice + if strings.HasPrefix(difyResponse.Event, "workflow_") { + if constant.DifyDebug { + text := "Workflow: " + difyResponse.Data.WorkflowId + if difyResponse.Event == "workflow_finished" { + text += " " + difyResponse.Data.Status + } + choice.Delta.SetReasoningContent(text + "\n") + } + } else if strings.HasPrefix(difyResponse.Event, "node_") { + if constant.DifyDebug { + text := "Node: " + difyResponse.Data.NodeType + if difyResponse.Event == "node_finished" { + text += " " + difyResponse.Data.Status + } + choice.Delta.SetReasoningContent(text + "\n") + } + } else if difyResponse.Event == "message" || difyResponse.Event == "agent_message" { + if difyResponse.Answer == "
Thinking... \n" { + difyResponse.Answer = "" + } else if difyResponse.Answer == "
" { + difyResponse.Answer = "
" + } + + choice.Delta.SetContentString(difyResponse.Answer) + } + response.Choices = append(response.Choices, choice) + return &response +} + +func difyStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var responseText string + usage := &dto.Usage{} + var nodeToken int + helper.SetEventStreamHeaders(c) + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var difyResponse DifyChunkChatCompletionResponse + err := json.Unmarshal([]byte(data), &difyResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + var openaiResponse dto.ChatCompletionsStreamResponse + if difyResponse.Event == "message_end" { + usage = &difyResponse.MetaData.Usage + return false + } else if difyResponse.Event == "error" { + return false + } else { + openaiResponse = *streamResponseDify2OpenAI(difyResponse) + if len(openaiResponse.Choices) != 0 { + responseText += openaiResponse.Choices[0].Delta.GetContentString() + if openaiResponse.Choices[0].Delta.ReasoningContent != nil { + nodeToken += 1 + } + } + } + err = helper.ObjectData(c, openaiResponse) + if err != nil { + common.SysError(err.Error()) + } + return true + }) + helper.Done(c) + if usage.TotalTokens == 0 { + usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) + } + usage.CompletionTokens += nodeToken + return usage, nil +} + +func difyHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var difyResponse DifyChatCompletionResponse + responseBody, err := io.ReadAll(resp.Body) + + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + common.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &difyResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + fullTextResponse := dto.OpenAITextResponse{ + Id: difyResponse.ConversationId, + Object: "chat.completion", + Created: common.GetTimestamp(), + Usage: difyResponse.MetaData.Usage, + } + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: difyResponse.Answer, + }, + FinishReason: "stop", + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + c.Writer.Write(jsonResponse) + return &difyResponse.MetaData.Usage, nil +} diff --git a/relay/channel/gemini/adaptor.go b/relay/channel/gemini/adaptor.go new file mode 100644 index 00000000..71eb9ba4 --- /dev/null +++ b/relay/channel/gemini/adaptor.go @@ -0,0 +1,271 @@ +package gemini + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/setting/model_setting" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + if !strings.HasPrefix(info.UpstreamModelName, "imagen") { + return nil, errors.New("not supported model for image generation") + } + + // convert size to aspect ratio + aspectRatio := "1:1" // default aspect ratio + switch request.Size { + case "1024x1024": + aspectRatio = "1:1" + case "1024x1792": + aspectRatio = "9:16" + case "1792x1024": + aspectRatio = "16:9" + } + + // build gemini imagen request + geminiRequest := GeminiImageRequest{ + Instances: []GeminiImageInstance{ + { + Prompt: request.Prompt, + }, + }, + Parameters: GeminiImageParameters{ + SampleCount: request.N, + AspectRatio: aspectRatio, + PersonGeneration: "allow_adult", // default allow adult + }, + } + + return geminiRequest, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { + // 新增逻辑:处理 -thinking- 格式 + if strings.Contains(info.UpstreamModelName, "-thinking-") { + parts := strings.Split(info.UpstreamModelName, "-thinking-") + info.UpstreamModelName = parts[0] + } else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配 + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") + } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") + } + } + + version := model_setting.GetGeminiVersionSetting(info.UpstreamModelName) + + if strings.HasPrefix(info.UpstreamModelName, "imagen") { + return fmt.Sprintf("%s/%s/models/%s:predict", info.BaseUrl, version, info.UpstreamModelName), nil + } + + if strings.HasPrefix(info.UpstreamModelName, "text-embedding") || + strings.HasPrefix(info.UpstreamModelName, "embedding") || + strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") { + return fmt.Sprintf("%s/%s/models/%s:embedContent", info.BaseUrl, version, info.UpstreamModelName), nil + } + + action := "generateContent" + if info.IsStream { + action = "streamGenerateContent?alt=sse" + } + return fmt.Sprintf("%s/%s/models/%s:%s", info.BaseUrl, version, info.UpstreamModelName, action), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("x-goog-api-key", info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + + geminiRequest, err := CovertGemini2OpenAI(*request, info) + if err != nil { + return nil, err + } + + return geminiRequest, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + if request.Input == nil { + return nil, errors.New("input is required") + } + + inputs := request.ParseInput() + if len(inputs) == 0 { + return nil, errors.New("input is empty") + } + + // only process the first input + geminiRequest := GeminiEmbeddingRequest{ + Content: GeminiChatContent{ + Parts: []GeminiPart{ + { + Text: inputs[0], + }, + }, + }, + } + + // set specific parameters for different models + // https://ai.google.dev/api/embeddings?hl=zh-cn#method:-models.embedcontent + switch info.UpstreamModelName { + case "text-embedding-004": + // except embedding-001 supports setting `OutputDimensionality` + if request.Dimensions > 0 { + geminiRequest.OutputDimensionality = request.Dimensions + } + } + + return geminiRequest, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == constant.RelayModeGemini { + if info.IsStream { + return GeminiTextGenerationStreamHandler(c, info, resp) + } else { + return GeminiTextGenerationHandler(c, info, resp) + } + } + + if strings.HasPrefix(info.UpstreamModelName, "imagen") { + return GeminiImageHandler(c, info, resp) + } + + // check if the model is an embedding model + if strings.HasPrefix(info.UpstreamModelName, "text-embedding") || + strings.HasPrefix(info.UpstreamModelName, "embedding") || + strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") { + return GeminiEmbeddingHandler(c, info, resp) + } + + if info.IsStream { + return GeminiChatStreamHandler(c, info, resp) + } else { + return GeminiChatHandler(c, info, resp) + } + + //if usage.(*dto.Usage).CompletionTokenDetails.ReasoningTokens > 100 { + // // 没有请求-thinking的情况下,产生思考token,则按照思考模型计费 + // if !strings.HasSuffix(info.OriginModelName, "-thinking") && + // !strings.HasSuffix(info.OriginModelName, "-nothinking") { + // thinkingModelName := info.OriginModelName + "-thinking" + // if operation_setting.SelfUseModeEnabled || helper.ContainPriceOrRatio(thinkingModelName) { + // info.OriginModelName = thinkingModelName + // } + // } + //} + + return nil, types.NewError(errors.New("not implemented"), types.ErrorCodeBadResponseBody) +} + +func GeminiImageHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewError(readErr, types.ErrorCodeBadResponseBody) + } + _ = resp.Body.Close() + + var geminiResponse GeminiImageResponse + if jsonErr := json.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { + return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) + } + + if len(geminiResponse.Predictions) == 0 { + return nil, types.NewError(errors.New("no images generated"), types.ErrorCodeBadResponseBody) + } + + // convert to openai format response + openAIResponse := dto.ImageResponse{ + Created: common.GetTimestamp(), + Data: make([]dto.ImageData, 0, len(geminiResponse.Predictions)), + } + + for _, prediction := range geminiResponse.Predictions { + if prediction.RaiFilteredReason != "" { + continue // skip filtered image + } + openAIResponse.Data = append(openAIResponse.Data, dto.ImageData{ + B64Json: prediction.BytesBase64Encoded, + }) + } + + jsonResponse, jsonErr := json.Marshal(openAIResponse) + if jsonErr != nil { + return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, _ = c.Writer.Write(jsonResponse) + + // https://github.com/google-gemini/cookbook/blob/719a27d752aac33f39de18a8d3cb42a70874917e/quickstarts/Counting_Tokens.ipynb + // each image has fixed 258 tokens + const imageTokens = 258 + generatedImages := len(openAIResponse.Data) + + usage := &dto.Usage{ + PromptTokens: imageTokens * generatedImages, // each generated image has fixed 258 tokens + CompletionTokens: 0, // image generation does not calculate completion tokens + TotalTokens: imageTokens * generatedImages, + } + + return usage, nil +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/gemini/constant.go b/relay/channel/gemini/constant.go new file mode 100644 index 00000000..2c972e37 --- /dev/null +++ b/relay/channel/gemini/constant.go @@ -0,0 +1,37 @@ +package gemini + +var ModelList = []string{ + // stable version + "gemini-1.5-pro", "gemini-1.5-flash", "gemini-1.5-flash-8b", + "gemini-2.0-flash", + // latest version + "gemini-1.5-pro-latest", "gemini-1.5-flash-latest", + // preview version + "gemini-2.0-flash-lite-preview", + // gemini exp + "gemini-exp-1206", + // flash exp + "gemini-2.0-flash-exp", + // pro exp + "gemini-2.0-pro-exp", + // thinking exp + "gemini-2.0-flash-thinking-exp", + "gemini-2.5-pro-exp-03-25", + "gemini-2.5-pro-preview-03-25", + // imagen models + "imagen-3.0-generate-002", + // embedding models + "gemini-embedding-exp-03-07", + "text-embedding-004", + "embedding-001", +} + +var SafetySettingList = []string{ + "HARM_CATEGORY_HARASSMENT", + "HARM_CATEGORY_HATE_SPEECH", + "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "HARM_CATEGORY_DANGEROUS_CONTENT", + "HARM_CATEGORY_CIVIC_INTEGRITY", +} + +var ChannelName = "google gemini" diff --git a/relay/channel/gemini/dto.go b/relay/channel/gemini/dto.go new file mode 100644 index 00000000..b22e092a --- /dev/null +++ b/relay/channel/gemini/dto.go @@ -0,0 +1,222 @@ +package gemini + +import "encoding/json" + +type GeminiChatRequest struct { + Contents []GeminiChatContent `json:"contents"` + SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"` + GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"` + Tools []GeminiChatTool `json:"tools,omitempty"` + SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"` +} + +type GeminiThinkingConfig struct { + IncludeThoughts bool `json:"includeThoughts,omitempty"` + ThinkingBudget *int `json:"thinkingBudget,omitempty"` +} + +func (c *GeminiThinkingConfig) SetThinkingBudget(budget int) { + c.ThinkingBudget = &budget +} + +type GeminiInlineData struct { + MimeType string `json:"mimeType"` + Data string `json:"data"` +} + +// UnmarshalJSON custom unmarshaler for GeminiInlineData to support snake_case and camelCase for MimeType +func (g *GeminiInlineData) UnmarshalJSON(data []byte) error { + type Alias GeminiInlineData // Use type alias to avoid recursion + var aux struct { + Alias + MimeTypeSnake string `json:"mime_type"` + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + *g = GeminiInlineData(aux.Alias) // Copy other fields if any in future + + // Prioritize snake_case if present + if aux.MimeTypeSnake != "" { + g.MimeType = aux.MimeTypeSnake + } else if aux.MimeType != "" { // Fallback to camelCase from Alias + g.MimeType = aux.MimeType + } + // g.Data would be populated by aux.Alias.Data + return nil +} + +type FunctionCall struct { + FunctionName string `json:"name"` + Arguments any `json:"args"` +} + +type FunctionResponse struct { + Name string `json:"name"` + Response map[string]interface{} `json:"response"` +} + +type GeminiPartExecutableCode struct { + Language string `json:"language,omitempty"` + Code string `json:"code,omitempty"` +} + +type GeminiPartCodeExecutionResult struct { + Outcome string `json:"outcome,omitempty"` + Output string `json:"output,omitempty"` +} + +type GeminiFileData struct { + MimeType string `json:"mimeType,omitempty"` + FileUri string `json:"fileUri,omitempty"` +} + +type GeminiPart struct { + Text string `json:"text,omitempty"` + Thought bool `json:"thought,omitempty"` + InlineData *GeminiInlineData `json:"inlineData,omitempty"` + FunctionCall *FunctionCall `json:"functionCall,omitempty"` + FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"` + FileData *GeminiFileData `json:"fileData,omitempty"` + ExecutableCode *GeminiPartExecutableCode `json:"executableCode,omitempty"` + CodeExecutionResult *GeminiPartCodeExecutionResult `json:"codeExecutionResult,omitempty"` +} + +// UnmarshalJSON custom unmarshaler for GeminiPart to support snake_case and camelCase for InlineData +func (p *GeminiPart) UnmarshalJSON(data []byte) error { + // Alias to avoid recursion during unmarshalling + type Alias GeminiPart + var aux struct { + Alias + InlineDataSnake *GeminiInlineData `json:"inline_data,omitempty"` // snake_case variant + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Assign fields from alias + *p = GeminiPart(aux.Alias) + + // Prioritize snake_case for InlineData if present + if aux.InlineDataSnake != nil { + p.InlineData = aux.InlineDataSnake + } else if aux.InlineData != nil { // Fallback to camelCase from Alias + p.InlineData = aux.InlineData + } + // Other fields like Text, FunctionCall etc. are already populated via aux.Alias + + return nil +} + +type GeminiChatContent struct { + Role string `json:"role,omitempty"` + Parts []GeminiPart `json:"parts"` +} + +type GeminiChatSafetySettings struct { + Category string `json:"category"` + Threshold string `json:"threshold"` +} + +type GeminiChatTool struct { + GoogleSearch any `json:"googleSearch,omitempty"` + GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"` + CodeExecution any `json:"codeExecution,omitempty"` + FunctionDeclarations any `json:"functionDeclarations,omitempty"` +} + +type GeminiChatGenerationConfig struct { + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"topP,omitempty"` + TopK float64 `json:"topK,omitempty"` + MaxOutputTokens uint `json:"maxOutputTokens,omitempty"` + CandidateCount int `json:"candidateCount,omitempty"` + StopSequences []string `json:"stopSequences,omitempty"` + ResponseMimeType string `json:"responseMimeType,omitempty"` + ResponseSchema any `json:"responseSchema,omitempty"` + Seed int64 `json:"seed,omitempty"` + ResponseModalities []string `json:"responseModalities,omitempty"` + ThinkingConfig *GeminiThinkingConfig `json:"thinkingConfig,omitempty"` + SpeechConfig json.RawMessage `json:"speechConfig,omitempty"` // RawMessage to allow flexible speech config +} + +type GeminiChatCandidate struct { + Content GeminiChatContent `json:"content"` + FinishReason *string `json:"finishReason"` + Index int64 `json:"index"` + SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"` +} + +type GeminiChatSafetyRating struct { + Category string `json:"category"` + Probability string `json:"probability"` +} + +type GeminiChatPromptFeedback struct { + SafetyRatings []GeminiChatSafetyRating `json:"safetyRatings"` +} + +type GeminiChatResponse struct { + Candidates []GeminiChatCandidate `json:"candidates"` + PromptFeedback GeminiChatPromptFeedback `json:"promptFeedback"` + UsageMetadata GeminiUsageMetadata `json:"usageMetadata"` +} + +type GeminiUsageMetadata struct { + PromptTokenCount int `json:"promptTokenCount"` + CandidatesTokenCount int `json:"candidatesTokenCount"` + TotalTokenCount int `json:"totalTokenCount"` + ThoughtsTokenCount int `json:"thoughtsTokenCount"` + PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"` +} + +type GeminiPromptTokensDetails struct { + Modality string `json:"modality"` + TokenCount int `json:"tokenCount"` +} + +// Imagen related structs +type GeminiImageRequest struct { + Instances []GeminiImageInstance `json:"instances"` + Parameters GeminiImageParameters `json:"parameters"` +} + +type GeminiImageInstance struct { + Prompt string `json:"prompt"` +} + +type GeminiImageParameters struct { + SampleCount int `json:"sampleCount,omitempty"` + AspectRatio string `json:"aspectRatio,omitempty"` + PersonGeneration string `json:"personGeneration,omitempty"` +} + +type GeminiImageResponse struct { + Predictions []GeminiImagePrediction `json:"predictions"` +} + +type GeminiImagePrediction struct { + MimeType string `json:"mimeType"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + RaiFilteredReason string `json:"raiFilteredReason,omitempty"` + SafetyAttributes any `json:"safetyAttributes,omitempty"` +} + +// Embedding related structs +type GeminiEmbeddingRequest struct { + Content GeminiChatContent `json:"content"` + TaskType string `json:"taskType,omitempty"` + Title string `json:"title,omitempty"` + OutputDimensionality int `json:"outputDimensionality,omitempty"` +} + +type GeminiEmbeddingResponse struct { + Embedding ContentEmbedding `json:"embedding"` +} + +type ContentEmbedding struct { + Values []float64 `json:"values"` +} diff --git a/relay/channel/gemini/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go new file mode 100644 index 00000000..0870e3fa --- /dev/null +++ b/relay/channel/gemini/relay-gemini-native.go @@ -0,0 +1,138 @@ +package gemini + +import ( + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer common.CloseResponseBodyGracefully(resp) + + // 读取响应体 + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + if common.DebugEnabled { + println(string(responseBody)) + } + + // 解析为 Gemini 原生响应格式 + var geminiResponse GeminiChatResponse + err = common.Unmarshal(responseBody, &geminiResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + // 计算使用量(基于 UsageMetadata) + usage := dto.Usage{ + PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount, + CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount, + TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount, + } + + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + + // 直接返回 Gemini 原生格式的 JSON 响应 + jsonResponse, err := common.Marshal(geminiResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + common.IOCopyBytesGracefully(c, resp, jsonResponse) + + return &usage, nil +} + +func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var usage = &dto.Usage{} + var imageCount int + + helper.SetEventStreamHeaders(c) + + responseText := strings.Builder{} + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var geminiResponse GeminiChatResponse + err := common.UnmarshalJsonStr(data, &geminiResponse) + if err != nil { + common.LogError(c, "error unmarshalling stream response: "+err.Error()) + return false + } + + // 统计图片数量 + for _, candidate := range geminiResponse.Candidates { + for _, part := range candidate.Content.Parts { + if part.InlineData != nil && part.InlineData.MimeType != "" { + imageCount++ + } + if part.Text != "" { + responseText.WriteString(part.Text) + } + } + } + + // 更新使用量统计 + if geminiResponse.UsageMetadata.TotalTokenCount != 0 { + usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount + usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount + usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + } + + // 直接发送 GeminiChatResponse 响应 + err = helper.StringData(c, data) + if err != nil { + common.LogError(c, err.Error()) + } + + return true + }) + + if imageCount != 0 { + if usage.CompletionTokens == 0 { + usage.CompletionTokens = imageCount * 258 + } + } + + // 如果usage.CompletionTokens为0,则使用本地统计的completion tokens + if usage.CompletionTokens == 0 { + str := responseText.String() + if len(str) > 0 { + usage = service.ResponseText2Usage(responseText.String(), info.UpstreamModelName, info.PromptTokens) + } else { + // 空补全,不需要使用量 + usage = &dto.Usage{} + } + } + + // 移除流式响应结尾的[Done],因为Gemini API没有发送Done的行为 + //helper.Done(c) + + return usage, nil +} diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go new file mode 100644 index 00000000..6f3babeb --- /dev/null +++ b/relay/channel/gemini/relay-gemini.go @@ -0,0 +1,958 @@ +package gemini + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/setting/model_setting" + "one-api/types" + "strconv" + "strings" + "unicode/utf8" + + "github.com/gin-gonic/gin" +) + +var geminiSupportedMimeTypes = map[string]bool{ + "application/pdf": true, + "audio/mpeg": true, + "audio/mp3": true, + "audio/wav": true, + "image/png": true, + "image/jpeg": true, + "text/plain": true, + "video/mov": true, + "video/mpeg": true, + "video/mp4": true, + "video/mpg": true, + "video/avi": true, + "video/wmv": true, + "video/mpegps": true, + "video/flv": true, +} + +// Gemini 允许的思考预算范围 +const ( + pro25MinBudget = 128 + pro25MaxBudget = 32768 + flash25MaxBudget = 24576 + flash25LiteMinBudget = 512 + flash25LiteMaxBudget = 24576 +) + +// clampThinkingBudget 根据模型名称将预算限制在允许的范围内 +func clampThinkingBudget(modelName string, budget int) int { + isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25") + is25FlashLite := strings.HasPrefix(modelName, "gemini-2.5-flash-lite") + + if is25FlashLite { + if budget < flash25LiteMinBudget { + return flash25LiteMinBudget + } + if budget > flash25LiteMaxBudget { + return flash25LiteMaxBudget + } + } else if isNew25Pro { + if budget < pro25MinBudget { + return pro25MinBudget + } + if budget > pro25MaxBudget { + return pro25MaxBudget + } + } else { // 其他模型 + if budget < 0 { + return 0 + } + if budget > flash25MaxBudget { + return flash25MaxBudget + } + } + return budget +} + +func ThinkingAdaptor(geminiRequest *GeminiChatRequest, info *relaycommon.RelayInfo) { + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { + modelName := info.UpstreamModelName + isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") && + !strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25") + + if strings.Contains(modelName, "-thinking-") { + parts := strings.SplitN(modelName, "-thinking-", 2) + if len(parts) == 2 && parts[1] != "" { + if budgetTokens, err := strconv.Atoi(parts[1]); err == nil { + clampedBudget := clampThinkingBudget(modelName, budgetTokens) + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(clampedBudget), + IncludeThoughts: true, + } + } + } + } else if strings.HasSuffix(modelName, "-thinking") { + unsupportedModels := []string{ + "gemini-2.5-pro-preview-05-06", + "gemini-2.5-pro-preview-03-25", + } + isUnsupported := false + for _, unsupportedModel := range unsupportedModels { + if strings.HasPrefix(modelName, unsupportedModel) { + isUnsupported = true + break + } + } + + if isUnsupported { + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + IncludeThoughts: true, + } + } else { + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + IncludeThoughts: true, + } + if geminiRequest.GenerationConfig.MaxOutputTokens > 0 { + budgetTokens := model_setting.GetGeminiSettings().ThinkingAdapterBudgetTokensPercentage * float64(geminiRequest.GenerationConfig.MaxOutputTokens) + clampedBudget := clampThinkingBudget(modelName, int(budgetTokens)) + geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampedBudget) + } + } + } else if strings.HasSuffix(modelName, "-nothinking") { + if !isNew25Pro { + geminiRequest.GenerationConfig.ThinkingConfig = &GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(0), + } + } + } + } +} + +// Setting safety to the lowest possible values since Gemini is already powerless enough +func CovertGemini2OpenAI(textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*GeminiChatRequest, error) { + + geminiRequest := GeminiChatRequest{ + Contents: make([]GeminiChatContent, 0, len(textRequest.Messages)), + GenerationConfig: GeminiChatGenerationConfig{ + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + MaxOutputTokens: textRequest.MaxTokens, + Seed: int64(textRequest.Seed), + }, + } + + if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) { + geminiRequest.GenerationConfig.ResponseModalities = []string{ + "TEXT", + "IMAGE", + } + } + + ThinkingAdaptor(&geminiRequest, info) + + safetySettings := make([]GeminiChatSafetySettings, 0, len(SafetySettingList)) + for _, category := range SafetySettingList { + safetySettings = append(safetySettings, GeminiChatSafetySettings{ + Category: category, + Threshold: model_setting.GetGeminiSafetySetting(category), + }) + } + geminiRequest.SafetySettings = safetySettings + + // openaiContent.FuncToToolCalls() + if textRequest.Tools != nil { + functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools)) + googleSearch := false + codeExecution := false + for _, tool := range textRequest.Tools { + if tool.Function.Name == "googleSearch" { + googleSearch = true + continue + } + if tool.Function.Name == "codeExecution" { + codeExecution = true + continue + } + if tool.Function.Parameters != nil { + + params, ok := tool.Function.Parameters.(map[string]interface{}) + if ok { + if props, hasProps := params["properties"].(map[string]interface{}); hasProps { + if len(props) == 0 { + tool.Function.Parameters = nil + } + } + } + } + // Clean the parameters before appending + cleanedParams := cleanFunctionParameters(tool.Function.Parameters) + tool.Function.Parameters = cleanedParams + functions = append(functions, tool.Function) + } + if codeExecution { + geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{ + CodeExecution: make(map[string]string), + }) + } + if googleSearch { + geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{ + GoogleSearch: make(map[string]string), + }) + } + if len(functions) > 0 { + geminiRequest.Tools = append(geminiRequest.Tools, GeminiChatTool{ + FunctionDeclarations: functions, + }) + } + // common.SysLog("tools: " + fmt.Sprintf("%+v", geminiRequest.Tools)) + // json_data, _ := json.Marshal(geminiRequest.Tools) + // common.SysLog("tools_json: " + string(json_data)) + } + + if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") { + geminiRequest.GenerationConfig.ResponseMimeType = "application/json" + + if textRequest.ResponseFormat.JsonSchema != nil && textRequest.ResponseFormat.JsonSchema.Schema != nil { + cleanedSchema := removeAdditionalPropertiesWithDepth(textRequest.ResponseFormat.JsonSchema.Schema, 0) + geminiRequest.GenerationConfig.ResponseSchema = cleanedSchema + } + } + tool_call_ids := make(map[string]string) + var system_content []string + //shouldAddDummyModelMessage := false + for _, message := range textRequest.Messages { + if message.Role == "system" { + system_content = append(system_content, message.StringContent()) + continue + } else if message.Role == "tool" || message.Role == "function" { + if len(geminiRequest.Contents) == 0 || geminiRequest.Contents[len(geminiRequest.Contents)-1].Role == "model" { + geminiRequest.Contents = append(geminiRequest.Contents, GeminiChatContent{ + Role: "user", + }) + } + var parts = &geminiRequest.Contents[len(geminiRequest.Contents)-1].Parts + name := "" + if message.Name != nil { + name = *message.Name + } else if val, exists := tool_call_ids[message.ToolCallId]; exists { + name = val + } + var contentMap map[string]interface{} + contentStr := message.StringContent() + + // 1. 尝试解析为 JSON 对象 + if err := json.Unmarshal([]byte(contentStr), &contentMap); err != nil { + // 2. 如果失败,尝试解析为 JSON 数组 + var contentSlice []interface{} + if err := json.Unmarshal([]byte(contentStr), &contentSlice); err == nil { + // 如果是数组,包装成对象 + contentMap = map[string]interface{}{"result": contentSlice} + } else { + // 3. 如果再次失败,作为纯文本处理 + contentMap = map[string]interface{}{"content": contentStr} + } + } + + functionResp := &FunctionResponse{ + Name: name, + Response: contentMap, + } + + *parts = append(*parts, GeminiPart{ + FunctionResponse: functionResp, + }) + continue + } + var parts []GeminiPart + content := GeminiChatContent{ + Role: message.Role, + } + // isToolCall := false + if message.ToolCalls != nil { + // message.Role = "model" + // isToolCall = true + for _, call := range message.ParseToolCalls() { + args := map[string]interface{}{} + if call.Function.Arguments != "" { + if json.Unmarshal([]byte(call.Function.Arguments), &args) != nil { + return nil, fmt.Errorf("invalid arguments for function %s, args: %s", call.Function.Name, call.Function.Arguments) + } + } + toolCall := GeminiPart{ + FunctionCall: &FunctionCall{ + FunctionName: call.Function.Name, + Arguments: args, + }, + } + parts = append(parts, toolCall) + tool_call_ids[call.ID] = call.Function.Name + } + } + + openaiContent := message.ParseContent() + imageNum := 0 + for _, part := range openaiContent { + if part.Type == dto.ContentTypeText { + if part.Text == "" { + continue + } + parts = append(parts, GeminiPart{ + Text: part.Text, + }) + } else if part.Type == dto.ContentTypeImageURL { + imageNum += 1 + + if constant.GeminiVisionMaxImageNum != -1 && imageNum > constant.GeminiVisionMaxImageNum { + return nil, fmt.Errorf("too many images in the message, max allowed is %d", constant.GeminiVisionMaxImageNum) + } + // 判断是否是url + if strings.HasPrefix(part.GetImageMedia().Url, "http") { + // 是url,获取文件的类型和base64编码的数据 + fileData, err := service.GetFileBase64FromUrl(part.GetImageMedia().Url) + if err != nil { + return nil, fmt.Errorf("get file base64 from url '%s' failed: %w", part.GetImageMedia().Url, err) + } + + // 校验 MimeType 是否在 Gemini 支持的白名单中 + if _, ok := geminiSupportedMimeTypes[strings.ToLower(fileData.MimeType)]; !ok { + url := part.GetImageMedia().Url + return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", fileData.MimeType, url, getSupportedMimeTypesList()) + } + + parts = append(parts, GeminiPart{ + InlineData: &GeminiInlineData{ + MimeType: fileData.MimeType, // 使用原始的 MimeType,因为大小写可能对API有意义 + Data: fileData.Base64Data, + }, + }) + } else { + format, base64String, err := service.DecodeBase64FileData(part.GetImageMedia().Url) + if err != nil { + return nil, fmt.Errorf("decode base64 image data failed: %s", err.Error()) + } + parts = append(parts, GeminiPart{ + InlineData: &GeminiInlineData{ + MimeType: format, + Data: base64String, + }, + }) + } + } else if part.Type == dto.ContentTypeFile { + if part.GetFile().FileId != "" { + return nil, fmt.Errorf("only base64 file is supported in gemini") + } + format, base64String, err := service.DecodeBase64FileData(part.GetFile().FileData) + if err != nil { + return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error()) + } + parts = append(parts, GeminiPart{ + InlineData: &GeminiInlineData{ + MimeType: format, + Data: base64String, + }, + }) + } else if part.Type == dto.ContentTypeInputAudio { + if part.GetInputAudio().Data == "" { + return nil, fmt.Errorf("only base64 audio is supported in gemini") + } + base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data) + if err != nil { + return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error()) + } + parts = append(parts, GeminiPart{ + InlineData: &GeminiInlineData{ + MimeType: "audio/" + part.GetInputAudio().Format, + Data: base64String, + }, + }) + } + } + + content.Parts = parts + + // there's no assistant role in gemini and API shall vomit if Role is not user or model + if content.Role == "assistant" { + content.Role = "model" + } + if len(content.Parts) > 0 { + geminiRequest.Contents = append(geminiRequest.Contents, content) + } + } + + if len(system_content) > 0 { + geminiRequest.SystemInstructions = &GeminiChatContent{ + Parts: []GeminiPart{ + { + Text: strings.Join(system_content, "\n"), + }, + }, + } + } + + return &geminiRequest, nil +} + +// Helper function to get a list of supported MIME types for error messages +func getSupportedMimeTypesList() []string { + keys := make([]string, 0, len(geminiSupportedMimeTypes)) + for k := range geminiSupportedMimeTypes { + keys = append(keys, k) + } + return keys +} + +// cleanFunctionParameters recursively removes unsupported fields from Gemini function parameters. +func cleanFunctionParameters(params interface{}) interface{} { + if params == nil { + return nil + } + + switch v := params.(type) { + case map[string]interface{}: + // Create a copy to avoid modifying the original + cleanedMap := make(map[string]interface{}) + for k, val := range v { + cleanedMap[k] = val + } + + // Remove unsupported root-level fields + delete(cleanedMap, "default") + delete(cleanedMap, "exclusiveMaximum") + delete(cleanedMap, "exclusiveMinimum") + delete(cleanedMap, "$schema") + delete(cleanedMap, "additionalProperties") + + // Check and clean 'format' for string types + if propType, typeExists := cleanedMap["type"].(string); typeExists && propType == "string" { + if formatValue, formatExists := cleanedMap["format"].(string); formatExists { + if formatValue != "enum" && formatValue != "date-time" { + delete(cleanedMap, "format") + } + } + } + + // Clean properties + if props, ok := cleanedMap["properties"].(map[string]interface{}); ok && props != nil { + cleanedProps := make(map[string]interface{}) + for propName, propValue := range props { + cleanedProps[propName] = cleanFunctionParameters(propValue) + } + cleanedMap["properties"] = cleanedProps + } + + // Recursively clean items in arrays + if items, ok := cleanedMap["items"].(map[string]interface{}); ok && items != nil { + cleanedMap["items"] = cleanFunctionParameters(items) + } + // Also handle items if it's an array of schemas + if itemsArray, ok := cleanedMap["items"].([]interface{}); ok { + cleanedItemsArray := make([]interface{}, len(itemsArray)) + for i, item := range itemsArray { + cleanedItemsArray[i] = cleanFunctionParameters(item) + } + cleanedMap["items"] = cleanedItemsArray + } + + // Recursively clean other schema composition keywords + for _, field := range []string{"allOf", "anyOf", "oneOf"} { + if nested, ok := cleanedMap[field].([]interface{}); ok { + cleanedNested := make([]interface{}, len(nested)) + for i, item := range nested { + cleanedNested[i] = cleanFunctionParameters(item) + } + cleanedMap[field] = cleanedNested + } + } + + // Recursively clean patternProperties + if patternProps, ok := cleanedMap["patternProperties"].(map[string]interface{}); ok { + cleanedPatternProps := make(map[string]interface{}) + for pattern, schema := range patternProps { + cleanedPatternProps[pattern] = cleanFunctionParameters(schema) + } + cleanedMap["patternProperties"] = cleanedPatternProps + } + + // Recursively clean definitions + if definitions, ok := cleanedMap["definitions"].(map[string]interface{}); ok { + cleanedDefinitions := make(map[string]interface{}) + for defName, defSchema := range definitions { + cleanedDefinitions[defName] = cleanFunctionParameters(defSchema) + } + cleanedMap["definitions"] = cleanedDefinitions + } + + // Recursively clean $defs (newer JSON Schema draft) + if defs, ok := cleanedMap["$defs"].(map[string]interface{}); ok { + cleanedDefs := make(map[string]interface{}) + for defName, defSchema := range defs { + cleanedDefs[defName] = cleanFunctionParameters(defSchema) + } + cleanedMap["$defs"] = cleanedDefs + } + + // Clean conditional keywords + for _, field := range []string{"if", "then", "else", "not"} { + if nested, ok := cleanedMap[field]; ok { + cleanedMap[field] = cleanFunctionParameters(nested) + } + } + + return cleanedMap + + case []interface{}: + // Handle arrays of schemas + cleanedArray := make([]interface{}, len(v)) + for i, item := range v { + cleanedArray[i] = cleanFunctionParameters(item) + } + return cleanedArray + + default: + // Not a map or array, return as is (e.g., could be a primitive) + return params + } +} + +func removeAdditionalPropertiesWithDepth(schema interface{}, depth int) interface{} { + if depth >= 5 { + return schema + } + + v, ok := schema.(map[string]interface{}) + if !ok || len(v) == 0 { + return schema + } + // 删除所有的title字段 + delete(v, "title") + delete(v, "$schema") + // 如果type不为object和array,则直接返回 + if typeVal, exists := v["type"]; !exists || (typeVal != "object" && typeVal != "array") { + return schema + } + switch v["type"] { + case "object": + delete(v, "additionalProperties") + // 处理 properties + if properties, ok := v["properties"].(map[string]interface{}); ok { + for key, value := range properties { + properties[key] = removeAdditionalPropertiesWithDepth(value, depth+1) + } + } + for _, field := range []string{"allOf", "anyOf", "oneOf"} { + if nested, ok := v[field].([]interface{}); ok { + for i, item := range nested { + nested[i] = removeAdditionalPropertiesWithDepth(item, depth+1) + } + } + } + case "array": + if items, ok := v["items"].(map[string]interface{}); ok { + v["items"] = removeAdditionalPropertiesWithDepth(items, depth+1) + } + } + + return v +} + +func unescapeString(s string) (string, error) { + var result []rune + escaped := false + i := 0 + + for i < len(s) { + r, size := utf8.DecodeRuneInString(s[i:]) // 正确解码UTF-8字符 + if r == utf8.RuneError { + return "", fmt.Errorf("invalid UTF-8 encoding") + } + + if escaped { + // 如果是转义符后的字符,检查其类型 + switch r { + case '"': + result = append(result, '"') + case '\\': + result = append(result, '\\') + case '/': + result = append(result, '/') + case 'b': + result = append(result, '\b') + case 'f': + result = append(result, '\f') + case 'n': + result = append(result, '\n') + case 'r': + result = append(result, '\r') + case 't': + result = append(result, '\t') + case '\'': + result = append(result, '\'') + default: + // 如果遇到一个非法的转义字符,直接按原样输出 + result = append(result, '\\', r) + } + escaped = false + } else { + if r == '\\' { + escaped = true // 记录反斜杠作为转义符 + } else { + result = append(result, r) + } + } + i += size // 移动到下一个字符 + } + + return string(result), nil +} +func unescapeMapOrSlice(data interface{}) interface{} { + switch v := data.(type) { + case map[string]interface{}: + for k, val := range v { + v[k] = unescapeMapOrSlice(val) + } + case []interface{}: + for i, val := range v { + v[i] = unescapeMapOrSlice(val) + } + case string: + if unescaped, err := unescapeString(v); err != nil { + return v + } else { + return unescaped + } + } + return data +} + +func getResponseToolCall(item *GeminiPart) *dto.ToolCallResponse { + var argsBytes []byte + var err error + if result, ok := item.FunctionCall.Arguments.(map[string]interface{}); ok { + argsBytes, err = json.Marshal(unescapeMapOrSlice(result)) + } else { + argsBytes, err = json.Marshal(item.FunctionCall.Arguments) + } + + if err != nil { + return nil + } + return &dto.ToolCallResponse{ + ID: fmt.Sprintf("call_%s", common.GetUUID()), + Type: "function", + Function: dto.FunctionResponse{ + Arguments: string(argsBytes), + Name: item.FunctionCall.FunctionName, + }, + } +} + +func responseGeminiChat2OpenAI(c *gin.Context, response *GeminiChatResponse) *dto.OpenAITextResponse { + fullTextResponse := dto.OpenAITextResponse{ + Id: helper.GetResponseID(c), + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), + } + isToolCall := false + for _, candidate := range response.Candidates { + choice := dto.OpenAITextResponseChoice{ + Index: int(candidate.Index), + Message: dto.Message{ + Role: "assistant", + Content: "", + }, + FinishReason: constant.FinishReasonStop, + } + if len(candidate.Content.Parts) > 0 { + var texts []string + var toolCalls []dto.ToolCallResponse + for _, part := range candidate.Content.Parts { + if part.FunctionCall != nil { + choice.FinishReason = constant.FinishReasonToolCalls + if call := getResponseToolCall(&part); call != nil { + toolCalls = append(toolCalls, *call) + } + } else if part.Thought { + choice.Message.ReasoningContent = part.Text + } else { + if part.ExecutableCode != nil { + texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```") + } else if part.CodeExecutionResult != nil { + texts = append(texts, "```output\n"+part.CodeExecutionResult.Output+"\n```") + } else { + // 过滤掉空行 + if part.Text != "\n" { + texts = append(texts, part.Text) + } + } + } + } + if len(toolCalls) > 0 { + choice.Message.SetToolCalls(toolCalls) + isToolCall = true + } + choice.Message.SetStringContent(strings.Join(texts, "\n")) + + } + if candidate.FinishReason != nil { + switch *candidate.FinishReason { + case "STOP": + choice.FinishReason = constant.FinishReasonStop + case "MAX_TOKENS": + choice.FinishReason = constant.FinishReasonLength + default: + choice.FinishReason = constant.FinishReasonContentFilter + } + } + if isToolCall { + choice.FinishReason = constant.FinishReasonToolCalls + } + + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponseGeminiChat2OpenAI(geminiResponse *GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool, bool) { + choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates)) + isStop := false + hasImage := false + for _, candidate := range geminiResponse.Candidates { + if candidate.FinishReason != nil && *candidate.FinishReason == "STOP" { + isStop = true + candidate.FinishReason = nil + } + choice := dto.ChatCompletionsStreamResponseChoice{ + Index: int(candidate.Index), + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + }, + } + var texts []string + isTools := false + isThought := false + if candidate.FinishReason != nil { + // p := GeminiConvertFinishReason(*candidate.FinishReason) + switch *candidate.FinishReason { + case "STOP": + choice.FinishReason = &constant.FinishReasonStop + case "MAX_TOKENS": + choice.FinishReason = &constant.FinishReasonLength + default: + choice.FinishReason = &constant.FinishReasonContentFilter + } + } + for _, part := range candidate.Content.Parts { + if part.InlineData != nil { + if strings.HasPrefix(part.InlineData.MimeType, "image") { + imgText := "![image](data:" + part.InlineData.MimeType + ";base64," + part.InlineData.Data + ")" + texts = append(texts, imgText) + hasImage = true + } + } else if part.FunctionCall != nil { + isTools = true + if call := getResponseToolCall(&part); call != nil { + call.SetIndex(len(choice.Delta.ToolCalls)) + choice.Delta.ToolCalls = append(choice.Delta.ToolCalls, *call) + } + } else if part.Thought { + isThought = true + texts = append(texts, part.Text) + } else { + if part.ExecutableCode != nil { + texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```\n") + } else if part.CodeExecutionResult != nil { + texts = append(texts, "```output\n"+part.CodeExecutionResult.Output+"\n```\n") + } else { + if part.Text != "\n" { + texts = append(texts, part.Text) + } + } + } + } + if isThought { + choice.Delta.SetReasoningContent(strings.Join(texts, "\n")) + } else { + choice.Delta.SetContentString(strings.Join(texts, "\n")) + } + if isTools { + choice.FinishReason = &constant.FinishReasonToolCalls + } + choices = append(choices, choice) + } + + var response dto.ChatCompletionsStreamResponse + response.Object = "chat.completion.chunk" + response.Choices = choices + return &response, isStop, hasImage +} + +func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + // responseText := "" + id := helper.GetResponseID(c) + createAt := common.GetTimestamp() + var usage = &dto.Usage{} + var imageCount int + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var geminiResponse GeminiChatResponse + err := common.UnmarshalJsonStr(data, &geminiResponse) + if err != nil { + common.LogError(c, "error unmarshalling stream response: "+err.Error()) + return false + } + + response, isStop, hasImage := streamResponseGeminiChat2OpenAI(&geminiResponse) + if hasImage { + imageCount++ + } + response.Id = id + response.Created = createAt + response.Model = info.UpstreamModelName + if geminiResponse.UsageMetadata.TotalTokenCount != 0 { + usage.PromptTokens = geminiResponse.UsageMetadata.PromptTokenCount + usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + } + err = helper.ObjectData(c, response) + if err != nil { + common.LogError(c, err.Error()) + } + if isStop { + response := helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, constant.FinishReasonStop) + helper.ObjectData(c, response) + } + return true + }) + + var response *dto.ChatCompletionsStreamResponse + + if imageCount != 0 { + if usage.CompletionTokens == 0 { + usage.CompletionTokens = imageCount * 258 + } + } + + usage.PromptTokensDetails.TextTokens = usage.PromptTokens + usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + + if info.ShouldIncludeUsage { + response = helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage) + err := helper.ObjectData(c, response) + if err != nil { + common.SysError("send final response failed: " + err.Error()) + } + } + helper.Done(c) + //resp.Body.Close() + return usage, nil +} + +func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + common.CloseResponseBodyGracefully(resp) + if common.DebugEnabled { + println(string(responseBody)) + } + var geminiResponse GeminiChatResponse + err = common.Unmarshal(responseBody, &geminiResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if len(geminiResponse.Candidates) == 0 { + return nil, types.NewError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody) + } + fullTextResponse := responseGeminiChat2OpenAI(c, &geminiResponse) + fullTextResponse.Model = info.UpstreamModelName + usage := dto.Usage{ + PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount, + CompletionTokens: geminiResponse.UsageMetadata.CandidatesTokenCount, + TotalTokens: geminiResponse.UsageMetadata.TotalTokenCount, + } + + usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount + usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + + for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails { + if detail.Modality == "AUDIO" { + usage.PromptTokensDetails.AudioTokens = detail.TokenCount + } else if detail.Modality == "TEXT" { + usage.PromptTokensDetails.TextTokens = detail.TokenCount + } + } + + fullTextResponse.Usage = usage + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + c.Writer.Write(jsonResponse) + return &usage, nil +} + +func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer common.CloseResponseBodyGracefully(resp) + + responseBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewError(readErr, types.ErrorCodeBadResponseBody) + } + + var geminiResponse GeminiEmbeddingResponse + if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { + return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) + } + + // convert to openai format response + openAIResponse := dto.OpenAIEmbeddingResponse{ + Object: "list", + Data: []dto.OpenAIEmbeddingResponseItem{ + { + Object: "embedding", + Embedding: geminiResponse.Embedding.Values, + Index: 0, + }, + }, + Model: info.UpstreamModelName, + } + + // calculate usage + // https://ai.google.dev/gemini-api/docs/pricing?hl=zh-cn#text-embedding-004 + // Google has not yet clarified how embedding models will be billed + // refer to openai billing method to use input tokens billing + // https://platform.openai.com/docs/guides/embeddings#what-are-embeddings + usage := &dto.Usage{ + PromptTokens: info.PromptTokens, + CompletionTokens: 0, + TotalTokens: info.PromptTokens, + } + openAIResponse.Usage = *usage + + jsonResponse, jsonErr := common.Marshal(openAIResponse) + if jsonErr != nil { + return nil, types.NewError(jsonErr, types.ErrorCodeBadResponseBody) + } + + common.IOCopyBytesGracefully(c, resp, jsonResponse) + return usage, nil +} diff --git a/relay/channel/jimeng/adaptor.go b/relay/channel/jimeng/adaptor.go new file mode 100644 index 00000000..0b743879 --- /dev/null +++ b/relay/channel/jimeng/adaptor.go @@ -0,0 +1,136 @@ +package jimeng + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/types" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/?Action=CVProcess&Version=2022-08-31", info.BaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error { + return errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +type LogoInfo struct { + AddLogo bool `json:"add_logo,omitempty"` + Position int `json:"position,omitempty"` + Language int `json:"language,omitempty"` + Opacity float64 `json:"opacity,omitempty"` + LogoTextContent string `json:"logo_text_content,omitempty"` +} + +type imageRequestPayload struct { + ReqKey string `json:"req_key"` // Service identifier, fixed value: jimeng_high_aes_general_v21_L + Prompt string `json:"prompt"` // Prompt for image generation, supports both Chinese and English + Seed int64 `json:"seed,omitempty"` // Random seed, default -1 (random) + Width int `json:"width,omitempty"` // Image width, default 512, range [256, 768] + Height int `json:"height,omitempty"` // Image height, default 512, range [256, 768] + UsePreLLM bool `json:"use_pre_llm,omitempty"` // Enable text expansion, default true + UseSR bool `json:"use_sr,omitempty"` // Enable super resolution, default true + ReturnURL bool `json:"return_url,omitempty"` // Whether to return image URL (valid for 24 hours) + LogoInfo LogoInfo `json:"logo_info,omitempty"` // Watermark information + ImageUrls []string `json:"image_urls,omitempty"` // Image URLs for input + BinaryData []string `json:"binary_data_base64,omitempty"` // Base64 encoded binary data +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + payload := imageRequestPayload{ + ReqKey: request.Model, + Prompt: request.Prompt, + } + if request.ResponseFormat == "" || request.ResponseFormat == "url" { + payload.ReturnURL = true // Default to returning image URLs + } + + if len(request.ExtraFields) > 0 { + if err := json.Unmarshal(request.ExtraFields, &payload); err != nil { + return nil, fmt.Errorf("failed to unmarshal extra fields: %w", err) + } + } + + return payload, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + fullRequestURL, err := a.GetRequestURL(info) + if err != nil { + return nil, fmt.Errorf("get request url failed: %w", err) + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + if err != nil { + return nil, fmt.Errorf("new request failed: %w", err) + } + err = Sign(c, req, info.ApiKey) + if err != nil { + return nil, fmt.Errorf("setup request header failed: %w", err) + } + resp, err := channel.DoRequest(c, req, info) + if err != nil { + return nil, fmt.Errorf("do request failed: %w", err) + } + return resp, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == relayconstant.RelayModeImagesGenerations { + usage, err = jimengImageHandler(c, resp, info) + } else if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/jimeng/constants.go b/relay/channel/jimeng/constants.go new file mode 100644 index 00000000..0d1764e5 --- /dev/null +++ b/relay/channel/jimeng/constants.go @@ -0,0 +1,9 @@ +package jimeng + +const ( + ChannelName = "jimeng" +) + +var ModelList = []string{ + "jimeng_high_aes_general_v21_L", +} diff --git a/relay/channel/jimeng/image.go b/relay/channel/jimeng/image.go new file mode 100644 index 00000000..3c6a1d99 --- /dev/null +++ b/relay/channel/jimeng/image.go @@ -0,0 +1,89 @@ +package jimeng + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type ImageResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + BinaryDataBase64 []string `json:"binary_data_base64"` + ImageUrls []string `json:"image_urls"` + RephraseResult string `json:"rephraser_result"` + RequestID string `json:"request_id"` + // Other fields are omitted for brevity + } `json:"data"` + RequestID string `json:"request_id"` + Status int `json:"status"` + TimeElapsed string `json:"time_elapsed"` +} + +func responseJimeng2OpenAIImage(_ *gin.Context, response *ImageResponse, info *relaycommon.RelayInfo) *dto.ImageResponse { + imageResponse := dto.ImageResponse{ + Created: info.StartTime.Unix(), + } + + for _, base64Data := range response.Data.BinaryDataBase64 { + imageResponse.Data = append(imageResponse.Data, dto.ImageData{ + B64Json: base64Data, + }) + } + for _, imageUrl := range response.Data.ImageUrls { + imageResponse.Data = append(imageResponse.Data, dto.ImageData{ + Url: imageUrl, + }) + } + + return &imageResponse +} + +// jimengImageHandler handles the Jimeng image generation response +func jimengImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) { + var jimengResponse ImageResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + common.CloseResponseBodyGracefully(resp) + + err = json.Unmarshal(responseBody, &jimengResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + // Check if the response indicates an error + if jimengResponse.Code != 10000 { + return nil, types.WithOpenAIError(types.OpenAIError{ + Message: jimengResponse.Message, + Type: "jimeng_error", + Param: "", + Code: fmt.Sprintf("%d", jimengResponse.Code), + }, resp.StatusCode) + } + + // Convert Jimeng response to OpenAI format + fullTextResponse := responseJimeng2OpenAIImage(c, &jimengResponse, info) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + return &dto.Usage{}, nil +} diff --git a/relay/channel/jimeng/sign.go b/relay/channel/jimeng/sign.go new file mode 100644 index 00000000..c9db6630 --- /dev/null +++ b/relay/channel/jimeng/sign.go @@ -0,0 +1,176 @@ +package jimeng + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "net/url" + "one-api/common" + "sort" + "strings" + "time" +) + +// SignRequestForJimeng 对即梦 API 请求进行签名,支持 http.Request 或 header+url+body 方式 +//func SignRequestForJimeng(req *http.Request, accessKey, secretKey string) error { +// var bodyBytes []byte +// var err error +// +// if req.Body != nil { +// bodyBytes, err = io.ReadAll(req.Body) +// if err != nil { +// return fmt.Errorf("read request body failed: %w", err) +// } +// _ = req.Body.Close() +// req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // rewind +// } else { +// bodyBytes = []byte{} +// } +// +// return signJimengHeaders(&req.Header, req.Method, req.URL, bodyBytes, accessKey, secretKey) +//} + +const HexPayloadHashKey = "HexPayloadHash" + +func SetPayloadHash(c *gin.Context, req any) error { + body, err := json.Marshal(req) + if err != nil { + return err + } + common.LogInfo(c, fmt.Sprintf("SetPayloadHash body: %s", body)) + payloadHash := sha256.Sum256(body) + hexPayloadHash := hex.EncodeToString(payloadHash[:]) + c.Set(HexPayloadHashKey, hexPayloadHash) + return nil +} +func getPayloadHash(c *gin.Context) string { + return c.GetString(HexPayloadHashKey) +} + +func Sign(c *gin.Context, req *http.Request, apiKey string) error { + header := req.Header + + var bodyBytes []byte + var err error + + if req.Body != nil { + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return err + } + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Rewind + } + + payloadHash := sha256.Sum256(bodyBytes) + hexPayloadHash := hex.EncodeToString(payloadHash[:]) + + method := c.Request.Method + u := req.URL + keyParts := strings.Split(apiKey, "|") + if len(keyParts) != 2 { + return errors.New("invalid api key format for jimeng: expected 'ak|sk'") + } + accessKey := strings.TrimSpace(keyParts[0]) + secretKey := strings.TrimSpace(keyParts[1]) + t := time.Now().UTC() + xDate := t.Format("20060102T150405Z") + shortDate := t.Format("20060102") + + host := u.Host + header.Set("Host", host) + header.Set("X-Date", xDate) + header.Set("X-Content-Sha256", hexPayloadHash) + + // Sort and encode query parameters to create canonical query string + queryParams := u.Query() + sortedKeys := make([]string, 0, len(queryParams)) + for k := range queryParams { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + var queryParts []string + for _, k := range sortedKeys { + values := queryParams[k] + sort.Strings(values) + for _, v := range values { + queryParts = append(queryParts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v))) + } + } + canonicalQueryString := strings.Join(queryParts, "&") + + headersToSign := map[string]string{ + "host": host, + "x-date": xDate, + "x-content-sha256": hexPayloadHash, + } + if header.Get("Content-Type") == "" { + header.Set("Content-Type", "application/json") + } + headersToSign["content-type"] = header.Get("Content-Type") + + var signedHeaderKeys []string + for k := range headersToSign { + signedHeaderKeys = append(signedHeaderKeys, k) + } + sort.Strings(signedHeaderKeys) + + var canonicalHeaders strings.Builder + for _, k := range signedHeaderKeys { + canonicalHeaders.WriteString(k) + canonicalHeaders.WriteString(":") + canonicalHeaders.WriteString(strings.TrimSpace(headersToSign[k])) + canonicalHeaders.WriteString("\n") + } + signedHeaders := strings.Join(signedHeaderKeys, ";") + + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + method, + u.Path, + canonicalQueryString, + canonicalHeaders.String(), + signedHeaders, + hexPayloadHash, + ) + + hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest)) + hexHashedCanonicalRequest := hex.EncodeToString(hashedCanonicalRequest[:]) + + region := "cn-north-1" + serviceName := "cv" + credentialScope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, serviceName) + stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s", + xDate, + credentialScope, + hexHashedCanonicalRequest, + ) + + kDate := hmacSHA256([]byte(secretKey), []byte(shortDate)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(serviceName)) + kSigning := hmacSHA256(kService, []byte("request")) + signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign))) + + authorization := fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + accessKey, + credentialScope, + signedHeaders, + signature, + ) + header.Set("Authorization", authorization) + return nil +} + +// hmacSHA256 计算 HMAC-SHA256 +func hmacSHA256(key []byte, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} diff --git a/relay/channel/jina/adaptor.go b/relay/channel/jina/adaptor.go new file mode 100644 index 00000000..408a5c6e --- /dev/null +++ b/relay/channel/jina/adaptor.go @@ -0,0 +1,92 @@ +package jina + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/relay/common_handler" + "one-api/relay/constant" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode == constant.RelayModeRerank { + return fmt.Sprintf("%s/v1/rerank", info.BaseUrl), nil + } else if info.RelayMode == constant.RelayModeEmbeddings { + return fmt.Sprintf("%s/v1/embeddings", info.BaseUrl), nil + } + return "", errors.New("invalid relay mode") +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.RelayMode == constant.RelayModeRerank { + usage, err = common_handler.RerankHandler(c, info, resp) + } else if info.RelayMode == constant.RelayModeEmbeddings { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/jina/constant.go b/relay/channel/jina/constant.go new file mode 100644 index 00000000..be290fb6 --- /dev/null +++ b/relay/channel/jina/constant.go @@ -0,0 +1,9 @@ +package jina + +var ModelList = []string{ + "jina-clip-v1", + "jina-reranker-v2-base-multilingual", + "jina-reranker-m0", +} + +var ChannelName = "jina" diff --git a/relay/channel/jina/relay-jina.go b/relay/channel/jina/relay-jina.go new file mode 100644 index 00000000..d83b5854 --- /dev/null +++ b/relay/channel/jina/relay-jina.go @@ -0,0 +1 @@ +package jina diff --git a/relay/channel/lingyiwanwu/constrants.go b/relay/channel/lingyiwanwu/constrants.go new file mode 100644 index 00000000..a6345071 --- /dev/null +++ b/relay/channel/lingyiwanwu/constrants.go @@ -0,0 +1,9 @@ +package lingyiwanwu + +// https://platform.lingyiwanwu.com/docs + +var ModelList = []string{ + "yi-large", "yi-medium", "yi-vision", "yi-medium-200k", "yi-spark", "yi-large-rag", "yi-large-turbo", "yi-large-preview", "yi-large-rag-preview", +} + +var ChannelName = "lingyiwanwu" diff --git a/relay/channel/minimax/constants.go b/relay/channel/minimax/constants.go new file mode 100644 index 00000000..c480cac9 --- /dev/null +++ b/relay/channel/minimax/constants.go @@ -0,0 +1,13 @@ +package minimax + +// https://www.minimaxi.com/document/guides/chat-model/V2?id=65e0736ab2845de20908e2dd + +var ModelList = []string{ + "abab6.5-chat", + "abab6.5s-chat", + "abab6-chat", + "abab5.5-chat", + "abab5.5s-chat", +} + +var ChannelName = "minimax" diff --git a/relay/channel/minimax/relay-minimax.go b/relay/channel/minimax/relay-minimax.go new file mode 100644 index 00000000..d0a15b0d --- /dev/null +++ b/relay/channel/minimax/relay-minimax.go @@ -0,0 +1,10 @@ +package minimax + +import ( + "fmt" + relaycommon "one-api/relay/common" +) + +func GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/v1/text/chatcompletion_v2", info.BaseUrl), nil +} diff --git a/relay/channel/mistral/adaptor.go b/relay/channel/mistral/adaptor.go new file mode 100644 index 00000000..434a1031 --- /dev/null +++ b/relay/channel/mistral/adaptor.go @@ -0,0 +1,88 @@ +package mistral + +import ( + "errors" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return relaycommon.GetFullRequestURL(info.BaseUrl, info.RequestURLPath, info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return requestOpenAI2Mistral(request), nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/mistral/constants.go b/relay/channel/mistral/constants.go new file mode 100644 index 00000000..7f5f3aca --- /dev/null +++ b/relay/channel/mistral/constants.go @@ -0,0 +1,12 @@ +package mistral + +var ModelList = []string{ + "open-mistral-7b", + "open-mixtral-8x7b", + "mistral-small-latest", + "mistral-medium-latest", + "mistral-large-latest", + "mistral-embed", +} + +var ChannelName = "mistral" diff --git a/relay/channel/mistral/text.go b/relay/channel/mistral/text.go new file mode 100644 index 00000000..e26c6101 --- /dev/null +++ b/relay/channel/mistral/text.go @@ -0,0 +1,78 @@ +package mistral + +import ( + "one-api/common" + "one-api/dto" + "regexp" +) + +var mistralToolCallIdRegexp = regexp.MustCompile("^[a-zA-Z0-9]{9}$") + +func requestOpenAI2Mistral(request *dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { + messages := make([]dto.Message, 0, len(request.Messages)) + idMap := make(map[string]string) + for _, message := range request.Messages { + // 1. tool_calls.id + toolCalls := message.ParseToolCalls() + if toolCalls != nil { + for i := range toolCalls { + if !mistralToolCallIdRegexp.MatchString(toolCalls[i].ID) { + if newId, ok := idMap[toolCalls[i].ID]; ok { + toolCalls[i].ID = newId + } else { + newId, err := common.GenerateRandomCharsKey(9) + if err == nil { + idMap[toolCalls[i].ID] = newId + toolCalls[i].ID = newId + } + } + } + } + message.SetToolCalls(toolCalls) + } + + // 2. tool_call_id + if message.ToolCallId != "" { + if newId, ok := idMap[message.ToolCallId]; ok { + message.ToolCallId = newId + } else { + if !mistralToolCallIdRegexp.MatchString(message.ToolCallId) { + newId, err := common.GenerateRandomCharsKey(9) + if err == nil { + idMap[message.ToolCallId] = newId + message.ToolCallId = newId + } + } + } + } + + mediaMessages := message.ParseContent() + if message.Role == "assistant" && message.ToolCalls != nil && message.Content == "" { + mediaMessages = []dto.MediaContent{} + } + for j, mediaMessage := range mediaMessages { + if mediaMessage.Type == dto.ContentTypeImageURL { + imageUrl := mediaMessage.GetImageMedia() + mediaMessage.ImageUrl = imageUrl.Url + mediaMessages[j] = mediaMessage + } + } + message.SetMediaContent(mediaMessages) + messages = append(messages, dto.Message{ + Role: message.Role, + Content: message.Content, + ToolCalls: message.ToolCalls, + ToolCallId: message.ToolCallId, + }) + } + return &dto.GeneralOpenAIRequest{ + Model: request.Model, + Stream: request.Stream, + Messages: messages, + Temperature: request.Temperature, + TopP: request.TopP, + MaxTokens: request.MaxTokens, + Tools: request.Tools, + ToolChoice: request.ToolChoice, + } +} diff --git a/relay/channel/mokaai/adaptor.go b/relay/channel/mokaai/adaptor.go new file mode 100644 index 00000000..b0b54b0c --- /dev/null +++ b/relay/channel/mokaai/adaptor.go @@ -0,0 +1,106 @@ +package mokaai + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return request, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + // https://cloud.baidu.com/doc/WENXINWORKSHOP/s/clntwmv7t + suffix := "chat/" + if strings.HasPrefix(info.UpstreamModelName, "m3e") { + suffix = "embeddings" + } + fullRequestURL := fmt.Sprintf("%s/%s", info.BaseUrl, suffix) + return fullRequestURL, nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + switch info.RelayMode { + case constant.RelayModeEmbeddings: + baiduEmbeddingRequest := embeddingRequestOpenAI2Moka(*request) + return baiduEmbeddingRequest, nil + default: + return nil, errors.New("not implemented") + } +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + + switch info.RelayMode { + case constant.RelayModeEmbeddings: + return mokaEmbeddingHandler(c, info, resp) + default: + // err, usage = mokaHandler(c, resp) + + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/mokaai/constants.go b/relay/channel/mokaai/constants.go new file mode 100644 index 00000000..415d83b7 --- /dev/null +++ b/relay/channel/mokaai/constants.go @@ -0,0 +1,9 @@ +package mokaai + +var ModelList = []string{ + "m3e-large", + "m3e-base", + "m3e-small", +} + +var ChannelName = "mokaai" \ No newline at end of file diff --git a/relay/channel/mokaai/relay-mokaai.go b/relay/channel/mokaai/relay-mokaai.go new file mode 100644 index 00000000..78f96d6d --- /dev/null +++ b/relay/channel/mokaai/relay-mokaai.go @@ -0,0 +1,82 @@ +package mokaai + +import ( + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +func embeddingRequestOpenAI2Moka(request dto.GeneralOpenAIRequest) *dto.EmbeddingRequest { + var input []string // Change input to []string + + switch v := request.Input.(type) { + case string: + input = []string{v} // Convert string to []string + case []string: + input = v // Already a []string, no conversion needed + case []interface{}: + for _, part := range v { + if str, ok := part.(string); ok { + input = append(input, str) // Append each string to the slice + } + } + } + return &dto.EmbeddingRequest{ + Input: input, + Model: request.Model, + } +} + +func embeddingResponseMoka2OpenAI(response *dto.EmbeddingResponse) *dto.OpenAIEmbeddingResponse { + openAIEmbeddingResponse := dto.OpenAIEmbeddingResponse{ + Object: "list", + Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(response.Data)), + Model: "baidu-embedding", + Usage: response.Usage, + } + for _, item := range response.Data { + openAIEmbeddingResponse.Data = append(openAIEmbeddingResponse.Data, dto.OpenAIEmbeddingResponseItem{ + Object: item.Object, + Index: item.Index, + Embedding: item.Embedding, + }) + } + return &openAIEmbeddingResponse +} + +func mokaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var baiduResponse dto.EmbeddingResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + common.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &baiduResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + // if baiduResponse.ErrorMsg != "" { + // return &dto.OpenAIErrorWithStatusCode{ + // Error: dto.OpenAIError{ + // Type: "baidu_error", + // Param: "", + // }, + // StatusCode: resp.StatusCode, + // }, nil + // } + fullTextResponse := embeddingResponseMoka2OpenAI(&baiduResponse) + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + common.IOCopyBytesGracefully(c, resp, jsonResponse) + return &fullTextResponse.Usage, nil +} diff --git a/relay/channel/moonshot/constants.go b/relay/channel/moonshot/constants.go new file mode 100644 index 00000000..a7da54b3 --- /dev/null +++ b/relay/channel/moonshot/constants.go @@ -0,0 +1,9 @@ +package moonshot + +var ModelList = []string{ + "moonshot-v1-8k", + "moonshot-v1-32k", + "moonshot-v1-128k", +} + +var ChannelName = "moonshot" diff --git a/relay/channel/ollama/adaptor.go b/relay/channel/ollama/adaptor.go new file mode 100644 index 00000000..b9e304fc --- /dev/null +++ b/relay/channel/ollama/adaptor.go @@ -0,0 +1,97 @@ +package ollama + +import ( + "errors" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch info.RelayMode { + case relayconstant.RelayModeEmbeddings: + return info.BaseUrl + "/api/embed", nil + default: + return relaycommon.GetFullRequestURL(info.BaseUrl, info.RequestURLPath, info.ChannelType), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return requestOpenAI2Ollama(*request) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return requestOpenAI2Embeddings(request), nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + if info.RelayMode == relayconstant.RelayModeEmbeddings { + usage, err = ollamaEmbeddingHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/ollama/constants.go b/relay/channel/ollama/constants.go new file mode 100644 index 00000000..682626a2 --- /dev/null +++ b/relay/channel/ollama/constants.go @@ -0,0 +1,7 @@ +package ollama + +var ModelList = []string{ + "llama3-7b", +} + +var ChannelName = "ollama" diff --git a/relay/channel/ollama/dto.go b/relay/channel/ollama/dto.go new file mode 100644 index 00000000..15c64cdc --- /dev/null +++ b/relay/channel/ollama/dto.go @@ -0,0 +1,45 @@ +package ollama + +import "one-api/dto" + +type OllamaRequest struct { + Model string `json:"model,omitempty"` + Messages []dto.Message `json:"messages,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Seed float64 `json:"seed,omitempty"` + Topp float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Stop any `json:"stop,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + Tools []dto.ToolCallRequest `json:"tools,omitempty"` + ResponseFormat any `json:"response_format,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + Suffix any `json:"suffix,omitempty"` + StreamOptions *dto.StreamOptions `json:"stream_options,omitempty"` + Prompt any `json:"prompt,omitempty"` +} + +type Options struct { + Seed int `json:"seed,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopK int `json:"top_k,omitempty"` + TopP float64 `json:"top_p,omitempty"` + FrequencyPenalty float64 `json:"frequency_penalty,omitempty"` + PresencePenalty float64 `json:"presence_penalty,omitempty"` + NumPredict int `json:"num_predict,omitempty"` + NumCtx int `json:"num_ctx,omitempty"` +} + +type OllamaEmbeddingRequest struct { + Model string `json:"model,omitempty"` + Input []string `json:"input"` + Options *Options `json:"options,omitempty"` +} + +type OllamaEmbeddingResponse struct { + Error string `json:"error,omitempty"` + Model string `json:"model"` + Embedding [][]float64 `json:"embeddings,omitempty"` +} diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go new file mode 100644 index 00000000..295349e3 --- /dev/null +++ b/relay/channel/ollama/relay-ollama.go @@ -0,0 +1,132 @@ +package ollama + +import ( + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/service" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func requestOpenAI2Ollama(request dto.GeneralOpenAIRequest) (*OllamaRequest, error) { + messages := make([]dto.Message, 0, len(request.Messages)) + for _, message := range request.Messages { + if !message.IsStringContent() { + mediaMessages := message.ParseContent() + for j, mediaMessage := range mediaMessages { + if mediaMessage.Type == dto.ContentTypeImageURL { + imageUrl := mediaMessage.GetImageMedia() + // check if not base64 + if strings.HasPrefix(imageUrl.Url, "http") { + fileData, err := service.GetFileBase64FromUrl(imageUrl.Url) + if err != nil { + return nil, err + } + imageUrl.Url = fmt.Sprintf("data:%s;base64,%s", fileData.MimeType, fileData.Base64Data) + } + mediaMessage.ImageUrl = imageUrl + mediaMessages[j] = mediaMessage + } + } + message.SetMediaContent(mediaMessages) + } + messages = append(messages, dto.Message{ + Role: message.Role, + Content: message.Content, + ToolCalls: message.ToolCalls, + ToolCallId: message.ToolCallId, + }) + } + str, ok := request.Stop.(string) + var Stop []string + if ok { + Stop = []string{str} + } else { + Stop, _ = request.Stop.([]string) + } + return &OllamaRequest{ + Model: request.Model, + Messages: messages, + Stream: request.Stream, + Temperature: request.Temperature, + Seed: request.Seed, + Topp: request.TopP, + TopK: request.TopK, + Stop: Stop, + Tools: request.Tools, + MaxTokens: request.MaxTokens, + ResponseFormat: request.ResponseFormat, + FrequencyPenalty: request.FrequencyPenalty, + PresencePenalty: request.PresencePenalty, + Prompt: request.Prompt, + StreamOptions: request.StreamOptions, + Suffix: request.Suffix, + }, nil +} + +func requestOpenAI2Embeddings(request dto.EmbeddingRequest) *OllamaEmbeddingRequest { + return &OllamaEmbeddingRequest{ + Model: request.Model, + Input: request.ParseInput(), + Options: &Options{ + Seed: int(request.Seed), + Temperature: request.Temperature, + TopP: request.TopP, + FrequencyPenalty: request.FrequencyPenalty, + PresencePenalty: request.PresencePenalty, + }, + } +} + +func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var ollamaEmbeddingResponse OllamaEmbeddingResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + common.CloseResponseBodyGracefully(resp) + err = common.Unmarshal(responseBody, &ollamaEmbeddingResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if ollamaEmbeddingResponse.Error != "" { + return nil, types.NewError(fmt.Errorf("ollama error: %s", ollamaEmbeddingResponse.Error), types.ErrorCodeBadResponseBody) + } + flattenedEmbeddings := flattenEmbeddings(ollamaEmbeddingResponse.Embedding) + data := make([]dto.OpenAIEmbeddingResponseItem, 0, 1) + data = append(data, dto.OpenAIEmbeddingResponseItem{ + Embedding: flattenedEmbeddings, + Object: "embedding", + }) + usage := &dto.Usage{ + TotalTokens: info.PromptTokens, + CompletionTokens: 0, + PromptTokens: info.PromptTokens, + } + embeddingResponse := &dto.OpenAIEmbeddingResponse{ + Object: "list", + Data: data, + Model: info.UpstreamModelName, + Usage: *usage, + } + doResponseBody, err := common.Marshal(embeddingResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + common.IOCopyBytesGracefully(c, resp, doResponseBody) + return usage, nil +} + +func flattenEmbeddings(embeddings [][]float64) []float64 { + flattened := []float64{} + for _, row := range embeddings { + flattened = append(flattened, row...) + } + return flattened +} diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go new file mode 100644 index 00000000..efd22878 --- /dev/null +++ b/relay/channel/openai/adaptor.go @@ -0,0 +1,491 @@ +package openai + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "one-api/constant" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/ai360" + "one-api/relay/channel/lingyiwanwu" + "one-api/relay/channel/minimax" + "one-api/relay/channel/moonshot" + "one-api/relay/channel/openrouter" + "one-api/relay/channel/xinference" + relaycommon "one-api/relay/common" + "one-api/relay/common_handler" + relayconstant "one-api/relay/constant" + "one-api/service" + "one-api/types" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { + ChannelType int + ResponseFormat string +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + //if !strings.Contains(request.Model, "claude") { + // return nil, fmt.Errorf("you are using openai channel type with path /v1/messages, only claude model supported convert, but got %s", request.Model) + //} + aiRequest, err := service.ClaudeToOpenAIRequest(*request, info) + if err != nil { + return nil, err + } + if info.SupportStreamOptions { + aiRequest.StreamOptions = &dto.StreamOptions{ + IncludeUsage: true, + } + } + return a.ConvertOpenAIRequest(c, info, aiRequest) +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + + // initialize ThinkingContentInfo when thinking_to_content is enabled + if info.ChannelSetting.ThinkingToContent { + info.ThinkingContentInfo = relaycommon.ThinkingContentInfo{ + IsFirstThinkingContent: true, + SendLastThinkingContent: false, + HasSentThinkingContent: false, + } + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayFormat == relaycommon.RelayFormatClaude { + return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil + } + if info.RelayMode == relayconstant.RelayModeRealtime { + if strings.HasPrefix(info.BaseUrl, "https://") { + baseUrl := strings.TrimPrefix(info.BaseUrl, "https://") + baseUrl = "wss://" + baseUrl + info.BaseUrl = baseUrl + } else if strings.HasPrefix(info.BaseUrl, "http://") { + baseUrl := strings.TrimPrefix(info.BaseUrl, "http://") + baseUrl = "ws://" + baseUrl + info.BaseUrl = baseUrl + } + } + switch info.ChannelType { + case constant.ChannelTypeAzure: + apiVersion := info.ApiVersion + if apiVersion == "" { + apiVersion = constant.AzureDefaultAPIVersion + } + // https://learn.microsoft.com/en-us/azure/cognitive-services/openai/chatgpt-quickstart?pivots=rest-api&tabs=command-line#rest-api + requestURL := strings.Split(info.RequestURLPath, "?")[0] + requestURL = fmt.Sprintf("%s?api-version=%s", requestURL, apiVersion) + task := strings.TrimPrefix(requestURL, "/v1/") + + // 特殊处理 responses API + if info.RelayMode == relayconstant.RelayModeResponses { + requestURL = fmt.Sprintf("/openai/v1/responses?api-version=preview") + return relaycommon.GetFullRequestURL(info.BaseUrl, requestURL, info.ChannelType), nil + } + + model_ := info.UpstreamModelName + // 2025年5月10日后创建的渠道不移除. + if info.ChannelCreateTime < constant.AzureNoRemoveDotTime { + model_ = strings.Replace(model_, ".", "", -1) + } + // https://github.com/songquanpeng/one-api/issues/67 + requestURL = fmt.Sprintf("/openai/deployments/%s/%s", model_, task) + if info.RelayMode == relayconstant.RelayModeRealtime { + requestURL = fmt.Sprintf("/openai/realtime?deployment=%s&api-version=%s", model_, apiVersion) + } + return relaycommon.GetFullRequestURL(info.BaseUrl, requestURL, info.ChannelType), nil + case constant.ChannelTypeMiniMax: + return minimax.GetRequestURL(info) + case constant.ChannelTypeCustom: + url := info.BaseUrl + url = strings.Replace(url, "{model}", info.UpstreamModelName, -1) + return url, nil + default: + return relaycommon.GetFullRequestURL(info.BaseUrl, info.RequestURLPath, info.ChannelType), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, header) + if info.ChannelType == constant.ChannelTypeAzure { + header.Set("api-key", info.ApiKey) + return nil + } + if info.ChannelType == constant.ChannelTypeOpenAI && "" != info.Organization { + header.Set("OpenAI-Organization", info.Organization) + } + if info.RelayMode == relayconstant.RelayModeRealtime { + swp := c.Request.Header.Get("Sec-WebSocket-Protocol") + if swp != "" { + items := []string{ + "realtime", + "openai-insecure-api-key." + info.ApiKey, + "openai-beta.realtime-v1", + } + header.Set("Sec-WebSocket-Protocol", strings.Join(items, ",")) + //req.Header.Set("Sec-WebSocket-Key", c.Request.Header.Get("Sec-WebSocket-Key")) + //req.Header.Set("Sec-Websocket-Extensions", c.Request.Header.Get("Sec-Websocket-Extensions")) + //req.Header.Set("Sec-Websocket-Version", c.Request.Header.Get("Sec-Websocket-Version")) + } else { + header.Set("openai-beta", "realtime=v1") + header.Set("Authorization", "Bearer "+info.ApiKey) + } + } else { + header.Set("Authorization", "Bearer "+info.ApiKey) + } + if info.ChannelType == constant.ChannelTypeOpenRouter { + header.Set("HTTP-Referer", "https://www.newapi.ai") + header.Set("X-Title", "New API") + } + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if info.ChannelType != constant.ChannelTypeOpenAI && info.ChannelType != constant.ChannelTypeAzure { + request.StreamOptions = nil + } + if info.ChannelType == constant.ChannelTypeOpenRouter { + if len(request.Usage) == 0 { + request.Usage = json.RawMessage(`{"include":true}`) + } + } + if strings.HasPrefix(request.Model, "o") { + if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 { + request.MaxCompletionTokens = request.MaxTokens + request.MaxTokens = 0 + } + request.Temperature = nil + if strings.HasSuffix(request.Model, "-high") { + request.ReasoningEffort = "high" + request.Model = strings.TrimSuffix(request.Model, "-high") + } else if strings.HasSuffix(request.Model, "-low") { + request.ReasoningEffort = "low" + request.Model = strings.TrimSuffix(request.Model, "-low") + } else if strings.HasSuffix(request.Model, "-medium") { + request.ReasoningEffort = "medium" + request.Model = strings.TrimSuffix(request.Model, "-medium") + } + info.ReasoningEffort = request.ReasoningEffort + info.UpstreamModelName = request.Model + + // o系列模型developer适配(o1-mini除外) + if !strings.HasPrefix(request.Model, "o1-mini") && !strings.HasPrefix(request.Model, "o1-preview") { + //修改第一个Message的内容,将system改为developer + if len(request.Messages) > 0 && request.Messages[0].Role == "system" { + request.Messages[0].Role = "developer" + } + } + } + + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + a.ResponseFormat = request.ResponseFormat + if info.RelayMode == relayconstant.RelayModeAudioSpeech { + jsonData, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("error marshalling object: %w", err) + } + return bytes.NewReader(jsonData), nil + } else { + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + writer.WriteField("model", request.Model) + + // 获取所有表单字段 + formData := c.Request.PostForm + + // 遍历表单字段并打印输出 + for key, values := range formData { + if key == "model" { + continue + } + for _, value := range values { + writer.WriteField(key, value) + } + } + + // 添加文件字段 + file, header, err := c.Request.FormFile("file") + if err != nil { + return nil, errors.New("file is required") + } + defer file.Close() + + part, err := writer.CreateFormFile("file", header.Filename) + if err != nil { + return nil, errors.New("create form file failed") + } + if _, err := io.Copy(part, file); err != nil { + return nil, errors.New("copy file failed") + } + + // 关闭 multipart 编写器以设置分界线 + writer.Close() + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + return &requestBody, nil + } +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + switch info.RelayMode { + case relayconstant.RelayModeImagesEdits: + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + writer.WriteField("model", request.Model) + // 获取所有表单字段 + formData := c.Request.PostForm + // 遍历表单字段并打印输出 + for key, values := range formData { + if key == "model" { + continue + } + for _, value := range values { + writer.WriteField(key, value) + } + } + + // Parse the multipart form to handle both single image and multiple images + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max memory + return nil, errors.New("failed to parse multipart form") + } + + if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil { + // Check if "image" field exists in any form, including array notation + var imageFiles []*multipart.FileHeader + var exists bool + + // First check for standard "image" field + if imageFiles, exists = c.Request.MultipartForm.File["image"]; !exists || len(imageFiles) == 0 { + // If not found, check for "image[]" field + if imageFiles, exists = c.Request.MultipartForm.File["image[]"]; !exists || len(imageFiles) == 0 { + // If still not found, iterate through all fields to find any that start with "image[" + foundArrayImages := false + for fieldName, files := range c.Request.MultipartForm.File { + if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { + foundArrayImages = true + for _, file := range files { + imageFiles = append(imageFiles, file) + } + } + } + + // If no image fields found at all + if !foundArrayImages && (len(imageFiles) == 0) { + return nil, errors.New("image is required") + } + } + } + + // Process all image files + for i, fileHeader := range imageFiles { + file, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("failed to open image file %d: %w", i, err) + } + defer file.Close() + + // If multiple images, use image[] as the field name + fieldName := "image" + if len(imageFiles) > 1 { + fieldName = "image[]" + } + + // Determine MIME type based on file extension + mimeType := detectImageMimeType(fileHeader.Filename) + + // Create a form file with the appropriate content type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename)) + h.Set("Content-Type", mimeType) + + part, err := writer.CreatePart(h) + if err != nil { + return nil, fmt.Errorf("create form part failed for image %d: %w", i, err) + } + + if _, err := io.Copy(part, file); err != nil { + return nil, fmt.Errorf("copy file failed for image %d: %w", i, err) + } + } + + // Handle mask file if present + if maskFiles, exists := c.Request.MultipartForm.File["mask"]; exists && len(maskFiles) > 0 { + maskFile, err := maskFiles[0].Open() + if err != nil { + return nil, errors.New("failed to open mask file") + } + defer maskFile.Close() + + // Determine MIME type for mask file + mimeType := detectImageMimeType(maskFiles[0].Filename) + + // Create a form file with the appropriate content type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename)) + h.Set("Content-Type", mimeType) + + maskPart, err := writer.CreatePart(h) + if err != nil { + return nil, errors.New("create form file failed for mask") + } + + if _, err := io.Copy(maskPart, maskFile); err != nil { + return nil, errors.New("copy mask file failed") + } + } + } else { + return nil, errors.New("no multipart form data found") + } + + // 关闭 multipart 编写器以设置分界线 + writer.Close() + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + return bytes.NewReader(requestBody.Bytes()), nil + + default: + return request, nil + } +} + +// detectImageMimeType determines the MIME type based on the file extension +func detectImageMimeType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + default: + // Try to detect from extension if possible + if strings.HasPrefix(ext, ".jp") { + return "image/jpeg" + } + // Default to png as a fallback + return "image/png" + } +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // 模型后缀转换 reasoning effort + if strings.HasSuffix(request.Model, "-high") { + request.Reasoning.Effort = "high" + request.Model = strings.TrimSuffix(request.Model, "-high") + } else if strings.HasSuffix(request.Model, "-low") { + request.Reasoning.Effort = "low" + request.Model = strings.TrimSuffix(request.Model, "-low") + } else if strings.HasSuffix(request.Model, "-medium") { + request.Reasoning.Effort = "medium" + request.Model = strings.TrimSuffix(request.Model, "-medium") + } + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + if info.RelayMode == relayconstant.RelayModeAudioTranscription || + info.RelayMode == relayconstant.RelayModeAudioTranslation || + info.RelayMode == relayconstant.RelayModeImagesEdits { + return channel.DoFormRequest(a, c, info, requestBody) + } else if info.RelayMode == relayconstant.RelayModeRealtime { + return channel.DoWssRequest(a, c, info, requestBody) + } else { + return channel.DoApiRequest(a, c, info, requestBody) + } +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case relayconstant.RelayModeRealtime: + err, usage = OpenaiRealtimeHandler(c, info) + case relayconstant.RelayModeAudioSpeech: + usage = OpenaiTTSHandler(c, resp, info) + case relayconstant.RelayModeAudioTranslation: + fallthrough + case relayconstant.RelayModeAudioTranscription: + err, usage = OpenaiSTTHandler(c, resp, info, a.ResponseFormat) + case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits: + usage, err = OpenaiHandlerWithUsage(c, info, resp) + case relayconstant.RelayModeRerank: + usage, err = common_handler.RerankHandler(c, info, resp) + case relayconstant.RelayModeResponses: + if info.IsStream { + usage, err = OaiResponsesStreamHandler(c, info, resp) + } else { + usage, err = OaiResponsesHandler(c, info, resp) + } + default: + if info.IsStream { + usage, err = OaiStreamHandler(c, info, resp) + } else { + usage, err = OpenaiHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + switch a.ChannelType { + case constant.ChannelType360: + return ai360.ModelList + case constant.ChannelTypeMoonshot: + return moonshot.ModelList + case constant.ChannelTypeLingYiWanWu: + return lingyiwanwu.ModelList + case constant.ChannelTypeMiniMax: + return minimax.ModelList + case constant.ChannelTypeXinference: + return xinference.ModelList + case constant.ChannelTypeOpenRouter: + return openrouter.ModelList + default: + return ModelList + } +} + +func (a *Adaptor) GetChannelName() string { + switch a.ChannelType { + case constant.ChannelType360: + return ai360.ChannelName + case constant.ChannelTypeMoonshot: + return moonshot.ChannelName + case constant.ChannelTypeLingYiWanWu: + return lingyiwanwu.ChannelName + case constant.ChannelTypeMiniMax: + return minimax.ChannelName + case constant.ChannelTypeXinference: + return xinference.ChannelName + case constant.ChannelTypeOpenRouter: + return openrouter.ChannelName + default: + return ChannelName + } +} diff --git a/relay/channel/openai/constant.go b/relay/channel/openai/constant.go new file mode 100644 index 00000000..c703e414 --- /dev/null +++ b/relay/channel/openai/constant.go @@ -0,0 +1,35 @@ +package openai + +var ModelList = []string{ + "gpt-3.5-turbo", "gpt-3.5-turbo-0613", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-0125", + "gpt-3.5-turbo-16k", "gpt-3.5-turbo-16k-0613", + "gpt-3.5-turbo-instruct", + "gpt-4", "gpt-4-0613", "gpt-4-1106-preview", "gpt-4-0125-preview", + "gpt-4-32k", "gpt-4-32k-0613", + "gpt-4-turbo-preview", "gpt-4-turbo", "gpt-4-turbo-2024-04-09", + "gpt-4-vision-preview", + "chatgpt-4o-latest", + "gpt-4o", "gpt-4o-2024-05-13", "gpt-4o-2024-08-06", "gpt-4o-2024-11-20", + "gpt-4o-mini", "gpt-4o-mini-2024-07-18", + "gpt-4.5-preview", "gpt-4.5-preview-2025-02-27", + "o1-preview", "o1-preview-2024-09-12", + "o1-mini", "o1-mini-2024-09-12", + "o3-mini", "o3-mini-2025-01-31", + "o3-mini-high", "o3-mini-2025-01-31-high", + "o3-mini-low", "o3-mini-2025-01-31-low", + "o3-mini-medium", "o3-mini-2025-01-31-medium", + "o1", "o1-2024-12-17", + "gpt-4o-audio-preview", "gpt-4o-audio-preview-2024-10-01", + "gpt-4o-realtime-preview", "gpt-4o-realtime-preview-2024-10-01", "gpt-4o-realtime-preview-2024-12-17", + "gpt-4o-mini-realtime-preview", "gpt-4o-mini-realtime-preview-2024-12-17", + "text-embedding-ada-002", "text-embedding-3-small", "text-embedding-3-large", + "text-curie-001", "text-babbage-001", "text-ada-001", + "text-moderation-latest", "text-moderation-stable", + "text-davinci-edit-001", + "davinci-002", "babbage-002", + "dall-e-3", + "whisper-1", + "tts-1", "tts-1-1106", "tts-1-hd", "tts-1-hd-1106", +} + +var ChannelName = "openai" diff --git a/relay/channel/openai/helper.go b/relay/channel/openai/helper.go new file mode 100644 index 00000000..a068c544 --- /dev/null +++ b/relay/channel/openai/helper.go @@ -0,0 +1,196 @@ +package openai + +import ( + "encoding/json" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "strings" + + "github.com/gin-gonic/gin" +) + +// 辅助函数 +func handleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { + info.SendResponseCount++ + switch info.RelayFormat { + case relaycommon.RelayFormatOpenAI: + return sendStreamData(c, info, data, forceFormat, thinkToContent) + case relaycommon.RelayFormatClaude: + return handleClaudeFormat(c, data, info) + } + return nil +} + +func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { + var streamResponse dto.ChatCompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { + return err + } + + if streamResponse.Usage != nil { + info.ClaudeConvertInfo.Usage = streamResponse.Usage + } + claudeResponses := service.StreamResponseOpenAI2Claude(&streamResponse, info) + for _, resp := range claudeResponses { + helper.ClaudeData(c, *resp) + } + return nil +} + +func ProcessStreamResponse(streamResponse dto.ChatCompletionsStreamResponse, responseTextBuilder *strings.Builder, toolCount *int) error { + for _, choice := range streamResponse.Choices { + responseTextBuilder.WriteString(choice.Delta.GetContentString()) + responseTextBuilder.WriteString(choice.Delta.GetReasoningContent()) + if choice.Delta.ToolCalls != nil { + if len(choice.Delta.ToolCalls) > *toolCount { + *toolCount = len(choice.Delta.ToolCalls) + } + for _, tool := range choice.Delta.ToolCalls { + responseTextBuilder.WriteString(tool.Function.Name) + responseTextBuilder.WriteString(tool.Function.Arguments) + } + } + } + return nil +} + +func processTokens(relayMode int, streamItems []string, responseTextBuilder *strings.Builder, toolCount *int) error { + streamResp := "[" + strings.Join(streamItems, ",") + "]" + + switch relayMode { + case relayconstant.RelayModeChatCompletions: + return processChatCompletions(streamResp, streamItems, responseTextBuilder, toolCount) + case relayconstant.RelayModeCompletions: + return processCompletions(streamResp, streamItems, responseTextBuilder) + } + return nil +} + +func processChatCompletions(streamResp string, streamItems []string, responseTextBuilder *strings.Builder, toolCount *int) error { + var streamResponses []dto.ChatCompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(streamResp), &streamResponses); err != nil { + // 一次性解析失败,逐个解析 + common.SysError("error unmarshalling stream response: " + err.Error()) + for _, item := range streamItems { + var streamResponse dto.ChatCompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(item), &streamResponse); err != nil { + return err + } + if err := ProcessStreamResponse(streamResponse, responseTextBuilder, toolCount); err != nil { + common.SysError("error processing stream response: " + err.Error()) + } + } + return nil + } + + // 批量处理所有响应 + for _, streamResponse := range streamResponses { + for _, choice := range streamResponse.Choices { + responseTextBuilder.WriteString(choice.Delta.GetContentString()) + responseTextBuilder.WriteString(choice.Delta.GetReasoningContent()) + if choice.Delta.ToolCalls != nil { + if len(choice.Delta.ToolCalls) > *toolCount { + *toolCount = len(choice.Delta.ToolCalls) + } + for _, tool := range choice.Delta.ToolCalls { + responseTextBuilder.WriteString(tool.Function.Name) + responseTextBuilder.WriteString(tool.Function.Arguments) + } + } + } + } + return nil +} + +func processCompletions(streamResp string, streamItems []string, responseTextBuilder *strings.Builder) error { + var streamResponses []dto.CompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(streamResp), &streamResponses); err != nil { + // 一次性解析失败,逐个解析 + common.SysError("error unmarshalling stream response: " + err.Error()) + for _, item := range streamItems { + var streamResponse dto.CompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(item), &streamResponse); err != nil { + continue + } + for _, choice := range streamResponse.Choices { + responseTextBuilder.WriteString(choice.Text) + } + } + return nil + } + + // 批量处理所有响应 + for _, streamResponse := range streamResponses { + for _, choice := range streamResponse.Choices { + responseTextBuilder.WriteString(choice.Text) + } + } + return nil +} + +func handleLastResponse(lastStreamData string, responseId *string, createAt *int64, + systemFingerprint *string, model *string, usage **dto.Usage, + containStreamUsage *bool, info *relaycommon.RelayInfo, + shouldSendLastResp *bool) error { + + var lastStreamResponse dto.ChatCompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(lastStreamData), &lastStreamResponse); err != nil { + return err + } + + *responseId = lastStreamResponse.Id + *createAt = lastStreamResponse.Created + *systemFingerprint = lastStreamResponse.GetSystemFingerprint() + *model = lastStreamResponse.Model + + if service.ValidUsage(lastStreamResponse.Usage) { + *containStreamUsage = true + *usage = lastStreamResponse.Usage + if !info.ShouldIncludeUsage { + *shouldSendLastResp = false + } + } + + return nil +} + +func handleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStreamData string, + responseId string, createAt int64, model string, systemFingerprint string, + usage *dto.Usage, containStreamUsage bool) { + + switch info.RelayFormat { + case relaycommon.RelayFormatOpenAI: + if info.ShouldIncludeUsage && !containStreamUsage { + response := helper.GenerateFinalUsageResponse(responseId, createAt, model, *usage) + response.SetSystemFingerprint(systemFingerprint) + helper.ObjectData(c, response) + } + helper.Done(c) + + case relaycommon.RelayFormatClaude: + info.ClaudeConvertInfo.Done = true + var streamResponse dto.ChatCompletionsStreamResponse + if err := json.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return + } + + info.ClaudeConvertInfo.Usage = usage + + claudeResponses := service.StreamResponseOpenAI2Claude(&streamResponse, info) + for _, resp := range claudeResponses { + helper.ClaudeData(c, *resp) + } + } +} + +func sendResponsesStreamData(c *gin.Context, streamResponse dto.ResponsesStreamResponse, data string) { + if data == "" { + return + } + helper.ResponseChunkData(c, streamResponse, data) +} diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go new file mode 100644 index 00000000..bfe8bcd3 --- /dev/null +++ b/relay/channel/openai/relay-openai.go @@ -0,0 +1,587 @@ +package openai + +import ( + "bytes" + "fmt" + "io" + "math" + "mime/multipart" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "os" + "path/filepath" + "strings" + + "one-api/types" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/pkg/errors" +) + +func sendStreamData(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error { + if data == "" { + return nil + } + + if !forceFormat && !thinkToContent { + return helper.StringData(c, data) + } + + var lastStreamResponse dto.ChatCompletionsStreamResponse + if err := common.UnmarshalJsonStr(data, &lastStreamResponse); err != nil { + return err + } + + if !thinkToContent { + return helper.ObjectData(c, lastStreamResponse) + } + + hasThinkingContent := false + hasContent := false + var thinkingContent strings.Builder + for _, choice := range lastStreamResponse.Choices { + if len(choice.Delta.GetReasoningContent()) > 0 { + hasThinkingContent = true + thinkingContent.WriteString(choice.Delta.GetReasoningContent()) + } + if len(choice.Delta.GetContentString()) > 0 { + hasContent = true + } + } + + // Handle think to content conversion + if info.ThinkingContentInfo.IsFirstThinkingContent { + if hasThinkingContent { + response := lastStreamResponse.Copy() + for i := range response.Choices { + // send `think` tag with thinking content + response.Choices[i].Delta.SetContentString("\n" + thinkingContent.String()) + response.Choices[i].Delta.ReasoningContent = nil + response.Choices[i].Delta.Reasoning = nil + } + info.ThinkingContentInfo.IsFirstThinkingContent = false + info.ThinkingContentInfo.HasSentThinkingContent = true + return helper.ObjectData(c, response) + } + } + + if lastStreamResponse.Choices == nil || len(lastStreamResponse.Choices) == 0 { + return helper.ObjectData(c, lastStreamResponse) + } + + // Process each choice + for i, choice := range lastStreamResponse.Choices { + // Handle transition from thinking to content + // only send `` tag when previous thinking content has been sent + if hasContent && !info.ThinkingContentInfo.SendLastThinkingContent && info.ThinkingContentInfo.HasSentThinkingContent { + response := lastStreamResponse.Copy() + for j := range response.Choices { + response.Choices[j].Delta.SetContentString("\n\n") + response.Choices[j].Delta.ReasoningContent = nil + response.Choices[j].Delta.Reasoning = nil + } + info.ThinkingContentInfo.SendLastThinkingContent = true + helper.ObjectData(c, response) + } + + // Convert reasoning content to regular content if any + if len(choice.Delta.GetReasoningContent()) > 0 { + lastStreamResponse.Choices[i].Delta.SetContentString(choice.Delta.GetReasoningContent()) + lastStreamResponse.Choices[i].Delta.ReasoningContent = nil + lastStreamResponse.Choices[i].Delta.Reasoning = nil + } else if !hasThinkingContent && !hasContent { + // flush thinking content + lastStreamResponse.Choices[i].Delta.ReasoningContent = nil + lastStreamResponse.Choices[i].Delta.Reasoning = nil + } + } + + return helper.ObjectData(c, lastStreamResponse) +} + +func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + common.LogError(c, "invalid response or response body") + return nil, types.NewError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse) + } + + defer common.CloseResponseBodyGracefully(resp) + + model := info.UpstreamModelName + var responseId string + var createAt int64 = 0 + var systemFingerprint string + var containStreamUsage bool + var responseTextBuilder strings.Builder + var toolCount int + var usage = &dto.Usage{} + var streamItems []string // store stream items + var forceFormat bool + var thinkToContent bool + + if info.ChannelSetting.ForceFormat { + forceFormat = true + } + + if info.ChannelSetting.ThinkingToContent { + thinkToContent = true + } + + var ( + lastStreamData string + ) + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + if lastStreamData != "" { + err := handleStreamFormat(c, info, lastStreamData, forceFormat, thinkToContent) + if err != nil { + common.SysError("error handling stream format: " + err.Error()) + } + } + lastStreamData = data + streamItems = append(streamItems, data) + return true + }) + + // 处理最后的响应 + shouldSendLastResp := true + if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage, + &containStreamUsage, info, &shouldSendLastResp); err != nil { + common.SysError("error handling last response: " + err.Error()) + } + + if shouldSendLastResp && info.RelayFormat == relaycommon.RelayFormatOpenAI { + _ = sendStreamData(c, info, lastStreamData, forceFormat, thinkToContent) + } + + // 处理token计算 + if err := processTokens(info.RelayMode, streamItems, &responseTextBuilder, &toolCount); err != nil { + common.SysError("error processing tokens: " + err.Error()) + } + + if !containStreamUsage { + usage = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens) + usage.CompletionTokens += toolCount * 7 + } else { + if info.ChannelType == constant.ChannelTypeDeepSeek { + if usage.PromptCacheHitTokens != 0 { + usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens + } + } + } + + handleFinalResponse(c, info, lastStreamData, responseId, createAt, model, systemFingerprint, usage, containStreamUsage) + + return usage, nil +} + +func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer common.CloseResponseBodyGracefully(resp) + + var simpleResponse dto.OpenAITextResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + err = common.Unmarshal(responseBody, &simpleResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if simpleResponse.Error != nil && simpleResponse.Error.Type != "" { + return nil, types.WithOpenAIError(*simpleResponse.Error, resp.StatusCode) + } + + forceFormat := false + if info.ChannelSetting.ForceFormat { + forceFormat = true + } + + if simpleResponse.Usage.TotalTokens == 0 || (simpleResponse.Usage.PromptTokens == 0 && simpleResponse.Usage.CompletionTokens == 0) { + completionTokens := 0 + for _, choice := range simpleResponse.Choices { + ctkm := service.CountTextToken(choice.Message.StringContent()+choice.Message.ReasoningContent+choice.Message.Reasoning, info.UpstreamModelName) + completionTokens += ctkm + } + simpleResponse.Usage = dto.Usage{ + PromptTokens: info.PromptTokens, + CompletionTokens: completionTokens, + TotalTokens: info.PromptTokens + completionTokens, + } + } + + switch info.RelayFormat { + case relaycommon.RelayFormatOpenAI: + if forceFormat { + responseBody, err = common.Marshal(simpleResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + } else { + break + } + case relaycommon.RelayFormatClaude: + claudeResp := service.ResponseOpenAI2Claude(&simpleResponse, info) + claudeRespStr, err := common.Marshal(claudeResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + responseBody = claudeRespStr + } + + common.IOCopyBytesGracefully(c, resp, responseBody) + + return &simpleResponse.Usage, nil +} + +func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) *dto.Usage { + // the status code has been judged before, if there is a body reading failure, + // it should be regarded as a non-recoverable error, so it should not return err for external retry. + // Analogous to nginx's load balancing, it will only retry if it can't be requested or + // if the upstream returns a specific status code, once the upstream has already written the header, + // the subsequent failure of the response body should be regarded as a non-recoverable error, + // and can be terminated directly. + defer common.CloseResponseBodyGracefully(resp) + usage := &dto.Usage{} + usage.PromptTokens = info.PromptTokens + usage.TotalTokens = info.PromptTokens + for k, v := range resp.Header { + c.Writer.Header().Set(k, v[0]) + } + c.Writer.WriteHeader(resp.StatusCode) + c.Writer.WriteHeaderNow() + _, err := io.Copy(c.Writer, resp.Body) + if err != nil { + common.LogError(c, err.Error()) + } + return usage +} + +func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*types.NewAPIError, *dto.Usage) { + defer common.CloseResponseBodyGracefully(resp) + + // count tokens by audio file duration + audioTokens, err := countAudioTokens(c) + if err != nil { + return types.NewError(err, types.ErrorCodeCountTokenFailed), nil + } + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewError(err, types.ErrorCodeReadResponseBodyFailed), nil + } + // 写入新的 response body + common.IOCopyBytesGracefully(c, resp, responseBody) + + usage := &dto.Usage{} + usage.PromptTokens = audioTokens + usage.CompletionTokens = 0 + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + return nil, usage +} + +func countAudioTokens(c *gin.Context) (int, error) { + body, err := common.GetRequestBody(c) + if err != nil { + return 0, errors.WithStack(err) + } + + var reqBody struct { + File *multipart.FileHeader `form:"file" binding:"required"` + } + c.Request.Body = io.NopCloser(bytes.NewReader(body)) + if err = c.ShouldBind(&reqBody); err != nil { + return 0, errors.WithStack(err) + } + ext := filepath.Ext(reqBody.File.Filename) // 获取文件扩展名 + reqFp, err := reqBody.File.Open() + if err != nil { + return 0, errors.WithStack(err) + } + defer reqFp.Close() + + tmpFp, err := os.CreateTemp("", "audio-*"+ext) + if err != nil { + return 0, errors.WithStack(err) + } + defer os.Remove(tmpFp.Name()) + + _, err = io.Copy(tmpFp, reqFp) + if err != nil { + return 0, errors.WithStack(err) + } + if err = tmpFp.Close(); err != nil { + return 0, errors.WithStack(err) + } + + duration, err := common.GetAudioDuration(c.Request.Context(), tmpFp.Name(), ext) + if err != nil { + return 0, errors.WithStack(err) + } + + return int(math.Round(math.Ceil(duration) / 60.0 * 1000)), nil // 1 minute 相当于 1k tokens +} + +func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) { + if info == nil || info.ClientWs == nil || info.TargetWs == nil { + return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil + } + + info.IsStream = true + clientConn := info.ClientWs + targetConn := info.TargetWs + + clientClosed := make(chan struct{}) + targetClosed := make(chan struct{}) + sendChan := make(chan []byte, 100) + receiveChan := make(chan []byte, 100) + errChan := make(chan error, 2) + + usage := &dto.RealtimeUsage{} + localUsage := &dto.RealtimeUsage{} + sumUsage := &dto.RealtimeUsage{} + + gopool.Go(func() { + defer func() { + if r := recover(); r != nil { + errChan <- fmt.Errorf("panic in client reader: %v", r) + } + }() + for { + select { + case <-c.Done(): + return + default: + _, message, err := clientConn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + errChan <- fmt.Errorf("error reading from client: %v", err) + } + close(clientClosed) + return + } + + realtimeEvent := &dto.RealtimeEvent{} + err = common.Unmarshal(message, realtimeEvent) + if err != nil { + errChan <- fmt.Errorf("error unmarshalling message: %v", err) + return + } + + if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdate { + if realtimeEvent.Session != nil { + if realtimeEvent.Session.Tools != nil { + info.RealtimeTools = realtimeEvent.Session.Tools + } + } + } + + textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName) + if err != nil { + errChan <- fmt.Errorf("error counting text token: %v", err) + return + } + common.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken)) + localUsage.TotalTokens += textToken + audioToken + localUsage.InputTokens += textToken + audioToken + localUsage.InputTokenDetails.TextTokens += textToken + localUsage.InputTokenDetails.AudioTokens += audioToken + + err = helper.WssString(c, targetConn, string(message)) + if err != nil { + errChan <- fmt.Errorf("error writing to target: %v", err) + return + } + + select { + case sendChan <- message: + default: + } + } + } + }) + + gopool.Go(func() { + defer func() { + if r := recover(); r != nil { + errChan <- fmt.Errorf("panic in target reader: %v", r) + } + }() + for { + select { + case <-c.Done(): + return + default: + _, message, err := targetConn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + errChan <- fmt.Errorf("error reading from target: %v", err) + } + close(targetClosed) + return + } + info.SetFirstResponseTime() + realtimeEvent := &dto.RealtimeEvent{} + err = common.Unmarshal(message, realtimeEvent) + if err != nil { + errChan <- fmt.Errorf("error unmarshalling message: %v", err) + return + } + + if realtimeEvent.Type == dto.RealtimeEventTypeResponseDone { + realtimeUsage := realtimeEvent.Response.Usage + if realtimeUsage != nil { + usage.TotalTokens += realtimeUsage.TotalTokens + usage.InputTokens += realtimeUsage.InputTokens + usage.OutputTokens += realtimeUsage.OutputTokens + usage.InputTokenDetails.AudioTokens += realtimeUsage.InputTokenDetails.AudioTokens + usage.InputTokenDetails.CachedTokens += realtimeUsage.InputTokenDetails.CachedTokens + usage.InputTokenDetails.TextTokens += realtimeUsage.InputTokenDetails.TextTokens + usage.OutputTokenDetails.AudioTokens += realtimeUsage.OutputTokenDetails.AudioTokens + usage.OutputTokenDetails.TextTokens += realtimeUsage.OutputTokenDetails.TextTokens + err := preConsumeUsage(c, info, usage, sumUsage) + if err != nil { + errChan <- fmt.Errorf("error consume usage: %v", err) + return + } + // 本次计费完成,清除 + usage = &dto.RealtimeUsage{} + + localUsage = &dto.RealtimeUsage{} + } else { + textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName) + if err != nil { + errChan <- fmt.Errorf("error counting text token: %v", err) + return + } + common.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken)) + localUsage.TotalTokens += textToken + audioToken + info.IsFirstRequest = false + localUsage.InputTokens += textToken + audioToken + localUsage.InputTokenDetails.TextTokens += textToken + localUsage.InputTokenDetails.AudioTokens += audioToken + err = preConsumeUsage(c, info, localUsage, sumUsage) + if err != nil { + errChan <- fmt.Errorf("error consume usage: %v", err) + return + } + // 本次计费完成,清除 + localUsage = &dto.RealtimeUsage{} + // print now usage + } + common.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage)) + common.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage)) + common.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage)) + + } else if realtimeEvent.Type == dto.RealtimeEventTypeSessionUpdated || realtimeEvent.Type == dto.RealtimeEventTypeSessionCreated { + realtimeSession := realtimeEvent.Session + if realtimeSession != nil { + // update audio format + info.InputAudioFormat = common.GetStringIfEmpty(realtimeSession.InputAudioFormat, info.InputAudioFormat) + info.OutputAudioFormat = common.GetStringIfEmpty(realtimeSession.OutputAudioFormat, info.OutputAudioFormat) + } + } else { + textToken, audioToken, err := service.CountTokenRealtime(info, *realtimeEvent, info.UpstreamModelName) + if err != nil { + errChan <- fmt.Errorf("error counting text token: %v", err) + return + } + common.LogInfo(c, fmt.Sprintf("type: %s, textToken: %d, audioToken: %d", realtimeEvent.Type, textToken, audioToken)) + localUsage.TotalTokens += textToken + audioToken + localUsage.OutputTokens += textToken + audioToken + localUsage.OutputTokenDetails.TextTokens += textToken + localUsage.OutputTokenDetails.AudioTokens += audioToken + } + + err = helper.WssString(c, clientConn, string(message)) + if err != nil { + errChan <- fmt.Errorf("error writing to client: %v", err) + return + } + + select { + case receiveChan <- message: + default: + } + } + } + }) + + select { + case <-clientClosed: + case <-targetClosed: + case err := <-errChan: + //return service.OpenAIErrorWrapper(err, "realtime_error", http.StatusInternalServerError), nil + common.LogError(c, "realtime error: "+err.Error()) + case <-c.Done(): + } + + if usage.TotalTokens != 0 { + _ = preConsumeUsage(c, info, usage, sumUsage) + } + + if localUsage.TotalTokens != 0 { + _ = preConsumeUsage(c, info, localUsage, sumUsage) + } + + // check usage total tokens, if 0, use local usage + + return nil, sumUsage +} + +func preConsumeUsage(ctx *gin.Context, info *relaycommon.RelayInfo, usage *dto.RealtimeUsage, totalUsage *dto.RealtimeUsage) error { + if usage == nil || totalUsage == nil { + return fmt.Errorf("invalid usage pointer") + } + + totalUsage.TotalTokens += usage.TotalTokens + totalUsage.InputTokens += usage.InputTokens + totalUsage.OutputTokens += usage.OutputTokens + totalUsage.InputTokenDetails.CachedTokens += usage.InputTokenDetails.CachedTokens + totalUsage.InputTokenDetails.TextTokens += usage.InputTokenDetails.TextTokens + totalUsage.InputTokenDetails.AudioTokens += usage.InputTokenDetails.AudioTokens + totalUsage.OutputTokenDetails.TextTokens += usage.OutputTokenDetails.TextTokens + totalUsage.OutputTokenDetails.AudioTokens += usage.OutputTokenDetails.AudioTokens + // clear usage + err := service.PreWssConsumeQuota(ctx, info, usage) + return err +} + +func OpenaiHandlerWithUsage(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer common.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + + var usageResp dto.SimpleResponse + err = common.Unmarshal(responseBody, &usageResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + // 写入新的 response body + common.IOCopyBytesGracefully(c, resp, responseBody) + + // Once we've written to the client, we should not return errors anymore + // because the upstream has already consumed resources and returned content + // We should still perform billing even if parsing fails + // format + if usageResp.InputTokens > 0 { + usageResp.PromptTokens += usageResp.InputTokens + } + if usageResp.OutputTokens > 0 { + usageResp.CompletionTokens += usageResp.OutputTokens + } + if usageResp.InputTokensDetails != nil { + usageResp.PromptTokensDetails.ImageTokens += usageResp.InputTokensDetails.ImageTokens + usageResp.PromptTokensDetails.TextTokens += usageResp.InputTokensDetails.TextTokens + } + return &usageResp.Usage, nil +} diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go new file mode 100644 index 00000000..d9dd96b9 --- /dev/null +++ b/relay/channel/openai/relay_responses.go @@ -0,0 +1,97 @@ +package openai + +import ( + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer common.CloseResponseBodyGracefully(resp) + + // read response body + var responsesResponse dto.OpenAIResponsesResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + err = common.Unmarshal(responseBody, &responsesResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if responsesResponse.Error != nil { + return nil, types.WithOpenAIError(*responsesResponse.Error, resp.StatusCode) + } + + // 写入新的 response body + common.IOCopyBytesGracefully(c, resp, responseBody) + + // compute usage + usage := dto.Usage{} + usage.PromptTokens = responsesResponse.Usage.InputTokens + usage.CompletionTokens = responsesResponse.Usage.OutputTokens + usage.TotalTokens = responsesResponse.Usage.TotalTokens + // 解析 Tools 用量 + for _, tool := range responsesResponse.Tools { + info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])].CallCount++ + } + return &usage, nil +} + +func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { + common.LogError(c, "invalid response or response body") + return nil, types.NewError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse) + } + + var usage = &dto.Usage{} + var responseTextBuilder strings.Builder + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + + // 检查当前数据是否包含 completed 状态和 usage 信息 + var streamResponse dto.ResponsesStreamResponse + if err := common.UnmarshalJsonStr(data, &streamResponse); err == nil { + sendResponsesStreamData(c, streamResponse, data) + switch streamResponse.Type { + case "response.completed": + usage.PromptTokens = streamResponse.Response.Usage.InputTokens + usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens + usage.TotalTokens = streamResponse.Response.Usage.TotalTokens + case "response.output_text.delta": + // 处理输出文本 + responseTextBuilder.WriteString(streamResponse.Delta) + case dto.ResponsesOutputTypeItemDone: + // 函数调用处理 + if streamResponse.Item != nil { + switch streamResponse.Item.Type { + case dto.BuildInCallWebSearchCall: + info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview].CallCount++ + } + } + } + } + return true + }) + + if usage.CompletionTokens == 0 { + // 计算输出文本的 token 数量 + tempStr := responseTextBuilder.String() + if len(tempStr) > 0 { + // 非正常结束,使用输出文本的 token 数量 + completionTokens := service.CountTextToken(tempStr, info.UpstreamModelName) + usage.CompletionTokens = completionTokens + } + } + + return usage, nil +} diff --git a/relay/channel/openrouter/constant.go b/relay/channel/openrouter/constant.go new file mode 100644 index 00000000..0372eb9a --- /dev/null +++ b/relay/channel/openrouter/constant.go @@ -0,0 +1,5 @@ +package openrouter + +var ModelList = []string{} + +var ChannelName = "openrouter" diff --git a/relay/channel/openrouter/dto.go b/relay/channel/openrouter/dto.go new file mode 100644 index 00000000..607f495b --- /dev/null +++ b/relay/channel/openrouter/dto.go @@ -0,0 +1,9 @@ +package openrouter + +type RequestReasoning struct { + // One of the following (not both): + Effort string `json:"effort,omitempty"` // Can be "high", "medium", or "low" (OpenAI-style) + MaxTokens int `json:"max_tokens,omitempty"` // Specific token limit (Anthropic-style) + // Optional: Default is false. All models support this. + Exclude bool `json:"exclude,omitempty"` // Set to true to exclude reasoning tokens from response +} diff --git a/relay/channel/palm/adaptor.go b/relay/channel/palm/adaptor.go new file mode 100644 index 00000000..a60dc4b2 --- /dev/null +++ b/relay/channel/palm/adaptor.go @@ -0,0 +1,91 @@ +package palm + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/v1beta2/models/chat-bison-001:generateMessage", info.BaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("x-goog-api-key", info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + var responseText string + err, responseText = palmStreamHandler(c, resp) + usage = service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens) + } else { + usage, err = palmHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/palm/constants.go b/relay/channel/palm/constants.go new file mode 100644 index 00000000..b5c881bf --- /dev/null +++ b/relay/channel/palm/constants.go @@ -0,0 +1,7 @@ +package palm + +var ModelList = []string{ + "PaLM-2", +} + +var ChannelName = "google palm" diff --git a/relay/channel/palm/dto.go b/relay/channel/palm/dto.go new file mode 100644 index 00000000..b8a48e73 --- /dev/null +++ b/relay/channel/palm/dto.go @@ -0,0 +1,38 @@ +package palm + +import "one-api/dto" + +type PaLMChatMessage struct { + Author string `json:"author"` + Content string `json:"content"` +} + +type PaLMFilter struct { + Reason string `json:"reason"` + Message string `json:"message"` +} + +type PaLMPrompt struct { + Messages []PaLMChatMessage `json:"messages"` +} + +type PaLMChatRequest struct { + Prompt PaLMPrompt `json:"prompt"` + Temperature *float64 `json:"temperature,omitempty"` + CandidateCount int `json:"candidateCount,omitempty"` + TopP float64 `json:"topP,omitempty"` + TopK uint `json:"topK,omitempty"` +} + +type PaLMError struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` +} + +type PaLMChatResponse struct { + Candidates []PaLMChatMessage `json:"candidates"` + Messages []dto.Message `json:"messages"` + Filters []PaLMFilter `json:"filters"` + Error PaLMError `json:"error"` +} diff --git a/relay/channel/palm/relay-palm.go b/relay/channel/palm/relay-palm.go new file mode 100644 index 00000000..4db31573 --- /dev/null +++ b/relay/channel/palm/relay-palm.go @@ -0,0 +1,162 @@ +package palm + +import ( + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#request-body +// https://developers.generativeai.google/api/rest/generativelanguage/models/generateMessage#response-body + +func requestOpenAI2PaLM(textRequest dto.GeneralOpenAIRequest) *PaLMChatRequest { + palmRequest := PaLMChatRequest{ + Prompt: PaLMPrompt{ + Messages: make([]PaLMChatMessage, 0, len(textRequest.Messages)), + }, + Temperature: textRequest.Temperature, + CandidateCount: textRequest.N, + TopP: textRequest.TopP, + TopK: textRequest.MaxTokens, + } + for _, message := range textRequest.Messages { + palmMessage := PaLMChatMessage{ + Content: message.StringContent(), + } + if message.Role == "user" { + palmMessage.Author = "0" + } else { + palmMessage.Author = "1" + } + palmRequest.Prompt.Messages = append(palmRequest.Prompt.Messages, palmMessage) + } + return &palmRequest +} + +func responsePaLM2OpenAI(response *PaLMChatResponse) *dto.OpenAITextResponse { + fullTextResponse := dto.OpenAITextResponse{ + Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Candidates)), + } + for i, candidate := range response.Candidates { + choice := dto.OpenAITextResponseChoice{ + Index: i, + Message: dto.Message{ + Role: "assistant", + Content: candidate.Content, + }, + FinishReason: "stop", + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponsePaLM2OpenAI(palmResponse *PaLMChatResponse) *dto.ChatCompletionsStreamResponse { + var choice dto.ChatCompletionsStreamResponseChoice + if len(palmResponse.Candidates) > 0 { + choice.Delta.SetContentString(palmResponse.Candidates[0].Content) + } + choice.FinishReason = &constant.FinishReasonStop + var response dto.ChatCompletionsStreamResponse + response.Object = "chat.completion.chunk" + response.Model = "palm2" + response.Choices = []dto.ChatCompletionsStreamResponseChoice{choice} + return &response +} + +func palmStreamHandler(c *gin.Context, resp *http.Response) (*types.NewAPIError, string) { + responseText := "" + responseId := helper.GetResponseID(c) + createdTime := common.GetTimestamp() + dataChan := make(chan string) + stopChan := make(chan bool) + go func() { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + common.SysError("error reading stream response: " + err.Error()) + stopChan <- true + return + } + common.CloseResponseBodyGracefully(resp) + var palmResponse PaLMChatResponse + err = json.Unmarshal(responseBody, &palmResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + stopChan <- true + return + } + fullTextResponse := streamResponsePaLM2OpenAI(&palmResponse) + fullTextResponse.Id = responseId + fullTextResponse.Created = createdTime + if len(palmResponse.Candidates) > 0 { + responseText = palmResponse.Candidates[0].Content + } + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + stopChan <- true + return + } + dataChan <- string(jsonResponse) + stopChan <- true + }() + helper.SetEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + c.Render(-1, common.CustomEvent{Data: "data: " + data}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + common.CloseResponseBodyGracefully(resp) + return nil, responseText +} + +func palmHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + common.CloseResponseBodyGracefully(resp) + var palmResponse PaLMChatResponse + err = json.Unmarshal(responseBody, &palmResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if palmResponse.Error.Code != 0 || len(palmResponse.Candidates) == 0 { + return nil, types.WithOpenAIError(types.OpenAIError{ + Message: palmResponse.Error.Message, + Type: palmResponse.Error.Status, + Param: "", + Code: palmResponse.Error.Code, + }, resp.StatusCode) + } + fullTextResponse := responsePaLM2OpenAI(&palmResponse) + completionTokens := service.CountTextToken(palmResponse.Candidates[0].Content, info.UpstreamModelName) + usage := dto.Usage{ + PromptTokens: info.PromptTokens, + CompletionTokens: completionTokens, + TotalTokens: info.PromptTokens + completionTokens, + } + fullTextResponse.Usage = usage + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + common.IOCopyBytesGracefully(c, resp, jsonResponse) + return &usage, nil +} diff --git a/relay/channel/perplexity/adaptor.go b/relay/channel/perplexity/adaptor.go new file mode 100644 index 00000000..19830aca --- /dev/null +++ b/relay/channel/perplexity/adaptor.go @@ -0,0 +1,92 @@ +package perplexity + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/chat/completions", info.BaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if request.TopP >= 1 { + request.TopP = 0.99 + } + return requestOpenAI2Perplexity(*request), nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/perplexity/constants.go b/relay/channel/perplexity/constants.go new file mode 100644 index 00000000..f9f030e0 --- /dev/null +++ b/relay/channel/perplexity/constants.go @@ -0,0 +1,7 @@ +package perplexity + +var ModelList = []string{ + "llama-3-sonar-small-32k-chat", "llama-3-sonar-small-32k-online", "llama-3-sonar-large-32k-chat", "llama-3-sonar-large-32k-online", "llama-3-8b-instruct", "llama-3-70b-instruct", "mixtral-8x7b-instruct", +} + +var ChannelName = "perplexity" diff --git a/relay/channel/perplexity/relay-perplexity.go b/relay/channel/perplexity/relay-perplexity.go new file mode 100644 index 00000000..9772aead --- /dev/null +++ b/relay/channel/perplexity/relay-perplexity.go @@ -0,0 +1,21 @@ +package perplexity + +import "one-api/dto" + +func requestOpenAI2Perplexity(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { + messages := make([]dto.Message, 0, len(request.Messages)) + for _, message := range request.Messages { + messages = append(messages, dto.Message{ + Role: message.Role, + Content: message.Content, + }) + } + return &dto.GeneralOpenAIRequest{ + Model: request.Model, + Stream: request.Stream, + Messages: messages, + Temperature: request.Temperature, + TopP: request.TopP, + MaxTokens: request.MaxTokens, + } +} diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go new file mode 100644 index 00000000..63c1c84d --- /dev/null +++ b/relay/channel/siliconflow/adaptor.go @@ -0,0 +1,104 @@ +package siliconflow + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + if info.RelayMode == constant.RelayModeRerank { + return fmt.Sprintf("%s/v1/rerank", info.BaseUrl), nil + } else if info.RelayMode == constant.RelayModeEmbeddings { + return fmt.Sprintf("%s/v1/embeddings", info.BaseUrl), nil + } else if info.RelayMode == constant.RelayModeChatCompletions { + return fmt.Sprintf("%s/v1/chat/completions", info.BaseUrl), nil + } else if info.RelayMode == constant.RelayModeCompletions { + return fmt.Sprintf("%s/v1/completions", info.BaseUrl), nil + } + return "", errors.New("invalid relay mode") +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", fmt.Sprintf("Bearer %s", info.ApiKey)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case constant.RelayModeRerank: + usage, err = siliconflowRerankHandler(c, info, resp) + case constant.RelayModeCompletions: + fallthrough + case constant.RelayModeChatCompletions: + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + case constant.RelayModeEmbeddings: + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/siliconflow/constant.go b/relay/channel/siliconflow/constant.go new file mode 100644 index 00000000..fea6fcd4 --- /dev/null +++ b/relay/channel/siliconflow/constant.go @@ -0,0 +1,51 @@ +package siliconflow + +var ModelList = []string{ + "THUDM/glm-4-9b-chat", + //"stabilityai/stable-diffusion-xl-base-1.0", + //"TencentARC/PhotoMaker", + "InstantX/InstantID", + //"stabilityai/stable-diffusion-2-1", + //"stabilityai/sd-turbo", + //"stabilityai/sdxl-turbo", + "ByteDance/SDXL-Lightning", + "deepseek-ai/deepseek-llm-67b-chat", + "Qwen/Qwen1.5-14B-Chat", + "Qwen/Qwen1.5-7B-Chat", + "Qwen/Qwen1.5-110B-Chat", + "Qwen/Qwen1.5-32B-Chat", + "01-ai/Yi-1.5-6B-Chat", + "01-ai/Yi-1.5-9B-Chat-16K", + "01-ai/Yi-1.5-34B-Chat-16K", + "THUDM/chatglm3-6b", + "deepseek-ai/DeepSeek-V2-Chat", + "Qwen/Qwen2-72B-Instruct", + "Qwen/Qwen2-7B-Instruct", + "Qwen/Qwen2-57B-A14B-Instruct", + //"stabilityai/stable-diffusion-3-medium", + "deepseek-ai/DeepSeek-Coder-V2-Instruct", + "Qwen/Qwen2-1.5B-Instruct", + "internlm/internlm2_5-7b-chat", + "BAAI/bge-large-en-v1.5", + "BAAI/bge-large-zh-v1.5", + "Pro/Qwen/Qwen2-7B-Instruct", + "Pro/Qwen/Qwen2-1.5B-Instruct", + "Pro/Qwen/Qwen1.5-7B-Chat", + "Pro/THUDM/glm-4-9b-chat", + "Pro/THUDM/chatglm3-6b", + "Pro/01-ai/Yi-1.5-9B-Chat-16K", + "Pro/01-ai/Yi-1.5-6B-Chat", + "Pro/google/gemma-2-9b-it", + "Pro/internlm/internlm2_5-7b-chat", + "Pro/meta-llama/Meta-Llama-3-8B-Instruct", + "Pro/mistralai/Mistral-7B-Instruct-v0.2", + "black-forest-labs/FLUX.1-schnell", + "FunAudioLLM/SenseVoiceSmall", + "netease-youdao/bce-embedding-base_v1", + "BAAI/bge-m3", + "internlm/internlm2_5-20b-chat", + "Qwen/Qwen2-Math-72B-Instruct", + "netease-youdao/bce-reranker-base_v1", + "BAAI/bge-reranker-v2-m3", +} +var ChannelName = "siliconflow" diff --git a/relay/channel/siliconflow/dto.go b/relay/channel/siliconflow/dto.go new file mode 100644 index 00000000..add0fd07 --- /dev/null +++ b/relay/channel/siliconflow/dto.go @@ -0,0 +1,17 @@ +package siliconflow + +import "one-api/dto" + +type SFTokens struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type SFMeta struct { + Tokens SFTokens `json:"tokens"` +} + +type SFRerankResponse struct { + Results []dto.RerankResponseResult `json:"results"` + Meta SFMeta `json:"meta"` +} diff --git a/relay/channel/siliconflow/relay-siliconflow.go b/relay/channel/siliconflow/relay-siliconflow.go new file mode 100644 index 00000000..fabaf9c6 --- /dev/null +++ b/relay/channel/siliconflow/relay-siliconflow.go @@ -0,0 +1,44 @@ +package siliconflow + +import ( + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +func siliconflowRerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + common.CloseResponseBodyGracefully(resp) + var siliconflowResp SFRerankResponse + err = json.Unmarshal(responseBody, &siliconflowResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + usage := &dto.Usage{ + PromptTokens: siliconflowResp.Meta.Tokens.InputTokens, + CompletionTokens: siliconflowResp.Meta.Tokens.OutputTokens, + TotalTokens: siliconflowResp.Meta.Tokens.InputTokens + siliconflowResp.Meta.Tokens.OutputTokens, + } + rerankResp := &dto.RerankResponse{ + Results: siliconflowResp.Results, + Usage: *usage, + } + + jsonResponse, err := json.Marshal(rerankResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + common.IOCopyBytesGracefully(c, resp, jsonResponse) + return usage, nil +} diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go new file mode 100644 index 00000000..8d057513 --- /dev/null +++ b/relay/channel/task/jimeng/adaptor.go @@ -0,0 +1,380 @@ +package jimeng + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "one-api/model" + "sort" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" +) + +// ============================ +// Request / Response structures +// ============================ + +type requestPayload struct { + ReqKey string `json:"req_key"` + BinaryDataBase64 []string `json:"binary_data_base64,omitempty"` + ImageUrls []string `json:"image_urls,omitempty"` + Prompt string `json:"prompt,omitempty"` + Seed int64 `json:"seed"` + AspectRatio string `json:"aspect_ratio"` +} + +type responsePayload struct { + Code int `json:"code"` + Message string `json:"message"` + RequestId string `json:"request_id"` + Data struct { + TaskID string `json:"task_id"` + } `json:"data"` +} + +type responseTask struct { + Code int `json:"code"` + Data struct { + BinaryDataBase64 []interface{} `json:"binary_data_base64"` + ImageUrls interface{} `json:"image_urls"` + RespData string `json:"resp_data"` + Status string `json:"status"` + VideoUrl string `json:"video_url"` + } `json:"data"` + Message string `json:"message"` + RequestId string `json:"request_id"` + Status int `json:"status"` + TimeElapsed string `json:"time_elapsed"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + accessKey string + secretKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.BaseUrl + + // apiKey format: "access_key|secret_key" + keyParts := strings.Split(info.ApiKey, "|") + if len(keyParts) == 2 { + a.accessKey = strings.TrimSpace(keyParts[0]) + a.secretKey = strings.TrimSpace(keyParts[1]) + } +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { + // Accept only POST /v1/video/generations as "generate" action. + action := constant.TaskActionGenerate + info.Action = action + + req := relaycommon.TaskSubmitReq{} + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Prompt) == "" { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest) + return + } + + // Store into context for later usage + c.Set("task_request", req) + return nil +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { + return fmt.Sprintf("%s/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + return a.signRequest(req, a.accessKey, a.secretKey) +} + +// BuildRequestBody converts request into Jimeng specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req := v.(relaycommon.TaskSubmitReq) + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, errors.Wrap(err, "convert request payload failed") + } + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + _ = resp.Body.Close() + + // Parse Jimeng response + var jResp responsePayload + if err := json.Unmarshal(responseBody, &jResp); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if jResp.Code != 10000 { + taskErr = service.TaskErrorWrapper(fmt.Errorf(jResp.Message), fmt.Sprintf("%d", jResp.Code), http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{"task_id": jResp.Data.TaskID}) + return jResp.Data.TaskID, responseBody, nil +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + + uri := fmt.Sprintf("%s/?Action=CVSync2AsyncGetResult&Version=2022-08-31", baseUrl) + payload := map[string]string{ + "req_key": "jimeng_vgfm_t2v_l20", // This is fixed value from doc: https://www.volcengine.com/docs/85621/1544774 + "task_id": taskID, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, errors.Wrap(err, "marshal fetch task payload failed") + } + + req, err := http.NewRequest(http.MethodPost, uri, bytes.NewBuffer(payloadBytes)) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + keyParts := strings.Split(key, "|") + if len(keyParts) != 2 { + return nil, fmt.Errorf("invalid api key format for jimeng: expected 'ak|sk'") + } + accessKey := strings.TrimSpace(keyParts[0]) + secretKey := strings.TrimSpace(keyParts[1]) + + if err := a.signRequest(req, accessKey, secretKey); err != nil { + return nil, errors.Wrap(err, "sign request failed") + } + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{"jimeng_vgfm_t2v_l20"} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "jimeng" +} + +func (a *TaskAdaptor) signRequest(req *http.Request, accessKey, secretKey string) error { + var bodyBytes []byte + var err error + + if req.Body != nil { + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return errors.Wrap(err, "read request body failed") + } + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) // Rewind + } else { + bodyBytes = []byte{} + } + + payloadHash := sha256.Sum256(bodyBytes) + hexPayloadHash := hex.EncodeToString(payloadHash[:]) + + t := time.Now().UTC() + xDate := t.Format("20060102T150405Z") + shortDate := t.Format("20060102") + + req.Header.Set("Host", req.URL.Host) + req.Header.Set("X-Date", xDate) + req.Header.Set("X-Content-Sha256", hexPayloadHash) + + // Sort and encode query parameters to create canonical query string + queryParams := req.URL.Query() + sortedKeys := make([]string, 0, len(queryParams)) + for k := range queryParams { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + var queryParts []string + for _, k := range sortedKeys { + values := queryParams[k] + sort.Strings(values) + for _, v := range values { + queryParts = append(queryParts, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(v))) + } + } + canonicalQueryString := strings.Join(queryParts, "&") + + headersToSign := map[string]string{ + "host": req.URL.Host, + "x-date": xDate, + "x-content-sha256": hexPayloadHash, + } + if req.Header.Get("Content-Type") != "" { + headersToSign["content-type"] = req.Header.Get("Content-Type") + } + + var signedHeaderKeys []string + for k := range headersToSign { + signedHeaderKeys = append(signedHeaderKeys, k) + } + sort.Strings(signedHeaderKeys) + + var canonicalHeaders strings.Builder + for _, k := range signedHeaderKeys { + canonicalHeaders.WriteString(k) + canonicalHeaders.WriteString(":") + canonicalHeaders.WriteString(strings.TrimSpace(headersToSign[k])) + canonicalHeaders.WriteString("\n") + } + signedHeaders := strings.Join(signedHeaderKeys, ";") + + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + req.Method, + req.URL.Path, + canonicalQueryString, + canonicalHeaders.String(), + signedHeaders, + hexPayloadHash, + ) + + hashedCanonicalRequest := sha256.Sum256([]byte(canonicalRequest)) + hexHashedCanonicalRequest := hex.EncodeToString(hashedCanonicalRequest[:]) + + region := "cn-north-1" + serviceName := "cv" + credentialScope := fmt.Sprintf("%s/%s/%s/request", shortDate, region, serviceName) + stringToSign := fmt.Sprintf("HMAC-SHA256\n%s\n%s\n%s", + xDate, + credentialScope, + hexHashedCanonicalRequest, + ) + + kDate := hmacSHA256([]byte(secretKey), []byte(shortDate)) + kRegion := hmacSHA256(kDate, []byte(region)) + kService := hmacSHA256(kRegion, []byte(serviceName)) + kSigning := hmacSHA256(kService, []byte("request")) + signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign))) + + authorization := fmt.Sprintf("HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + accessKey, + credentialScope, + signedHeaders, + signature, + ) + req.Header.Set("Authorization", authorization) + return nil +} + +func hmacSHA256(key []byte, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { + r := requestPayload{ + ReqKey: "jimeng_vgfm_i2v_l20", + Prompt: req.Prompt, + AspectRatio: "16:9", // Default aspect ratio + Seed: -1, // Default to random + } + + // Handle one-of image_urls or binary_data_base64 + if req.Image != "" { + if strings.HasPrefix(req.Image, "http") { + r.ImageUrls = []string{req.Image} + } else { + r.BinaryDataBase64 = []string{req.Image} + } + } + metadata := req.Metadata + medaBytes, err := json.Marshal(metadata) + if err != nil { + return nil, errors.Wrap(err, "metadata marshal metadata failed") + } + err = json.Unmarshal(medaBytes, &r) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + return &r, nil +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + resTask := responseTask{} + if err := json.Unmarshal(respBody, &resTask); err != nil { + return nil, errors.Wrap(err, "unmarshal task result failed") + } + taskResult := relaycommon.TaskInfo{} + if resTask.Code == 10000 { + taskResult.Code = 0 + } else { + taskResult.Code = resTask.Code // todo uni code + taskResult.Reason = resTask.Message + taskResult.Status = model.TaskStatusFailure + taskResult.Progress = "100%" + } + switch resTask.Data.Status { + case "in_queue": + taskResult.Status = model.TaskStatusQueued + taskResult.Progress = "10%" + case "done": + taskResult.Status = model.TaskStatusSuccess + taskResult.Progress = "100%" + } + taskResult.Url = resTask.Data.VideoUrl + return &taskResult, nil +} diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go new file mode 100644 index 00000000..afa39201 --- /dev/null +++ b/relay/channel/task/kling/adaptor.go @@ -0,0 +1,346 @@ +package kling + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/samber/lo" + "io" + "net/http" + "one-api/model" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt" + "github.com/pkg/errors" + + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" +) + +// ============================ +// Request / Response structures +// ============================ + +type SubmitReq struct { + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Image string `json:"image,omitempty"` + Size string `json:"size,omitempty"` + Duration int `json:"duration,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type requestPayload struct { + Prompt string `json:"prompt,omitempty"` + Image string `json:"image,omitempty"` + Mode string `json:"mode,omitempty"` + Duration string `json:"duration,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + ModelName string `json:"model_name,omitempty"` + CfgScale float64 `json:"cfg_scale,omitempty"` +} + +type responsePayload struct { + Code int `json:"code"` + Message string `json:"message"` + RequestId string `json:"request_id"` + Data struct { + TaskId string `json:"task_id"` + TaskStatus string `json:"task_status"` + TaskStatusMsg string `json:"task_status_msg"` + TaskResult struct { + Videos []struct { + Id string `json:"id"` + Url string `json:"url"` + Duration string `json:"duration"` + } `json:"videos"` + } `json:"task_result"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` + } `json:"data"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + accessKey string + secretKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.BaseUrl + + // apiKey format: "access_key|secret_key" + keyParts := strings.Split(info.ApiKey, "|") + if len(keyParts) == 2 { + a.accessKey = strings.TrimSpace(keyParts[0]) + a.secretKey = strings.TrimSpace(keyParts[1]) + } +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { + // Accept only POST /v1/video/generations as "generate" action. + action := constant.TaskActionGenerate + info.Action = action + + var req SubmitReq + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Prompt) == "" { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest) + return + } + + // Store into context for later usage + c.Set("task_request", req) + return nil +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { + path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video") + return fmt.Sprintf("%s%s", a.baseURL, path), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { + token, err := a.createJWTToken() + if err != nil { + return fmt.Errorf("failed to create JWT token: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "kling-sdk/1.0") + return nil +} + +// BuildRequestBody converts request into Kling specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) { + v, exists := c.Get("task_request") + if !exists { + return nil, fmt.Errorf("request not found in context") + } + req := v.(SubmitReq) + + body, err := a.convertToRequestPayload(&req) + if err != nil { + return nil, err + } + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +// DoRequest delegates to common helper. +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { + if action := c.GetString("action"); action != "" { + info.Action = action + } + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +// DoResponse handles upstream response, returns taskID etc. +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + + // Attempt Kling response parse first. + var kResp responsePayload + if err := json.Unmarshal(responseBody, &kResp); err == nil && kResp.Code == 0 { + c.JSON(http.StatusOK, gin.H{"task_id": kResp.Data.TaskId}) + return kResp.Data.TaskId, responseBody, nil + } + + // Fallback generic task response. + var generic dto.TaskResponse[string] + if err := json.Unmarshal(responseBody, &generic); err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + + if !generic.IsSuccess() { + taskErr = service.TaskErrorWrapper(fmt.Errorf(generic.Message), generic.Code, http.StatusInternalServerError) + return + } + + c.JSON(http.StatusOK, gin.H{"task_id": generic.Data}) + return generic.Data, responseBody, nil +} + +// FetchTask fetch task status +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + taskID, ok := body["task_id"].(string) + if !ok { + return nil, fmt.Errorf("invalid task_id") + } + action, ok := body["action"].(string) + if !ok { + return nil, fmt.Errorf("invalid action") + } + path := lo.Ternary(action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video") + url := fmt.Sprintf("%s%s/%s", baseUrl, path, taskID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + token, err := a.createJWTTokenWithKey(key) + if err != nil { + token = key + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "kling-sdk/1.0") + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{"kling-v1", "kling-v1-6", "kling-v2-master"} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "kling" +} + +// ============================ +// helpers +// ============================ + +func (a *TaskAdaptor) convertToRequestPayload(req *SubmitReq) (*requestPayload, error) { + r := requestPayload{ + Prompt: req.Prompt, + Image: req.Image, + Mode: defaultString(req.Mode, "std"), + Duration: fmt.Sprintf("%d", defaultInt(req.Duration, 5)), + AspectRatio: a.getAspectRatio(req.Size), + ModelName: req.Model, + CfgScale: 0.5, + } + if r.ModelName == "" { + r.ModelName = "kling-v1" + } + metadata := req.Metadata + medaBytes, err := json.Marshal(metadata) + if err != nil { + return nil, errors.Wrap(err, "metadata marshal metadata failed") + } + err = json.Unmarshal(medaBytes, &r) + if err != nil { + return nil, errors.Wrap(err, "unmarshal metadata failed") + } + return &r, nil +} + +func (a *TaskAdaptor) getAspectRatio(size string) string { + switch size { + case "1024x1024", "512x512": + return "1:1" + case "1280x720", "1920x1080": + return "16:9" + case "720x1280", "1080x1920": + return "9:16" + default: + return "1:1" + } +} + +func defaultString(s, def string) string { + if strings.TrimSpace(s) == "" { + return def + } + return s +} + +func defaultInt(v int, def int) int { + if v == 0 { + return def + } + return v +} + +// ============================ +// JWT helpers +// ============================ + +func (a *TaskAdaptor) createJWTToken() (string, error) { + return a.createJWTTokenWithKeys(a.accessKey, a.secretKey) +} + +func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) { + parts := strings.Split(apiKey, "|") + if len(parts) != 2 { + return "", fmt.Errorf("invalid API key format, expected 'access_key,secret_key'") + } + return a.createJWTTokenWithKeys(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) +} + +func (a *TaskAdaptor) createJWTTokenWithKeys(accessKey, secretKey string) (string, error) { + if accessKey == "" || secretKey == "" { + return "", fmt.Errorf("access key and secret key are required") + } + now := time.Now().Unix() + claims := jwt.MapClaims{ + "iss": accessKey, + "exp": now + 1800, // 30 minutes + "nbf": now - 5, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + token.Header["typ"] = "JWT" + return token.SignedString([]byte(secretKey)) +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + resPayload := responsePayload{} + err := json.Unmarshal(respBody, &resPayload) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal response body") + } + taskInfo := &relaycommon.TaskInfo{} + taskInfo.Code = resPayload.Code + taskInfo.TaskID = resPayload.Data.TaskId + taskInfo.Reason = resPayload.Message + //任务状态,枚举值:submitted(已提交)、processing(处理中)、succeed(成功)、failed(失败) + status := resPayload.Data.TaskStatus + switch status { + case "submitted": + taskInfo.Status = model.TaskStatusSubmitted + case "processing": + taskInfo.Status = model.TaskStatusInProgress + case "succeed": + taskInfo.Status = model.TaskStatusSuccess + case "failed": + taskInfo.Status = model.TaskStatusFailure + default: + return nil, fmt.Errorf("unknown task status: %s", status) + } + if videos := resPayload.Data.TaskResult.Videos; len(videos) > 0 { + video := videos[0] + taskInfo.Url = video.Url + } + return taskInfo, nil +} diff --git a/relay/channel/task/suno/adaptor.go b/relay/channel/task/suno/adaptor.go new file mode 100644 index 00000000..9c04c7ad --- /dev/null +++ b/relay/channel/task/suno/adaptor.go @@ -0,0 +1,176 @@ +package suno + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" + "strings" + "time" +) + +type TaskAdaptor struct { + ChannelType int +} + +func (a *TaskAdaptor) ParseTaskResult([]byte) (*relaycommon.TaskInfo, error) { + return nil, fmt.Errorf("not implement") // todo implement this method if needed +} + +func (a *TaskAdaptor) Init(info *relaycommon.TaskRelayInfo) { + a.ChannelType = info.ChannelType +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.TaskRelayInfo) (taskErr *dto.TaskError) { + action := strings.ToUpper(c.Param("action")) + + var sunoRequest *dto.SunoSubmitReq + err := common.UnmarshalBodyReusable(c, &sunoRequest) + if err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + return + } + err = actionValidate(c, sunoRequest, action) + if err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "invalid_request", http.StatusBadRequest) + return + } + + if sunoRequest.ContinueClipId != "" { + if sunoRequest.TaskID == "" { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("task id is empty"), "invalid_request", http.StatusBadRequest) + return + } + info.OriginTaskID = sunoRequest.TaskID + } + + info.Action = action + c.Set("task_request", sunoRequest) + return nil +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.TaskRelayInfo) (string, error) { + baseURL := info.BaseUrl + fullRequestURL := fmt.Sprintf("%s%s", baseURL, "/suno/submit/"+info.Action) + return fullRequestURL, nil +} + +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.TaskRelayInfo) error { + req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) + req.Header.Set("Accept", c.Request.Header.Get("Accept")) + req.Header.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.TaskRelayInfo) (io.Reader, error) { + sunoRequest, ok := c.Get("task_request") + if !ok { + err := common.UnmarshalBodyReusable(c, &sunoRequest) + if err != nil { + return nil, err + } + } + data, err := json.Marshal(sunoRequest) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.TaskRelayInfo, requestBody io.Reader) (*http.Response, error) { + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.TaskRelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + return + } + var sunoResponse dto.TaskResponse[string] + err = json.Unmarshal(responseBody, &sunoResponse) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "unmarshal_response_body_failed", http.StatusInternalServerError) + return + } + if !sunoResponse.IsSuccess() { + taskErr = service.TaskErrorWrapper(fmt.Errorf(sunoResponse.Message), sunoResponse.Code, http.StatusInternalServerError) + return + } + + for k, v := range resp.Header { + c.Writer.Header().Set(k, v[0]) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + + _, err = io.Copy(c.Writer, bytes.NewBuffer(responseBody)) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError) + return + } + + return sunoResponse.Data, nil, nil +} + +func (a *TaskAdaptor) GetModelList() []string { + return ModelList +} + +func (a *TaskAdaptor) GetChannelName() string { + return ChannelName +} + +func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any) (*http.Response, error) { + requestUrl := fmt.Sprintf("%s/suno/fetch", baseUrl) + byteBody, err := json.Marshal(body) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", requestUrl, bytes.NewBuffer(byteBody)) + if err != nil { + common.SysError(fmt.Sprintf("Get Task error: %v", err)) + return nil, err + } + defer req.Body.Close() + // 设置超时时间 + timeout := time.Second * 15 + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + // 使用带有超时的 context 创建新的请求 + req = req.WithContext(ctx) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + resp, err := service.GetHttpClient().Do(req) + if err != nil { + return nil, err + } + return resp, nil +} + +func actionValidate(c *gin.Context, sunoRequest *dto.SunoSubmitReq, action string) (err error) { + switch action { + case constant.SunoActionMusic: + if sunoRequest.Mv == "" { + sunoRequest.Mv = "chirp-v3-0" + } + case constant.SunoActionLyrics: + if sunoRequest.Prompt == "" { + err = fmt.Errorf("prompt_empty") + return + } + default: + err = fmt.Errorf("invalid_action") + } + return +} diff --git a/relay/channel/task/suno/models.go b/relay/channel/task/suno/models.go new file mode 100644 index 00000000..967cf1b1 --- /dev/null +++ b/relay/channel/task/suno/models.go @@ -0,0 +1,7 @@ +package suno + +var ModelList = []string{ + "suno_music", "suno_lyrics", +} + +var ChannelName = "suno" diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go new file mode 100644 index 00000000..520276a7 --- /dev/null +++ b/relay/channel/tencent/adaptor.go @@ -0,0 +1,113 @@ +package tencent + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/types" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { + Sign string + AppID int64 + Action string + Version string + Timestamp int64 +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + a.Action = "ChatCompletions" + a.Version = "2023-09-01" + a.Timestamp = common.GetTimestamp() +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/", info.BaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", a.Sign) + req.Set("X-TC-Action", a.Action) + req.Set("X-TC-Version", a.Version) + req.Set("X-TC-Timestamp", strconv.FormatInt(a.Timestamp, 10)) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + apiKey := common.GetContextKeyString(c, constant.ContextKeyChannelKey) + apiKey = strings.TrimPrefix(apiKey, "Bearer ") + appId, secretId, secretKey, err := parseTencentConfig(apiKey) + a.AppID = appId + if err != nil { + return nil, err + } + tencentRequest := requestOpenAI2Tencent(a, *request) + // we have to calculate the sign here + a.Sign = getTencentSign(*tencentRequest, a, secretId, secretKey) + return tencentRequest, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = tencentStreamHandler(c, info, resp) + } else { + usage, err = tencentHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/tencent/constants.go b/relay/channel/tencent/constants.go new file mode 100644 index 00000000..d4d9cc1f --- /dev/null +++ b/relay/channel/tencent/constants.go @@ -0,0 +1,10 @@ +package tencent + +var ModelList = []string{ + "hunyuan-lite", + "hunyuan-standard", + "hunyuan-standard-256K", + "hunyuan-pro", +} + +var ChannelName = "tencent" diff --git a/relay/channel/tencent/dto.go b/relay/channel/tencent/dto.go new file mode 100644 index 00000000..65c548a9 --- /dev/null +++ b/relay/channel/tencent/dto.go @@ -0,0 +1,75 @@ +package tencent + +type TencentMessage struct { + Role string `json:"Role"` + Content string `json:"Content"` +} + +type TencentChatRequest struct { + // 模型名称,可选值包括 hunyuan-lite、hunyuan-standard、hunyuan-standard-256K、hunyuan-pro。 + // 各模型介绍请阅读 [产品概述](https://cloud.tencent.com/document/product/1729/104753) 中的说明。 + // + // 注意: + // 不同的模型计费不同,请根据 [购买指南](https://cloud.tencent.com/document/product/1729/97731) 按需调用。 + Model *string `json:"Model"` + // 聊天上下文信息。 + // 说明: + // 1. 长度最多为 40,按对话时间从旧到新在数组中排列。 + // 2. Message.Role 可选值:system、user、assistant。 + // 其中,system 角色可选,如存在则必须位于列表的最开始。user 和 assistant 需交替出现(一问一答),以 user 提问开始和结束,且 Content 不能为空。Role 的顺序示例:[system(可选) user assistant user assistant user ...]。 + // 3. Messages 中 Content 总长度不能超过模型输入长度上限(可参考 [产品概述](https://cloud.tencent.com/document/product/1729/104753) 文档),超过则会截断最前面的内容,只保留尾部内容。 + Messages []*TencentMessage `json:"Messages"` + // 流式调用开关。 + // 说明: + // 1. 未传值时默认为非流式调用(false)。 + // 2. 流式调用时以 SSE 协议增量返回结果(返回值取 Choices[n].Delta 中的值,需要拼接增量数据才能获得完整结果)。 + // 3. 非流式调用时: + // 调用方式与普通 HTTP 请求无异。 + // 接口响应耗时较长,**如需更低时延建议设置为 true**。 + // 只返回一次最终结果(返回值取 Choices[n].Message 中的值)。 + // + // 注意: + // 通过 SDK 调用时,流式和非流式调用需用**不同的方式**获取返回值,具体参考 SDK 中的注释或示例(在各语言 SDK 代码仓库的 examples/hunyuan/v20230901/ 目录中)。 + Stream *bool `json:"Stream,omitempty"` + // 说明: + // 1. 影响输出文本的多样性,取值越大,生成文本的多样性越强。 + // 2. 取值区间为 [0.0, 1.0],未传值时使用各模型推荐值。 + // 3. 非必要不建议使用,不合理的取值会影响效果。 + TopP *float64 `json:"TopP,omitempty"` + // 说明: + // 1. 较高的数值会使输出更加随机,而较低的数值会使其更加集中和确定。 + // 2. 取值区间为 [0.0, 2.0],未传值时使用各模型推荐值。 + // 3. 非必要不建议使用,不合理的取值会影响效果。 + Temperature *float64 `json:"Temperature,omitempty"` +} + +type TencentError struct { + Code int `json:"Code"` + Message string `json:"Message"` +} + +type TencentUsage struct { + PromptTokens int `json:"PromptTokens"` + CompletionTokens int `json:"CompletionTokens"` + TotalTokens int `json:"TotalTokens"` +} + +type TencentResponseChoices struct { + FinishReason string `json:"FinishReason,omitempty"` // 流式结束标志位,为 stop 则表示尾包 + Messages TencentMessage `json:"Message,omitempty"` // 内容,同步模式返回内容,流模式为 null 输出 content 内容总数最多支持 1024token。 + Delta TencentMessage `json:"Delta,omitempty"` // 内容,流模式返回内容,同步模式为 null 输出 content 内容总数最多支持 1024token。 +} + +type TencentChatResponse struct { + Choices []TencentResponseChoices `json:"Choices,omitempty"` // 结果 + Created int64 `json:"Created,omitempty"` // unix 时间戳的字符串 + Id string `json:"Id,omitempty"` // 会话 id + Usage TencentUsage `json:"Usage,omitempty"` // token 数量 + Error TencentError `json:"Error,omitempty"` // 错误信息 注意:此字段可能返回 null,表示取不到有效值 + Note string `json:"Note,omitempty"` // 注释 + ReqID string `json:"Req_id,omitempty"` // 唯一请求 Id,每次请求都会返回。用于反馈接口入参 +} + +type TencentChatResponseSB struct { + Response TencentChatResponse `json:"Response,omitempty"` +} diff --git a/relay/channel/tencent/relay-tencent.go b/relay/channel/tencent/relay-tencent.go new file mode 100644 index 00000000..c3d96c49 --- /dev/null +++ b/relay/channel/tencent/relay-tencent.go @@ -0,0 +1,233 @@ +package tencent + +import ( + "bufio" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// https://cloud.tencent.com/document/product/1729/97732 + +func requestOpenAI2Tencent(a *Adaptor, request dto.GeneralOpenAIRequest) *TencentChatRequest { + messages := make([]*TencentMessage, 0, len(request.Messages)) + for i := 0; i < len(request.Messages); i++ { + message := request.Messages[i] + messages = append(messages, &TencentMessage{ + Content: message.StringContent(), + Role: message.Role, + }) + } + var req = TencentChatRequest{ + Stream: &request.Stream, + Messages: messages, + Model: &request.Model, + } + if request.TopP != 0 { + req.TopP = &request.TopP + } + req.Temperature = request.Temperature + return &req +} + +func responseTencent2OpenAI(response *TencentChatResponse) *dto.OpenAITextResponse { + fullTextResponse := dto.OpenAITextResponse{ + Id: response.Id, + Object: "chat.completion", + Created: common.GetTimestamp(), + Usage: dto.Usage{ + PromptTokens: response.Usage.PromptTokens, + CompletionTokens: response.Usage.CompletionTokens, + TotalTokens: response.Usage.TotalTokens, + }, + } + if len(response.Choices) > 0 { + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: response.Choices[0].Messages.Content, + }, + FinishReason: response.Choices[0].FinishReason, + } + fullTextResponse.Choices = append(fullTextResponse.Choices, choice) + } + return &fullTextResponse +} + +func streamResponseTencent2OpenAI(TencentResponse *TencentChatResponse) *dto.ChatCompletionsStreamResponse { + response := dto.ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "tencent-hunyuan", + } + if len(TencentResponse.Choices) > 0 { + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString(TencentResponse.Choices[0].Delta.Content) + if TencentResponse.Choices[0].FinishReason == "stop" { + choice.FinishReason = &constant.FinishReasonStop + } + response.Choices = append(response.Choices, choice) + } + return &response +} + +func tencentStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var responseText string + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + + helper.SetEventStreamHeaders(c) + + for scanner.Scan() { + data := scanner.Text() + if len(data) < 5 || !strings.HasPrefix(data, "data:") { + continue + } + data = strings.TrimPrefix(data, "data:") + + var tencentResponse TencentChatResponse + err := json.Unmarshal([]byte(data), &tencentResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + continue + } + + response := streamResponseTencent2OpenAI(&tencentResponse) + if len(response.Choices) != 0 { + responseText += response.Choices[0].Delta.GetContentString() + } + + err = helper.ObjectData(c, response) + if err != nil { + common.SysError(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + common.SysError("error reading stream: " + err.Error()) + } + + helper.Done(c) + + common.CloseResponseBodyGracefully(resp) + + return service.ResponseText2Usage(responseText, info.UpstreamModelName, info.PromptTokens), nil +} + +func tencentHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var tencentSb TencentChatResponseSB + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + common.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &tencentSb) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if tencentSb.Response.Error.Code != 0 { + return nil, types.WithOpenAIError(types.OpenAIError{ + Message: tencentSb.Response.Error.Message, + Code: tencentSb.Response.Error.Code, + }, resp.StatusCode) + } + fullTextResponse := responseTencent2OpenAI(&tencentSb.Response) + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + common.IOCopyBytesGracefully(c, resp, jsonResponse) + return &fullTextResponse.Usage, nil +} + +func parseTencentConfig(config string) (appId int64, secretId string, secretKey string, err error) { + parts := strings.Split(config, "|") + if len(parts) != 3 { + err = errors.New("invalid tencent config") + return + } + appId, err = strconv.ParseInt(parts[0], 10, 64) + secretId = parts[1] + secretKey = parts[2] + return +} + +func sha256hex(s string) string { + b := sha256.Sum256([]byte(s)) + return hex.EncodeToString(b[:]) +} + +func hmacSha256(s, key string) string { + hashed := hmac.New(sha256.New, []byte(key)) + hashed.Write([]byte(s)) + return string(hashed.Sum(nil)) +} + +func getTencentSign(req TencentChatRequest, adaptor *Adaptor, secId, secKey string) string { + // build canonical request string + host := "hunyuan.tencentcloudapi.com" + httpRequestMethod := "POST" + canonicalURI := "/" + canonicalQueryString := "" + canonicalHeaders := fmt.Sprintf("content-type:%s\nhost:%s\nx-tc-action:%s\n", + "application/json", host, strings.ToLower(adaptor.Action)) + signedHeaders := "content-type;host;x-tc-action" + payload, _ := json.Marshal(req) + hashedRequestPayload := sha256hex(string(payload)) + canonicalRequest := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", + httpRequestMethod, + canonicalURI, + canonicalQueryString, + canonicalHeaders, + signedHeaders, + hashedRequestPayload) + // build string to sign + algorithm := "TC3-HMAC-SHA256" + requestTimestamp := strconv.FormatInt(adaptor.Timestamp, 10) + timestamp, _ := strconv.ParseInt(requestTimestamp, 10, 64) + t := time.Unix(timestamp, 0).UTC() + // must be the format 2006-01-02, ref to package time for more info + date := t.Format("2006-01-02") + credentialScope := fmt.Sprintf("%s/%s/tc3_request", date, "hunyuan") + hashedCanonicalRequest := sha256hex(canonicalRequest) + string2sign := fmt.Sprintf("%s\n%s\n%s\n%s", + algorithm, + requestTimestamp, + credentialScope, + hashedCanonicalRequest) + + // sign string + secretDate := hmacSha256(date, "TC3"+secKey) + secretService := hmacSha256("hunyuan", secretDate) + secretKey := hmacSha256("tc3_request", secretService) + signature := hex.EncodeToString([]byte(hmacSha256(string2sign, secretKey))) + + // build authorization + authorization := fmt.Sprintf("%s Credential=%s/%s, SignedHeaders=%s, Signature=%s", + algorithm, + secId, + credentialScope, + signedHeaders, + signature) + return authorization +} diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go new file mode 100644 index 00000000..fa895de0 --- /dev/null +++ b/relay/channel/vertex/adaptor.go @@ -0,0 +1,262 @@ +package vertex + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/claude" + "one-api/relay/channel/gemini" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/setting/model_setting" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +const ( + RequestModeClaude = 1 + RequestModeGemini = 2 + RequestModeLlama = 3 +) + +var claudeModelMap = map[string]string{ + "claude-3-sonnet-20240229": "claude-3-sonnet@20240229", + "claude-3-opus-20240229": "claude-3-opus@20240229", + "claude-3-haiku-20240307": "claude-3-haiku@20240307", + "claude-3-5-sonnet-20240620": "claude-3-5-sonnet@20240620", + "claude-3-5-sonnet-20241022": "claude-3-5-sonnet-v2@20241022", + "claude-3-7-sonnet-20250219": "claude-3-7-sonnet@20250219", + "claude-sonnet-4-20250514": "claude-sonnet-4@20250514", + "claude-opus-4-20250514": "claude-opus-4@20250514", +} + +const anthropicVersion = "vertex-2023-10-16" + +type Adaptor struct { + RequestMode int + AccountCredentials Credentials +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + if v, ok := claudeModelMap[info.UpstreamModelName]; ok { + c.Set("request_model", v) + } else { + c.Set("request_model", request.Model) + } + vertexClaudeReq := copyRequest(request, anthropicVersion) + return vertexClaudeReq, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + if strings.HasPrefix(info.UpstreamModelName, "claude") { + a.RequestMode = RequestModeClaude + } else if strings.HasPrefix(info.UpstreamModelName, "gemini") { + a.RequestMode = RequestModeGemini + } else if strings.Contains(info.UpstreamModelName, "llama") { + a.RequestMode = RequestModeLlama + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + adc := &Credentials{} + if err := json.Unmarshal([]byte(info.ApiKey), adc); err != nil { + return "", fmt.Errorf("failed to decode credentials file: %w", err) + } + region := GetModelRegion(info.ApiVersion, info.OriginModelName) + a.AccountCredentials = *adc + suffix := "" + if a.RequestMode == RequestModeGemini { + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { + // 新增逻辑:处理 -thinking- 格式 + if strings.Contains(info.UpstreamModelName, "-thinking-") { + parts := strings.Split(info.UpstreamModelName, "-thinking-") + info.UpstreamModelName = parts[0] + } else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配 + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") + } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") + } + } + + if info.IsStream { + suffix = "streamGenerateContent?alt=sse" + } else { + suffix = "generateContent" + } + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", + adc.ProjectID, + info.UpstreamModelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + region, + adc.ProjectID, + region, + info.UpstreamModelName, + suffix, + ), nil + } + } else if a.RequestMode == RequestModeClaude { + if info.IsStream { + suffix = "streamRawPredict?alt=sse" + } else { + suffix = "rawPredict" + } + model := info.UpstreamModelName + if v, ok := claudeModelMap[info.UpstreamModelName]; ok { + model = v + } + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s", + adc.ProjectID, + model, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", + region, + adc.ProjectID, + region, + model, + suffix, + ), nil + } + } else if a.RequestMode == RequestModeLlama { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", + region, + adc.ProjectID, + region, + ), nil + } + return "", errors.New("unsupported request mode") +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + accessToken, err := getAccessToken(a, info) + if err != nil { + return err + } + req.Set("Authorization", "Bearer "+accessToken) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if a.RequestMode == RequestModeClaude { + claudeReq, err := claude.RequestOpenAI2ClaudeMessage(*request) + if err != nil { + return nil, err + } + vertexClaudeReq := copyRequest(claudeReq, anthropicVersion) + c.Set("request_model", claudeReq.Model) + info.UpstreamModelName = claudeReq.Model + return vertexClaudeReq, nil + } else if a.RequestMode == RequestModeGemini { + geminiRequest, err := gemini.CovertGemini2OpenAI(*request, info) + if err != nil { + return nil, err + } + c.Set("request_model", request.Model) + return geminiRequest, nil + } else if a.RequestMode == RequestModeLlama { + return request, nil + } + return nil, errors.New("unsupported request mode") +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + switch a.RequestMode { + case RequestModeClaude: + err, usage = claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + case RequestModeGemini: + if info.RelayMode == constant.RelayModeGemini { + usage, err = gemini.GeminiTextGenerationStreamHandler(c, info, resp) + } else { + usage, err = gemini.GeminiChatStreamHandler(c, info, resp) + } + case RequestModeLlama: + usage, err = openai.OaiStreamHandler(c, info, resp) + } + } else { + switch a.RequestMode { + case RequestModeClaude: + err, usage = claude.ClaudeHandler(c, resp, claude.RequestModeMessage, info) + case RequestModeGemini: + if info.RelayMode == constant.RelayModeGemini { + usage, err = gemini.GeminiTextGenerationHandler(c, info, resp) + } else { + usage, err = gemini.GeminiChatHandler(c, info, resp) + } + case RequestModeLlama: + usage, err = openai.OpenaiHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + var modelList []string + for i, s := range ModelList { + modelList = append(modelList, s) + ModelList[i] = s + } + for i, s := range claude.ModelList { + modelList = append(modelList, s) + claude.ModelList[i] = s + } + for i, s := range gemini.ModelList { + modelList = append(modelList, s) + gemini.ModelList[i] = s + } + return modelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/vertex/constants.go b/relay/channel/vertex/constants.go new file mode 100644 index 00000000..c39e23d1 --- /dev/null +++ b/relay/channel/vertex/constants.go @@ -0,0 +1,15 @@ +package vertex + +var ModelList = []string{ + //"claude-3-sonnet-20240229", + //"claude-3-opus-20240229", + //"claude-3-haiku-20240307", + //"claude-3-5-sonnet-20240620", + + //"gemini-1.5-pro-latest", "gemini-1.5-flash-latest", + //"gemini-1.5-pro-001", "gemini-1.5-flash-001", "gemini-pro", "gemini-pro-vision", + + "meta/llama3-405b-instruct-maas", +} + +var ChannelName = "vertex-ai" diff --git a/relay/channel/vertex/dto.go b/relay/channel/vertex/dto.go new file mode 100644 index 00000000..4a571612 --- /dev/null +++ b/relay/channel/vertex/dto.go @@ -0,0 +1,37 @@ +package vertex + +import ( + "one-api/dto" +) + +type VertexAIClaudeRequest struct { + AnthropicVersion string `json:"anthropic_version"` + Messages []dto.ClaudeMessage `json:"messages"` + System any `json:"system,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + TopK int `json:"top_k,omitempty"` + Tools any `json:"tools,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Thinking *dto.Thinking `json:"thinking,omitempty"` +} + +func copyRequest(req *dto.ClaudeRequest, version string) *VertexAIClaudeRequest { + return &VertexAIClaudeRequest{ + AnthropicVersion: version, + System: req.System, + Messages: req.Messages, + MaxTokens: req.MaxTokens, + Stream: req.Stream, + Temperature: req.Temperature, + TopP: req.TopP, + TopK: req.TopK, + StopSequences: req.StopSequences, + Tools: req.Tools, + ToolChoice: req.ToolChoice, + Thinking: req.Thinking, + } +} diff --git a/relay/channel/vertex/relay-vertex.go b/relay/channel/vertex/relay-vertex.go new file mode 100644 index 00000000..5ed87665 --- /dev/null +++ b/relay/channel/vertex/relay-vertex.go @@ -0,0 +1,19 @@ +package vertex + +import "one-api/common" + +func GetModelRegion(other string, localModelName string) string { + // if other is json string + if common.IsJsonObject(other) { + m, err := common.StrToMap(other) + if err != nil { + return other // return original if parsing fails + } + if m[localModelName] != nil { + return m[localModelName].(string) + } else { + return m["default"].(string) + } + } + return other +} diff --git a/relay/channel/vertex/service_account.go b/relay/channel/vertex/service_account.go new file mode 100644 index 00000000..5a97c021 --- /dev/null +++ b/relay/channel/vertex/service_account.go @@ -0,0 +1,134 @@ +package vertex + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "github.com/bytedance/gopkg/cache/asynccache" + "github.com/golang-jwt/jwt" + "net/http" + "net/url" + relaycommon "one-api/relay/common" + "one-api/service" + "strings" + + "fmt" + "time" +) + +type Credentials struct { + ProjectID string `json:"project_id"` + PrivateKeyID string `json:"private_key_id"` + PrivateKey string `json:"private_key"` + ClientEmail string `json:"client_email"` + ClientID string `json:"client_id"` +} + +var Cache = asynccache.NewAsyncCache(asynccache.Options{ + RefreshDuration: time.Minute * 35, + EnableExpire: true, + ExpireDuration: time.Minute * 30, + Fetcher: func(key string) (interface{}, error) { + return nil, errors.New("not found") + }, +}) + +func getAccessToken(a *Adaptor, info *relaycommon.RelayInfo) (string, error) { + cacheKey := fmt.Sprintf("access-token-%d", info.ChannelId) + val, err := Cache.Get(cacheKey) + if err == nil { + return val.(string), nil + } + + signedJWT, err := createSignedJWT(a.AccountCredentials.ClientEmail, a.AccountCredentials.PrivateKey) + if err != nil { + return "", fmt.Errorf("failed to create signed JWT: %w", err) + } + newToken, err := exchangeJwtForAccessToken(signedJWT, info) + if err != nil { + return "", fmt.Errorf("failed to exchange JWT for access token: %w", err) + } + if err := Cache.SetDefault(cacheKey, newToken); err { + return newToken, nil + } + return newToken, nil +} + +func createSignedJWT(email, privateKeyPEM string) (string, error) { + + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "-----BEGIN PRIVATE KEY-----", "") + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "-----END PRIVATE KEY-----", "") + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "\r", "") + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "\n", "") + privateKeyPEM = strings.ReplaceAll(privateKeyPEM, "\\n", "") + + block, _ := pem.Decode([]byte("-----BEGIN PRIVATE KEY-----\n" + privateKeyPEM + "\n-----END PRIVATE KEY-----")) + if block == nil { + return "", fmt.Errorf("failed to parse PEM block containing the private key") + } + + privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", err + } + + rsaPrivateKey, ok := privateKey.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("not an RSA private key") + } + + now := time.Now() + claims := jwt.MapClaims{ + "iss": email, + "scope": "https://www.googleapis.com/auth/cloud-platform", + "aud": "https://www.googleapis.com/oauth2/v4/token", + "exp": now.Add(time.Minute * 35).Unix(), + "iat": now.Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signedToken, err := token.SignedString(rsaPrivateKey) + if err != nil { + return "", err + } + + return signedToken, nil +} + +func exchangeJwtForAccessToken(signedJWT string, info *relaycommon.RelayInfo) (string, error) { + + authURL := "https://www.googleapis.com/oauth2/v4/token" + data := url.Values{} + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + data.Set("assertion", signedJWT) + + var client *http.Client + var err error + if info.ChannelSetting.Proxy != "" { + client, err = service.NewProxyHttpClient(info.ChannelSetting.Proxy) + if err != nil { + return "", fmt.Errorf("new proxy http client failed: %w", err) + } + } else { + client = service.GetHttpClient() + } + + resp, err := client.PostForm(authURL, data) + if err != nil { + return "", err + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + + if accessToken, ok := result["access_token"].(string); ok { + return accessToken, nil + } + + return "", fmt.Errorf("failed to get access token: %v", result) +} diff --git a/relay/channel/volcengine/adaptor.go b/relay/channel/volcengine/adaptor.go new file mode 100644 index 00000000..af15d636 --- /dev/null +++ b/relay/channel/volcengine/adaptor.go @@ -0,0 +1,251 @@ +package volcengine + +import ( + "bytes" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/types" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + switch info.RelayMode { + case constant.RelayModeImagesEdits: + + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + + writer.WriteField("model", request.Model) + // 获取所有表单字段 + formData := c.Request.PostForm + // 遍历表单字段并打印输出 + for key, values := range formData { + if key == "model" { + continue + } + for _, value := range values { + writer.WriteField(key, value) + } + } + + // Parse the multipart form to handle both single image and multiple images + if err := c.Request.ParseMultipartForm(32 << 20); err != nil { // 32MB max memory + return nil, errors.New("failed to parse multipart form") + } + + if c.Request.MultipartForm != nil && c.Request.MultipartForm.File != nil { + // Check if "image" field exists in any form, including array notation + var imageFiles []*multipart.FileHeader + var exists bool + + // First check for standard "image" field + if imageFiles, exists = c.Request.MultipartForm.File["image"]; !exists || len(imageFiles) == 0 { + // If not found, check for "image[]" field + if imageFiles, exists = c.Request.MultipartForm.File["image[]"]; !exists || len(imageFiles) == 0 { + // If still not found, iterate through all fields to find any that start with "image[" + foundArrayImages := false + for fieldName, files := range c.Request.MultipartForm.File { + if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { + foundArrayImages = true + for _, file := range files { + imageFiles = append(imageFiles, file) + } + } + } + + // If no image fields found at all + if !foundArrayImages && (len(imageFiles) == 0) { + return nil, errors.New("image is required") + } + } + } + + // Process all image files + for i, fileHeader := range imageFiles { + file, err := fileHeader.Open() + if err != nil { + return nil, fmt.Errorf("failed to open image file %d: %w", i, err) + } + defer file.Close() + + // If multiple images, use image[] as the field name + fieldName := "image" + if len(imageFiles) > 1 { + fieldName = "image[]" + } + + // Determine MIME type based on file extension + mimeType := detectImageMimeType(fileHeader.Filename) + + // Create a form file with the appropriate content type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, fileHeader.Filename)) + h.Set("Content-Type", mimeType) + + part, err := writer.CreatePart(h) + if err != nil { + return nil, fmt.Errorf("create form part failed for image %d: %w", i, err) + } + + if _, err := io.Copy(part, file); err != nil { + return nil, fmt.Errorf("copy file failed for image %d: %w", i, err) + } + } + + // Handle mask file if present + if maskFiles, exists := c.Request.MultipartForm.File["mask"]; exists && len(maskFiles) > 0 { + maskFile, err := maskFiles[0].Open() + if err != nil { + return nil, errors.New("failed to open mask file") + } + defer maskFile.Close() + + // Determine MIME type for mask file + mimeType := detectImageMimeType(maskFiles[0].Filename) + + // Create a form file with the appropriate content type + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="mask"; filename="%s"`, maskFiles[0].Filename)) + h.Set("Content-Type", mimeType) + + maskPart, err := writer.CreatePart(h) + if err != nil { + return nil, errors.New("create form file failed for mask") + } + + if _, err := io.Copy(maskPart, maskFile); err != nil { + return nil, errors.New("copy mask file failed") + } + } + } else { + return nil, errors.New("no multipart form data found") + } + + // 关闭 multipart 编写器以设置分界线 + writer.Close() + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + return bytes.NewReader(requestBody.Bytes()), nil + + default: + return request, nil + } +} + +// detectImageMimeType determines the MIME type based on the file extension +func detectImageMimeType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + switch ext { + case ".jpg", ".jpeg": + return "image/jpeg" + case ".png": + return "image/png" + case ".webp": + return "image/webp" + default: + // Try to detect from extension if possible + if strings.HasPrefix(ext, ".jp") { + return "image/jpeg" + } + // Default to png as a fallback + return "image/png" + } +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch info.RelayMode { + case constant.RelayModeChatCompletions: + if strings.HasPrefix(info.UpstreamModelName, "bot") { + return fmt.Sprintf("%s/api/v3/bots/chat/completions", info.BaseUrl), nil + } + return fmt.Sprintf("%s/api/v3/chat/completions", info.BaseUrl), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/api/v3/embeddings", info.BaseUrl), nil + case constant.RelayModeImagesGenerations: + return fmt.Sprintf("%s/api/v3/images/generations", info.BaseUrl), nil + default: + } + return "", fmt.Errorf("unsupported relay mode: %d", info.RelayMode) +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case constant.RelayModeChatCompletions: + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + case constant.RelayModeEmbeddings: + usage, err = openai.OpenaiHandler(c, info, resp) + case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits: + usage, err = openai.OpenaiHandlerWithUsage(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/volcengine/constants.go b/relay/channel/volcengine/constants.go new file mode 100644 index 00000000..30cc902e --- /dev/null +++ b/relay/channel/volcengine/constants.go @@ -0,0 +1,13 @@ +package volcengine + +var ModelList = []string{ + "Doubao-pro-128k", + "Doubao-pro-32k", + "Doubao-pro-4k", + "Doubao-lite-128k", + "Doubao-lite-32k", + "Doubao-lite-4k", + "Doubao-embedding", +} + +var ChannelName = "volcengine" diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go new file mode 100644 index 00000000..8d880137 --- /dev/null +++ b/relay/channel/xai/adaptor.go @@ -0,0 +1,128 @@ +package xai + +import ( + "errors" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/types" + "strings" + + "one-api/relay/constant" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + //panic("implement me") + return nil, errors.New("not available") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //not available + return nil, errors.New("not available") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + xaiRequest := ImageRequest{ + Model: request.Model, + Prompt: request.Prompt, + N: request.N, + ResponseFormat: request.ResponseFormat, + } + return xaiRequest, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return relaycommon.GetFullRequestURL(info.BaseUrl, info.RequestURLPath, info.ChannelType), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + req.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if strings.HasSuffix(info.UpstreamModelName, "-search") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-search") + request.Model = info.UpstreamModelName + toMap := request.ToMap() + toMap["search_parameters"] = map[string]any{ + "mode": "on", + } + return toMap, nil + } + if strings.HasPrefix(request.Model, "grok-3-mini") { + if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 { + request.MaxCompletionTokens = request.MaxTokens + request.MaxTokens = 0 + } + if strings.HasSuffix(request.Model, "-high") { + request.ReasoningEffort = "high" + request.Model = strings.TrimSuffix(request.Model, "-high") + } else if strings.HasSuffix(request.Model, "-low") { + request.ReasoningEffort = "low" + request.Model = strings.TrimSuffix(request.Model, "-low") + } else if strings.HasSuffix(request.Model, "-medium") { + request.ReasoningEffort = "medium" + request.Model = strings.TrimSuffix(request.Model, "-medium") + } + info.ReasoningEffort = request.ReasoningEffort + info.UpstreamModelName = request.Model + } + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //not available + return nil, errors.New("not available") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case constant.RelayModeImagesGenerations, constant.RelayModeImagesEdits: + usage, err = openai.OpenaiHandlerWithUsage(c, info, resp) + default: + if info.IsStream { + usage, err = xAIStreamHandler(c, info, resp) + } else { + usage, err = xAIHandler(c, info, resp) + } + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/xai/constants.go b/relay/channel/xai/constants.go new file mode 100644 index 00000000..311b4bb6 --- /dev/null +++ b/relay/channel/xai/constants.go @@ -0,0 +1,20 @@ +package xai + +var ModelList = []string{ + // grok-4 + "grok-4", "grok-4-0709", "grok-4-0709-search", + // grok-3 + "grok-3-beta", "grok-3-mini-beta", + // grok-3 mini + "grok-3-fast-beta", "grok-3-mini-fast-beta", + // extend grok-3-mini reasoning + "grok-3-mini-beta-high", "grok-3-mini-beta-low", "grok-3-mini-beta-medium", + "grok-3-mini-fast-beta-high", "grok-3-mini-fast-beta-low", "grok-3-mini-fast-beta-medium", + // image model + "grok-2-image", + // legacy models + "grok-2", "grok-2-vision", + "grok-beta", "grok-vision-beta", +} + +var ChannelName = "xai" diff --git a/relay/channel/xai/dto.go b/relay/channel/xai/dto.go new file mode 100644 index 00000000..107a980a --- /dev/null +++ b/relay/channel/xai/dto.go @@ -0,0 +1,27 @@ +package xai + +import "one-api/dto" + +// ChatCompletionResponse represents the response from XAI chat completion API +type ChatCompletionResponse struct { + Id string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []dto.OpenAITextResponseChoice `json:"choices"` + Usage *dto.Usage `json:"usage"` + SystemFingerprint string `json:"system_fingerprint"` +} + +// quality, size or style are not supported by xAI API at the moment. +type ImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt" binding:"required"` + N int `json:"n,omitempty"` + // Size string `json:"size,omitempty"` + // Quality string `json:"quality,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + // Style string `json:"style,omitempty"` + // User string `json:"user,omitempty"` + // ExtraFields json.RawMessage `json:"extra_fields,omitempty"` +} diff --git a/relay/channel/xai/text.go b/relay/channel/xai/text.go new file mode 100644 index 00000000..4d098102 --- /dev/null +++ b/relay/channel/xai/text.go @@ -0,0 +1,107 @@ +package xai + +import ( + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func streamResponseXAI2OpenAI(xAIResp *dto.ChatCompletionsStreamResponse, usage *dto.Usage) *dto.ChatCompletionsStreamResponse { + if xAIResp == nil { + return nil + } + if xAIResp.Usage != nil { + xAIResp.Usage.CompletionTokens = usage.CompletionTokens + } + openAIResp := &dto.ChatCompletionsStreamResponse{ + Id: xAIResp.Id, + Object: xAIResp.Object, + Created: xAIResp.Created, + Model: xAIResp.Model, + Choices: xAIResp.Choices, + Usage: xAIResp.Usage, + } + + return openAIResp +} + +func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + usage := &dto.Usage{} + var responseTextBuilder strings.Builder + var toolCount int + var containStreamUsage bool + + helper.SetEventStreamHeaders(c) + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var xAIResp *dto.ChatCompletionsStreamResponse + err := json.Unmarshal([]byte(data), &xAIResp) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + + // 把 xAI 的usage转换为 OpenAI 的usage + if xAIResp.Usage != nil { + containStreamUsage = true + usage.PromptTokens = xAIResp.Usage.PromptTokens + usage.TotalTokens = xAIResp.Usage.TotalTokens + usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + } + + openaiResponse := streamResponseXAI2OpenAI(xAIResp, usage) + _ = openai.ProcessStreamResponse(*openaiResponse, &responseTextBuilder, &toolCount) + err = helper.ObjectData(c, openaiResponse) + if err != nil { + common.SysError(err.Error()) + } + return true + }) + + if !containStreamUsage { + usage = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens) + usage.CompletionTokens += toolCount * 7 + } + + helper.Done(c) + common.CloseResponseBodyGracefully(resp) + return usage, nil +} + +func xAIHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer common.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + var xaiResponse ChatCompletionResponse + err = common.Unmarshal(responseBody, &xaiResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if xaiResponse.Usage != nil { + xaiResponse.Usage.CompletionTokens = xaiResponse.Usage.TotalTokens - xaiResponse.Usage.PromptTokens + xaiResponse.Usage.CompletionTokenDetails.TextTokens = xaiResponse.Usage.CompletionTokens - xaiResponse.Usage.CompletionTokenDetails.ReasoningTokens + } + + // new body + encodeJson, err := common.Marshal(xaiResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + + common.IOCopyBytesGracefully(c, resp, encodeJson) + + return xaiResponse.Usage, nil +} diff --git a/relay/channel/xinference/constant.go b/relay/channel/xinference/constant.go new file mode 100644 index 00000000..a119084f --- /dev/null +++ b/relay/channel/xinference/constant.go @@ -0,0 +1,8 @@ +package xinference + +var ModelList = []string{ + "bge-reranker-v2-m3", + "jina-reranker-v2", +} + +var ChannelName = "xinference" diff --git a/relay/channel/xinference/dto.go b/relay/channel/xinference/dto.go new file mode 100644 index 00000000..35f339fe --- /dev/null +++ b/relay/channel/xinference/dto.go @@ -0,0 +1,11 @@ +package xinference + +type XinRerankResponseDocument struct { + Document any `json:"document,omitempty"` + Index int `json:"index"` + RelevanceScore float64 `json:"relevance_score"` +} + +type XinRerankResponse struct { + Results []XinRerankResponseDocument `json:"results"` +} diff --git a/relay/channel/xunfei/adaptor.go b/relay/channel/xunfei/adaptor.go new file mode 100644 index 00000000..0d218ada --- /dev/null +++ b/relay/channel/xunfei/adaptor.go @@ -0,0 +1,99 @@ +package xunfei + +import ( + "errors" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { + request *dto.GeneralOpenAIRequest +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return "", nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + a.request = request + return request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + // xunfei's request is not http request, so we don't need to do anything here + dummyResp := &http.Response{} + dummyResp.StatusCode = http.StatusOK + return dummyResp, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + splits := strings.Split(info.ApiKey, "|") + if len(splits) != 3 { + return nil, types.NewError(errors.New("invalid auth"), types.ErrorCodeChannelInvalidKey) + } + if a.request == nil { + return nil, types.NewError(errors.New("request is nil"), types.ErrorCodeInvalidRequest) + } + if info.IsStream { + usage, err = xunfeiStreamHandler(c, *a.request, splits[0], splits[1], splits[2]) + } else { + usage, err = xunfeiHandler(c, *a.request, splits[0], splits[1], splits[2]) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/xunfei/constants.go b/relay/channel/xunfei/constants.go new file mode 100644 index 00000000..e19f0113 --- /dev/null +++ b/relay/channel/xunfei/constants.go @@ -0,0 +1,12 @@ +package xunfei + +var ModelList = []string{ + "SparkDesk", + "SparkDesk-v1.1", + "SparkDesk-v2.1", + "SparkDesk-v3.1", + "SparkDesk-v3.5", + "SparkDesk-v4.0", +} + +var ChannelName = "xunfei" diff --git a/relay/channel/xunfei/dto.go b/relay/channel/xunfei/dto.go new file mode 100644 index 00000000..c169e5f7 --- /dev/null +++ b/relay/channel/xunfei/dto.go @@ -0,0 +1,59 @@ +package xunfei + +import "one-api/dto" + +type XunfeiMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type XunfeiChatRequest struct { + Header struct { + AppId string `json:"app_id"` + } `json:"header"` + Parameter struct { + Chat struct { + Domain string `json:"domain,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopK int `json:"top_k,omitempty"` + MaxTokens uint `json:"max_tokens,omitempty"` + Auditing bool `json:"auditing,omitempty"` + } `json:"chat"` + } `json:"parameter"` + Payload struct { + Message struct { + Text []XunfeiMessage `json:"text"` + } `json:"message"` + } `json:"payload"` +} + +type XunfeiChatResponseTextItem struct { + Content string `json:"content"` + Role string `json:"role"` + Index int `json:"index"` +} + +type XunfeiChatResponse struct { + Header struct { + Code int `json:"code"` + Message string `json:"message"` + Sid string `json:"sid"` + Status int `json:"status"` + } `json:"header"` + Payload struct { + Choices struct { + Status int `json:"status"` + Seq int `json:"seq"` + Text []XunfeiChatResponseTextItem `json:"text"` + } `json:"choices"` + Usage struct { + //Text struct { + // QuestionTokens string `json:"question_tokens"` + // PromptTokens string `json:"prompt_tokens"` + // CompletionTokens string `json:"completion_tokens"` + // TotalTokens string `json:"total_tokens"` + //} `json:"text"` + Text dto.Usage `json:"text"` + } `json:"usage"` + } `json:"payload"` +} diff --git a/relay/channel/xunfei/relay-xunfei.go b/relay/channel/xunfei/relay-xunfei.go new file mode 100644 index 00000000..373ad605 --- /dev/null +++ b/relay/channel/xunfei/relay-xunfei.go @@ -0,0 +1,287 @@ +package xunfei + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/url" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/relay/helper" + "one-api/types" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +// https://console.xfyun.cn/services/cbm +// https://www.xfyun.cn/doc/spark/Web.html + +func requestOpenAI2Xunfei(request dto.GeneralOpenAIRequest, xunfeiAppId string, domain string) *XunfeiChatRequest { + messages := make([]XunfeiMessage, 0, len(request.Messages)) + shouldCovertSystemMessage := !strings.HasSuffix(request.Model, "3.5") + for _, message := range request.Messages { + if message.Role == "system" && shouldCovertSystemMessage { + messages = append(messages, XunfeiMessage{ + Role: "user", + Content: message.StringContent(), + }) + messages = append(messages, XunfeiMessage{ + Role: "assistant", + Content: "Okay", + }) + } else { + messages = append(messages, XunfeiMessage{ + Role: message.Role, + Content: message.StringContent(), + }) + } + } + xunfeiRequest := XunfeiChatRequest{} + xunfeiRequest.Header.AppId = xunfeiAppId + xunfeiRequest.Parameter.Chat.Domain = domain + xunfeiRequest.Parameter.Chat.Temperature = request.Temperature + xunfeiRequest.Parameter.Chat.TopK = request.N + xunfeiRequest.Parameter.Chat.MaxTokens = request.MaxTokens + xunfeiRequest.Payload.Message.Text = messages + return &xunfeiRequest +} + +func responseXunfei2OpenAI(response *XunfeiChatResponse) *dto.OpenAITextResponse { + if len(response.Payload.Choices.Text) == 0 { + response.Payload.Choices.Text = []XunfeiChatResponseTextItem{ + { + Content: "", + }, + } + } + choice := dto.OpenAITextResponseChoice{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: response.Payload.Choices.Text[0].Content, + }, + FinishReason: constant.FinishReasonStop, + } + fullTextResponse := dto.OpenAITextResponse{ + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: []dto.OpenAITextResponseChoice{choice}, + Usage: response.Payload.Usage.Text, + } + return &fullTextResponse +} + +func streamResponseXunfei2OpenAI(xunfeiResponse *XunfeiChatResponse) *dto.ChatCompletionsStreamResponse { + if len(xunfeiResponse.Payload.Choices.Text) == 0 { + xunfeiResponse.Payload.Choices.Text = []XunfeiChatResponseTextItem{ + { + Content: "", + }, + } + } + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString(xunfeiResponse.Payload.Choices.Text[0].Content) + if xunfeiResponse.Payload.Choices.Status == 2 { + choice.FinishReason = &constant.FinishReasonStop + } + response := dto.ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "SparkDesk", + Choices: []dto.ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func buildXunfeiAuthUrl(hostUrl string, apiKey, apiSecret string) string { + HmacWithShaToBase64 := func(algorithm, data, key string) string { + mac := hmac.New(sha256.New, []byte(key)) + mac.Write([]byte(data)) + encodeData := mac.Sum(nil) + return base64.StdEncoding.EncodeToString(encodeData) + } + ul, err := url.Parse(hostUrl) + if err != nil { + fmt.Println(err) + } + date := time.Now().UTC().Format(time.RFC1123) + signString := []string{"host: " + ul.Host, "date: " + date, "GET " + ul.Path + " HTTP/1.1"} + sign := strings.Join(signString, "\n") + sha := HmacWithShaToBase64("hmac-sha256", sign, apiSecret) + authUrl := fmt.Sprintf("hmac username=\"%s\", algorithm=\"%s\", headers=\"%s\", signature=\"%s\"", apiKey, + "hmac-sha256", "host date request-line", sha) + authorization := base64.StdEncoding.EncodeToString([]byte(authUrl)) + v := url.Values{} + v.Add("host", ul.Host) + v.Add("date", date) + v.Add("authorization", authorization) + callUrl := hostUrl + "?" + v.Encode() + return callUrl +} + +func xunfeiStreamHandler(c *gin.Context, textRequest dto.GeneralOpenAIRequest, appId string, apiSecret string, apiKey string) (*dto.Usage, *types.NewAPIError) { + domain, authUrl := getXunfeiAuthUrl(c, apiKey, apiSecret, textRequest.Model) + dataChan, stopChan, err := xunfeiMakeRequest(textRequest, domain, authUrl, appId) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeDoRequestFailed) + } + helper.SetEventStreamHeaders(c) + var usage dto.Usage + c.Stream(func(w io.Writer) bool { + select { + case xunfeiResponse := <-dataChan: + usage.PromptTokens += xunfeiResponse.Payload.Usage.Text.PromptTokens + usage.CompletionTokens += xunfeiResponse.Payload.Usage.Text.CompletionTokens + usage.TotalTokens += xunfeiResponse.Payload.Usage.Text.TotalTokens + response := streamResponseXunfei2OpenAI(&xunfeiResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + return &usage, nil +} + +func xunfeiHandler(c *gin.Context, textRequest dto.GeneralOpenAIRequest, appId string, apiSecret string, apiKey string) (*dto.Usage, *types.NewAPIError) { + domain, authUrl := getXunfeiAuthUrl(c, apiKey, apiSecret, textRequest.Model) + dataChan, stopChan, err := xunfeiMakeRequest(textRequest, domain, authUrl, appId) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeDoRequestFailed) + } + var usage dto.Usage + var content string + var xunfeiResponse XunfeiChatResponse + stop := false + for !stop { + select { + case xunfeiResponse = <-dataChan: + if len(xunfeiResponse.Payload.Choices.Text) == 0 { + continue + } + content += xunfeiResponse.Payload.Choices.Text[0].Content + usage.PromptTokens += xunfeiResponse.Payload.Usage.Text.PromptTokens + usage.CompletionTokens += xunfeiResponse.Payload.Usage.Text.CompletionTokens + usage.TotalTokens += xunfeiResponse.Payload.Usage.Text.TotalTokens + case stop = <-stopChan: + } + } + if len(xunfeiResponse.Payload.Choices.Text) == 0 { + xunfeiResponse.Payload.Choices.Text = []XunfeiChatResponseTextItem{ + { + Content: "", + }, + } + } + xunfeiResponse.Payload.Choices.Text[0].Content = content + + response := responseXunfei2OpenAI(&xunfeiResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + _, _ = c.Writer.Write(jsonResponse) + return &usage, nil +} + +func xunfeiMakeRequest(textRequest dto.GeneralOpenAIRequest, domain, authUrl, appId string) (chan XunfeiChatResponse, chan bool, error) { + d := websocket.Dialer{ + HandshakeTimeout: 5 * time.Second, + } + conn, resp, err := d.Dial(authUrl, nil) + if err != nil || resp.StatusCode != 101 { + return nil, nil, err + } + data := requestOpenAI2Xunfei(textRequest, appId, domain) + err = conn.WriteJSON(data) + if err != nil { + return nil, nil, err + } + + dataChan := make(chan XunfeiChatResponse) + stopChan := make(chan bool) + go func() { + for { + _, msg, err := conn.ReadMessage() + if err != nil { + common.SysError("error reading stream response: " + err.Error()) + break + } + var response XunfeiChatResponse + err = json.Unmarshal(msg, &response) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + break + } + dataChan <- response + if response.Payload.Choices.Status == 2 { + err := conn.Close() + if err != nil { + common.SysError("error closing websocket connection: " + err.Error()) + } + break + } + } + stopChan <- true + }() + + return dataChan, stopChan, nil +} + +func apiVersion2domain(apiVersion string) string { + switch apiVersion { + case "v1.1": + return "lite" + case "v2.1": + return "generalv2" + case "v3.1": + return "generalv3" + case "v3.5": + return "generalv3.5" + case "v4.0": + return "4.0Ultra" + } + return "general" + apiVersion +} + +func getXunfeiAuthUrl(c *gin.Context, apiKey string, apiSecret string, modelName string) (string, string) { + apiVersion := getAPIVersion(c, modelName) + domain := apiVersion2domain(apiVersion) + authUrl := buildXunfeiAuthUrl(fmt.Sprintf("wss://spark-api.xf-yun.com/%s/chat", apiVersion), apiKey, apiSecret) + return domain, authUrl +} + +func getAPIVersion(c *gin.Context, modelName string) string { + query := c.Request.URL.Query() + apiVersion := query.Get("api-version") + if apiVersion != "" { + return apiVersion + } + parts := strings.Split(modelName, "-") + if len(parts) == 2 { + apiVersion = parts[1] + return apiVersion + + } + apiVersion = c.GetString("api_version") + if apiVersion != "" { + return apiVersion + } + apiVersion = "v1.1" + common.SysLog("api_version not found, using default: " + apiVersion) + return apiVersion +} diff --git a/relay/channel/zhipu/adaptor.go b/relay/channel/zhipu/adaptor.go new file mode 100644 index 00000000..43344428 --- /dev/null +++ b/relay/channel/zhipu/adaptor.go @@ -0,0 +1,96 @@ +package zhipu + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + method := "invoke" + if info.IsStream { + method = "sse-invoke" + } + return fmt.Sprintf("%s/api/paas/v3/model-api/%s/%s", info.BaseUrl, info.UpstreamModelName, method), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + token := getZhipuToken(info.ApiKey) + req.Set("Authorization", token) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if request.TopP >= 1 { + request.TopP = 0.99 + } + return requestOpenAI2Zhipu(*request), nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = zhipuStreamHandler(c, info, resp) + } else { + usage, err = zhipuHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/zhipu/constants.go b/relay/channel/zhipu/constants.go new file mode 100644 index 00000000..81b18d63 --- /dev/null +++ b/relay/channel/zhipu/constants.go @@ -0,0 +1,7 @@ +package zhipu + +var ModelList = []string{ + "chatglm_turbo", "chatglm_pro", "chatglm_std", "chatglm_lite", +} + +var ChannelName = "zhipu" diff --git a/relay/channel/zhipu/dto.go b/relay/channel/zhipu/dto.go new file mode 100644 index 00000000..2682dd3a --- /dev/null +++ b/relay/channel/zhipu/dto.go @@ -0,0 +1,46 @@ +package zhipu + +import ( + "one-api/dto" + "time" +) + +type ZhipuMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type ZhipuRequest struct { + Prompt []ZhipuMessage `json:"prompt"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + RequestId string `json:"request_id,omitempty"` + Incremental bool `json:"incremental,omitempty"` +} + +type ZhipuResponseData struct { + TaskId string `json:"task_id"` + RequestId string `json:"request_id"` + TaskStatus string `json:"task_status"` + Choices []ZhipuMessage `json:"choices"` + dto.Usage `json:"usage"` +} + +type ZhipuResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Success bool `json:"success"` + Data ZhipuResponseData `json:"data"` +} + +type ZhipuStreamMetaResponse struct { + RequestId string `json:"request_id"` + TaskId string `json:"task_id"` + TaskStatus string `json:"task_status"` + dto.Usage `json:"usage"` +} + +type zhipuTokenData struct { + Token string + ExpiryTime time.Time +} diff --git a/relay/channel/zhipu/relay-zhipu.go b/relay/channel/zhipu/relay-zhipu.go new file mode 100644 index 00000000..916a200d --- /dev/null +++ b/relay/channel/zhipu/relay-zhipu.go @@ -0,0 +1,245 @@ +package zhipu + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/types" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt" +) + +// https://open.bigmodel.cn/doc/api#chatglm_std +// chatglm_std, chatglm_lite +// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/invoke +// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/sse-invoke + +var zhipuTokens sync.Map +var expSeconds int64 = 24 * 3600 + +func getZhipuToken(apikey string) string { + data, ok := zhipuTokens.Load(apikey) + if ok { + tokenData := data.(zhipuTokenData) + if time.Now().Before(tokenData.ExpiryTime) { + return tokenData.Token + } + } + + split := strings.Split(apikey, ".") + if len(split) != 2 { + common.SysError("invalid zhipu key: " + apikey) + return "" + } + + id := split[0] + secret := split[1] + + expMillis := time.Now().Add(time.Duration(expSeconds)*time.Second).UnixNano() / 1e6 + expiryTime := time.Now().Add(time.Duration(expSeconds) * time.Second) + + timestamp := time.Now().UnixNano() / 1e6 + + payload := jwt.MapClaims{ + "api_key": id, + "exp": expMillis, + "timestamp": timestamp, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) + + token.Header["alg"] = "HS256" + token.Header["sign_type"] = "SIGN" + + tokenString, err := token.SignedString([]byte(secret)) + if err != nil { + return "" + } + + zhipuTokens.Store(apikey, zhipuTokenData{ + Token: tokenString, + ExpiryTime: expiryTime, + }) + + return tokenString +} + +func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *ZhipuRequest { + messages := make([]ZhipuMessage, 0, len(request.Messages)) + for _, message := range request.Messages { + if message.Role == "system" { + messages = append(messages, ZhipuMessage{ + Role: "system", + Content: message.StringContent(), + }) + messages = append(messages, ZhipuMessage{ + Role: "user", + Content: "Okay", + }) + } else { + messages = append(messages, ZhipuMessage{ + Role: message.Role, + Content: message.StringContent(), + }) + } + } + return &ZhipuRequest{ + Prompt: messages, + Temperature: request.Temperature, + TopP: request.TopP, + Incremental: false, + } +} + +func responseZhipu2OpenAI(response *ZhipuResponse) *dto.OpenAITextResponse { + fullTextResponse := dto.OpenAITextResponse{ + Id: response.Data.TaskId, + Object: "chat.completion", + Created: common.GetTimestamp(), + Choices: make([]dto.OpenAITextResponseChoice, 0, len(response.Data.Choices)), + Usage: response.Data.Usage, + } + for i, choice := range response.Data.Choices { + openaiChoice := dto.OpenAITextResponseChoice{ + Index: i, + Message: dto.Message{ + Role: choice.Role, + Content: strings.Trim(choice.Content, "\""), + }, + FinishReason: "", + } + if i == len(response.Data.Choices)-1 { + openaiChoice.FinishReason = "stop" + } + fullTextResponse.Choices = append(fullTextResponse.Choices, openaiChoice) + } + return &fullTextResponse +} + +func streamResponseZhipu2OpenAI(zhipuResponse string) *dto.ChatCompletionsStreamResponse { + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString(zhipuResponse) + response := dto.ChatCompletionsStreamResponse{ + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "chatglm", + Choices: []dto.ChatCompletionsStreamResponseChoice{choice}, + } + return &response +} + +func streamMetaResponseZhipu2OpenAI(zhipuResponse *ZhipuStreamMetaResponse) (*dto.ChatCompletionsStreamResponse, *dto.Usage) { + var choice dto.ChatCompletionsStreamResponseChoice + choice.Delta.SetContentString("") + choice.FinishReason = &constant.FinishReasonStop + response := dto.ChatCompletionsStreamResponse{ + Id: zhipuResponse.RequestId, + Object: "chat.completion.chunk", + Created: common.GetTimestamp(), + Model: "chatglm", + Choices: []dto.ChatCompletionsStreamResponseChoice{choice}, + } + return &response, &zhipuResponse.Usage +} + +func zhipuStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var usage *dto.Usage + scanner := bufio.NewScanner(resp.Body) + scanner.Split(bufio.ScanLines) + dataChan := make(chan string) + metaChan := make(chan string) + stopChan := make(chan bool) + go func() { + for scanner.Scan() { + data := scanner.Text() + lines := strings.Split(data, "\n") + for i, line := range lines { + if len(line) < 5 { + continue + } + if line[:5] == "data:" { + dataChan <- line[5:] + if i != len(lines)-1 { + dataChan <- "\n" + } + } else if line[:5] == "meta:" { + metaChan <- line[5:] + } + } + } + stopChan <- true + }() + helper.SetEventStreamHeaders(c) + c.Stream(func(w io.Writer) bool { + select { + case data := <-dataChan: + response := streamResponseZhipu2OpenAI(data) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case data := <-metaChan: + var zhipuResponse ZhipuStreamMetaResponse + err := json.Unmarshal([]byte(data), &zhipuResponse) + if err != nil { + common.SysError("error unmarshalling stream response: " + err.Error()) + return true + } + response, zhipuUsage := streamMetaResponseZhipu2OpenAI(&zhipuResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + return true + } + usage = zhipuUsage + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonResponse)}) + return true + case <-stopChan: + c.Render(-1, common.CustomEvent{Data: "data: [DONE]"}) + return false + } + }) + common.CloseResponseBodyGracefully(resp) + return usage, nil +} + +func zhipuHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var zhipuResponse ZhipuResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + common.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &zhipuResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if !zhipuResponse.Success { + return nil, types.WithOpenAIError(types.OpenAIError{ + Message: zhipuResponse.Msg, + Code: zhipuResponse.Code, + }, resp.StatusCode) + } + fullTextResponse := responseZhipu2OpenAI(&zhipuResponse) + jsonResponse, err := json.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + c.Writer.Header().Set("Content-Type", "application/json") + c.Writer.WriteHeader(resp.StatusCode) + _, err = c.Writer.Write(jsonResponse) + return &fullTextResponse.Usage, nil +} diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go new file mode 100644 index 00000000..edd7a534 --- /dev/null +++ b/relay/channel/zhipu_4v/adaptor.go @@ -0,0 +1,99 @@ +package zhipu_4v + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + //TODO implement me + panic("implement me") + return nil, nil +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + baseUrl := fmt.Sprintf("%s/api/paas/v4", info.BaseUrl) + switch info.RelayMode { + case relayconstant.RelayModeEmbeddings: + return fmt.Sprintf("%s/embeddings", baseUrl), nil + default: + return fmt.Sprintf("%s/chat/completions", baseUrl), nil + } +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error { + channel.SetupApiRequestHeader(info, c, req) + token := getZhipuToken(info.ApiKey) + req.Set("Authorization", token) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + if request == nil { + return nil, errors.New("request is nil") + } + if request.TopP >= 1 { + request.TopP = 0.99 + } + return requestOpenAI2Zhipu(*request), nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, nil +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return request, nil +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + // TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + if info.IsStream { + usage, err = openai.OaiStreamHandler(c, info, resp) + } else { + usage, err = openai.OpenaiHandler(c, info, resp) + } + return +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/zhipu_4v/constants.go b/relay/channel/zhipu_4v/constants.go new file mode 100644 index 00000000..816fa536 --- /dev/null +++ b/relay/channel/zhipu_4v/constants.go @@ -0,0 +1,7 @@ +package zhipu_4v + +var ModelList = []string{ + "glm-4", "glm-4v", "glm-3-turbo", "glm-4-alltools", "glm-4-plus", "glm-4-0520", "glm-4-air", "glm-4-airx", "glm-4-long", "glm-4-flash", "glm-4v-plus", +} + +var ChannelName = "zhipu_4v" diff --git a/relay/channel/zhipu_4v/dto.go b/relay/channel/zhipu_4v/dto.go new file mode 100644 index 00000000..4d867679 --- /dev/null +++ b/relay/channel/zhipu_4v/dto.go @@ -0,0 +1,59 @@ +package zhipu_4v + +import ( + "one-api/dto" + "time" +) + +// type ZhipuMessage struct { +// Role string `json:"role,omitempty"` +// Content string `json:"content,omitempty"` +// ToolCalls any `json:"tool_calls,omitempty"` +// ToolCallId any `json:"tool_call_id,omitempty"` +// } +// +// type ZhipuRequest struct { +// Model string `json:"model"` +// Stream bool `json:"stream,omitempty"` +// Messages []ZhipuMessage `json:"messages"` +// Temperature float64 `json:"temperature,omitempty"` +// TopP float64 `json:"top_p,omitempty"` +// MaxTokens int `json:"max_tokens,omitempty"` +// Stop []string `json:"stop,omitempty"` +// RequestId string `json:"request_id,omitempty"` +// Tools any `json:"tools,omitempty"` +// ToolChoice any `json:"tool_choice,omitempty"` +// } +// +// type ZhipuV4TextResponseChoice struct { +// Index int `json:"index"` +// ZhipuMessage `json:"message"` +// FinishReason string `json:"finish_reason"` +// } +type ZhipuV4Response struct { + Id string `json:"id"` + Created int64 `json:"created"` + Model string `json:"model"` + TextResponseChoices []dto.OpenAITextResponseChoice `json:"choices"` + Usage dto.Usage `json:"usage"` + Error dto.OpenAIError `json:"error"` +} + +// +//type ZhipuV4StreamResponseChoice struct { +// Index int `json:"index,omitempty"` +// Delta ZhipuMessage `json:"delta"` +// FinishReason *string `json:"finish_reason,omitempty"` +//} + +type ZhipuV4StreamResponse struct { + Id string `json:"id"` + Created int64 `json:"created"` + Choices []dto.ChatCompletionsStreamResponseChoice `json:"choices"` + Usage dto.Usage `json:"usage"` +} + +type tokenData struct { + Token string + ExpiryTime time.Time +} diff --git a/relay/channel/zhipu_4v/relay-zhipu_v4.go b/relay/channel/zhipu_4v/relay-zhipu_v4.go new file mode 100644 index 00000000..271dda8f --- /dev/null +++ b/relay/channel/zhipu_4v/relay-zhipu_v4.go @@ -0,0 +1,113 @@ +package zhipu_4v + +import ( + "github.com/golang-jwt/jwt" + "one-api/common" + "one-api/dto" + "strings" + "sync" + "time" +) + +// https://open.bigmodel.cn/doc/api#chatglm_std +// chatglm_std, chatglm_lite +// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/invoke +// https://open.bigmodel.cn/api/paas/v3/model-api/chatglm_std/sse-invoke + +var zhipuTokens sync.Map +var expSeconds int64 = 24 * 3600 + +func getZhipuToken(apikey string) string { + data, ok := zhipuTokens.Load(apikey) + if ok { + tokenData := data.(tokenData) + if time.Now().Before(tokenData.ExpiryTime) { + return tokenData.Token + } + } + + split := strings.Split(apikey, ".") + if len(split) != 2 { + common.SysError("invalid zhipu key: " + apikey) + return "" + } + + id := split[0] + secret := split[1] + + expMillis := time.Now().Add(time.Duration(expSeconds)*time.Second).UnixNano() / 1e6 + expiryTime := time.Now().Add(time.Duration(expSeconds) * time.Second) + + timestamp := time.Now().UnixNano() / 1e6 + + payload := jwt.MapClaims{ + "api_key": id, + "exp": expMillis, + "timestamp": timestamp, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) + + token.Header["alg"] = "HS256" + token.Header["sign_type"] = "SIGN" + + tokenString, err := token.SignedString([]byte(secret)) + if err != nil { + return "" + } + + zhipuTokens.Store(apikey, tokenData{ + Token: tokenString, + ExpiryTime: expiryTime, + }) + + return tokenString +} + +func requestOpenAI2Zhipu(request dto.GeneralOpenAIRequest) *dto.GeneralOpenAIRequest { + messages := make([]dto.Message, 0, len(request.Messages)) + for _, message := range request.Messages { + if !message.IsStringContent() { + mediaMessages := message.ParseContent() + for j, mediaMessage := range mediaMessages { + if mediaMessage.Type == dto.ContentTypeImageURL { + imageUrl := mediaMessage.GetImageMedia() + // check if base64 + if strings.HasPrefix(imageUrl.Url, "data:image/") { + // 去除base64数据的URL前缀(如果有) + if idx := strings.Index(imageUrl.Url, ","); idx != -1 { + imageUrl.Url = imageUrl.Url[idx+1:] + } + } + mediaMessage.ImageUrl = imageUrl + mediaMessages[j] = mediaMessage + } + } + message.SetMediaContent(mediaMessages) + } + messages = append(messages, dto.Message{ + Role: message.Role, + Content: message.Content, + ToolCalls: message.ToolCalls, + ToolCallId: message.ToolCallId, + }) + } + str, ok := request.Stop.(string) + var Stop []string + if ok { + Stop = []string{str} + } else { + Stop, _ = request.Stop.([]string) + } + return &dto.GeneralOpenAIRequest{ + Model: request.Model, + Stream: request.Stream, + Messages: messages, + Temperature: request.Temperature, + TopP: request.TopP, + MaxTokens: request.MaxTokens, + Stop: Stop, + Tools: request.Tools, + ToolChoice: request.ToolChoice, + } +} diff --git a/relay/claude_handler.go b/relay/claude_handler.go new file mode 100644 index 00000000..5f38960e --- /dev/null +++ b/relay/claude_handler.go @@ -0,0 +1,162 @@ +package relay + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/setting/model_setting" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func getAndValidateClaudeRequest(c *gin.Context) (textRequest *dto.ClaudeRequest, err error) { + textRequest = &dto.ClaudeRequest{} + err = c.ShouldBindJSON(textRequest) + if err != nil { + return nil, err + } + if textRequest.Messages == nil || len(textRequest.Messages) == 0 { + return nil, errors.New("field messages is required") + } + if textRequest.Model == "" { + return nil, errors.New("field model is required") + } + return textRequest, nil +} + +func ClaudeHelper(c *gin.Context) (newAPIError *types.NewAPIError) { + + relayInfo := relaycommon.GenRelayInfoClaude(c) + + // get & validate textRequest 获取并验证文本请求 + textRequest, err := getAndValidateClaudeRequest(c) + if err != nil { + return types.NewError(err, types.ErrorCodeInvalidRequest) + } + + if textRequest.Stream { + relayInfo.IsStream = true + } + + err = helper.ModelMappedHelper(c, relayInfo, textRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError) + } + + promptTokens, err := getClaudePromptTokens(textRequest, relayInfo) + // count messages token error 计算promptTokens错误 + if err != nil { + return types.NewError(err, types.ErrorCodeCountTokenFailed) + } + + priceData, err := helper.ModelPriceHelper(c, relayInfo, promptTokens, int(textRequest.MaxTokens)) + if err != nil { + return types.NewError(err, types.ErrorCodeModelPriceError) + } + + // pre-consume quota 预消耗配额 + preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + + if newAPIError != nil { + return newAPIError + } + defer func() { + if newAPIError != nil { + returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) + } + }() + + adaptor := GetAdaptor(relayInfo.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + } + adaptor.Init(relayInfo) + var requestBody io.Reader + + if textRequest.MaxTokens == 0 { + textRequest.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(textRequest.Model)) + } + + if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && + strings.HasSuffix(textRequest.Model, "-thinking") { + if textRequest.Thinking == nil { + // 因为BudgetTokens 必须大于1024 + if textRequest.MaxTokens < 1280 { + textRequest.MaxTokens = 1280 + } + + // BudgetTokens 为 max_tokens 的 80% + textRequest.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](int(float64(textRequest.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), + } + // TODO: 临时处理 + // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking + textRequest.TopP = 0 + textRequest.Temperature = common.GetPointer[float64](1.0) + } + textRequest.Model = strings.TrimSuffix(textRequest.Model, "-thinking") + relayInfo.UpstreamModelName = textRequest.Model + } + + convertedRequest, err := adaptor.ConvertClaudeRequest(c, relayInfo, textRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + jsonData, err := common.Marshal(convertedRequest) + if common.DebugEnabled { + println("requestBody: ", string(jsonData)) + } + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + requestBody = bytes.NewBuffer(jsonData) + + statusCodeMappingStr := c.GetString("status_code_mapping") + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, relayInfo, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + if resp != nil { + httpResp = resp.(*http.Response) + relayInfo.IsStream = relayInfo.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, relayInfo) + //log.Printf("usage: %v", usage) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + service.PostClaudeConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "") + return nil +} + +func getClaudePromptTokens(textRequest *dto.ClaudeRequest, info *relaycommon.RelayInfo) (int, error) { + var promptTokens int + var err error + switch info.RelayMode { + default: + promptTokens, err = service.CountTokenClaudeRequest(*textRequest, info.UpstreamModelName) + } + info.PromptTokens = promptTokens + return promptTokens, err +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go new file mode 100644 index 00000000..45fde019 --- /dev/null +++ b/relay/common/relay_info.go @@ -0,0 +1,344 @@ +package common + +import ( + "one-api/common" + "one-api/constant" + "one-api/dto" + relayconstant "one-api/relay/constant" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +type ThinkingContentInfo struct { + IsFirstThinkingContent bool + SendLastThinkingContent bool + HasSentThinkingContent bool +} + +const ( + LastMessageTypeNone = "none" + LastMessageTypeText = "text" + LastMessageTypeTools = "tools" + LastMessageTypeThinking = "thinking" +) + +type ClaudeConvertInfo struct { + LastMessagesType string + Index int + Usage *dto.Usage + FinishReason string + Done bool +} + +const ( + RelayFormatOpenAI = "openai" + RelayFormatClaude = "claude" + RelayFormatGemini = "gemini" + RelayFormatOpenAIResponses = "openai_responses" + RelayFormatOpenAIAudio = "openai_audio" + RelayFormatOpenAIImage = "openai_image" + RelayFormatRerank = "rerank" + RelayFormatEmbedding = "embedding" +) + +type RerankerInfo struct { + Documents []any + ReturnDocuments bool +} + +type BuildInToolInfo struct { + ToolName string + CallCount int + SearchContextSize string +} + +type ResponsesUsageInfo struct { + BuiltInTools map[string]*BuildInToolInfo +} + +type RelayInfo struct { + ChannelType int + ChannelId int + TokenId int + TokenKey string + UserId int + UsingGroup string // 使用的分组 + UserGroup string // 用户所在分组 + TokenUnlimited bool + StartTime time.Time + FirstResponseTime time.Time + isFirstResponse bool + //SendLastReasoningResponse bool + ApiType int + IsStream bool + IsPlayground bool + UsePrice bool + RelayMode int + UpstreamModelName string + OriginModelName string + //RecodeModelName string + RequestURLPath string + ApiVersion string + PromptTokens int + ApiKey string + Organization string + BaseUrl string + SupportStreamOptions bool + ShouldIncludeUsage bool + IsModelMapped bool + ClientWs *websocket.Conn + TargetWs *websocket.Conn + InputAudioFormat string + OutputAudioFormat string + RealtimeTools []dto.RealTimeTool + IsFirstRequest bool + AudioUsage bool + ReasoningEffort string + ChannelSetting dto.ChannelSettings + ParamOverride map[string]interface{} + UserSetting dto.UserSetting + UserEmail string + UserQuota int + RelayFormat string + SendResponseCount int + ChannelCreateTime int64 + ThinkingContentInfo + *ClaudeConvertInfo + *RerankerInfo + *ResponsesUsageInfo +} + +// 定义支持流式选项的通道类型 +var streamSupportedChannels = map[int]bool{ + constant.ChannelTypeOpenAI: true, + constant.ChannelTypeAnthropic: true, + constant.ChannelTypeAws: true, + constant.ChannelTypeGemini: true, + constant.ChannelCloudflare: true, + constant.ChannelTypeAzure: true, + constant.ChannelTypeVolcEngine: true, + constant.ChannelTypeOllama: true, + constant.ChannelTypeXai: true, + constant.ChannelTypeDeepSeek: true, + constant.ChannelTypeBaiduV2: true, +} + +func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo { + info := GenRelayInfo(c) + info.ClientWs = ws + info.InputAudioFormat = "pcm16" + info.OutputAudioFormat = "pcm16" + info.IsFirstRequest = true + return info +} + +func GenRelayInfoClaude(c *gin.Context) *RelayInfo { + info := GenRelayInfo(c) + info.RelayFormat = RelayFormatClaude + info.ShouldIncludeUsage = false + info.ClaudeConvertInfo = &ClaudeConvertInfo{ + LastMessagesType: LastMessageTypeNone, + } + return info +} + +func GenRelayInfoRerank(c *gin.Context, req *dto.RerankRequest) *RelayInfo { + info := GenRelayInfo(c) + info.RelayMode = relayconstant.RelayModeRerank + info.RelayFormat = RelayFormatRerank + info.RerankerInfo = &RerankerInfo{ + Documents: req.Documents, + ReturnDocuments: req.GetReturnDocuments(), + } + return info +} + +func GenRelayInfoOpenAIAudio(c *gin.Context) *RelayInfo { + info := GenRelayInfo(c) + info.RelayFormat = RelayFormatOpenAIAudio + return info +} + +func GenRelayInfoEmbedding(c *gin.Context) *RelayInfo { + info := GenRelayInfo(c) + info.RelayFormat = RelayFormatEmbedding + return info +} + +func GenRelayInfoResponses(c *gin.Context, req *dto.OpenAIResponsesRequest) *RelayInfo { + info := GenRelayInfo(c) + info.RelayMode = relayconstant.RelayModeResponses + info.RelayFormat = RelayFormatOpenAIResponses + + info.SupportStreamOptions = false + + info.ResponsesUsageInfo = &ResponsesUsageInfo{ + BuiltInTools: make(map[string]*BuildInToolInfo), + } + if len(req.Tools) > 0 { + for _, tool := range req.Tools { + toolType := common.Interface2String(tool["type"]) + info.ResponsesUsageInfo.BuiltInTools[toolType] = &BuildInToolInfo{ + ToolName: toolType, + CallCount: 0, + } + switch toolType { + case dto.BuildInToolWebSearchPreview: + searchContextSize := common.Interface2String(tool["search_context_size"]) + if searchContextSize == "" { + searchContextSize = "medium" + } + info.ResponsesUsageInfo.BuiltInTools[toolType].SearchContextSize = searchContextSize + } + } + } + info.IsStream = req.Stream + return info +} + +func GenRelayInfoGemini(c *gin.Context) *RelayInfo { + info := GenRelayInfo(c) + info.RelayFormat = RelayFormatGemini + info.ShouldIncludeUsage = false + return info +} + +func GenRelayInfoImage(c *gin.Context) *RelayInfo { + info := GenRelayInfo(c) + info.RelayFormat = RelayFormatOpenAIImage + return info +} + +func GenRelayInfo(c *gin.Context) *RelayInfo { + channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType) + channelId := common.GetContextKeyInt(c, constant.ContextKeyChannelId) + paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride) + + tokenId := common.GetContextKeyInt(c, constant.ContextKeyTokenId) + tokenKey := common.GetContextKeyString(c, constant.ContextKeyTokenKey) + userId := common.GetContextKeyInt(c, constant.ContextKeyUserId) + tokenUnlimited := common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited) + startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime) + // firstResponseTime = time.Now() - 1 second + + apiType, _ := common.ChannelType2APIType(channelType) + + info := &RelayInfo{ + UserQuota: common.GetContextKeyInt(c, constant.ContextKeyUserQuota), + UserEmail: common.GetContextKeyString(c, constant.ContextKeyUserEmail), + isFirstResponse: true, + RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path), + BaseUrl: common.GetContextKeyString(c, constant.ContextKeyChannelBaseUrl), + RequestURLPath: c.Request.URL.String(), + ChannelType: channelType, + ChannelId: channelId, + TokenId: tokenId, + TokenKey: tokenKey, + UserId: userId, + UsingGroup: common.GetContextKeyString(c, constant.ContextKeyUsingGroup), + UserGroup: common.GetContextKeyString(c, constant.ContextKeyUserGroup), + TokenUnlimited: tokenUnlimited, + StartTime: startTime, + FirstResponseTime: startTime.Add(-time.Second), + OriginModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel), + UpstreamModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel), + //RecodeModelName: c.GetString("original_model"), + IsModelMapped: false, + ApiType: apiType, + ApiVersion: c.GetString("api_version"), + ApiKey: common.GetContextKeyString(c, constant.ContextKeyChannelKey), + Organization: c.GetString("channel_organization"), + + ChannelCreateTime: c.GetInt64("channel_create_time"), + ParamOverride: paramOverride, + RelayFormat: RelayFormatOpenAI, + ThinkingContentInfo: ThinkingContentInfo{ + IsFirstThinkingContent: true, + SendLastThinkingContent: false, + }, + } + if strings.HasPrefix(c.Request.URL.Path, "/pg") { + info.IsPlayground = true + info.RequestURLPath = strings.TrimPrefix(info.RequestURLPath, "/pg") + info.RequestURLPath = "/v1" + info.RequestURLPath + } + if info.BaseUrl == "" { + info.BaseUrl = constant.ChannelBaseURLs[channelType] + } + if info.ChannelType == constant.ChannelTypeAzure { + info.ApiVersion = GetAPIVersion(c) + } + if info.ChannelType == constant.ChannelTypeVertexAi { + info.ApiVersion = c.GetString("region") + } + if streamSupportedChannels[info.ChannelType] { + info.SupportStreamOptions = true + } + + channelSetting, ok := common.GetContextKeyType[dto.ChannelSettings](c, constant.ContextKeyChannelSetting) + if ok { + info.ChannelSetting = channelSetting + } + userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting) + if ok { + info.UserSetting = userSetting + } + + return info +} + +func (info *RelayInfo) SetPromptTokens(promptTokens int) { + info.PromptTokens = promptTokens +} + +func (info *RelayInfo) SetIsStream(isStream bool) { + info.IsStream = isStream +} + +func (info *RelayInfo) SetFirstResponseTime() { + if info.isFirstResponse { + info.FirstResponseTime = time.Now() + info.isFirstResponse = false + } +} + +func (info *RelayInfo) HasSendResponse() bool { + return info.FirstResponseTime.After(info.StartTime) +} + +type TaskRelayInfo struct { + *RelayInfo + Action string + OriginTaskID string + + ConsumeQuota bool +} + +func GenTaskRelayInfo(c *gin.Context) *TaskRelayInfo { + info := &TaskRelayInfo{ + RelayInfo: GenRelayInfo(c), + } + return info +} + +type TaskSubmitReq struct { + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Image string `json:"image,omitempty"` + Size string `json:"size,omitempty"` + Duration int `json:"duration,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +type TaskInfo struct { + Code int `json:"code"` + TaskID string `json:"task_id"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Url string `json:"url,omitempty"` + Progress string `json:"progress,omitempty"` +} diff --git a/relay/common/relay_utils.go b/relay/common/relay_utils.go new file mode 100644 index 00000000..29086585 --- /dev/null +++ b/relay/common/relay_utils.go @@ -0,0 +1,34 @@ +package common + +import ( + "fmt" + "github.com/gin-gonic/gin" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "one-api/constant" + "strings" +) + +func GetFullRequestURL(baseURL string, requestURL string, channelType int) string { + fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) + + if strings.HasPrefix(baseURL, "https://gateway.ai.cloudflare.com") { + switch channelType { + case constant.ChannelTypeOpenAI: + fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/v1")) + case constant.ChannelTypeAzure: + fullRequestURL = fmt.Sprintf("%s%s", baseURL, strings.TrimPrefix(requestURL, "/openai/deployments")) + } + } + return fullRequestURL +} + +func GetAPIVersion(c *gin.Context) string { + query := c.Request.URL.Query() + apiVersion := query.Get("api-version") + if apiVersion == "" { + apiVersion = c.GetString("api_version") + } + return apiVersion +} diff --git a/relay/common_handler/rerank.go b/relay/common_handler/rerank.go new file mode 100644 index 00000000..ce823b3a --- /dev/null +++ b/relay/common_handler/rerank.go @@ -0,0 +1,73 @@ +package common_handler + +import ( + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/relay/channel/xinference" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +func RerankHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeReadResponseBodyFailed) + } + common.CloseResponseBodyGracefully(resp) + if common.DebugEnabled { + println("reranker response body: ", string(responseBody)) + } + var jinaResp dto.RerankResponse + if info.ChannelType == constant.ChannelTypeXinference { + var xinRerankResponse xinference.XinRerankResponse + err = common.Unmarshal(responseBody, &xinRerankResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + jinaRespResults := make([]dto.RerankResponseResult, len(xinRerankResponse.Results)) + for i, result := range xinRerankResponse.Results { + respResult := dto.RerankResponseResult{ + Index: result.Index, + RelevanceScore: result.RelevanceScore, + } + if info.ReturnDocuments { + var document any + if result.Document != nil { + if doc, ok := result.Document.(string); ok { + if doc == "" { + document = info.Documents[result.Index] + } else { + document = doc + } + } else { + document = result.Document + } + } + respResult.Document = document + } + jinaRespResults[i] = respResult + } + jinaResp = dto.RerankResponse{ + Results: jinaRespResults, + Usage: dto.Usage{ + PromptTokens: info.PromptTokens, + TotalTokens: info.PromptTokens, + }, + } + } else { + err = common.Unmarshal(responseBody, &jinaResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + jinaResp.Usage.PromptTokens = jinaResp.Usage.TotalTokens + } + + c.Writer.Header().Set("Content-Type", "application/json") + c.JSON(http.StatusOK, jinaResp) + return &jinaResp.Usage, nil +} diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go new file mode 100644 index 00000000..b5195752 --- /dev/null +++ b/relay/constant/relay_mode.go @@ -0,0 +1,167 @@ +package constant + +import ( + "net/http" + "strings" +) + +const ( + RelayModeUnknown = iota + RelayModeChatCompletions + RelayModeCompletions + RelayModeEmbeddings + RelayModeModerations + RelayModeImagesGenerations + RelayModeImagesEdits + RelayModeEdits + + RelayModeMidjourneyImagine + RelayModeMidjourneyDescribe + RelayModeMidjourneyBlend + RelayModeMidjourneyChange + RelayModeMidjourneySimpleChange + RelayModeMidjourneyNotify + RelayModeMidjourneyTaskFetch + RelayModeMidjourneyTaskImageSeed + RelayModeMidjourneyTaskFetchByCondition + RelayModeMidjourneyAction + RelayModeMidjourneyModal + RelayModeMidjourneyShorten + RelayModeSwapFace + RelayModeMidjourneyUpload + RelayModeMidjourneyVideo + RelayModeMidjourneyEdits + + RelayModeAudioSpeech // tts + RelayModeAudioTranscription // whisper + RelayModeAudioTranslation // whisper + + RelayModeSunoFetch + RelayModeSunoFetchByID + RelayModeSunoSubmit + + RelayModeKlingFetchByID + RelayModeKlingSubmit + + RelayModeJimengFetchByID + RelayModeJimengSubmit + + RelayModeRerank + + RelayModeResponses + + RelayModeRealtime + + RelayModeGemini +) + +func Path2RelayMode(path string) int { + relayMode := RelayModeUnknown + if strings.HasPrefix(path, "/v1/chat/completions") || strings.HasPrefix(path, "/pg/chat/completions") { + relayMode = RelayModeChatCompletions + } else if strings.HasPrefix(path, "/v1/completions") { + relayMode = RelayModeCompletions + } else if strings.HasPrefix(path, "/v1/embeddings") { + relayMode = RelayModeEmbeddings + } else if strings.HasSuffix(path, "embeddings") { + relayMode = RelayModeEmbeddings + } else if strings.HasPrefix(path, "/v1/moderations") { + relayMode = RelayModeModerations + } else if strings.HasPrefix(path, "/v1/images/generations") { + relayMode = RelayModeImagesGenerations + } else if strings.HasPrefix(path, "/v1/images/edits") { + relayMode = RelayModeImagesEdits + } else if strings.HasPrefix(path, "/v1/edits") { + relayMode = RelayModeEdits + } else if strings.HasPrefix(path, "/v1/responses") { + relayMode = RelayModeResponses + } else if strings.HasPrefix(path, "/v1/audio/speech") { + relayMode = RelayModeAudioSpeech + } else if strings.HasPrefix(path, "/v1/audio/transcriptions") { + relayMode = RelayModeAudioTranscription + } else if strings.HasPrefix(path, "/v1/audio/translations") { + relayMode = RelayModeAudioTranslation + } else if strings.HasPrefix(path, "/v1/rerank") { + relayMode = RelayModeRerank + } else if strings.HasPrefix(path, "/v1/realtime") { + relayMode = RelayModeRealtime + } else if strings.HasPrefix(path, "/v1beta/models") || strings.HasPrefix(path, "/v1/models") { + relayMode = RelayModeGemini + } + return relayMode +} + +func Path2RelayModeMidjourney(path string) int { + relayMode := RelayModeUnknown + if strings.HasSuffix(path, "/mj/submit/action") { + // midjourney plus + relayMode = RelayModeMidjourneyAction + } else if strings.HasSuffix(path, "/mj/submit/modal") { + // midjourney plus + relayMode = RelayModeMidjourneyModal + } else if strings.HasSuffix(path, "/mj/submit/shorten") { + // midjourney plus + relayMode = RelayModeMidjourneyShorten + } else if strings.HasSuffix(path, "/mj/insight-face/swap") { + // midjourney plus + relayMode = RelayModeSwapFace + } else if strings.HasSuffix(path, "/submit/upload-discord-images") { + // midjourney plus + relayMode = RelayModeMidjourneyUpload + } else if strings.HasSuffix(path, "/mj/submit/imagine") { + relayMode = RelayModeMidjourneyImagine + } else if strings.HasSuffix(path, "/mj/submit/video") { + relayMode = RelayModeMidjourneyVideo + } else if strings.HasSuffix(path, "/mj/submit/edits") { + relayMode = RelayModeMidjourneyEdits + } else if strings.HasSuffix(path, "/mj/submit/blend") { + relayMode = RelayModeMidjourneyBlend + } else if strings.HasSuffix(path, "/mj/submit/describe") { + relayMode = RelayModeMidjourneyDescribe + } else if strings.HasSuffix(path, "/mj/notify") { + relayMode = RelayModeMidjourneyNotify + } else if strings.HasSuffix(path, "/mj/submit/change") { + relayMode = RelayModeMidjourneyChange + } else if strings.HasSuffix(path, "/mj/submit/simple-change") { + relayMode = RelayModeMidjourneyChange + } else if strings.HasSuffix(path, "/fetch") { + relayMode = RelayModeMidjourneyTaskFetch + } else if strings.HasSuffix(path, "/image-seed") { + relayMode = RelayModeMidjourneyTaskImageSeed + } else if strings.HasSuffix(path, "/list-by-condition") { + relayMode = RelayModeMidjourneyTaskFetchByCondition + } + return relayMode +} + +func Path2RelaySuno(method, path string) int { + relayMode := RelayModeUnknown + if method == http.MethodPost && strings.HasSuffix(path, "/fetch") { + relayMode = RelayModeSunoFetch + } else if method == http.MethodGet && strings.Contains(path, "/fetch/") { + relayMode = RelayModeSunoFetchByID + } else if strings.Contains(path, "/submit/") { + relayMode = RelayModeSunoSubmit + } + return relayMode +} + +func Path2RelayKling(method, path string) int { + relayMode := RelayModeUnknown + if method == http.MethodPost && strings.HasSuffix(path, "/video/generations") { + relayMode = RelayModeKlingSubmit + } else if method == http.MethodGet && strings.Contains(path, "/video/generations/") { + relayMode = RelayModeKlingFetchByID + } + return relayMode +} + +func Path2RelayJimeng(method, path string) int { + relayMode := RelayModeUnknown + if method == http.MethodPost && strings.HasSuffix(path, "/video/generations") { + relayMode = RelayModeJimengSubmit + } else if method == http.MethodGet && strings.Contains(path, "/video/generations/") { + relayMode = RelayModeJimengFetchByID + } + return relayMode +} diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go new file mode 100644 index 00000000..be11bb2b --- /dev/null +++ b/relay/embedding_handler.go @@ -0,0 +1,116 @@ +package relay + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +func getEmbeddingPromptToken(embeddingRequest dto.EmbeddingRequest) int { + token := service.CountTokenInput(embeddingRequest.Input, embeddingRequest.Model) + return token +} + +func validateEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, embeddingRequest dto.EmbeddingRequest) error { + if embeddingRequest.Input == nil { + return fmt.Errorf("input is empty") + } + if info.RelayMode == relayconstant.RelayModeModerations && embeddingRequest.Model == "" { + embeddingRequest.Model = "omni-moderation-latest" + } + if info.RelayMode == relayconstant.RelayModeEmbeddings && embeddingRequest.Model == "" { + embeddingRequest.Model = c.Param("model") + } + return nil +} + +func EmbeddingHelper(c *gin.Context) (newAPIError *types.NewAPIError) { + relayInfo := relaycommon.GenRelayInfoEmbedding(c) + + var embeddingRequest *dto.EmbeddingRequest + err := common.UnmarshalBodyReusable(c, &embeddingRequest) + if err != nil { + common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error())) + return types.NewError(err, types.ErrorCodeInvalidRequest) + } + + err = validateEmbeddingRequest(c, relayInfo, *embeddingRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeInvalidRequest) + } + + err = helper.ModelMappedHelper(c, relayInfo, embeddingRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError) + } + + promptToken := getEmbeddingPromptToken(*embeddingRequest) + relayInfo.PromptTokens = promptToken + + priceData, err := helper.ModelPriceHelper(c, relayInfo, promptToken, 0) + if err != nil { + return types.NewError(err, types.ErrorCodeModelPriceError) + } + // pre-consume quota 预消耗配额 + preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + if newAPIError != nil { + return newAPIError + } + defer func() { + if newAPIError != nil { + returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) + } + }() + + adaptor := GetAdaptor(relayInfo.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + } + adaptor.Init(relayInfo) + + convertedRequest, err := adaptor.ConvertEmbeddingRequest(c, relayInfo, *embeddingRequest) + + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + requestBody := bytes.NewBuffer(jsonData) + statusCodeMappingStr := c.GetString("status_code_mapping") + resp, err := adaptor.DoRequest(c, relayInfo, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, relayInfo) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "") + return nil +} diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go new file mode 100644 index 00000000..e448b491 --- /dev/null +++ b/relay/gemini_handler.go @@ -0,0 +1,234 @@ +package relay + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/relay/channel/gemini" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/setting" + "one-api/setting/model_setting" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func getAndValidateGeminiRequest(c *gin.Context) (*gemini.GeminiChatRequest, error) { + request := &gemini.GeminiChatRequest{} + err := common.UnmarshalBodyReusable(c, request) + if err != nil { + return nil, err + } + if len(request.Contents) == 0 { + return nil, errors.New("contents is required") + } + return request, nil +} + +// 流模式 +// /v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=xxx +func checkGeminiStreamMode(c *gin.Context, relayInfo *relaycommon.RelayInfo) { + if c.Query("alt") == "sse" { + relayInfo.IsStream = true + } + + // if strings.Contains(c.Request.URL.Path, "streamGenerateContent") { + // relayInfo.IsStream = true + // } +} + +func checkGeminiInputSensitive(textRequest *gemini.GeminiChatRequest) ([]string, error) { + var inputTexts []string + for _, content := range textRequest.Contents { + for _, part := range content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + } + } + if len(inputTexts) == 0 { + return nil, nil + } + + sensitiveWords, err := service.CheckSensitiveInput(inputTexts) + return sensitiveWords, err +} + +func getGeminiInputTokens(req *gemini.GeminiChatRequest, info *relaycommon.RelayInfo) int { + // 计算输入 token 数量 + var inputTexts []string + for _, content := range req.Contents { + for _, part := range content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + } + } + + inputText := strings.Join(inputTexts, "\n") + inputTokens := service.CountTokenInput(inputText, info.UpstreamModelName) + info.PromptTokens = inputTokens + return inputTokens +} + +func isNoThinkingRequest(req *gemini.GeminiChatRequest) bool { + if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { + return *req.GenerationConfig.ThinkingConfig.ThinkingBudget <= 0 + } + return false +} + +func trimModelThinking(modelName string) string { + // 去除模型名称中的 -nothinking 后缀 + if strings.HasSuffix(modelName, "-nothinking") { + return strings.TrimSuffix(modelName, "-nothinking") + } + // 去除模型名称中的 -thinking 后缀 + if strings.HasSuffix(modelName, "-thinking") { + return strings.TrimSuffix(modelName, "-thinking") + } + + // 去除模型名称中的 -thinking-number + if strings.Contains(modelName, "-thinking-") { + parts := strings.Split(modelName, "-thinking-") + if len(parts) > 1 { + return parts[0] + "-thinking" + } + } + return modelName +} + +func GeminiHelper(c *gin.Context) (newAPIError *types.NewAPIError) { + req, err := getAndValidateGeminiRequest(c) + if err != nil { + common.LogError(c, fmt.Sprintf("getAndValidateGeminiRequest error: %s", err.Error())) + return types.NewError(err, types.ErrorCodeInvalidRequest) + } + + relayInfo := relaycommon.GenRelayInfoGemini(c) + + // 检查 Gemini 流式模式 + checkGeminiStreamMode(c, relayInfo) + + if setting.ShouldCheckPromptSensitive() { + sensitiveWords, err := checkGeminiInputSensitive(req) + if err != nil { + common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(sensitiveWords, ", "))) + return types.NewError(err, types.ErrorCodeSensitiveWordsDetected) + } + } + + // model mapped 模型映射 + err = helper.ModelMappedHelper(c, relayInfo, req) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError) + } + + if value, exists := c.Get("prompt_tokens"); exists { + promptTokens := value.(int) + relayInfo.SetPromptTokens(promptTokens) + } else { + promptTokens := getGeminiInputTokens(req, relayInfo) + c.Set("prompt_tokens", promptTokens) + } + + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { + if isNoThinkingRequest(req) { + // check is thinking + if !strings.Contains(relayInfo.OriginModelName, "-nothinking") { + // try to get no thinking model price + noThinkingModelName := relayInfo.OriginModelName + "-nothinking" + containPrice := helper.ContainPriceOrRatio(noThinkingModelName) + if containPrice { + relayInfo.OriginModelName = noThinkingModelName + relayInfo.UpstreamModelName = noThinkingModelName + } + } + } + if req.GenerationConfig.ThinkingConfig == nil { + gemini.ThinkingAdaptor(req, relayInfo) + } + } + + priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.GenerationConfig.MaxOutputTokens)) + if err != nil { + return types.NewError(err, types.ErrorCodeModelPriceError) + } + + // pre consume quota + preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + if newAPIError != nil { + return newAPIError + } + defer func() { + if newAPIError != nil { + returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) + } + }() + + adaptor := GetAdaptor(relayInfo.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + } + + adaptor.Init(relayInfo) + + // Clean up empty system instruction + if req.SystemInstructions != nil { + hasContent := false + for _, part := range req.SystemInstructions.Parts { + if part.Text != "" { + hasContent = true + break + } + } + if !hasContent { + req.SystemInstructions = nil + } + } + + requestBody, err := json.Marshal(req) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + + if common.DebugEnabled { + println("Gemini request body: %s", string(requestBody)) + } + + resp, err := adaptor.DoRequest(c, relayInfo, bytes.NewReader(requestBody)) + if err != nil { + common.LogError(c, "Do gemini request failed: "+err.Error()) + return types.NewError(err, types.ErrorCodeDoRequestFailed) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + relayInfo.IsStream = relayInfo.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), relayInfo) + if openaiErr != nil { + service.ResetStatusCode(openaiErr, statusCodeMappingStr) + return openaiErr + } + + postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "") + return nil +} diff --git a/relay/helper/common.go b/relay/helper/common.go new file mode 100644 index 00000000..5d23b512 --- /dev/null +++ b/relay/helper/common.go @@ -0,0 +1,167 @@ +package helper + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/types" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func SetEventStreamHeaders(c *gin.Context) { + // 检查是否已经设置过头部 + if _, exists := c.Get("event_stream_headers_set"); exists { + return + } + + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + c.Writer.Header().Set("X-Accel-Buffering", "no") + + // 设置标志,表示头部已经设置过 + c.Set("event_stream_headers_set", true) +} + +func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error { + jsonData, err := json.Marshal(resp) + if err != nil { + common.SysError("error marshalling stream response: " + err.Error()) + } else { + c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}) + c.Render(-1, common.CustomEvent{Data: "data: " + string(jsonData)}) + } + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } else { + return errors.New("streaming error: flusher not found") + } + return nil +} + +func ClaudeChunkData(c *gin.Context, resp dto.ClaudeResponse, data string) { + c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}) + c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("data: %s\n", data)}) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } +} + +func ResponseChunkData(c *gin.Context, resp dto.ResponsesStreamResponse, data string) { + c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("event: %s\n", resp.Type)}) + c.Render(-1, common.CustomEvent{Data: fmt.Sprintf("data: %s", data)}) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } +} + +func StringData(c *gin.Context, str string) error { + //str = strings.TrimPrefix(str, "data: ") + //str = strings.TrimSuffix(str, "\r") + c.Render(-1, common.CustomEvent{Data: "data: " + str}) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } else { + return errors.New("streaming error: flusher not found") + } + return nil +} + +func PingData(c *gin.Context) error { + c.Writer.Write([]byte(": PING\n\n")) + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + } else { + return errors.New("streaming error: flusher not found") + } + return nil +} + +func ObjectData(c *gin.Context, object interface{}) error { + if object == nil { + return errors.New("object is nil") + } + jsonData, err := common.Marshal(object) + if err != nil { + return fmt.Errorf("error marshalling object: %w", err) + } + return StringData(c, string(jsonData)) +} + +func Done(c *gin.Context) { + _ = StringData(c, "[DONE]") +} + +func WssString(c *gin.Context, ws *websocket.Conn, str string) error { + if ws == nil { + common.LogError(c, "websocket connection is nil") + return errors.New("websocket connection is nil") + } + //common.LogInfo(c, fmt.Sprintf("sending message: %s", str)) + return ws.WriteMessage(1, []byte(str)) +} + +func WssObject(c *gin.Context, ws *websocket.Conn, object interface{}) error { + jsonData, err := json.Marshal(object) + if err != nil { + return fmt.Errorf("error marshalling object: %w", err) + } + if ws == nil { + common.LogError(c, "websocket connection is nil") + return errors.New("websocket connection is nil") + } + //common.LogInfo(c, fmt.Sprintf("sending message: %s", jsonData)) + return ws.WriteMessage(1, jsonData) +} + +func WssError(c *gin.Context, ws *websocket.Conn, openaiError types.OpenAIError) { + errorObj := &dto.RealtimeEvent{ + Type: "error", + EventId: GetLocalRealtimeID(c), + Error: &openaiError, + } + _ = WssObject(c, ws, errorObj) +} + +func GetResponseID(c *gin.Context) string { + logID := c.GetString(common.RequestIdKey) + return fmt.Sprintf("chatcmpl-%s", logID) +} + +func GetLocalRealtimeID(c *gin.Context) string { + logID := c.GetString(common.RequestIdKey) + return fmt.Sprintf("evt_%s", logID) +} + +func GenerateStopResponse(id string, createAt int64, model string, finishReason string) *dto.ChatCompletionsStreamResponse { + return &dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: createAt, + Model: model, + SystemFingerprint: nil, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + FinishReason: &finishReason, + }, + }, + } +} + +func GenerateFinalUsageResponse(id string, createAt int64, model string, usage dto.Usage) *dto.ChatCompletionsStreamResponse { + return &dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: createAt, + Model: model, + SystemFingerprint: nil, + Choices: make([]dto.ChatCompletionsStreamResponseChoice, 0), + Usage: &usage, + } +} diff --git a/relay/helper/model_mapped.go b/relay/helper/model_mapped.go new file mode 100644 index 00000000..c1735149 --- /dev/null +++ b/relay/helper/model_mapped.go @@ -0,0 +1,92 @@ +package helper + +import ( + "encoding/json" + "errors" + "fmt" + common2 "one-api/common" + "one-api/dto" + "one-api/relay/common" + + "github.com/gin-gonic/gin" +) + +func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request any) error { + // map model name + modelMapping := c.GetString("model_mapping") + if modelMapping != "" && modelMapping != "{}" { + modelMap := make(map[string]string) + err := json.Unmarshal([]byte(modelMapping), &modelMap) + if err != nil { + return fmt.Errorf("unmarshal_model_mapping_failed") + } + + // 支持链式模型重定向,最终使用链尾的模型 + currentModel := info.OriginModelName + visitedModels := map[string]bool{ + currentModel: true, + } + for { + if mappedModel, exists := modelMap[currentModel]; exists && mappedModel != "" { + // 模型重定向循环检测,避免无限循环 + if visitedModels[mappedModel] { + if mappedModel == currentModel { + if currentModel == info.OriginModelName { + info.IsModelMapped = false + return nil + } else { + info.IsModelMapped = true + break + } + } + return errors.New("model_mapping_contains_cycle") + } + visitedModels[mappedModel] = true + currentModel = mappedModel + info.IsModelMapped = true + } else { + break + } + } + if info.IsModelMapped { + info.UpstreamModelName = currentModel + } + } + if request != nil { + switch info.RelayFormat { + case common.RelayFormatGemini: + // Gemini 模型映射 + case common.RelayFormatClaude: + if claudeRequest, ok := request.(*dto.ClaudeRequest); ok { + claudeRequest.Model = info.UpstreamModelName + } + case common.RelayFormatOpenAIResponses: + if openAIResponsesRequest, ok := request.(*dto.OpenAIResponsesRequest); ok { + openAIResponsesRequest.Model = info.UpstreamModelName + } + case common.RelayFormatOpenAIAudio: + if openAIAudioRequest, ok := request.(*dto.AudioRequest); ok { + openAIAudioRequest.Model = info.UpstreamModelName + } + case common.RelayFormatOpenAIImage: + if imageRequest, ok := request.(*dto.ImageRequest); ok { + imageRequest.Model = info.UpstreamModelName + } + case common.RelayFormatRerank: + if rerankRequest, ok := request.(*dto.RerankRequest); ok { + rerankRequest.Model = info.UpstreamModelName + } + case common.RelayFormatEmbedding: + if embeddingRequest, ok := request.(*dto.EmbeddingRequest); ok { + embeddingRequest.Model = info.UpstreamModelName + } + default: + if openAIRequest, ok := request.(*dto.GeneralOpenAIRequest); ok { + openAIRequest.Model = info.UpstreamModelName + } else { + common2.LogWarn(c, fmt.Sprintf("model mapped but request type %T not supported", request)) + } + } + } + return nil +} diff --git a/relay/helper/price.go b/relay/helper/price.go new file mode 100644 index 00000000..e80578e5 --- /dev/null +++ b/relay/helper/price.go @@ -0,0 +1,161 @@ +package helper + +import ( + "fmt" + "one-api/common" + relaycommon "one-api/relay/common" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +type GroupRatioInfo struct { + GroupRatio float64 + GroupSpecialRatio float64 + HasSpecialRatio bool +} + +type PriceData struct { + ModelPrice float64 + ModelRatio float64 + CompletionRatio float64 + CacheRatio float64 + CacheCreationRatio float64 + ImageRatio float64 + UsePrice bool + ShouldPreConsumedQuota int + GroupRatioInfo GroupRatioInfo +} + +func (p PriceData) ToSetting() string { + return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, ShouldPreConsumedQuota: %d, ImageRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio) +} + +// HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present +func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) GroupRatioInfo { + groupRatioInfo := GroupRatioInfo{ + GroupRatio: 1.0, // default ratio + GroupSpecialRatio: -1, + } + + // check auto group + autoGroup, exists := ctx.Get("auto_group") + if exists { + if common.DebugEnabled { + println(fmt.Sprintf("final group: %s", autoGroup)) + } + relayInfo.UsingGroup = autoGroup.(string) + } + + // check user group special ratio + userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) + if ok { + // user group special ratio + groupRatioInfo.GroupSpecialRatio = userGroupRatio + groupRatioInfo.GroupRatio = userGroupRatio + groupRatioInfo.HasSpecialRatio = true + } else { + // normal group ratio + groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup) + } + + return groupRatioInfo +} + +func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, maxTokens int) (PriceData, error) { + modelPrice, usePrice := ratio_setting.GetModelPrice(info.OriginModelName, false) + + groupRatioInfo := HandleGroupRatio(c, info) + + var preConsumedQuota int + var modelRatio float64 + var completionRatio float64 + var cacheRatio float64 + var imageRatio float64 + var cacheCreationRatio float64 + if !usePrice { + preConsumedTokens := common.PreConsumedQuota + if maxTokens != 0 { + preConsumedTokens = promptTokens + maxTokens + } + var success bool + var matchName string + modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName) + if !success { + acceptUnsetRatio := false + if info.UserSetting.AcceptUnsetRatioModel { + acceptUnsetRatio = true + } + if !acceptUnsetRatio { + return PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开始自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName) + } + } + completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName) + cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName) + cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName) + imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName) + ratio := modelRatio * groupRatioInfo.GroupRatio + preConsumedQuota = int(float64(preConsumedTokens) * ratio) + } else { + preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + } + + priceData := PriceData{ + ModelPrice: modelPrice, + ModelRatio: modelRatio, + CompletionRatio: completionRatio, + GroupRatioInfo: groupRatioInfo, + UsePrice: usePrice, + CacheRatio: cacheRatio, + ImageRatio: imageRatio, + CacheCreationRatio: cacheCreationRatio, + ShouldPreConsumedQuota: preConsumedQuota, + } + + if common.DebugEnabled { + println(fmt.Sprintf("model_price_helper result: %s", priceData.ToSetting())) + } + + return priceData, nil +} + +type PerCallPriceData struct { + ModelPrice float64 + Quota int + GroupRatioInfo GroupRatioInfo +} + +// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task) +func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) PerCallPriceData { + groupRatioInfo := HandleGroupRatio(c, info) + + modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) + // 如果没有配置价格,则使用默认价格 + if !success { + defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[info.OriginModelName] + if !ok { + modelPrice = 0.1 + } else { + modelPrice = defaultPrice + } + } + quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + priceData := PerCallPriceData{ + ModelPrice: modelPrice, + Quota: quota, + GroupRatioInfo: groupRatioInfo, + } + return priceData +} + +func ContainPriceOrRatio(modelName string) bool { + _, ok := ratio_setting.GetModelPrice(modelName, false) + if ok { + return true + } + _, ok, _ = ratio_setting.GetModelRatio(modelName) + if ok { + return true + } + return false +} diff --git a/relay/helper/stream_scanner.go b/relay/helper/stream_scanner.go new file mode 100644 index 00000000..b526b1c0 --- /dev/null +++ b/relay/helper/stream_scanner.go @@ -0,0 +1,259 @@ +package helper + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + relaycommon "one-api/relay/common" + "one-api/setting/operation_setting" + "strings" + "sync" + "time" + + "github.com/bytedance/gopkg/util/gopool" + + "github.com/gin-gonic/gin" +) + +const ( + InitialScannerBufferSize = 64 << 10 // 64KB (64*1024) + MaxScannerBufferSize = 10 << 20 // 10MB (10*1024*1024) + DefaultPingInterval = 10 * time.Second +) + +func StreamScannerHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, dataHandler func(data string) bool) { + + if resp == nil || dataHandler == nil { + return + } + + // 确保响应体总是被关闭 + defer func() { + if resp.Body != nil { + resp.Body.Close() + } + }() + + streamingTimeout := time.Duration(constant.StreamingTimeout) * time.Second + if strings.HasPrefix(info.UpstreamModelName, "o") { + // twice timeout for thinking model + streamingTimeout *= 2 + } + + var ( + stopChan = make(chan bool, 3) // 增加缓冲区避免阻塞 + scanner = bufio.NewScanner(resp.Body) + ticker = time.NewTicker(streamingTimeout) + pingTicker *time.Ticker + writeMutex sync.Mutex // Mutex to protect concurrent writes + wg sync.WaitGroup // 用于等待所有 goroutine 退出 + ) + + generalSettings := operation_setting.GetGeneralSetting() + pingEnabled := generalSettings.PingIntervalEnabled + pingInterval := time.Duration(generalSettings.PingIntervalSeconds) * time.Second + if pingInterval <= 0 { + pingInterval = DefaultPingInterval + } + + if pingEnabled { + pingTicker = time.NewTicker(pingInterval) + } + + if common.DebugEnabled { + // print timeout and ping interval for debugging + println("relay timeout seconds:", common.RelayTimeout) + println("streaming timeout seconds:", int64(streamingTimeout.Seconds())) + println("ping interval seconds:", int64(pingInterval.Seconds())) + } + + // 改进资源清理,确保所有 goroutine 正确退出 + defer func() { + // 通知所有 goroutine 停止 + common.SafeSendBool(stopChan, true) + + ticker.Stop() + if pingTicker != nil { + pingTicker.Stop() + } + + // 等待所有 goroutine 退出,最多等待5秒 + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + common.LogError(c, "timeout waiting for goroutines to exit") + } + + close(stopChan) + }() + + scanner.Buffer(make([]byte, InitialScannerBufferSize), MaxScannerBufferSize) + scanner.Split(bufio.ScanLines) + SetEventStreamHeaders(c) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + ctx = context.WithValue(ctx, "stop_chan", stopChan) + + // Handle ping data sending with improved error handling + if pingEnabled && pingTicker != nil { + wg.Add(1) + gopool.Go(func() { + defer func() { + wg.Done() + if r := recover(); r != nil { + common.LogError(c, fmt.Sprintf("ping goroutine panic: %v", r)) + common.SafeSendBool(stopChan, true) + } + if common.DebugEnabled { + println("ping goroutine exited") + } + }() + + // 添加超时保护,防止 goroutine 无限运行 + maxPingDuration := 30 * time.Minute // 最大 ping 持续时间 + pingTimeout := time.NewTimer(maxPingDuration) + defer pingTimeout.Stop() + + for { + select { + case <-pingTicker.C: + // 使用超时机制防止写操作阻塞 + done := make(chan error, 1) + go func() { + writeMutex.Lock() + defer writeMutex.Unlock() + done <- PingData(c) + }() + + select { + case err := <-done: + if err != nil { + common.LogError(c, "ping data error: "+err.Error()) + return + } + if common.DebugEnabled { + println("ping data sent") + } + case <-time.After(10 * time.Second): + common.LogError(c, "ping data send timeout") + return + case <-ctx.Done(): + return + case <-stopChan: + return + } + case <-ctx.Done(): + return + case <-stopChan: + return + case <-c.Request.Context().Done(): + // 监听客户端断开连接 + return + case <-pingTimeout.C: + common.LogError(c, "ping goroutine max duration reached") + return + } + } + }) + } + + // Scanner goroutine with improved error handling + wg.Add(1) + common.RelayCtxGo(ctx, func() { + defer func() { + wg.Done() + if r := recover(); r != nil { + common.LogError(c, fmt.Sprintf("scanner goroutine panic: %v", r)) + } + common.SafeSendBool(stopChan, true) + if common.DebugEnabled { + println("scanner goroutine exited") + } + }() + + for scanner.Scan() { + // 检查是否需要停止 + select { + case <-stopChan: + return + case <-ctx.Done(): + return + case <-c.Request.Context().Done(): + return + default: + } + + ticker.Reset(streamingTimeout) + data := scanner.Text() + if common.DebugEnabled { + println(data) + } + + if len(data) < 6 { + continue + } + if data[:5] != "data:" && data[:6] != "[DONE]" { + continue + } + data = data[5:] + data = strings.TrimLeft(data, " ") + data = strings.TrimSuffix(data, "\r") + if !strings.HasPrefix(data, "[DONE]") { + info.SetFirstResponseTime() + + // 使用超时机制防止写操作阻塞 + done := make(chan bool, 1) + go func() { + writeMutex.Lock() + defer writeMutex.Unlock() + done <- dataHandler(data) + }() + + select { + case success := <-done: + if !success { + return + } + case <-time.After(10 * time.Second): + common.LogError(c, "data handler timeout") + return + case <-ctx.Done(): + return + case <-stopChan: + return + } + } + } + + if err := scanner.Err(); err != nil { + if err != io.EOF { + common.LogError(c, "scanner error: "+err.Error()) + } + } + }) + + // 主循环等待完成或超时 + select { + case <-ticker.C: + // 超时处理逻辑 + common.LogError(c, "streaming timeout") + case <-stopChan: + // 正常结束 + common.LogInfo(c, "streaming finished") + case <-c.Request.Context().Done(): + // 客户端断开连接 + common.LogInfo(c, "client disconnected") + } +} diff --git a/relay/image_handler.go b/relay/image_handler.go new file mode 100644 index 00000000..8e059863 --- /dev/null +++ b/relay/image_handler.go @@ -0,0 +1,247 @@ +package relay + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/model" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "one-api/setting" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func getAndValidImageRequest(c *gin.Context, info *relaycommon.RelayInfo) (*dto.ImageRequest, error) { + imageRequest := &dto.ImageRequest{} + + switch info.RelayMode { + case relayconstant.RelayModeImagesEdits: + _, err := c.MultipartForm() + if err != nil { + return nil, err + } + formData := c.Request.PostForm + imageRequest.Prompt = formData.Get("prompt") + imageRequest.Model = formData.Get("model") + imageRequest.N = common.String2Int(formData.Get("n")) + imageRequest.Quality = formData.Get("quality") + imageRequest.Size = formData.Get("size") + + if imageRequest.Model == "gpt-image-1" { + if imageRequest.Quality == "" { + imageRequest.Quality = "standard" + } + } + if imageRequest.N == 0 { + imageRequest.N = 1 + } + + if info.ApiType == constant.APITypeVolcEngine { + watermark := formData.Has("watermark") + imageRequest.Watermark = &watermark + } + default: + err := common.UnmarshalBodyReusable(c, imageRequest) + if err != nil { + return nil, err + } + + if imageRequest.Model == "" { + imageRequest.Model = "dall-e-3" + } + + if strings.Contains(imageRequest.Size, "×") { + return nil, errors.New("size an unexpected error occurred in the parameter, please use 'x' instead of the multiplication sign '×'") + } + + // Not "256x256", "512x512", or "1024x1024" + if imageRequest.Model == "dall-e-2" || imageRequest.Model == "dall-e" { + if imageRequest.Size != "" && imageRequest.Size != "256x256" && imageRequest.Size != "512x512" && imageRequest.Size != "1024x1024" { + return nil, errors.New("size must be one of 256x256, 512x512, or 1024x1024 for dall-e-2 or dall-e") + } + if imageRequest.Size == "" { + imageRequest.Size = "1024x1024" + } + } else if imageRequest.Model == "dall-e-3" { + if imageRequest.Size != "" && imageRequest.Size != "1024x1024" && imageRequest.Size != "1024x1792" && imageRequest.Size != "1792x1024" { + return nil, errors.New("size must be one of 1024x1024, 1024x1792 or 1792x1024 for dall-e-3") + } + if imageRequest.Quality == "" { + imageRequest.Quality = "standard" + } + if imageRequest.Size == "" { + imageRequest.Size = "1024x1024" + } + } else if imageRequest.Model == "gpt-image-1" { + if imageRequest.Quality == "" { + imageRequest.Quality = "auto" + } + } + + if imageRequest.Prompt == "" { + return nil, errors.New("prompt is required") + } + + if imageRequest.N == 0 { + imageRequest.N = 1 + } + } + + if setting.ShouldCheckPromptSensitive() { + words, err := service.CheckSensitiveInput(imageRequest.Prompt) + if err != nil { + common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ","))) + return nil, err + } + } + return imageRequest, nil +} + +func ImageHelper(c *gin.Context) (newAPIError *types.NewAPIError) { + relayInfo := relaycommon.GenRelayInfoImage(c) + + imageRequest, err := getAndValidImageRequest(c, relayInfo) + if err != nil { + common.LogError(c, fmt.Sprintf("getAndValidImageRequest failed: %s", err.Error())) + return types.NewError(err, types.ErrorCodeInvalidRequest) + } + + err = helper.ModelMappedHelper(c, relayInfo, imageRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError) + } + + priceData, err := helper.ModelPriceHelper(c, relayInfo, len(imageRequest.Prompt), 0) + if err != nil { + return types.NewError(err, types.ErrorCodeModelPriceError) + } + var preConsumedQuota int + var quota int + var userQuota int + if !priceData.UsePrice { + // modelRatio 16 = modelPrice $0.04 + // per 1 modelRatio = $0.04 / 16 + // priceData.ModelPrice = 0.0025 * priceData.ModelRatio + preConsumedQuota, userQuota, newAPIError = preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + if newAPIError != nil { + return newAPIError + } + defer func() { + if newAPIError != nil { + returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) + } + }() + + } else { + sizeRatio := 1.0 + qualityRatio := 1.0 + + if strings.HasPrefix(imageRequest.Model, "dall-e") { + // Size + if imageRequest.Size == "256x256" { + sizeRatio = 0.4 + } else if imageRequest.Size == "512x512" { + sizeRatio = 0.45 + } else if imageRequest.Size == "1024x1024" { + sizeRatio = 1 + } else if imageRequest.Size == "1024x1792" || imageRequest.Size == "1792x1024" { + sizeRatio = 2 + } + + if imageRequest.Model == "dall-e-3" && imageRequest.Quality == "hd" { + qualityRatio = 2.0 + if imageRequest.Size == "1024x1792" || imageRequest.Size == "1792x1024" { + qualityRatio = 1.5 + } + } + } + + // reset model price + priceData.ModelPrice *= sizeRatio * qualityRatio * float64(imageRequest.N) + quota = int(priceData.ModelPrice * priceData.GroupRatioInfo.GroupRatio * common.QuotaPerUnit) + userQuota, err = model.GetUserQuota(relayInfo.UserId, false) + if err != nil { + return types.NewError(err, types.ErrorCodeQueryDataError) + } + if userQuota-quota < 0 { + return types.NewError(fmt.Errorf("image pre-consumed quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(quota)), types.ErrorCodeInsufficientUserQuota) + } + } + + adaptor := GetAdaptor(relayInfo.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + } + adaptor.Init(relayInfo) + + var requestBody io.Reader + + convertedRequest, err := adaptor.ConvertImageRequest(c, relayInfo, *imageRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + if relayInfo.RelayMode == relayconstant.RelayModeImagesEdits { + requestBody = convertedRequest.(io.Reader) + } else { + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + requestBody = bytes.NewBuffer(jsonData) + } + + if common.DebugEnabled { + println(fmt.Sprintf("image request body: %s", requestBody)) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + resp, err := adaptor.DoRequest(c, relayInfo, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + relayInfo.IsStream = relayInfo.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, relayInfo) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + if usage.(*dto.Usage).TotalTokens == 0 { + usage.(*dto.Usage).TotalTokens = imageRequest.N + } + if usage.(*dto.Usage).PromptTokens == 0 { + usage.(*dto.Usage).PromptTokens = imageRequest.N + } + quality := "standard" + if imageRequest.Quality == "hd" { + quality = "hd" + } + + logContent := fmt.Sprintf("大小 %s, 品质 %s", imageRequest.Size, quality) + postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, logContent) + return nil +} diff --git a/relay/relay-mj.go b/relay/relay-mj.go new file mode 100644 index 00000000..e7f316b9 --- /dev/null +++ b/relay/relay-mj.go @@ -0,0 +1,668 @@ +package relay + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/model" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "one-api/setting" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +func RelayMidjourneyImage(c *gin.Context) { + taskId := c.Param("id") + midjourneyTask := model.GetByOnlyMJId(taskId) + if midjourneyTask == nil { + c.JSON(400, gin.H{ + "error": "midjourney_task_not_found", + }) + return + } + var httpClient *http.Client + if channel, err := model.CacheGetChannel(midjourneyTask.ChannelId); err == nil { + proxy := channel.GetSetting().Proxy + if proxy != "" { + if httpClient, err = service.NewProxyHttpClient(proxy); err != nil { + c.JSON(400, gin.H{ + "error": "proxy_url_invalid", + }) + return + } + } + } + if httpClient == nil { + httpClient = service.GetHttpClient() + } + resp, err := httpClient.Get(midjourneyTask.ImageUrl) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "http_get_image_failed", + }) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + responseBody, _ := io.ReadAll(resp.Body) + c.JSON(resp.StatusCode, gin.H{ + "error": string(responseBody), + }) + return + } + // 从Content-Type头获取MIME类型 + contentType := resp.Header.Get("Content-Type") + if contentType == "" { + // 如果无法确定内容类型,则默认为jpeg + contentType = "image/jpeg" + } + // 设置响应的内容类型 + c.Writer.Header().Set("Content-Type", contentType) + // 将图片流式传输到响应体 + _, err = io.Copy(c.Writer, resp.Body) + if err != nil { + log.Println("Failed to stream image:", err) + } + return +} + +func RelayMidjourneyNotify(c *gin.Context) *dto.MidjourneyResponse { + var midjRequest dto.MidjourneyDto + err := common.UnmarshalBodyReusable(c, &midjRequest) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "bind_request_body_failed", + Properties: nil, + Result: "", + } + } + midjourneyTask := model.GetByOnlyMJId(midjRequest.MjId) + if midjourneyTask == nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "midjourney_task_not_found", + Properties: nil, + Result: "", + } + } + midjourneyTask.Progress = midjRequest.Progress + midjourneyTask.PromptEn = midjRequest.PromptEn + midjourneyTask.State = midjRequest.State + midjourneyTask.SubmitTime = midjRequest.SubmitTime + midjourneyTask.StartTime = midjRequest.StartTime + midjourneyTask.FinishTime = midjRequest.FinishTime + midjourneyTask.ImageUrl = midjRequest.ImageUrl + midjourneyTask.VideoUrl = midjRequest.VideoUrl + videoUrlsStr, _ := json.Marshal(midjRequest.VideoUrls) + midjourneyTask.VideoUrls = string(videoUrlsStr) + midjourneyTask.Status = midjRequest.Status + midjourneyTask.FailReason = midjRequest.FailReason + err = midjourneyTask.Update() + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "update_midjourney_task_failed", + } + } + + return nil +} + +func coverMidjourneyTaskDto(c *gin.Context, originTask *model.Midjourney) (midjourneyTask dto.MidjourneyDto) { + midjourneyTask.MjId = originTask.MjId + midjourneyTask.Progress = originTask.Progress + midjourneyTask.PromptEn = originTask.PromptEn + midjourneyTask.State = originTask.State + midjourneyTask.SubmitTime = originTask.SubmitTime + midjourneyTask.StartTime = originTask.StartTime + midjourneyTask.FinishTime = originTask.FinishTime + midjourneyTask.ImageUrl = "" + if originTask.ImageUrl != "" && setting.MjForwardUrlEnabled { + midjourneyTask.ImageUrl = setting.ServerAddress + "/mj/image/" + originTask.MjId + if originTask.Status != "SUCCESS" { + midjourneyTask.ImageUrl += "?rand=" + strconv.FormatInt(time.Now().UnixNano(), 10) + } + } else { + midjourneyTask.ImageUrl = originTask.ImageUrl + } + if originTask.VideoUrl != "" { + midjourneyTask.VideoUrl = originTask.VideoUrl + } + midjourneyTask.Status = originTask.Status + midjourneyTask.FailReason = originTask.FailReason + midjourneyTask.Action = originTask.Action + midjourneyTask.Description = originTask.Description + midjourneyTask.Prompt = originTask.Prompt + if originTask.Buttons != "" { + var buttons []dto.ActionButton + err := json.Unmarshal([]byte(originTask.Buttons), &buttons) + if err == nil { + midjourneyTask.Buttons = buttons + } + } + if originTask.VideoUrls != "" { + var videoUrls []dto.ImgUrls + err := json.Unmarshal([]byte(originTask.VideoUrls), &videoUrls) + if err == nil { + midjourneyTask.VideoUrls = videoUrls + } + } + if originTask.Properties != "" { + var properties dto.Properties + err := json.Unmarshal([]byte(originTask.Properties), &properties) + if err == nil { + midjourneyTask.Properties = &properties + } + } + return +} + +func RelaySwapFace(c *gin.Context) *dto.MidjourneyResponse { + startTime := time.Now().UnixNano() / int64(time.Millisecond) + tokenId := c.GetInt("token_id") + userId := c.GetInt("id") + //group := c.GetString("group") + channelId := c.GetInt("channel_id") + relayInfo := relaycommon.GenRelayInfo(c) + var swapFaceRequest dto.SwapFaceRequest + err := common.UnmarshalBodyReusable(c, &swapFaceRequest) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "bind_request_body_failed") + } + if swapFaceRequest.SourceBase64 == "" || swapFaceRequest.TargetBase64 == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "sour_base64_and_target_base64_is_required") + } + modelName := service.CoverActionToModelName(constant.MjActionSwapFace) + + priceData := helper.ModelPriceHelperPerCall(c, relayInfo) + + userQuota, err := model.GetUserQuota(userId, false) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: err.Error(), + } + } + + if userQuota-priceData.Quota < 0 { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "quota_not_enough", + } + } + requestURL := getMjRequestPath(c.Request.URL.String()) + baseURL := c.GetString("base_url") + fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) + mjResp, _, err := service.DoMidjourneyHttpRequest(c, time.Second*60, fullRequestURL) + if err != nil { + return &mjResp.Response + } + defer func() { + if mjResp.StatusCode == 200 && mjResp.Response.Code == 1 { + err := service.PostConsumeQuota(relayInfo, priceData.Quota, 0, true) + if err != nil { + common.SysError("error consuming token remain quota: " + err.Error()) + } + + tokenName := c.GetString("token_name") + logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, constant.MjActionSwapFace) + other := service.GenerateMjOtherInfo(priceData) + model.RecordConsumeLog(c, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: channelId, + ModelName: modelName, + TokenName: tokenName, + Quota: priceData.Quota, + Content: logContent, + TokenId: tokenId, + UserQuota: userQuota, + Group: relayInfo.UsingGroup, + Other: other, + }) + model.UpdateUserUsedQuotaAndRequestCount(userId, priceData.Quota) + model.UpdateChannelUsedQuota(channelId, priceData.Quota) + } + }() + midjResponse := &mjResp.Response + midjourneyTask := &model.Midjourney{ + UserId: userId, + Code: midjResponse.Code, + Action: constant.MjActionSwapFace, + MjId: midjResponse.Result, + Prompt: "InsightFace", + PromptEn: "", + Description: midjResponse.Description, + State: "", + SubmitTime: startTime, + StartTime: time.Now().UnixNano() / int64(time.Millisecond), + FinishTime: 0, + ImageUrl: "", + Status: "", + Progress: "0%", + FailReason: "", + ChannelId: c.GetInt("channel_id"), + Quota: priceData.Quota, + } + err = midjourneyTask.Insert() + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "insert_midjourney_task_failed") + } + c.Writer.WriteHeader(mjResp.StatusCode) + respBody, err := json.Marshal(midjResponse) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "unmarshal_response_body_failed") + } + _, err = io.Copy(c.Writer, bytes.NewBuffer(respBody)) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "copy_response_body_failed") + } + return nil +} + +func RelayMidjourneyTaskImageSeed(c *gin.Context) *dto.MidjourneyResponse { + taskId := c.Param("id") + userId := c.GetInt("id") + originTask := model.GetByMJId(userId, taskId) + if originTask == nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "task_no_found") + } + channel, err := model.GetChannelById(originTask.ChannelId, true) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "get_channel_info_failed") + } + if channel.Status != common.ChannelStatusEnabled { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "该任务所属渠道已被禁用") + } + c.Set("channel_id", originTask.ChannelId) + c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) + + requestURL := getMjRequestPath(c.Request.URL.String()) + fullRequestURL := fmt.Sprintf("%s%s", channel.GetBaseURL(), requestURL) + midjResponseWithStatus, _, err := service.DoMidjourneyHttpRequest(c, time.Second*30, fullRequestURL) + if err != nil { + return &midjResponseWithStatus.Response + } + midjResponse := &midjResponseWithStatus.Response + c.Writer.WriteHeader(midjResponseWithStatus.StatusCode) + respBody, err := json.Marshal(midjResponse) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "unmarshal_response_body_failed") + } + common.IOCopyBytesGracefully(c, nil, respBody) + return nil +} + +func RelayMidjourneyTask(c *gin.Context, relayMode int) *dto.MidjourneyResponse { + userId := c.GetInt("id") + var err error + var respBody []byte + switch relayMode { + case relayconstant.RelayModeMidjourneyTaskFetch: + taskId := c.Param("id") + originTask := model.GetByMJId(userId, taskId) + if originTask == nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "task_no_found", + } + } + midjourneyTask := coverMidjourneyTaskDto(c, originTask) + respBody, err = json.Marshal(midjourneyTask) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "unmarshal_response_body_failed", + } + } + case relayconstant.RelayModeMidjourneyTaskFetchByCondition: + var condition = struct { + IDs []string `json:"ids"` + }{} + err = c.BindJSON(&condition) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "do_request_failed", + } + } + var tasks []dto.MidjourneyDto + if len(condition.IDs) != 0 { + originTasks := model.GetByMJIds(userId, condition.IDs) + for _, originTask := range originTasks { + midjourneyTask := coverMidjourneyTaskDto(c, originTask) + tasks = append(tasks, midjourneyTask) + } + } + if tasks == nil { + tasks = make([]dto.MidjourneyDto, 0) + } + respBody, err = json.Marshal(tasks) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "unmarshal_response_body_failed", + } + } + } + + c.Writer.Header().Set("Content-Type", "application/json") + + _, err = io.Copy(c.Writer, bytes.NewBuffer(respBody)) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "copy_response_body_failed", + } + } + return nil +} + +func RelayMidjourneySubmit(c *gin.Context, relayMode int) *dto.MidjourneyResponse { + + //tokenId := c.GetInt("token_id") + //channelType := c.GetInt("channel") + userId := c.GetInt("id") + group := c.GetString("group") + channelId := c.GetInt("channel_id") + relayInfo := relaycommon.GenRelayInfo(c) + consumeQuota := true + var midjRequest dto.MidjourneyRequest + err := common.UnmarshalBodyReusable(c, &midjRequest) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "bind_request_body_failed") + } + + if relayMode == relayconstant.RelayModeMidjourneyAction { // midjourney plus,需要从customId中获取任务信息 + mjErr := service.CoverPlusActionToNormalAction(&midjRequest) + if mjErr != nil { + return mjErr + } + relayMode = relayconstant.RelayModeMidjourneyChange + } + if relayMode == relayconstant.RelayModeMidjourneyVideo { + midjRequest.Action = constant.MjActionVideo + } + + if relayMode == relayconstant.RelayModeMidjourneyImagine { //绘画任务,此类任务可重复 + if midjRequest.Prompt == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "prompt_is_required") + } + midjRequest.Action = constant.MjActionImagine + } else if relayMode == relayconstant.RelayModeMidjourneyDescribe { //按图生文任务,此类任务可重复 + midjRequest.Action = constant.MjActionDescribe + } else if relayMode == relayconstant.RelayModeMidjourneyEdits { //编辑任务,此类任务可重复 + midjRequest.Action = constant.MjActionEdits + } else if relayMode == relayconstant.RelayModeMidjourneyShorten { //缩短任务,此类任务可重复,plus only + midjRequest.Action = constant.MjActionShorten + } else if relayMode == relayconstant.RelayModeMidjourneyBlend { //绘画任务,此类任务可重复 + midjRequest.Action = constant.MjActionBlend + } else if relayMode == relayconstant.RelayModeMidjourneyUpload { //绘画任务,此类任务可重复 + midjRequest.Action = constant.MjActionUpload + } else if midjRequest.TaskId != "" { //放大、变换任务,此类任务,如果重复且已有结果,远端api会直接返回最终结果 + mjId := "" + if relayMode == relayconstant.RelayModeMidjourneyChange { + if midjRequest.TaskId == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "task_id_is_required") + } else if midjRequest.Action == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "action_is_required") + } else if midjRequest.Index == 0 { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "index_is_required") + } + //action = midjRequest.Action + mjId = midjRequest.TaskId + } else if relayMode == relayconstant.RelayModeMidjourneySimpleChange { + if midjRequest.Content == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "content_is_required") + } + params := service.ConvertSimpleChangeParams(midjRequest.Content) + if params == nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "content_parse_failed") + } + mjId = params.TaskId + midjRequest.Action = params.Action + } else if relayMode == relayconstant.RelayModeMidjourneyModal { + //if midjRequest.MaskBase64 == "" { + // return service.MidjourneyErrorWrapper(constant.MjRequestError, "mask_base64_is_required") + //} + mjId = midjRequest.TaskId + midjRequest.Action = constant.MjActionModal + } else if relayMode == relayconstant.RelayModeMidjourneyVideo { + midjRequest.Action = constant.MjActionVideo + if midjRequest.TaskId == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "task_id_is_required") + } else if midjRequest.Action == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "action_is_required") + } + mjId = midjRequest.TaskId + } + + originTask := model.GetByMJId(userId, mjId) + if originTask == nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "task_not_found") + } else { //原任务的Status=SUCCESS,则可以做放大UPSCALE、变换VARIATION等动作,此时必须使用原来的请求地址才能正确处理 + if setting.MjActionCheckSuccessEnabled { + if originTask.Status != "SUCCESS" && relayMode != relayconstant.RelayModeMidjourneyModal { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "task_status_not_success") + } + } + channel, err := model.GetChannelById(originTask.ChannelId, true) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "get_channel_info_failed") + } + if channel.Status != common.ChannelStatusEnabled { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "该任务所属渠道已被禁用") + } + c.Set("base_url", channel.GetBaseURL()) + c.Set("channel_id", originTask.ChannelId) + c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) + log.Printf("检测到此操作为放大、变换、重绘,获取原channel信息: %s,%s", strconv.Itoa(originTask.ChannelId), channel.GetBaseURL()) + } + midjRequest.Prompt = originTask.Prompt + + //if channelType == common.ChannelTypeMidjourneyPlus { + // // plus + //} else { + // // 普通版渠道 + // + //} + } + + if midjRequest.Action == constant.MjActionInPaint || midjRequest.Action == constant.MjActionCustomZoom { + consumeQuota = false + } + + //baseURL := common.ChannelBaseURLs[channelType] + requestURL := getMjRequestPath(c.Request.URL.String()) + + baseURL := c.GetString("base_url") + + //midjRequest.NotifyHook = "http://127.0.0.1:3000/mj/notify" + + fullRequestURL := fmt.Sprintf("%s%s", baseURL, requestURL) + + modelName := service.CoverActionToModelName(midjRequest.Action) + + priceData := helper.ModelPriceHelperPerCall(c, relayInfo) + + userQuota, err := model.GetUserQuota(userId, false) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: err.Error(), + } + } + + if consumeQuota && userQuota-priceData.Quota < 0 { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "quota_not_enough", + } + } + + midjResponseWithStatus, responseBody, err := service.DoMidjourneyHttpRequest(c, time.Second*60, fullRequestURL) + if err != nil { + return &midjResponseWithStatus.Response + } + midjResponse := &midjResponseWithStatus.Response + + defer func() { + if consumeQuota && midjResponseWithStatus.StatusCode == 200 { + err := service.PostConsumeQuota(relayInfo, priceData.Quota, 0, true) + if err != nil { + common.SysError("error consuming token remain quota: " + err.Error()) + } + tokenName := c.GetString("token_name") + logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s,ID %s", priceData.ModelPrice, priceData.GroupRatioInfo.GroupRatio, midjRequest.Action, midjResponse.Result) + other := service.GenerateMjOtherInfo(priceData) + model.RecordConsumeLog(c, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: channelId, + ModelName: modelName, + TokenName: tokenName, + Quota: priceData.Quota, + Content: logContent, + TokenId: relayInfo.TokenId, + UserQuota: userQuota, + Group: group, + Other: other, + }) + model.UpdateUserUsedQuotaAndRequestCount(userId, priceData.Quota) + model.UpdateChannelUsedQuota(channelId, priceData.Quota) + } + }() + + // 文档:https://github.com/novicezk/midjourney-proxy/blob/main/docs/api.md + //1-提交成功 + // 21-任务已存在(处理中或者有结果了) {"code":21,"description":"任务已存在","result":"0741798445574458","properties":{"status":"SUCCESS","imageUrl":"https://xxxx"}} + // 22-排队中 {"code":22,"description":"排队中,前面还有1个任务","result":"0741798445574458","properties":{"numberOfQueues":1,"discordInstanceId":"1118138338562560102"}} + // 23-队列已满,请稍后再试 {"code":23,"description":"队列已满,请稍后尝试","result":"14001929738841620","properties":{"discordInstanceId":"1118138338562560102"}} + // 24-prompt包含敏感词 {"code":24,"description":"可能包含敏感词","properties":{"promptEn":"nude body","bannedWord":"nude"}} + // other: 提交错误,description为错误描述 + midjourneyTask := &model.Midjourney{ + UserId: userId, + Code: midjResponse.Code, + Action: midjRequest.Action, + MjId: midjResponse.Result, + Prompt: midjRequest.Prompt, + PromptEn: "", + Description: midjResponse.Description, + State: "", + SubmitTime: time.Now().UnixNano() / int64(time.Millisecond), + StartTime: 0, + FinishTime: 0, + ImageUrl: "", + Status: "", + Progress: "0%", + FailReason: "", + ChannelId: c.GetInt("channel_id"), + Quota: priceData.Quota, + } + if midjResponse.Code == 3 { + //无实例账号自动禁用渠道(No available account instance) + channel, err := model.GetChannelById(midjourneyTask.ChannelId, true) + if err != nil { + common.SysError("get_channel_null: " + err.Error()) + } + if channel.GetAutoBan() && common.AutomaticDisableChannelEnabled { + model.UpdateChannelStatus(midjourneyTask.ChannelId, "", 2, "No available account instance") + } + } + if midjResponse.Code != 1 && midjResponse.Code != 21 && midjResponse.Code != 22 { + //非1-提交成功,21-任务已存在和22-排队中,则记录错误原因 + midjourneyTask.FailReason = midjResponse.Description + consumeQuota = false + } + + if midjResponse.Code == 21 { //21-任务已存在(处理中或者有结果了) + // 将 properties 转换为一个 map + properties, ok := midjResponse.Properties.(map[string]interface{}) + if ok { + imageUrl, ok1 := properties["imageUrl"].(string) + status, ok2 := properties["status"].(string) + if ok1 && ok2 { + midjourneyTask.ImageUrl = imageUrl + midjourneyTask.Status = status + if status == "SUCCESS" { + midjourneyTask.Progress = "100%" + midjourneyTask.StartTime = time.Now().UnixNano() / int64(time.Millisecond) + midjourneyTask.FinishTime = time.Now().UnixNano() / int64(time.Millisecond) + midjResponse.Code = 1 + } + } + } + //修改返回值 + if midjRequest.Action != constant.MjActionInPaint && midjRequest.Action != constant.MjActionCustomZoom { + newBody := strings.Replace(string(responseBody), `"code":21`, `"code":1`, -1) + responseBody = []byte(newBody) + } + } + if midjResponse.Code == 1 && midjRequest.Action == "UPLOAD" { + midjourneyTask.Progress = "100%" + midjourneyTask.Status = "SUCCESS" + } + err = midjourneyTask.Insert() + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "insert_midjourney_task_failed", + } + } + + if midjResponse.Code == 22 { //22-排队中,说明任务已存在 + //修改返回值 + newBody := strings.Replace(string(responseBody), `"code":22`, `"code":1`, -1) + responseBody = []byte(newBody) + } + //resp.Body = io.NopCloser(bytes.NewBuffer(responseBody)) + bodyReader := io.NopCloser(bytes.NewBuffer(responseBody)) + + //for k, v := range resp.Header { + // c.Writer.Header().Set(k, v[0]) + //} + c.Writer.WriteHeader(midjResponseWithStatus.StatusCode) + + _, err = io.Copy(c.Writer, bodyReader) + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "copy_response_body_failed", + } + } + err = bodyReader.Close() + if err != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: "close_response_body_failed", + } + } + return nil +} + +type taskChangeParams struct { + ID string + Action string + Index int +} + +func getMjRequestPath(path string) string { + requestURL := path + if strings.Contains(requestURL, "/mj-") { + urls := strings.Split(requestURL, "/mj/") + if len(urls) < 2 { + return requestURL + } + requestURL = "/mj/" + urls[1] + } + return requestURL +} diff --git a/relay/relay-text.go b/relay/relay-text.go new file mode 100644 index 00000000..60327074 --- /dev/null +++ b/relay/relay-text.go @@ -0,0 +1,571 @@ +package relay + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/model" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "one-api/setting" + "one-api/setting/model_setting" + "one-api/setting/operation_setting" + "one-api/types" + "strings" + "time" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/shopspring/decimal" + + "github.com/gin-gonic/gin" +) + +func getAndValidateTextRequest(c *gin.Context, relayInfo *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) { + textRequest := &dto.GeneralOpenAIRequest{} + err := common.UnmarshalBodyReusable(c, textRequest) + if err != nil { + return nil, err + } + if relayInfo.RelayMode == relayconstant.RelayModeModerations && textRequest.Model == "" { + textRequest.Model = "text-moderation-latest" + } + if relayInfo.RelayMode == relayconstant.RelayModeEmbeddings && textRequest.Model == "" { + textRequest.Model = c.Param("model") + } + + if textRequest.MaxTokens > math.MaxInt32/2 { + return nil, errors.New("max_tokens is invalid") + } + if textRequest.Model == "" { + return nil, errors.New("model is required") + } + if textRequest.WebSearchOptions != nil { + if textRequest.WebSearchOptions.SearchContextSize != "" { + validSizes := map[string]bool{ + "high": true, + "medium": true, + "low": true, + } + if !validSizes[textRequest.WebSearchOptions.SearchContextSize] { + return nil, errors.New("invalid search_context_size, must be one of: high, medium, low") + } + } else { + textRequest.WebSearchOptions.SearchContextSize = "medium" + } + } + switch relayInfo.RelayMode { + case relayconstant.RelayModeCompletions: + if textRequest.Prompt == "" { + return nil, errors.New("field prompt is required") + } + case relayconstant.RelayModeChatCompletions: + if len(textRequest.Messages) == 0 { + return nil, errors.New("field messages is required") + } + case relayconstant.RelayModeEmbeddings: + case relayconstant.RelayModeModerations: + if textRequest.Input == nil || textRequest.Input == "" { + return nil, errors.New("field input is required") + } + case relayconstant.RelayModeEdits: + if textRequest.Instruction == "" { + return nil, errors.New("field instruction is required") + } + } + relayInfo.IsStream = textRequest.Stream + return textRequest, nil +} + +func TextHelper(c *gin.Context) (newAPIError *types.NewAPIError) { + + relayInfo := relaycommon.GenRelayInfo(c) + + // get & validate textRequest 获取并验证文本请求 + textRequest, err := getAndValidateTextRequest(c, relayInfo) + + if err != nil { + return types.NewError(err, types.ErrorCodeInvalidRequest) + } + + if textRequest.WebSearchOptions != nil { + c.Set("chat_completion_web_search_context_size", textRequest.WebSearchOptions.SearchContextSize) + } + + if setting.ShouldCheckPromptSensitive() { + words, err := checkRequestSensitive(textRequest, relayInfo) + if err != nil { + common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", "))) + return types.NewError(err, types.ErrorCodeSensitiveWordsDetected) + } + } + + err = helper.ModelMappedHelper(c, relayInfo, textRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError) + } + + // 获取 promptTokens,如果上下文中已经存在,则直接使用 + var promptTokens int + if value, exists := c.Get("prompt_tokens"); exists { + promptTokens = value.(int) + relayInfo.PromptTokens = promptTokens + } else { + promptTokens, err = getPromptTokens(textRequest, relayInfo) + // count messages token error 计算promptTokens错误 + if err != nil { + return types.NewError(err, types.ErrorCodeCountTokenFailed) + } + c.Set("prompt_tokens", promptTokens) + } + + priceData, err := helper.ModelPriceHelper(c, relayInfo, promptTokens, int(math.Max(float64(textRequest.MaxTokens), float64(textRequest.MaxCompletionTokens)))) + if err != nil { + return types.NewError(err, types.ErrorCodeModelPriceError) + } + + // pre-consume quota 预消耗配额 + preConsumedQuota, userQuota, newApiErr := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + if newApiErr != nil { + return newApiErr + } + defer func() { + if newApiErr != nil { + returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) + } + }() + includeUsage := false + // 判断用户是否需要返回使用情况 + if textRequest.StreamOptions != nil && textRequest.StreamOptions.IncludeUsage { + includeUsage = true + } + + // 如果不支持StreamOptions,将StreamOptions设置为nil + if !relayInfo.SupportStreamOptions || !textRequest.Stream { + textRequest.StreamOptions = nil + } else { + // 如果支持StreamOptions,且请求中没有设置StreamOptions,根据配置文件设置StreamOptions + if constant.ForceStreamOption { + textRequest.StreamOptions = &dto.StreamOptions{ + IncludeUsage: true, + } + } + } + + if includeUsage { + relayInfo.ShouldIncludeUsage = true + } + + adaptor := GetAdaptor(relayInfo.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + } + adaptor.Init(relayInfo) + var requestBody io.Reader + + if model_setting.GetGlobalSettings().PassThroughRequestEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertOpenAIRequest(c, relayInfo, textRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + + // apply param override + if len(relayInfo.ParamOverride) > 0 { + reqMap := make(map[string]interface{}) + _ = common.Unmarshal(jsonData, &reqMap) + for key, value := range relayInfo.ParamOverride { + reqMap[key] = value + } + jsonData, err = common.Marshal(reqMap) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + } + } + + if common.DebugEnabled { + println("requestBody: ", string(jsonData)) + } + requestBody = bytes.NewBuffer(jsonData) + } + + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, relayInfo, requestBody) + + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + if resp != nil { + httpResp = resp.(*http.Response) + relayInfo.IsStream = relayInfo.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newApiErr = service.RelayErrorHandler(httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return newApiErr + } + } + + usage, newApiErr := adaptor.DoResponse(c, httpResp, relayInfo) + if newApiErr != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return newApiErr + } + + if strings.HasPrefix(relayInfo.OriginModelName, "gpt-4o-audio") { + service.PostAudioConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "") + } else { + postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "") + } + return nil +} + +func getPromptTokens(textRequest *dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (int, error) { + var promptTokens int + var err error + switch info.RelayMode { + case relayconstant.RelayModeChatCompletions: + promptTokens, err = service.CountTokenChatRequest(info, *textRequest) + case relayconstant.RelayModeCompletions: + promptTokens = service.CountTokenInput(textRequest.Prompt, textRequest.Model) + case relayconstant.RelayModeModerations: + promptTokens = service.CountTokenInput(textRequest.Input, textRequest.Model) + case relayconstant.RelayModeEmbeddings: + promptTokens = service.CountTokenInput(textRequest.Input, textRequest.Model) + default: + err = errors.New("unknown relay mode") + promptTokens = 0 + } + info.PromptTokens = promptTokens + return promptTokens, err +} + +func checkRequestSensitive(textRequest *dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) ([]string, error) { + var err error + var words []string + switch info.RelayMode { + case relayconstant.RelayModeChatCompletions: + words, err = service.CheckSensitiveMessages(textRequest.Messages) + case relayconstant.RelayModeCompletions: + words, err = service.CheckSensitiveInput(textRequest.Prompt) + case relayconstant.RelayModeModerations: + words, err = service.CheckSensitiveInput(textRequest.Input) + case relayconstant.RelayModeEmbeddings: + words, err = service.CheckSensitiveInput(textRequest.Input) + } + return words, err +} + +// 预扣费并返回用户剩余配额 +func preConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) (int, int, *types.NewAPIError) { + userQuota, err := model.GetUserQuota(relayInfo.UserId, false) + if err != nil { + return 0, 0, types.NewError(err, types.ErrorCodeQueryDataError) + } + if userQuota <= 0 { + return 0, 0, types.NewErrorWithStatusCode(errors.New("user quota is not enough"), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden) + } + if userQuota-preConsumedQuota < 0 { + return 0, 0, types.NewErrorWithStatusCode(fmt.Errorf("pre-consume quota failed, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden) + } + relayInfo.UserQuota = userQuota + if userQuota > 100*preConsumedQuota { + // 用户额度充足,判断令牌额度是否充足 + if !relayInfo.TokenUnlimited { + // 非无限令牌,判断令牌额度是否充足 + tokenQuota := c.GetInt("token_quota") + if tokenQuota > 100*preConsumedQuota { + // 令牌额度充足,信任令牌 + preConsumedQuota = 0 + common.LogInfo(c, fmt.Sprintf("user %d quota %s and token %d quota %d are enough, trusted and no need to pre-consume", relayInfo.UserId, common.FormatQuota(userQuota), relayInfo.TokenId, tokenQuota)) + } + } else { + // in this case, we do not pre-consume quota + // because the user has enough quota + preConsumedQuota = 0 + common.LogInfo(c, fmt.Sprintf("user %d with unlimited token has enough quota %s, trusted and no need to pre-consume", relayInfo.UserId, common.FormatQuota(userQuota))) + } + } + + if preConsumedQuota > 0 { + err := service.PreConsumeTokenQuota(relayInfo, preConsumedQuota) + if err != nil { + return 0, 0, types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden) + } + err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota) + if err != nil { + return 0, 0, types.NewError(err, types.ErrorCodeUpdateDataError) + } + } + return preConsumedQuota, userQuota, nil +} + +func returnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo, userQuota int, preConsumedQuota int) { + if preConsumedQuota != 0 { + gopool.Go(func() { + relayInfoCopy := *relayInfo + + err := service.PostConsumeQuota(&relayInfoCopy, -preConsumedQuota, 0, false) + if err != nil { + common.SysError("error return pre-consumed quota: " + err.Error()) + } + }) + } +} + +func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, + usage *dto.Usage, preConsumedQuota int, userQuota int, priceData helper.PriceData, extraContent string) { + if usage == nil { + usage = &dto.Usage{ + PromptTokens: relayInfo.PromptTokens, + CompletionTokens: 0, + TotalTokens: relayInfo.PromptTokens, + } + extraContent += "(可能是请求出错)" + } + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + promptTokens := usage.PromptTokens + cacheTokens := usage.PromptTokensDetails.CachedTokens + imageTokens := usage.PromptTokensDetails.ImageTokens + audioTokens := usage.PromptTokensDetails.AudioTokens + completionTokens := usage.CompletionTokens + modelName := relayInfo.OriginModelName + + tokenName := ctx.GetString("token_name") + completionRatio := priceData.CompletionRatio + cacheRatio := priceData.CacheRatio + imageRatio := priceData.ImageRatio + modelRatio := priceData.ModelRatio + groupRatio := priceData.GroupRatioInfo.GroupRatio + modelPrice := priceData.ModelPrice + + // Convert values to decimal for precise calculation + dPromptTokens := decimal.NewFromInt(int64(promptTokens)) + dCacheTokens := decimal.NewFromInt(int64(cacheTokens)) + dImageTokens := decimal.NewFromInt(int64(imageTokens)) + dAudioTokens := decimal.NewFromInt(int64(audioTokens)) + dCompletionTokens := decimal.NewFromInt(int64(completionTokens)) + dCompletionRatio := decimal.NewFromFloat(completionRatio) + dCacheRatio := decimal.NewFromFloat(cacheRatio) + dImageRatio := decimal.NewFromFloat(imageRatio) + dModelRatio := decimal.NewFromFloat(modelRatio) + dGroupRatio := decimal.NewFromFloat(groupRatio) + dModelPrice := decimal.NewFromFloat(modelPrice) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + + ratio := dModelRatio.Mul(dGroupRatio) + + // openai web search 工具计费 + var dWebSearchQuota decimal.Decimal + var webSearchPrice float64 + // response api 格式工具计费 + if relayInfo.ResponsesUsageInfo != nil { + if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool.CallCount > 0 { + // 计算 web search 调用的配额 (配额 = 价格 * 调用次数 / 1000 * 分组倍率) + webSearchPrice = operation_setting.GetWebSearchPricePerThousand(modelName, webSearchTool.SearchContextSize) + dWebSearchQuota = decimal.NewFromFloat(webSearchPrice). + Mul(decimal.NewFromInt(int64(webSearchTool.CallCount))). + Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Web Search 调用 %d 次,上下文大小 %s,调用花费 %s", + webSearchTool.CallCount, webSearchTool.SearchContextSize, dWebSearchQuota.String()) + } + } else if strings.HasSuffix(modelName, "search-preview") { + // search-preview 模型不支持 response api + searchContextSize := ctx.GetString("chat_completion_web_search_context_size") + if searchContextSize == "" { + searchContextSize = "medium" + } + webSearchPrice = operation_setting.GetWebSearchPricePerThousand(modelName, searchContextSize) + dWebSearchQuota = decimal.NewFromFloat(webSearchPrice). + Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Web Search 调用 1 次,上下文大小 %s,调用花费 %s", + searchContextSize, dWebSearchQuota.String()) + } + // claude web search tool 计费 + var dClaudeWebSearchQuota decimal.Decimal + var claudeWebSearchPrice float64 + claudeWebSearchCallCount := ctx.GetInt("claude_web_search_requests") + if claudeWebSearchCallCount > 0 { + claudeWebSearchPrice = operation_setting.GetClaudeWebSearchPricePerThousand() + dClaudeWebSearchQuota = decimal.NewFromFloat(claudeWebSearchPrice). + Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit).Mul(decimal.NewFromInt(int64(claudeWebSearchCallCount))) + extraContent += fmt.Sprintf("Claude Web Search 调用 %d 次,调用花费 %s", + claudeWebSearchCallCount, dClaudeWebSearchQuota.String()) + } + // file search tool 计费 + var dFileSearchQuota decimal.Decimal + var fileSearchPrice float64 + if relayInfo.ResponsesUsageInfo != nil { + if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists && fileSearchTool.CallCount > 0 { + fileSearchPrice = operation_setting.GetFileSearchPricePerThousand() + dFileSearchQuota = decimal.NewFromFloat(fileSearchPrice). + Mul(decimal.NewFromInt(int64(fileSearchTool.CallCount))). + Div(decimal.NewFromInt(1000)).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("File Search 调用 %d 次,调用花费 %s", + fileSearchTool.CallCount, dFileSearchQuota.String()) + } + } + + var quotaCalculateDecimal decimal.Decimal + + var audioInputQuota decimal.Decimal + var audioInputPrice float64 + if !priceData.UsePrice { + baseTokens := dPromptTokens + // 减去 cached tokens + var cachedTokensWithRatio decimal.Decimal + if !dCacheTokens.IsZero() { + baseTokens = baseTokens.Sub(dCacheTokens) + cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio) + } + + // 减去 image tokens + var imageTokensWithRatio decimal.Decimal + if !dImageTokens.IsZero() { + baseTokens = baseTokens.Sub(dImageTokens) + imageTokensWithRatio = dImageTokens.Mul(dImageRatio) + } + + // 减去 Gemini audio tokens + if !dAudioTokens.IsZero() { + audioInputPrice = operation_setting.GetGeminiInputAudioPricePerMillionTokens(modelName) + if audioInputPrice > 0 { + // 重新计算 base tokens + baseTokens = baseTokens.Sub(dAudioTokens) + audioInputQuota = decimal.NewFromFloat(audioInputPrice).Div(decimal.NewFromInt(1000000)).Mul(dAudioTokens).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Audio Input 花费 %s", audioInputQuota.String()) + } + } + promptQuota := baseTokens.Add(cachedTokensWithRatio).Add(imageTokensWithRatio) + + completionQuota := dCompletionTokens.Mul(dCompletionRatio) + + quotaCalculateDecimal = promptQuota.Add(completionQuota).Mul(ratio) + + if !ratio.IsZero() && quotaCalculateDecimal.LessThanOrEqual(decimal.Zero) { + quotaCalculateDecimal = decimal.NewFromInt(1) + } + } else { + quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) + } + // 添加 responses tools call 调用的配额 + quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) + quotaCalculateDecimal = quotaCalculateDecimal.Add(dFileSearchQuota) + // 添加 audio input 独立计费 + quotaCalculateDecimal = quotaCalculateDecimal.Add(audioInputQuota) + + quota := int(quotaCalculateDecimal.Round(0).IntPart()) + totalTokens := promptTokens + completionTokens + + var logContent string + if !priceData.UsePrice { + logContent = fmt.Sprintf("模型倍率 %.2f,补全倍率 %.2f,分组倍率 %.2f", modelRatio, completionRatio, groupRatio) + } else { + logContent = fmt.Sprintf("模型价格 %.2f,分组倍率 %.2f", modelPrice, groupRatio) + } + + // record all the consume log even if quota is 0 + if totalTokens == 0 { + // in this case, must be some error happened + // we cannot just return, because we may have to return the pre-consumed quota + quota = 0 + logContent += fmt.Sprintf("(可能是上游超时)") + common.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+ + "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, preConsumedQuota)) + } else { + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + quotaDelta := quota - preConsumedQuota + if quotaDelta != 0 { + err := service.PostConsumeQuota(relayInfo, quotaDelta, preConsumedQuota, true) + if err != nil { + common.LogError(ctx, "error consuming token remain quota: "+err.Error()) + } + } + + logModel := modelName + if strings.HasPrefix(logModel, "gpt-4-gizmo") { + logModel = "gpt-4-gizmo-*" + logContent += fmt.Sprintf(",模型 %s", modelName) + } + if strings.HasPrefix(logModel, "gpt-4o-gizmo") { + logModel = "gpt-4o-gizmo-*" + logContent += fmt.Sprintf(",模型 %s", modelName) + } + if extraContent != "" { + logContent += ", " + extraContent + } + other := service.GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) + if imageTokens != 0 { + other["image"] = true + other["image_ratio"] = imageRatio + other["image_output"] = imageTokens + } + if !dWebSearchQuota.IsZero() { + if relayInfo.ResponsesUsageInfo != nil { + if webSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists { + other["web_search"] = true + other["web_search_call_count"] = webSearchTool.CallCount + other["web_search_price"] = webSearchPrice + } + } else if strings.HasSuffix(modelName, "search-preview") { + other["web_search"] = true + other["web_search_call_count"] = 1 + other["web_search_price"] = webSearchPrice + } + } else if !dClaudeWebSearchQuota.IsZero() { + other["web_search"] = true + other["web_search_call_count"] = claudeWebSearchCallCount + other["web_search_price"] = claudeWebSearchPrice + } + if !dFileSearchQuota.IsZero() && relayInfo.ResponsesUsageInfo != nil { + if fileSearchTool, exists := relayInfo.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolFileSearch]; exists { + other["file_search"] = true + other["file_search_call_count"] = fileSearchTool.CallCount + other["file_search_price"] = fileSearchPrice + } + } + if !audioInputQuota.IsZero() { + other["audio_input_seperate_price"] = true + other["audio_input_token_count"] = audioTokens + other["audio_input_price"] = audioInputPrice + } + model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + ModelName: logModel, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: relayInfo.TokenId, + UserQuota: userQuota, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) +} diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go new file mode 100644 index 00000000..2ce12a87 --- /dev/null +++ b/relay/relay_adaptor.go @@ -0,0 +1,115 @@ +package relay + +import ( + "one-api/constant" + commonconstant "one-api/constant" + "one-api/relay/channel" + "one-api/relay/channel/ali" + "one-api/relay/channel/aws" + "one-api/relay/channel/baidu" + "one-api/relay/channel/baidu_v2" + "one-api/relay/channel/claude" + "one-api/relay/channel/cloudflare" + "one-api/relay/channel/cohere" + "one-api/relay/channel/coze" + "one-api/relay/channel/deepseek" + "one-api/relay/channel/dify" + "one-api/relay/channel/gemini" + "one-api/relay/channel/jimeng" + "one-api/relay/channel/jina" + "one-api/relay/channel/mistral" + "one-api/relay/channel/mokaai" + "one-api/relay/channel/ollama" + "one-api/relay/channel/openai" + "one-api/relay/channel/palm" + "one-api/relay/channel/perplexity" + "one-api/relay/channel/siliconflow" + taskjimeng "one-api/relay/channel/task/jimeng" + "one-api/relay/channel/task/kling" + "one-api/relay/channel/task/suno" + "one-api/relay/channel/tencent" + "one-api/relay/channel/vertex" + "one-api/relay/channel/volcengine" + "one-api/relay/channel/xai" + "one-api/relay/channel/xunfei" + "one-api/relay/channel/zhipu" + "one-api/relay/channel/zhipu_4v" +) + +func GetAdaptor(apiType int) channel.Adaptor { + switch apiType { + case constant.APITypeAli: + return &ali.Adaptor{} + case constant.APITypeAnthropic: + return &claude.Adaptor{} + case constant.APITypeBaidu: + return &baidu.Adaptor{} + case constant.APITypeGemini: + return &gemini.Adaptor{} + case constant.APITypeOpenAI: + return &openai.Adaptor{} + case constant.APITypePaLM: + return &palm.Adaptor{} + case constant.APITypeTencent: + return &tencent.Adaptor{} + case constant.APITypeXunfei: + return &xunfei.Adaptor{} + case constant.APITypeZhipu: + return &zhipu.Adaptor{} + case constant.APITypeZhipuV4: + return &zhipu_4v.Adaptor{} + case constant.APITypeOllama: + return &ollama.Adaptor{} + case constant.APITypePerplexity: + return &perplexity.Adaptor{} + case constant.APITypeAws: + return &aws.Adaptor{} + case constant.APITypeCohere: + return &cohere.Adaptor{} + case constant.APITypeDify: + return &dify.Adaptor{} + case constant.APITypeJina: + return &jina.Adaptor{} + case constant.APITypeCloudflare: + return &cloudflare.Adaptor{} + case constant.APITypeSiliconFlow: + return &siliconflow.Adaptor{} + case constant.APITypeVertexAi: + return &vertex.Adaptor{} + case constant.APITypeMistral: + return &mistral.Adaptor{} + case constant.APITypeDeepSeek: + return &deepseek.Adaptor{} + case constant.APITypeMokaAI: + return &mokaai.Adaptor{} + case constant.APITypeVolcEngine: + return &volcengine.Adaptor{} + case constant.APITypeBaiduV2: + return &baidu_v2.Adaptor{} + case constant.APITypeOpenRouter: + return &openai.Adaptor{} + case constant.APITypeXinference: + return &openai.Adaptor{} + case constant.APITypeXai: + return &xai.Adaptor{} + case constant.APITypeCoze: + return &coze.Adaptor{} + case constant.APITypeJimeng: + return &jimeng.Adaptor{} + } + return nil +} + +func GetTaskAdaptor(platform commonconstant.TaskPlatform) channel.TaskAdaptor { + switch platform { + //case constant.APITypeAIProxyLibrary: + // return &aiproxy.Adaptor{} + case commonconstant.TaskPlatformSuno: + return &suno.TaskAdaptor{} + case commonconstant.TaskPlatformKling: + return &kling.TaskAdaptor{} + case commonconstant.TaskPlatformJimeng: + return &taskjimeng.TaskAdaptor{} + } + return nil +} diff --git a/relay/relay_task.go b/relay/relay_task.go new file mode 100644 index 00000000..25f63d40 --- /dev/null +++ b/relay/relay_task.go @@ -0,0 +1,289 @@ +package relay + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/model" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/service" + "one-api/setting/ratio_setting" + + "github.com/gin-gonic/gin" +) + +/* +Task 任务通过平台、Action 区分任务 +*/ +func RelayTaskSubmit(c *gin.Context, relayMode int) (taskErr *dto.TaskError) { + platform := constant.TaskPlatform(c.GetString("platform")) + relayInfo := relaycommon.GenTaskRelayInfo(c) + + adaptor := GetTaskAdaptor(platform) + if adaptor == nil { + return service.TaskErrorWrapperLocal(fmt.Errorf("invalid api platform: %s", platform), "invalid_api_platform", http.StatusBadRequest) + } + adaptor.Init(relayInfo) + // get & validate taskRequest 获取并验证文本请求 + taskErr = adaptor.ValidateRequestAndSetAction(c, relayInfo) + if taskErr != nil { + return + } + + modelName := relayInfo.OriginModelName + if modelName == "" { + modelName = service.CoverTaskActionToModelName(platform, relayInfo.Action) + } + modelPrice, success := ratio_setting.GetModelPrice(modelName, true) + if !success { + defaultPrice, ok := ratio_setting.GetDefaultModelRatioMap()[modelName] + if !ok { + modelPrice = 0.1 + } else { + modelPrice = defaultPrice + } + } + + // 预扣 + groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup) + var ratio float64 + userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) + if hasUserGroupRatio { + ratio = modelPrice * userGroupRatio + } else { + ratio = modelPrice * groupRatio + } + userQuota, err := model.GetUserQuota(relayInfo.UserId, false) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "get_user_quota_failed", http.StatusInternalServerError) + return + } + quota := int(ratio * common.QuotaPerUnit) + if userQuota-quota < 0 { + taskErr = service.TaskErrorWrapperLocal(errors.New("user quota is not enough"), "quota_not_enough", http.StatusForbidden) + return + } + + if relayInfo.OriginTaskID != "" { + originTask, exist, err := model.GetByTaskId(relayInfo.UserId, relayInfo.OriginTaskID) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "get_origin_task_failed", http.StatusInternalServerError) + return + } + if !exist { + taskErr = service.TaskErrorWrapperLocal(errors.New("task_origin_not_exist"), "task_not_exist", http.StatusBadRequest) + return + } + if originTask.ChannelId != relayInfo.ChannelId { + channel, err := model.GetChannelById(originTask.ChannelId, true) + if err != nil { + taskErr = service.TaskErrorWrapperLocal(err, "channel_not_found", http.StatusBadRequest) + return + } + if channel.Status != common.ChannelStatusEnabled { + return service.TaskErrorWrapperLocal(errors.New("该任务所属渠道已被禁用"), "task_channel_disable", http.StatusBadRequest) + } + c.Set("base_url", channel.GetBaseURL()) + c.Set("channel_id", originTask.ChannelId) + c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", channel.Key)) + + relayInfo.BaseUrl = channel.GetBaseURL() + relayInfo.ChannelId = originTask.ChannelId + } + } + + // build body + requestBody, err := adaptor.BuildRequestBody(c, relayInfo) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError) + return + } + // do request + resp, err := adaptor.DoRequest(c, relayInfo, requestBody) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "do_request_failed", http.StatusInternalServerError) + return + } + // handle response + if resp != nil && resp.StatusCode != http.StatusOK { + responseBody, _ := io.ReadAll(resp.Body) + taskErr = service.TaskErrorWrapper(fmt.Errorf(string(responseBody)), "fail_to_fetch_task", resp.StatusCode) + return + } + + defer func() { + // release quota + if relayInfo.ConsumeQuota && taskErr == nil { + + err := service.PostConsumeQuota(relayInfo.RelayInfo, quota, 0, true) + if err != nil { + common.SysError("error consuming token remain quota: " + err.Error()) + } + if quota != 0 { + tokenName := c.GetString("token_name") + gRatio := groupRatio + if hasUserGroupRatio { + gRatio = userGroupRatio + } + logContent := fmt.Sprintf("模型固定价格 %.2f,分组倍率 %.2f,操作 %s", modelPrice, gRatio, relayInfo.Action) + other := make(map[string]interface{}) + other["model_price"] = modelPrice + other["group_ratio"] = groupRatio + if hasUserGroupRatio { + other["user_group_ratio"] = userGroupRatio + } + model.RecordConsumeLog(c, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + ModelName: modelName, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: relayInfo.TokenId, + UserQuota: userQuota, + Group: relayInfo.UsingGroup, + Other: other, + }) + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + } + }() + + taskID, taskData, taskErr := adaptor.DoResponse(c, resp, relayInfo) + if taskErr != nil { + return + } + relayInfo.ConsumeQuota = true + // insert task + task := model.InitTask(platform, relayInfo) + task.TaskID = taskID + task.Quota = quota + task.Data = taskData + task.Action = relayInfo.Action + err = task.Insert() + if err != nil { + taskErr = service.TaskErrorWrapper(err, "insert_task_failed", http.StatusInternalServerError) + return + } + return nil +} + +var fetchRespBuilders = map[int]func(c *gin.Context) (respBody []byte, taskResp *dto.TaskError){ + relayconstant.RelayModeSunoFetchByID: sunoFetchByIDRespBodyBuilder, + relayconstant.RelayModeSunoFetch: sunoFetchRespBodyBuilder, + relayconstant.RelayModeKlingFetchByID: videoFetchByIDRespBodyBuilder, +} + +func RelayTaskFetch(c *gin.Context, relayMode int) (taskResp *dto.TaskError) { + respBuilder, ok := fetchRespBuilders[relayMode] + if !ok { + taskResp = service.TaskErrorWrapperLocal(errors.New("invalid_relay_mode"), "invalid_relay_mode", http.StatusBadRequest) + } + + respBody, taskErr := respBuilder(c) + if taskErr != nil { + return taskErr + } + + c.Writer.Header().Set("Content-Type", "application/json") + _, err := io.Copy(c.Writer, bytes.NewBuffer(respBody)) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "copy_response_body_failed", http.StatusInternalServerError) + return + } + return +} + +func sunoFetchRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) { + userId := c.GetInt("id") + var condition = struct { + IDs []any `json:"ids"` + Action string `json:"action"` + }{} + err := c.BindJSON(&condition) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "invalid_request", http.StatusBadRequest) + return + } + var tasks []any + if len(condition.IDs) > 0 { + taskModels, err := model.GetByTaskIds(userId, condition.IDs) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "get_tasks_failed", http.StatusInternalServerError) + return + } + for _, task := range taskModels { + tasks = append(tasks, TaskModel2Dto(task)) + } + } else { + tasks = make([]any, 0) + } + respBody, err = json.Marshal(dto.TaskResponse[[]any]{ + Code: "success", + Data: tasks, + }) + return +} + +func sunoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) { + taskId := c.Param("id") + userId := c.GetInt("id") + + originTask, exist, err := model.GetByTaskId(userId, taskId) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "get_task_failed", http.StatusInternalServerError) + return + } + if !exist { + taskResp = service.TaskErrorWrapperLocal(errors.New("task_not_exist"), "task_not_exist", http.StatusBadRequest) + return + } + + respBody, err = json.Marshal(dto.TaskResponse[any]{ + Code: "success", + Data: TaskModel2Dto(originTask), + }) + return +} + +func videoFetchByIDRespBodyBuilder(c *gin.Context) (respBody []byte, taskResp *dto.TaskError) { + taskId := c.Param("task_id") + userId := c.GetInt("id") + + originTask, exist, err := model.GetByTaskId(userId, taskId) + if err != nil { + taskResp = service.TaskErrorWrapper(err, "get_task_failed", http.StatusInternalServerError) + return + } + if !exist { + taskResp = service.TaskErrorWrapperLocal(errors.New("task_not_exist"), "task_not_exist", http.StatusBadRequest) + return + } + + respBody, err = json.Marshal(dto.TaskResponse[any]{ + Code: "success", + Data: TaskModel2Dto(originTask), + }) + return +} + +func TaskModel2Dto(task *model.Task) *dto.TaskDto { + return &dto.TaskDto{ + TaskID: task.TaskID, + Action: task.Action, + Status: string(task.Status), + FailReason: task.FailReason, + SubmitTime: task.SubmitTime, + StartTime: task.StartTime, + FinishTime: task.FinishTime, + Progress: task.Progress, + Data: task.Data, + } +} diff --git a/relay/rerank_handler.go b/relay/rerank_handler.go new file mode 100644 index 00000000..a092de4b --- /dev/null +++ b/relay/rerank_handler.go @@ -0,0 +1,110 @@ +package relay + +import ( + "bytes" + "fmt" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +func getRerankPromptToken(rerankRequest dto.RerankRequest) int { + token := service.CountTokenInput(rerankRequest.Query, rerankRequest.Model) + for _, document := range rerankRequest.Documents { + tkm := service.CountTokenInput(document, rerankRequest.Model) + token += tkm + } + return token +} + +func RerankHelper(c *gin.Context, relayMode int) (newAPIError *types.NewAPIError) { + + var rerankRequest *dto.RerankRequest + err := common.UnmarshalBodyReusable(c, &rerankRequest) + if err != nil { + common.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error())) + return types.NewError(err, types.ErrorCodeInvalidRequest) + } + + relayInfo := relaycommon.GenRelayInfoRerank(c, rerankRequest) + + if rerankRequest.Query == "" { + return types.NewError(fmt.Errorf("query is empty"), types.ErrorCodeInvalidRequest) + } + if len(rerankRequest.Documents) == 0 { + return types.NewError(fmt.Errorf("documents is empty"), types.ErrorCodeInvalidRequest) + } + + err = helper.ModelMappedHelper(c, relayInfo, rerankRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError) + } + + promptToken := getRerankPromptToken(*rerankRequest) + relayInfo.PromptTokens = promptToken + + priceData, err := helper.ModelPriceHelper(c, relayInfo, promptToken, 0) + if err != nil { + return types.NewError(err, types.ErrorCodeModelPriceError) + } + // pre-consume quota 预消耗配额 + preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + if newAPIError != nil { + return newAPIError + } + defer func() { + if newAPIError != nil { + returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) + } + }() + + adaptor := GetAdaptor(relayInfo.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + } + adaptor.Init(relayInfo) + + convertedRequest, err := adaptor.ConvertRerankRequest(c, relayInfo.RelayMode, *rerankRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + requestBody := bytes.NewBuffer(jsonData) + if common.DebugEnabled { + println(fmt.Sprintf("Rerank request body: %s", requestBody.String())) + } + resp, err := adaptor.DoRequest(c, relayInfo, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, relayInfo) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "") + return nil +} diff --git a/relay/responses_handler.go b/relay/responses_handler.go new file mode 100644 index 00000000..52d1db6e --- /dev/null +++ b/relay/responses_handler.go @@ -0,0 +1,169 @@ +package relay + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/setting" + "one-api/setting/model_setting" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func getAndValidateResponsesRequest(c *gin.Context) (*dto.OpenAIResponsesRequest, error) { + request := &dto.OpenAIResponsesRequest{} + err := common.UnmarshalBodyReusable(c, request) + if err != nil { + return nil, err + } + if request.Model == "" { + return nil, errors.New("model is required") + } + if len(request.Input) == 0 { + return nil, errors.New("input is required") + } + return request, nil + +} + +func checkInputSensitive(textRequest *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) ([]string, error) { + sensitiveWords, err := service.CheckSensitiveInput(textRequest.Input) + return sensitiveWords, err +} + +func getInputTokens(req *dto.OpenAIResponsesRequest, info *relaycommon.RelayInfo) int { + inputTokens := service.CountTokenInput(req.Input, req.Model) + info.PromptTokens = inputTokens + return inputTokens +} + +func ResponsesHelper(c *gin.Context) (newAPIError *types.NewAPIError) { + req, err := getAndValidateResponsesRequest(c) + if err != nil { + common.LogError(c, fmt.Sprintf("getAndValidateResponsesRequest error: %s", err.Error())) + return types.NewError(err, types.ErrorCodeInvalidRequest) + } + + relayInfo := relaycommon.GenRelayInfoResponses(c, req) + + if setting.ShouldCheckPromptSensitive() { + sensitiveWords, err := checkInputSensitive(req, relayInfo) + if err != nil { + common.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(sensitiveWords, ", "))) + return types.NewError(err, types.ErrorCodeSensitiveWordsDetected) + } + } + + err = helper.ModelMappedHelper(c, relayInfo, req) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError) + } + + if value, exists := c.Get("prompt_tokens"); exists { + promptTokens := value.(int) + relayInfo.SetPromptTokens(promptTokens) + } else { + promptTokens := getInputTokens(req, relayInfo) + c.Set("prompt_tokens", promptTokens) + } + + priceData, err := helper.ModelPriceHelper(c, relayInfo, relayInfo.PromptTokens, int(req.MaxOutputTokens)) + if err != nil { + return types.NewError(err, types.ErrorCodeModelPriceError) + } + // pre consume quota + preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + if newAPIError != nil { + return newAPIError + } + defer func() { + if newAPIError != nil { + returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) + } + }() + adaptor := GetAdaptor(relayInfo.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + } + adaptor.Init(relayInfo) + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewError(err, types.ErrorCodeReadRequestBodyFailed) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, relayInfo, *req) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + // apply param override + if len(relayInfo.ParamOverride) > 0 { + reqMap := make(map[string]interface{}) + err = json.Unmarshal(jsonData, &reqMap) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid) + } + for key, value := range relayInfo.ParamOverride { + reqMap[key] = value + } + jsonData, err = json.Marshal(reqMap) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + } + + if common.DebugEnabled { + println("requestBody: ", string(jsonData)) + } + requestBody = bytes.NewBuffer(jsonData) + } + + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, relayInfo, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + if resp != nil { + httpResp = resp.(*http.Response) + + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, relayInfo) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + if strings.HasPrefix(relayInfo.OriginModelName, "gpt-4o-audio") { + service.PostAudioConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "") + } else { + postConsumeQuota(c, relayInfo, usage.(*dto.Usage), preConsumedQuota, userQuota, priceData, "") + } + return nil +} diff --git a/relay/websocket.go b/relay/websocket.go new file mode 100644 index 00000000..659e27d5 --- /dev/null +++ b/relay/websocket.go @@ -0,0 +1,76 @@ +package relay + +import ( + "fmt" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func WssHelper(c *gin.Context, ws *websocket.Conn) (newAPIError *types.NewAPIError) { + relayInfo := relaycommon.GenRelayInfoWs(c, ws) + + // get & validate textRequest 获取并验证文本请求 + //realtimeEvent, err := getAndValidateWssRequest(c, ws) + //if err != nil { + // common.LogError(c, fmt.Sprintf("getAndValidateWssRequest failed: %s", err.Error())) + // return service.OpenAIErrorWrapperLocal(err, "invalid_text_request", http.StatusBadRequest) + //} + + err := helper.ModelMappedHelper(c, relayInfo, nil) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError) + } + + priceData, err := helper.ModelPriceHelper(c, relayInfo, 0, 0) + if err != nil { + return types.NewError(err, types.ErrorCodeModelPriceError) + } + + // pre-consume quota 预消耗配额 + preConsumedQuota, userQuota, newAPIError := preConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + if newAPIError != nil { + return newAPIError + } + + defer func() { + if newAPIError != nil { + returnPreConsumedQuota(c, relayInfo, userQuota, preConsumedQuota) + } + }() + + adaptor := GetAdaptor(relayInfo.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", relayInfo.ApiType), types.ErrorCodeInvalidApiType) + } + adaptor.Init(relayInfo) + //var requestBody io.Reader + //firstWssRequest, _ := c.Get("first_wss_request") + //requestBody = bytes.NewBuffer(firstWssRequest.([]byte)) + + statusCodeMappingStr := c.GetString("status_code_mapping") + resp, err := adaptor.DoRequest(c, relayInfo, nil) + if err != nil { + return types.NewError(err, types.ErrorCodeDoRequestFailed) + } + + if resp != nil { + relayInfo.TargetWs = resp.(*websocket.Conn) + defer relayInfo.TargetWs.Close() + } + + usage, newAPIError := adaptor.DoResponse(c, nil, relayInfo) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + service.PostWssConsumeQuota(c, relayInfo, relayInfo.UpstreamModelName, usage.(*dto.RealtimeUsage), preConsumedQuota, + userQuota, priceData, "") + return nil +} diff --git a/router/api-router.go b/router/api-router.go new file mode 100644 index 00000000..bc49803a --- /dev/null +++ b/router/api-router.go @@ -0,0 +1,179 @@ +package router + +import ( + "one-api/controller" + "one-api/middleware" + + "github.com/gin-contrib/gzip" + "github.com/gin-gonic/gin" +) + +func SetApiRouter(router *gin.Engine) { + apiRouter := router.Group("/api") + apiRouter.Use(gzip.Gzip(gzip.DefaultCompression)) + apiRouter.Use(middleware.GlobalAPIRateLimit()) + { + apiRouter.GET("/setup", controller.GetSetup) + apiRouter.POST("/setup", controller.PostSetup) + apiRouter.GET("/status", controller.GetStatus) + apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) + apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) + apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) + apiRouter.GET("/notice", controller.GetNotice) + apiRouter.GET("/about", controller.GetAbout) + //apiRouter.GET("/midjourney", controller.GetMidjourney) + apiRouter.GET("/home_page_content", controller.GetHomePageContent) + apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) + apiRouter.GET("/verification", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) + apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) + apiRouter.POST("/user/reset", middleware.CriticalRateLimit(), controller.ResetPassword) + apiRouter.GET("/oauth/github", middleware.CriticalRateLimit(), controller.GitHubOAuth) + apiRouter.GET("/oauth/oidc", middleware.CriticalRateLimit(), controller.OidcAuth) + apiRouter.GET("/oauth/linuxdo", middleware.CriticalRateLimit(), controller.LinuxdoOAuth) + apiRouter.GET("/oauth/state", middleware.CriticalRateLimit(), controller.GenerateOAuthCode) + apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth) + apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), controller.WeChatBind) + apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), controller.EmailBind) + apiRouter.GET("/oauth/telegram/login", middleware.CriticalRateLimit(), controller.TelegramLogin) + apiRouter.GET("/oauth/telegram/bind", middleware.CriticalRateLimit(), controller.TelegramBind) + apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) + + apiRouter.POST("/stripe/webhook", controller.StripeWebhook) + + userRoute := apiRouter.Group("/user") + { + userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) + userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login) + //userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog) + userRoute.GET("/logout", controller.Logout) + userRoute.GET("/epay/notify", controller.EpayNotify) + userRoute.GET("/groups", controller.GetUserGroups) + + selfRoute := userRoute.Group("/") + selfRoute.Use(middleware.UserAuth()) + { + selfRoute.GET("/self/groups", controller.GetUserGroups) + selfRoute.GET("/self", controller.GetSelf) + selfRoute.GET("/models", controller.GetUserModels) + selfRoute.PUT("/self", controller.UpdateSelf) + selfRoute.DELETE("/self", controller.DeleteSelf) + selfRoute.GET("/token", controller.GenerateAccessToken) + selfRoute.GET("/aff", controller.GetAffCode) + selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) + selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay) + selfRoute.POST("/amount", controller.RequestAmount) + selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) + selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) + selfRoute.POST("/aff_transfer", controller.TransferAffQuota) + selfRoute.PUT("/setting", controller.UpdateUserSetting) + } + + adminRoute := userRoute.Group("/") + adminRoute.Use(middleware.AdminAuth()) + { + adminRoute.GET("/", controller.GetAllUsers) + adminRoute.GET("/search", controller.SearchUsers) + adminRoute.GET("/:id", controller.GetUser) + adminRoute.POST("/", controller.CreateUser) + adminRoute.POST("/manage", controller.ManageUser) + adminRoute.PUT("/", controller.UpdateUser) + adminRoute.DELETE("/:id", controller.DeleteUser) + } + } + optionRoute := apiRouter.Group("/option") + optionRoute.Use(middleware.RootAuth()) + { + optionRoute.GET("/", controller.GetOptions) + optionRoute.PUT("/", controller.UpdateOption) + optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) + optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 + } + ratioSyncRoute := apiRouter.Group("/ratio_sync") + ratioSyncRoute.Use(middleware.RootAuth()) + { + ratioSyncRoute.GET("/channels", controller.GetSyncableChannels) + ratioSyncRoute.POST("/fetch", controller.FetchUpstreamRatios) + } + channelRoute := apiRouter.Group("/channel") + channelRoute.Use(middleware.AdminAuth()) + { + channelRoute.GET("/", controller.GetAllChannels) + channelRoute.GET("/search", controller.SearchChannels) + channelRoute.GET("/models", controller.ChannelListModels) + channelRoute.GET("/models_enabled", controller.EnabledListModels) + channelRoute.GET("/:id", controller.GetChannel) + channelRoute.GET("/test", controller.TestAllChannels) + channelRoute.GET("/test/:id", controller.TestChannel) + channelRoute.GET("/update_balance", controller.UpdateAllChannelsBalance) + channelRoute.GET("/update_balance/:id", controller.UpdateChannelBalance) + channelRoute.POST("/", controller.AddChannel) + channelRoute.PUT("/", controller.UpdateChannel) + channelRoute.DELETE("/disabled", controller.DeleteDisabledChannel) + channelRoute.POST("/tag/disabled", controller.DisableTagChannels) + channelRoute.POST("/tag/enabled", controller.EnableTagChannels) + channelRoute.PUT("/tag", controller.EditTagChannels) + channelRoute.DELETE("/:id", controller.DeleteChannel) + channelRoute.POST("/batch", controller.DeleteChannelBatch) + channelRoute.POST("/fix", controller.FixChannelsAbilities) + channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) + channelRoute.POST("/fetch_models", controller.FetchModels) + channelRoute.POST("/batch/tag", controller.BatchSetChannelTag) + channelRoute.GET("/tag/models", controller.GetTagModels) + channelRoute.POST("/copy/:id", controller.CopyChannel) + } + tokenRoute := apiRouter.Group("/token") + tokenRoute.Use(middleware.UserAuth()) + { + tokenRoute.GET("/", controller.GetAllTokens) + tokenRoute.GET("/search", controller.SearchTokens) + tokenRoute.GET("/:id", controller.GetToken) + tokenRoute.POST("/", controller.AddToken) + tokenRoute.PUT("/", controller.UpdateToken) + tokenRoute.DELETE("/:id", controller.DeleteToken) + tokenRoute.POST("/batch", controller.DeleteTokenBatch) + } + redemptionRoute := apiRouter.Group("/redemption") + redemptionRoute.Use(middleware.AdminAuth()) + { + redemptionRoute.GET("/", controller.GetAllRedemptions) + redemptionRoute.GET("/search", controller.SearchRedemptions) + redemptionRoute.GET("/:id", controller.GetRedemption) + redemptionRoute.POST("/", controller.AddRedemption) + redemptionRoute.PUT("/", controller.UpdateRedemption) + redemptionRoute.DELETE("/invalid", controller.DeleteInvalidRedemption) + redemptionRoute.DELETE("/:id", controller.DeleteRedemption) + } + logRoute := apiRouter.Group("/log") + logRoute.GET("/", middleware.AdminAuth(), controller.GetAllLogs) + logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs) + logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat) + logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat) + logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs) + logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs) + logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs) + + dataRoute := apiRouter.Group("/data") + dataRoute.GET("/", middleware.AdminAuth(), controller.GetAllQuotaDates) + dataRoute.GET("/self", middleware.UserAuth(), controller.GetUserQuotaDates) + + logRoute.Use(middleware.CORS()) + { + logRoute.GET("/token", controller.GetLogByKey) + + } + groupRoute := apiRouter.Group("/group") + groupRoute.Use(middleware.AdminAuth()) + { + groupRoute.GET("/", controller.GetGroups) + } + mjRoute := apiRouter.Group("/mj") + mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney) + mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney) + + taskRoute := apiRouter.Group("/task") + { + taskRoute.GET("/self", middleware.UserAuth(), controller.GetUserTask) + taskRoute.GET("/", middleware.AdminAuth(), controller.GetAllTask) + } + } +} diff --git a/router/dashboard.go b/router/dashboard.go new file mode 100644 index 00000000..94000679 --- /dev/null +++ b/router/dashboard.go @@ -0,0 +1,22 @@ +package router + +import ( + "github.com/gin-contrib/gzip" + "github.com/gin-gonic/gin" + "one-api/controller" + "one-api/middleware" +) + +func SetDashboardRouter(router *gin.Engine) { + apiRouter := router.Group("/") + apiRouter.Use(gzip.Gzip(gzip.DefaultCompression)) + apiRouter.Use(middleware.GlobalAPIRateLimit()) + apiRouter.Use(middleware.CORS()) + apiRouter.Use(middleware.TokenAuth()) + { + apiRouter.GET("/dashboard/billing/subscription", controller.GetSubscription) + apiRouter.GET("/v1/dashboard/billing/subscription", controller.GetSubscription) + apiRouter.GET("/dashboard/billing/usage", controller.GetUsage) + apiRouter.GET("/v1/dashboard/billing/usage", controller.GetUsage) + } +} diff --git a/router/main.go b/router/main.go new file mode 100644 index 00000000..0d2bfdce --- /dev/null +++ b/router/main.go @@ -0,0 +1,31 @@ +package router + +import ( + "embed" + "fmt" + "github.com/gin-gonic/gin" + "net/http" + "one-api/common" + "os" + "strings" +) + +func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { + SetApiRouter(router) + SetDashboardRouter(router) + SetRelayRouter(router) + SetVideoRouter(router) + frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL") + if common.IsMasterNode && frontendBaseUrl != "" { + frontendBaseUrl = "" + common.SysLog("FRONTEND_BASE_URL is ignored on master node") + } + if frontendBaseUrl == "" { + SetWebRouter(router, buildFS, indexPage) + } else { + frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/") + router.NoRoute(func(c *gin.Context) { + c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI)) + }) + } +} diff --git a/router/relay-router.go b/router/relay-router.go new file mode 100644 index 00000000..5b293dbd --- /dev/null +++ b/router/relay-router.go @@ -0,0 +1,115 @@ +package router + +import ( + "one-api/controller" + "one-api/middleware" + "one-api/relay" + + "github.com/gin-gonic/gin" +) + +func SetRelayRouter(router *gin.Engine) { + router.Use(middleware.CORS()) + router.Use(middleware.DecompressRequestMiddleware()) + router.Use(middleware.StatsMiddleware()) + // https://platform.openai.com/docs/api-reference/introduction + modelsRouter := router.Group("/v1/models") + modelsRouter.Use(middleware.TokenAuth()) + { + modelsRouter.GET("", controller.ListModels) + modelsRouter.GET("/:model", controller.RetrieveModel) + } + playgroundRouter := router.Group("/pg") + playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute()) + { + playgroundRouter.POST("/chat/completions", controller.Playground) + } + relayV1Router := router.Group("/v1") + relayV1Router.Use(middleware.TokenAuth()) + relayV1Router.Use(middleware.ModelRequestRateLimit()) + { + // WebSocket 路由 + wsRouter := relayV1Router.Group("") + wsRouter.Use(middleware.Distribute()) + wsRouter.GET("/realtime", controller.WssRelay) + } + { + //http router + httpRouter := relayV1Router.Group("") + httpRouter.Use(middleware.Distribute()) + httpRouter.POST("/messages", controller.RelayClaude) + httpRouter.POST("/completions", controller.Relay) + httpRouter.POST("/chat/completions", controller.Relay) + httpRouter.POST("/edits", controller.Relay) + httpRouter.POST("/images/generations", controller.Relay) + httpRouter.POST("/images/edits", controller.Relay) + httpRouter.POST("/images/variations", controller.RelayNotImplemented) + httpRouter.POST("/embeddings", controller.Relay) + httpRouter.POST("/engines/:model/embeddings", controller.Relay) + httpRouter.POST("/audio/transcriptions", controller.Relay) + httpRouter.POST("/audio/translations", controller.Relay) + httpRouter.POST("/audio/speech", controller.Relay) + httpRouter.POST("/responses", controller.Relay) + httpRouter.GET("/files", controller.RelayNotImplemented) + httpRouter.POST("/files", controller.RelayNotImplemented) + httpRouter.DELETE("/files/:id", controller.RelayNotImplemented) + httpRouter.GET("/files/:id", controller.RelayNotImplemented) + httpRouter.GET("/files/:id/content", controller.RelayNotImplemented) + httpRouter.POST("/fine-tunes", controller.RelayNotImplemented) + httpRouter.GET("/fine-tunes", controller.RelayNotImplemented) + httpRouter.GET("/fine-tunes/:id", controller.RelayNotImplemented) + httpRouter.POST("/fine-tunes/:id/cancel", controller.RelayNotImplemented) + httpRouter.GET("/fine-tunes/:id/events", controller.RelayNotImplemented) + httpRouter.DELETE("/models/:model", controller.RelayNotImplemented) + httpRouter.POST("/moderations", controller.Relay) + httpRouter.POST("/rerank", controller.Relay) + httpRouter.POST("/models/*path", controller.Relay) + } + + relayMjRouter := router.Group("/mj") + registerMjRouterGroup(relayMjRouter) + + relayMjModeRouter := router.Group("/:mode/mj") + registerMjRouterGroup(relayMjModeRouter) + //relayMjRouter.Use() + + relaySunoRouter := router.Group("/suno") + relaySunoRouter.Use(middleware.TokenAuth(), middleware.Distribute()) + { + relaySunoRouter.POST("/submit/:action", controller.RelayTask) + relaySunoRouter.POST("/fetch", controller.RelayTask) + relaySunoRouter.GET("/fetch/:id", controller.RelayTask) + } + + relayGeminiRouter := router.Group("/v1beta") + relayGeminiRouter.Use(middleware.TokenAuth()) + relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) + relayGeminiRouter.Use(middleware.Distribute()) + { + // Gemini API 路径格式: /v1beta/models/{model_name}:{action} + relayGeminiRouter.POST("/models/*path", controller.Relay) + } +} + +func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) { + relayMjRouter.GET("/image/:id", relay.RelayMidjourneyImage) + relayMjRouter.Use(middleware.TokenAuth(), middleware.Distribute()) + { + relayMjRouter.POST("/submit/action", controller.RelayMidjourney) + relayMjRouter.POST("/submit/shorten", controller.RelayMidjourney) + relayMjRouter.POST("/submit/modal", controller.RelayMidjourney) + relayMjRouter.POST("/submit/imagine", controller.RelayMidjourney) + relayMjRouter.POST("/submit/change", controller.RelayMidjourney) + relayMjRouter.POST("/submit/simple-change", controller.RelayMidjourney) + relayMjRouter.POST("/submit/describe", controller.RelayMidjourney) + relayMjRouter.POST("/submit/blend", controller.RelayMidjourney) + relayMjRouter.POST("/submit/edits", controller.RelayMidjourney) + relayMjRouter.POST("/submit/video", controller.RelayMidjourney) + relayMjRouter.POST("/notify", controller.RelayMidjourney) + relayMjRouter.GET("/task/:id/fetch", controller.RelayMidjourney) + relayMjRouter.GET("/task/:id/image-seed", controller.RelayMidjourney) + relayMjRouter.POST("/task/list-by-condition", controller.RelayMidjourney) + relayMjRouter.POST("/insight-face/swap", controller.RelayMidjourney) + relayMjRouter.POST("/submit/upload-discord-images", controller.RelayMidjourney) + } +} diff --git a/router/video-router.go b/router/video-router.go new file mode 100644 index 00000000..9e605d54 --- /dev/null +++ b/router/video-router.go @@ -0,0 +1,24 @@ +package router + +import ( + "one-api/controller" + "one-api/middleware" + + "github.com/gin-gonic/gin" +) + +func SetVideoRouter(router *gin.Engine) { + videoV1Router := router.Group("/v1") + videoV1Router.Use(middleware.TokenAuth(), middleware.Distribute()) + { + videoV1Router.POST("/video/generations", controller.RelayTask) + videoV1Router.GET("/video/generations/:task_id", controller.RelayTask) + } + + klingV1Router := router.Group("/kling/v1") + klingV1Router.Use(middleware.KlingRequestConvert(), middleware.TokenAuth(), middleware.Distribute()) + { + klingV1Router.POST("/videos/text2video", controller.RelayTask) + klingV1Router.POST("/videos/image2video", controller.RelayTask) + } +} diff --git a/router/web-router.go b/router/web-router.go new file mode 100644 index 00000000..57cd61ac --- /dev/null +++ b/router/web-router.go @@ -0,0 +1,28 @@ +package router + +import ( + "embed" + "github.com/gin-contrib/gzip" + "github.com/gin-contrib/static" + "github.com/gin-gonic/gin" + "net/http" + "one-api/common" + "one-api/controller" + "one-api/middleware" + "strings" +) + +func SetWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { + router.Use(gzip.Gzip(gzip.DefaultCompression)) + router.Use(middleware.GlobalWebRateLimit()) + router.Use(middleware.Cache()) + router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/dist"))) + router.NoRoute(func(c *gin.Context) { + if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") { + controller.RelayNotFound(c) + return + } + c.Header("Cache-Control", "no-cache") + c.Data(http.StatusOK, "text/html; charset=utf-8", indexPage) + }) +} diff --git a/service/audio.go b/service/audio.go new file mode 100644 index 00000000..c4b6f01b --- /dev/null +++ b/service/audio.go @@ -0,0 +1,48 @@ +package service + +import ( + "encoding/base64" + "fmt" + "strings" +) + +func parseAudio(audioBase64 string, format string) (duration float64, err error) { + audioData, err := base64.StdEncoding.DecodeString(audioBase64) + if err != nil { + return 0, fmt.Errorf("base64 decode error: %v", err) + } + + var samplesCount int + var sampleRate int + + switch format { + case "pcm16": + samplesCount = len(audioData) / 2 // 16位 = 2字节每样本 + sampleRate = 24000 // 24kHz + case "g711_ulaw", "g711_alaw": + samplesCount = len(audioData) // 8位 = 1字节每样本 + sampleRate = 8000 // 8kHz + default: + samplesCount = len(audioData) // 8位 = 1字节每样本 + sampleRate = 8000 // 8kHz + } + + duration = float64(samplesCount) / float64(sampleRate) + return duration, nil +} + +func DecodeBase64AudioData(audioBase64 string) (string, error) { + // 检查并移除 data:audio/xxx;base64, 前缀 + idx := strings.Index(audioBase64, ",") + if idx != -1 { + audioBase64 = audioBase64[idx+1:] + } + + // 解码 Base64 数据 + _, err := base64.StdEncoding.DecodeString(audioBase64) + if err != nil { + return "", fmt.Errorf("base64 decode error: %v", err) + } + + return audioBase64, nil +} diff --git a/service/cf_worker.go b/service/cf_worker.go new file mode 100644 index 00000000..ae6e1ffe --- /dev/null +++ b/service/cf_worker.go @@ -0,0 +1,57 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "one-api/common" + "one-api/setting" + "strings" +) + +// WorkerRequest Worker请求的数据结构 +type WorkerRequest struct { + URL string `json:"url"` + Key string `json:"key"` + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Body json.RawMessage `json:"body,omitempty"` +} + +// DoWorkerRequest 通过Worker发送请求 +func DoWorkerRequest(req *WorkerRequest) (*http.Response, error) { + if !setting.EnableWorker() { + return nil, fmt.Errorf("worker not enabled") + } + if !setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { + return nil, fmt.Errorf("only support https url") + } + + workerUrl := setting.WorkerUrl + if !strings.HasSuffix(workerUrl, "/") { + workerUrl += "/" + } + + // 序列化worker请求数据 + workerPayload, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal worker payload: %v", err) + } + + return http.Post(workerUrl, "application/json", bytes.NewBuffer(workerPayload)) +} + +func DoDownloadRequest(originUrl string) (resp *http.Response, err error) { + if setting.EnableWorker() { + common.SysLog(fmt.Sprintf("downloading file from worker: %s", originUrl)) + req := &WorkerRequest{ + URL: originUrl, + Key: setting.WorkerValidKey, + } + return DoWorkerRequest(req) + } else { + common.SysLog(fmt.Sprintf("downloading from origin: %s", originUrl)) + return http.Get(originUrl) + } +} diff --git a/service/channel.go b/service/channel.go new file mode 100644 index 00000000..4d38e6ed --- /dev/null +++ b/service/channel.go @@ -0,0 +1,101 @@ +package service + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/model" + "one-api/setting/operation_setting" + "one-api/types" + "strings" +) + +func formatNotifyType(channelId int, status int) string { + return fmt.Sprintf("%s_%d_%d", dto.NotifyTypeChannelUpdate, channelId, status) +} + +// disable & notify +func DisableChannel(channelError types.ChannelError, reason string) { + success := model.UpdateChannelStatus(channelError.ChannelId, channelError.UsingKey, common.ChannelStatusAutoDisabled, reason) + if success { + subject := fmt.Sprintf("通道「%s」(#%d)已被禁用", channelError.ChannelName, channelError.ChannelId) + content := fmt.Sprintf("通道「%s」(#%d)已被禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, reason) + NotifyRootUser(formatNotifyType(channelError.ChannelId, common.ChannelStatusAutoDisabled), subject, content) + } +} + +func EnableChannel(channelId int, usingKey string, channelName string) { + success := model.UpdateChannelStatus(channelId, usingKey, common.ChannelStatusEnabled, "") + if success { + subject := fmt.Sprintf("通道「%s」(#%d)已被启用", channelName, channelId) + content := fmt.Sprintf("通道「%s」(#%d)已被启用", channelName, channelId) + NotifyRootUser(formatNotifyType(channelId, common.ChannelStatusEnabled), subject, content) + } +} + +func ShouldDisableChannel(channelType int, err *types.NewAPIError) bool { + if !common.AutomaticDisableChannelEnabled { + return false + } + if err == nil { + return false + } + if types.IsChannelError(err) { + return true + } + if types.IsLocalError(err) { + return false + } + if err.StatusCode == http.StatusUnauthorized { + return true + } + if err.StatusCode == http.StatusForbidden { + switch channelType { + case constant.ChannelTypeGemini: + return true + } + } + oaiErr := err.ToOpenAIError() + switch oaiErr.Code { + case "invalid_api_key": + return true + case "account_deactivated": + return true + case "billing_not_active": + return true + case "pre_consume_token_quota_failed": + return true + } + switch oaiErr.Type { + case "insufficient_quota": + return true + case "insufficient_user_quota": + return true + // https://docs.anthropic.com/claude/reference/errors + case "authentication_error": + return true + case "permission_error": + return true + case "forbidden": + return true + } + + lowerMessage := strings.ToLower(err.Error()) + search, _ := AcSearch(lowerMessage, operation_setting.AutomaticDisableKeywords, true) + return search +} + +func ShouldEnableChannel(newAPIError *types.NewAPIError, status int) bool { + if !common.AutomaticEnableChannelEnabled { + return false + } + if newAPIError != nil { + return false + } + if status != common.ChannelStatusAutoDisabled { + return false + } + return true +} diff --git a/service/convert.go b/service/convert.go new file mode 100644 index 00000000..593b59d9 --- /dev/null +++ b/service/convert.go @@ -0,0 +1,440 @@ +package service + +import ( + "encoding/json" + "fmt" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/relay/channel/openrouter" + relaycommon "one-api/relay/common" + "strings" +) + +func ClaudeToOpenAIRequest(claudeRequest dto.ClaudeRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) { + openAIRequest := dto.GeneralOpenAIRequest{ + Model: claudeRequest.Model, + MaxTokens: claudeRequest.MaxTokens, + Temperature: claudeRequest.Temperature, + TopP: claudeRequest.TopP, + Stream: claudeRequest.Stream, + } + + isOpenRouter := info.ChannelType == constant.ChannelTypeOpenRouter + + if claudeRequest.Thinking != nil && claudeRequest.Thinking.Type == "enabled" { + if isOpenRouter { + reasoning := openrouter.RequestReasoning{ + MaxTokens: claudeRequest.Thinking.GetBudgetTokens(), + } + reasoningJSON, err := json.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("failed to marshal reasoning: %w", err) + } + openAIRequest.Reasoning = reasoningJSON + } else { + thinkingSuffix := "-thinking" + if strings.HasSuffix(info.OriginModelName, thinkingSuffix) && + !strings.HasSuffix(openAIRequest.Model, thinkingSuffix) { + openAIRequest.Model = openAIRequest.Model + thinkingSuffix + } + } + } + + // Convert stop sequences + if len(claudeRequest.StopSequences) == 1 { + openAIRequest.Stop = claudeRequest.StopSequences[0] + } else if len(claudeRequest.StopSequences) > 1 { + openAIRequest.Stop = claudeRequest.StopSequences + } + + // Convert tools + tools, _ := common.Any2Type[[]dto.Tool](claudeRequest.Tools) + openAITools := make([]dto.ToolCallRequest, 0) + for _, claudeTool := range tools { + openAITool := dto.ToolCallRequest{ + Type: "function", + Function: dto.FunctionRequest{ + Name: claudeTool.Name, + Description: claudeTool.Description, + Parameters: claudeTool.InputSchema, + }, + } + openAITools = append(openAITools, openAITool) + } + openAIRequest.Tools = openAITools + + // Convert messages + openAIMessages := make([]dto.Message, 0) + + // Add system message if present + if claudeRequest.System != nil { + if claudeRequest.IsStringSystem() && claudeRequest.GetStringSystem() != "" { + openAIMessage := dto.Message{ + Role: "system", + } + openAIMessage.SetStringContent(claudeRequest.GetStringSystem()) + openAIMessages = append(openAIMessages, openAIMessage) + } else { + systems := claudeRequest.ParseSystem() + if len(systems) > 0 { + openAIMessage := dto.Message{ + Role: "system", + } + isOpenRouterClaude := isOpenRouter && strings.HasPrefix(info.UpstreamModelName, "anthropic/claude") + if isOpenRouterClaude { + systemMediaMessages := make([]dto.MediaContent, 0, len(systems)) + for _, system := range systems { + message := dto.MediaContent{ + Type: "text", + Text: system.GetText(), + CacheControl: system.CacheControl, + } + systemMediaMessages = append(systemMediaMessages, message) + } + openAIMessage.SetMediaContent(systemMediaMessages) + } else { + systemStr := "" + for _, system := range systems { + if system.Text != nil { + systemStr += *system.Text + } + } + openAIMessage.SetStringContent(systemStr) + } + openAIMessages = append(openAIMessages, openAIMessage) + } + } + } + for _, claudeMessage := range claudeRequest.Messages { + openAIMessage := dto.Message{ + Role: claudeMessage.Role, + } + + //log.Printf("claudeMessage.Content: %v", claudeMessage.Content) + if claudeMessage.IsStringContent() { + openAIMessage.SetStringContent(claudeMessage.GetStringContent()) + } else { + content, err := claudeMessage.ParseContent() + if err != nil { + return nil, err + } + contents := content + var toolCalls []dto.ToolCallRequest + mediaMessages := make([]dto.MediaContent, 0, len(contents)) + + for _, mediaMsg := range contents { + switch mediaMsg.Type { + case "text": + message := dto.MediaContent{ + Type: "text", + Text: mediaMsg.GetText(), + CacheControl: mediaMsg.CacheControl, + } + mediaMessages = append(mediaMessages, message) + case "image": + // Handle image conversion (base64 to URL or keep as is) + imageData := fmt.Sprintf("data:%s;base64,%s", mediaMsg.Source.MediaType, mediaMsg.Source.Data) + //textContent += fmt.Sprintf("[Image: %s]", imageData) + mediaMessage := dto.MediaContent{ + Type: "image_url", + ImageUrl: &dto.MessageImageUrl{Url: imageData}, + } + mediaMessages = append(mediaMessages, mediaMessage) + case "tool_use": + toolCall := dto.ToolCallRequest{ + ID: mediaMsg.Id, + Type: "function", + Function: dto.FunctionRequest{ + Name: mediaMsg.Name, + Arguments: toJSONString(mediaMsg.Input), + }, + } + toolCalls = append(toolCalls, toolCall) + case "tool_result": + // Add tool result as a separate message + oaiToolMessage := dto.Message{ + Role: "tool", + Name: &mediaMsg.Name, + ToolCallId: mediaMsg.ToolUseId, + } + //oaiToolMessage.SetStringContent(*mediaMsg.GetMediaContent().Text) + if mediaMsg.IsStringContent() { + oaiToolMessage.SetStringContent(mediaMsg.GetStringContent()) + } else { + mediaContents := mediaMsg.ParseMediaContent() + encodeJson, _ := common.Marshal(mediaContents) + oaiToolMessage.SetStringContent(string(encodeJson)) + } + openAIMessages = append(openAIMessages, oaiToolMessage) + } + } + + if len(toolCalls) > 0 { + openAIMessage.SetToolCalls(toolCalls) + } + + if len(mediaMessages) > 0 && len(toolCalls) == 0 { + openAIMessage.SetMediaContent(mediaMessages) + } + } + if len(openAIMessage.ParseContent()) > 0 || len(openAIMessage.ToolCalls) > 0 { + openAIMessages = append(openAIMessages, openAIMessage) + } + } + + openAIRequest.Messages = openAIMessages + + return &openAIRequest, nil +} + +func OpenAIErrorToClaudeError(openAIError *dto.OpenAIErrorWithStatusCode) *dto.ClaudeErrorWithStatusCode { + claudeError := dto.ClaudeError{ + Type: "new_api_error", + Message: openAIError.Error.Message, + } + return &dto.ClaudeErrorWithStatusCode{ + Error: claudeError, + StatusCode: openAIError.StatusCode, + } +} + +func ClaudeErrorToOpenAIError(claudeError *dto.ClaudeErrorWithStatusCode) *dto.OpenAIErrorWithStatusCode { + openAIError := dto.OpenAIError{ + Message: claudeError.Error.Message, + Type: "new_api_error", + } + return &dto.OpenAIErrorWithStatusCode{ + Error: openAIError, + StatusCode: claudeError.StatusCode, + } +} + +func generateStopBlock(index int) *dto.ClaudeResponse { + return &dto.ClaudeResponse{ + Type: "content_block_stop", + Index: common.GetPointer[int](index), + } +} + +func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) []*dto.ClaudeResponse { + var claudeResponses []*dto.ClaudeResponse + if info.SendResponseCount == 1 { + msg := &dto.ClaudeMediaMessage{ + Id: openAIResponse.Id, + Model: openAIResponse.Model, + Type: "message", + Role: "assistant", + Usage: &dto.ClaudeUsage{ + InputTokens: info.PromptTokens, + OutputTokens: 0, + }, + } + msg.SetContent(make([]any, 0)) + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_start", + Message: msg, + }) + claudeResponses = append(claudeResponses) + //claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + // Type: "ping", + //}) + if openAIResponse.IsToolCall() { + resp := &dto.ClaudeResponse{ + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Id: openAIResponse.GetFirstToolCall().ID, + Type: "tool_use", + Name: openAIResponse.GetFirstToolCall().Function.Name, + }, + } + resp.SetIndex(0) + claudeResponses = append(claudeResponses, resp) + } else { + //resp := &dto.ClaudeResponse{ + // Type: "content_block_start", + // ContentBlock: &dto.ClaudeMediaMessage{ + // Type: "text", + // Text: common.GetPointer[string](""), + // }, + //} + //resp.SetIndex(0) + //claudeResponses = append(claudeResponses, resp) + } + return claudeResponses + } + + if len(openAIResponse.Choices) == 0 { + // no choices + // TODO: handle this case + return claudeResponses + } else { + chosenChoice := openAIResponse.Choices[0] + if chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != "" { + // should be done + info.FinishReason = *chosenChoice.FinishReason + return claudeResponses + } + if info.Done { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + oaiUsage := info.ClaudeConvertInfo.Usage + if oaiUsage != nil { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_delta", + Usage: &dto.ClaudeUsage{ + InputTokens: oaiUsage.PromptTokens, + OutputTokens: oaiUsage.CompletionTokens, + CacheCreationInputTokens: oaiUsage.PromptTokensDetails.CachedCreationTokens, + CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens, + }, + Delta: &dto.ClaudeMediaMessage{ + StopReason: common.GetPointer[string](stopReasonOpenAI2Claude(info.FinishReason)), + }, + }) + } + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Type: "message_stop", + }) + } else { + var claudeResponse dto.ClaudeResponse + var isEmpty bool + claudeResponse.Type = "content_block_delta" + if len(chosenChoice.Delta.ToolCalls) > 0 { + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeTools { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + info.ClaudeConvertInfo.Index++ + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Id: openAIResponse.GetFirstToolCall().ID, + Type: "tool_use", + Name: openAIResponse.GetFirstToolCall().Function.Name, + Input: map[string]interface{}{}, + }, + }) + } + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools + // tools delta + claudeResponse.Delta = &dto.ClaudeMediaMessage{ + Type: "input_json_delta", + PartialJson: &chosenChoice.Delta.ToolCalls[0].Function.Arguments, + } + } else { + reasoning := chosenChoice.Delta.GetReasoningContent() + textContent := chosenChoice.Delta.GetContentString() + if reasoning != "" || textContent != "" { + if reasoning != "" { + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeThinking { + //info.ClaudeConvertInfo.Index++ + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "thinking", + Thinking: "", + }, + }) + } + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeThinking + // text delta + claudeResponse.Delta = &dto.ClaudeMediaMessage{ + Type: "thinking_delta", + Thinking: reasoning, + } + } else { + if info.ClaudeConvertInfo.LastMessagesType != relaycommon.LastMessageTypeText { + if info.LastMessagesType == relaycommon.LastMessageTypeThinking || info.LastMessagesType == relaycommon.LastMessageTypeTools { + claudeResponses = append(claudeResponses, generateStopBlock(info.ClaudeConvertInfo.Index)) + info.ClaudeConvertInfo.Index++ + } + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](""), + }, + }) + } + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText + // text delta + claudeResponse.Delta = &dto.ClaudeMediaMessage{ + Type: "text_delta", + Text: common.GetPointer[string](textContent), + } + } + } else { + isEmpty = true + } + } + claudeResponse.Index = &info.ClaudeConvertInfo.Index + if !isEmpty { + claudeResponses = append(claudeResponses, &claudeResponse) + } + } + } + + return claudeResponses +} + +func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.ClaudeResponse { + var stopReason string + contents := make([]dto.ClaudeMediaMessage, 0) + claudeResponse := &dto.ClaudeResponse{ + Id: openAIResponse.Id, + Type: "message", + Role: "assistant", + Model: openAIResponse.Model, + } + for _, choice := range openAIResponse.Choices { + stopReason = stopReasonOpenAI2Claude(choice.FinishReason) + claudeContent := dto.ClaudeMediaMessage{} + if choice.FinishReason == "tool_calls" { + claudeContent.Type = "tool_use" + claudeContent.Id = choice.Message.ToolCallId + claudeContent.Name = choice.Message.ParseToolCalls()[0].Function.Name + var mapParams map[string]interface{} + if err := json.Unmarshal([]byte(choice.Message.ParseToolCalls()[0].Function.Arguments), &mapParams); err == nil { + claudeContent.Input = mapParams + } else { + claudeContent.Input = choice.Message.ParseToolCalls()[0].Function.Arguments + } + } else { + claudeContent.Type = "text" + claudeContent.SetText(choice.Message.StringContent()) + } + contents = append(contents, claudeContent) + } + claudeResponse.Content = contents + claudeResponse.StopReason = stopReason + claudeResponse.Usage = &dto.ClaudeUsage{ + InputTokens: openAIResponse.PromptTokens, + OutputTokens: openAIResponse.CompletionTokens, + } + + return claudeResponse +} + +func stopReasonOpenAI2Claude(reason string) string { + switch reason { + case "stop": + return "end_turn" + case "stop_sequence": + return "stop_sequence" + case "max_tokens": + return "max_tokens" + case "tool_calls": + return "tool_use" + default: + return reason + } +} + +func toJSONString(v interface{}) string { + b, err := json.Marshal(v) + if err != nil { + return "{}" + } + return string(b) +} diff --git a/service/epay.go b/service/epay.go new file mode 100644 index 00000000..a8259d21 --- /dev/null +++ b/service/epay.go @@ -0,0 +1,12 @@ +package service + +import ( + "one-api/setting" +) + +func GetCallbackAddress() string { + if setting.CustomCallbackAddress == "" { + return setting.ServerAddress + } + return setting.CustomCallbackAddress +} diff --git a/service/error.go b/service/error.go new file mode 100644 index 00000000..a0713b55 --- /dev/null +++ b/service/error.go @@ -0,0 +1,155 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/types" + "strconv" + "strings" +) + +func MidjourneyErrorWrapper(code int, desc string) *dto.MidjourneyResponse { + return &dto.MidjourneyResponse{ + Code: code, + Description: desc, + } +} + +func MidjourneyErrorWithStatusCodeWrapper(code int, desc string, statusCode int) *dto.MidjourneyResponseWithStatusCode { + return &dto.MidjourneyResponseWithStatusCode{ + StatusCode: statusCode, + Response: *MidjourneyErrorWrapper(code, desc), + } +} + +//// OpenAIErrorWrapper wraps an error into an OpenAIErrorWithStatusCode +//func OpenAIErrorWrapper(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode { +// text := err.Error() +// lowerText := strings.ToLower(text) +// if !strings.HasPrefix(lowerText, "get file base64 from url") && !strings.HasPrefix(lowerText, "mime type is not supported") { +// if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") { +// common.SysLog(fmt.Sprintf("error: %s", text)) +// text = "请求上游地址失败" +// } +// } +// openAIError := dto.OpenAIError{ +// Message: text, +// Type: "new_api_error", +// Code: code, +// } +// return &dto.OpenAIErrorWithStatusCode{ +// Error: openAIError, +// StatusCode: statusCode, +// } +//} +// +//func OpenAIErrorWrapperLocal(err error, code string, statusCode int) *dto.OpenAIErrorWithStatusCode { +// openaiErr := OpenAIErrorWrapper(err, code, statusCode) +// openaiErr.LocalError = true +// return openaiErr +//} + +func ClaudeErrorWrapper(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode { + text := err.Error() + lowerText := strings.ToLower(text) + if !strings.HasPrefix(lowerText, "get file base64 from url") { + if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") { + common.SysLog(fmt.Sprintf("error: %s", text)) + text = "请求上游地址失败" + } + } + claudeError := dto.ClaudeError{ + Message: text, + Type: "new_api_error", + } + return &dto.ClaudeErrorWithStatusCode{ + Error: claudeError, + StatusCode: statusCode, + } +} + +func ClaudeErrorWrapperLocal(err error, code string, statusCode int) *dto.ClaudeErrorWithStatusCode { + claudeErr := ClaudeErrorWrapper(err, code, statusCode) + claudeErr.LocalError = true + return claudeErr +} + +func RelayErrorHandler(resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { + newApiErr = &types.NewAPIError{ + StatusCode: resp.StatusCode, + ErrorType: types.ErrorTypeOpenAIError, + } + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return + } + common.CloseResponseBodyGracefully(resp) + var errResponse dto.GeneralErrorResponse + + err = common.Unmarshal(responseBody, &errResponse) + if err != nil { + if showBodyWhenFail { + newApiErr.Err = fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)) + } else { + newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode) + } + return + } + if errResponse.Error.Message != "" { + // General format error (OpenAI, Anthropic, Gemini, etc.) + newApiErr = types.WithOpenAIError(errResponse.Error, resp.StatusCode) + } else { + newApiErr = types.NewErrorWithStatusCode(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) + newApiErr.ErrorType = types.ErrorTypeOpenAIError + } + return +} + +func ResetStatusCode(newApiErr *types.NewAPIError, statusCodeMappingStr string) { + if statusCodeMappingStr == "" || statusCodeMappingStr == "{}" { + return + } + statusCodeMapping := make(map[string]string) + err := json.Unmarshal([]byte(statusCodeMappingStr), &statusCodeMapping) + if err != nil { + return + } + if newApiErr.StatusCode == http.StatusOK { + return + } + codeStr := strconv.Itoa(newApiErr.StatusCode) + if _, ok := statusCodeMapping[codeStr]; ok { + intCode, _ := strconv.Atoi(statusCodeMapping[codeStr]) + newApiErr.StatusCode = intCode + } +} + +func TaskErrorWrapperLocal(err error, code string, statusCode int) *dto.TaskError { + openaiErr := TaskErrorWrapper(err, code, statusCode) + openaiErr.LocalError = true + return openaiErr +} + +func TaskErrorWrapper(err error, code string, statusCode int) *dto.TaskError { + text := err.Error() + lowerText := strings.ToLower(text) + if strings.Contains(lowerText, "post") || strings.Contains(lowerText, "dial") || strings.Contains(lowerText, "http") { + common.SysLog(fmt.Sprintf("error: %s", text)) + text = "请求上游地址失败" + } + //避免暴露内部错误 + taskError := &dto.TaskError{ + Code: code, + Message: text, + StatusCode: statusCode, + Error: err, + } + + return taskError +} diff --git a/service/file_decoder.go b/service/file_decoder.go new file mode 100644 index 00000000..c1d4fb0c --- /dev/null +++ b/service/file_decoder.go @@ -0,0 +1,135 @@ +package service + +import ( + "encoding/base64" + "fmt" + "io" + "one-api/common" + "one-api/constant" + "one-api/dto" + "strings" +) + +func GetFileBase64FromUrl(url string) (*dto.LocalFileData, error) { + var maxFileSize = constant.MaxFileDownloadMB * 1024 * 1024 + + resp, err := DoDownloadRequest(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Always use LimitReader to prevent oversized downloads + fileBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxFileSize+1))) + if err != nil { + return nil, err + } + // Check actual size after reading + if len(fileBytes) > maxFileSize { + return nil, fmt.Errorf("file size exceeds maximum allowed size: %dMB", constant.MaxFileDownloadMB) + } + + // Convert to base64 + base64Data := base64.StdEncoding.EncodeToString(fileBytes) + + mimeType := resp.Header.Get("Content-Type") + if len(strings.Split(mimeType, ";")) > 1 { + // If Content-Type has parameters, take the first part + mimeType = strings.Split(mimeType, ";")[0] + } + if mimeType == "application/octet-stream" { + if common.DebugEnabled { + println("MIME type is application/octet-stream, trying to guess from URL or filename") + } + // try to guess the MIME type from the url last segment + urlParts := strings.Split(url, "/") + if len(urlParts) > 0 { + lastSegment := urlParts[len(urlParts)-1] + if strings.Contains(lastSegment, ".") { + // Extract the file extension + filename := strings.Split(lastSegment, ".") + if len(filename) > 1 { + ext := strings.ToLower(filename[len(filename)-1]) + // Guess MIME type based on file extension + mimeType = GetMimeTypeByExtension(ext) + } + } + } else { + // try to guess the MIME type from the file extension + fileName := resp.Header.Get("Content-Disposition") + if fileName != "" { + // Extract the filename from the Content-Disposition header + parts := strings.Split(fileName, ";") + for _, part := range parts { + if strings.HasPrefix(strings.TrimSpace(part), "filename=") { + fileName = strings.TrimSpace(strings.TrimPrefix(part, "filename=")) + // Remove quotes if present + if len(fileName) > 2 && fileName[0] == '"' && fileName[len(fileName)-1] == '"' { + fileName = fileName[1 : len(fileName)-1] + } + // Guess MIME type based on file extension + if ext := strings.ToLower(strings.TrimPrefix(fileName, ".")); ext != "" { + mimeType = GetMimeTypeByExtension(ext) + } + break + } + } + } + } + } + + return &dto.LocalFileData{ + Base64Data: base64Data, + MimeType: mimeType, + Size: int64(len(fileBytes)), + }, nil +} + +func GetMimeTypeByExtension(ext string) string { + // Convert to lowercase for case-insensitive comparison + ext = strings.ToLower(ext) + switch ext { + // Text files + case "txt", "md", "markdown", "csv", "json", "xml", "html", "htm": + return "text/plain" + + // Image files + case "jpg", "jpeg": + return "image/jpeg" + case "png": + return "image/png" + case "gif": + return "image/gif" + + // Audio files + case "mp3": + return "audio/mp3" + case "wav": + return "audio/wav" + case "mpeg": + return "audio/mpeg" + + // Video files + case "mp4": + return "video/mp4" + case "wmv": + return "video/wmv" + case "flv": + return "video/flv" + case "mov": + return "video/mov" + case "mpg": + return "video/mpg" + case "avi": + return "video/avi" + case "mpegps": + return "video/mpegps" + + // Document files + case "pdf": + return "application/pdf" + + default: + return "application/octet-stream" // Default for unknown types + } +} diff --git a/service/http_client.go b/service/http_client.go new file mode 100644 index 00000000..b191ddd7 --- /dev/null +++ b/service/http_client.go @@ -0,0 +1,81 @@ +package service + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "one-api/common" + "time" + + "golang.org/x/net/proxy" +) + +var httpClient *http.Client + +func InitHttpClient() { + if common.RelayTimeout == 0 { + httpClient = &http.Client{} + } else { + httpClient = &http.Client{ + Timeout: time.Duration(common.RelayTimeout) * time.Second, + } + } +} + +func GetHttpClient() *http.Client { + return httpClient +} + +// NewProxyHttpClient 创建支持代理的 HTTP 客户端 +func NewProxyHttpClient(proxyURL string) (*http.Client, error) { + if proxyURL == "" { + return http.DefaultClient, nil + } + + parsedURL, err := url.Parse(proxyURL) + if err != nil { + return nil, err + } + + switch parsedURL.Scheme { + case "http", "https": + return &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(parsedURL), + }, + }, nil + + case "socks5", "socks5h": + // 获取认证信息 + var auth *proxy.Auth + if parsedURL.User != nil { + auth = &proxy.Auth{ + User: parsedURL.User.Username(), + Password: "", + } + if password, ok := parsedURL.User.Password(); ok { + auth.Password = password + } + } + + // 创建 SOCKS5 代理拨号器 + // proxy.SOCKS5 使用 tcp 参数,所有 TCP 连接包括 DNS 查询都将通过代理进行。行为与 socks5h 相同 + dialer, err := proxy.SOCKS5("tcp", parsedURL.Host, auth, proxy.Direct) + if err != nil { + return nil, err + } + + return &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + }, + }, + }, nil + + default: + return nil, fmt.Errorf("unsupported proxy scheme: %s", parsedURL.Scheme) + } +} diff --git a/service/image.go b/service/image.go new file mode 100644 index 00000000..252093f1 --- /dev/null +++ b/service/image.go @@ -0,0 +1,172 @@ +package service + +import ( + "bytes" + "encoding/base64" + "errors" + "fmt" + "image" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "strings" + + "golang.org/x/image/webp" +) + +func DecodeBase64ImageData(base64String string) (image.Config, string, string, error) { + // 去除base64数据的URL前缀(如果有) + if idx := strings.Index(base64String, ","); idx != -1 { + base64String = base64String[idx+1:] + } + + // 将base64字符串解码为字节切片 + decodedData, err := base64.StdEncoding.DecodeString(base64String) + if err != nil { + fmt.Println("Error: Failed to decode base64 string") + return image.Config{}, "", "", fmt.Errorf("failed to decode base64 string: %s", err.Error()) + } + + // 创建一个bytes.Buffer用于存储解码后的数据 + reader := bytes.NewReader(decodedData) + config, format, err := getImageConfig(reader) + return config, format, base64String, err +} + +func DecodeBase64FileData(base64String string) (string, string, error) { + var mimeType string + var idx int + idx = strings.Index(base64String, ",") + if idx == -1 { + _, file_type, base64, err := DecodeBase64ImageData(base64String) + return "image/" + file_type, base64, err + } + mimeType = base64String[:idx] + base64String = base64String[idx+1:] + idx = strings.Index(mimeType, ";") + if idx == -1 { + _, file_type, base64, err := DecodeBase64ImageData(base64String) + return "image/" + file_type, base64, err + } + mimeType = mimeType[:idx] + idx = strings.Index(mimeType, ":") + if idx == -1 { + _, file_type, base64, err := DecodeBase64ImageData(base64String) + return "image/" + file_type, base64, err + } + mimeType = mimeType[idx+1:] + return mimeType, base64String, nil +} + +// GetImageFromUrl 获取图片的类型和base64编码的数据 +func GetImageFromUrl(url string) (mimeType string, data string, err error) { + resp, err := DoDownloadRequest(url) + if err != nil { + return "", "", fmt.Errorf("failed to download image: %w", err) + } + defer resp.Body.Close() + + // Check HTTP status code + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("failed to download image: HTTP %d", resp.StatusCode) + } + + contentType := resp.Header.Get("Content-Type") + if contentType != "application/octet-stream" && !strings.HasPrefix(contentType, "image/") { + return "", "", fmt.Errorf("invalid content type: %s, required image/*", contentType) + } + maxImageSize := int64(constant.MaxFileDownloadMB * 1024 * 1024) + + // Check Content-Length if available + if resp.ContentLength > maxImageSize { + return "", "", fmt.Errorf("image size %d exceeds maximum allowed size of %d bytes", resp.ContentLength, maxImageSize) + } + + // Use LimitReader to prevent reading oversized images + limitReader := io.LimitReader(resp.Body, maxImageSize) + buffer := &bytes.Buffer{} + + written, err := io.Copy(buffer, limitReader) + if err != nil { + return "", "", fmt.Errorf("failed to read image data: %w", err) + } + if written >= maxImageSize { + return "", "", fmt.Errorf("image size exceeds maximum allowed size of %d bytes", maxImageSize) + } + + data = base64.StdEncoding.EncodeToString(buffer.Bytes()) + mimeType = contentType + + // Handle application/octet-stream type + if mimeType == "application/octet-stream" { + _, format, _, err := DecodeBase64ImageData(data) + if err != nil { + return "", "", err + } + mimeType = "image/" + format + } + + return mimeType, data, nil +} + +func DecodeUrlImageData(imageUrl string) (image.Config, string, error) { + response, err := DoDownloadRequest(imageUrl) + if err != nil { + common.SysLog(fmt.Sprintf("fail to get image from url: %s", err.Error())) + return image.Config{}, "", err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + err = errors.New(fmt.Sprintf("fail to get image from url: %s", response.Status)) + return image.Config{}, "", err + } + + mimeType := response.Header.Get("Content-Type") + + if mimeType != "application/octet-stream" && !strings.HasPrefix(mimeType, "image/") { + return image.Config{}, "", fmt.Errorf("invalid content type: %s, required image/*", mimeType) + } + + var readData []byte + for _, limit := range []int64{1024 * 8, 1024 * 24, 1024 * 64} { + common.SysLog(fmt.Sprintf("try to decode image config with limit: %d", limit)) + + // 从response.Body读取更多的数据直到达到当前的限制 + additionalData := make([]byte, limit-int64(len(readData))) + n, _ := io.ReadFull(response.Body, additionalData) + readData = append(readData, additionalData[:n]...) + + // 使用io.MultiReader组合已经读取的数据和response.Body + limitReader := io.MultiReader(bytes.NewReader(readData), response.Body) + + var config image.Config + var format string + config, format, err = getImageConfig(limitReader) + if err == nil { + return config, format, nil + } + } + + return image.Config{}, "", err // 返回最后一个错误 +} + +func getImageConfig(reader io.Reader) (image.Config, string, error) { + // 读取图片的头部信息来获取图片尺寸 + config, format, err := image.DecodeConfig(reader) + if err != nil { + err = errors.New(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error())) + common.SysLog(err.Error()) + config, err = webp.DecodeConfig(reader) + if err != nil { + err = errors.New(fmt.Sprintf("fail to decode image config(webp): %s", err.Error())) + common.SysLog(err.Error()) + } + format = "webp" + } + if err != nil { + return image.Config{}, "", err + } + return config, format, nil +} diff --git a/service/log_info_generate.go b/service/log_info_generate.go new file mode 100644 index 00000000..020a2ba9 --- /dev/null +++ b/service/log_info_generate.go @@ -0,0 +1,83 @@ +package service + +import ( + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + + "github.com/gin-gonic/gin" +) + +func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64, + cacheTokens int, cacheRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} { + other := make(map[string]interface{}) + other["model_ratio"] = modelRatio + other["group_ratio"] = groupRatio + other["completion_ratio"] = completionRatio + other["cache_tokens"] = cacheTokens + other["cache_ratio"] = cacheRatio + other["model_price"] = modelPrice + other["user_group_ratio"] = userGroupRatio + other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli()) + if relayInfo.ReasoningEffort != "" { + other["reasoning_effort"] = relayInfo.ReasoningEffort + } + if relayInfo.IsModelMapped { + other["is_model_mapped"] = true + other["upstream_model_name"] = relayInfo.UpstreamModelName + } + adminInfo := make(map[string]interface{}) + adminInfo["use_channel"] = ctx.GetStringSlice("use_channel") + isMultiKey := common.GetContextKeyBool(ctx, constant.ContextKeyChannelIsMultiKey) + if isMultiKey { + adminInfo["is_multi_key"] = true + adminInfo["multi_key_index"] = common.GetContextKeyInt(ctx, constant.ContextKeyChannelMultiKeyIndex) + } + other["admin_info"] = adminInfo + return other +} + +func GenerateWssOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} { + info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio) + info["ws"] = true + info["audio_input"] = usage.InputTokenDetails.AudioTokens + info["audio_output"] = usage.OutputTokenDetails.AudioTokens + info["text_input"] = usage.InputTokenDetails.TextTokens + info["text_output"] = usage.OutputTokenDetails.TextTokens + info["audio_ratio"] = audioRatio + info["audio_completion_ratio"] = audioCompletionRatio + return info +} + +func GenerateAudioOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, modelRatio, groupRatio, completionRatio, audioRatio, audioCompletionRatio, modelPrice, userGroupRatio float64) map[string]interface{} { + info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, 0, 0.0, modelPrice, userGroupRatio) + info["audio"] = true + info["audio_input"] = usage.PromptTokensDetails.AudioTokens + info["audio_output"] = usage.CompletionTokenDetails.AudioTokens + info["text_input"] = usage.PromptTokensDetails.TextTokens + info["text_output"] = usage.CompletionTokenDetails.TextTokens + info["audio_ratio"] = audioRatio + info["audio_completion_ratio"] = audioCompletionRatio + return info +} + +func GenerateClaudeOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelRatio, groupRatio, completionRatio float64, + cacheTokens int, cacheRatio float64, cacheCreationTokens int, cacheCreationRatio float64, modelPrice float64, userGroupRatio float64) map[string]interface{} { + info := GenerateTextOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, cacheTokens, cacheRatio, modelPrice, userGroupRatio) + info["claude"] = true + info["cache_creation_tokens"] = cacheCreationTokens + info["cache_creation_ratio"] = cacheCreationRatio + return info +} + +func GenerateMjOtherInfo(priceData helper.PerCallPriceData) map[string]interface{} { + other := make(map[string]interface{}) + other["model_price"] = priceData.ModelPrice + other["group_ratio"] = priceData.GroupRatioInfo.GroupRatio + if priceData.GroupRatioInfo.HasSpecialRatio { + other["user_group_ratio"] = priceData.GroupRatioInfo.GroupSpecialRatio + } + return other +} diff --git a/service/midjourney.go b/service/midjourney.go new file mode 100644 index 00000000..1fc19682 --- /dev/null +++ b/service/midjourney.go @@ -0,0 +1,258 @@ +package service + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + relayconstant "one-api/relay/constant" + "one-api/setting" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +func CoverActionToModelName(mjAction string) string { + modelName := "mj_" + strings.ToLower(mjAction) + if mjAction == constant.MjActionSwapFace { + modelName = "swap_face" + } + return modelName +} + +func GetMjRequestModel(relayMode int, midjRequest *dto.MidjourneyRequest) (string, *dto.MidjourneyResponse, bool) { + action := "" + if relayMode == relayconstant.RelayModeMidjourneyAction { + // plus request + err := CoverPlusActionToNormalAction(midjRequest) + if err != nil { + return "", err, false + } + action = midjRequest.Action + } else { + switch relayMode { + case relayconstant.RelayModeMidjourneyImagine: + action = constant.MjActionImagine + case relayconstant.RelayModeMidjourneyVideo: + action = constant.MjActionVideo + case relayconstant.RelayModeMidjourneyEdits: + action = constant.MjActionEdits + case relayconstant.RelayModeMidjourneyDescribe: + action = constant.MjActionDescribe + case relayconstant.RelayModeMidjourneyBlend: + action = constant.MjActionBlend + case relayconstant.RelayModeMidjourneyShorten: + action = constant.MjActionShorten + case relayconstant.RelayModeMidjourneyChange: + action = midjRequest.Action + case relayconstant.RelayModeMidjourneyModal: + action = constant.MjActionModal + case relayconstant.RelayModeSwapFace: + action = constant.MjActionSwapFace + case relayconstant.RelayModeMidjourneyUpload: + action = constant.MjActionUpload + case relayconstant.RelayModeMidjourneySimpleChange: + params := ConvertSimpleChangeParams(midjRequest.Content) + if params == nil { + return "", MidjourneyErrorWrapper(constant.MjRequestError, "invalid_request"), false + } + action = params.Action + case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition, relayconstant.RelayModeMidjourneyNotify: + return "", nil, true + default: + return "", MidjourneyErrorWrapper(constant.MjRequestError, "unknown_relay_action"), false + } + } + modelName := CoverActionToModelName(action) + return modelName, nil, true +} + +func CoverPlusActionToNormalAction(midjRequest *dto.MidjourneyRequest) *dto.MidjourneyResponse { + // "customId": "MJ::JOB::upsample::2::3dbbd469-36af-4a0f-8f02-df6c579e7011" + customId := midjRequest.CustomId + if customId == "" { + return MidjourneyErrorWrapper(constant.MjRequestError, "custom_id_is_required") + } + splits := strings.Split(customId, "::") + var action string + if splits[1] == "JOB" { + action = splits[2] + } else { + action = splits[1] + } + + if action == "" { + return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action") + } + if strings.Contains(action, "upsample") { + index, err := strconv.Atoi(splits[3]) + if err != nil { + return MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + } + midjRequest.Index = index + midjRequest.Action = constant.MjActionUpscale + } else if strings.Contains(action, "variation") { + midjRequest.Index = 1 + if action == "variation" { + index, err := strconv.Atoi(splits[3]) + if err != nil { + return MidjourneyErrorWrapper(constant.MjRequestError, "index_parse_failed") + } + midjRequest.Index = index + midjRequest.Action = constant.MjActionVariation + } else if action == "low_variation" { + midjRequest.Action = constant.MjActionLowVariation + } else if action == "high_variation" { + midjRequest.Action = constant.MjActionHighVariation + } + } else if strings.Contains(action, "pan") { + midjRequest.Action = constant.MjActionPan + midjRequest.Index = 1 + } else if strings.Contains(action, "reroll") { + midjRequest.Action = constant.MjActionReRoll + midjRequest.Index = 1 + } else if action == "Outpaint" { + midjRequest.Action = constant.MjActionZoom + midjRequest.Index = 1 + } else if action == "CustomZoom" { + midjRequest.Action = constant.MjActionCustomZoom + midjRequest.Index = 1 + } else if action == "Inpaint" { + midjRequest.Action = constant.MjActionInPaint + midjRequest.Index = 1 + } else { + return MidjourneyErrorWrapper(constant.MjRequestError, "unknown_action:"+customId) + } + return nil +} + +func ConvertSimpleChangeParams(content string) *dto.MidjourneyRequest { + split := strings.Split(content, " ") + if len(split) != 2 { + return nil + } + + action := strings.ToLower(split[1]) + changeParams := &dto.MidjourneyRequest{} + changeParams.TaskId = split[0] + + if action[0] == 'u' { + changeParams.Action = "UPSCALE" + } else if action[0] == 'v' { + changeParams.Action = "VARIATION" + } else if action == "r" { + changeParams.Action = "REROLL" + return changeParams + } else { + return nil + } + + index, err := strconv.Atoi(action[1:2]) + if err != nil || index < 1 || index > 4 { + return nil + } + changeParams.Index = index + return changeParams +} + +func DoMidjourneyHttpRequest(c *gin.Context, timeout time.Duration, fullRequestURL string) (*dto.MidjourneyResponseWithStatusCode, []byte, error) { + var nullBytes []byte + //var requestBody io.Reader + //requestBody = c.Request.Body + // read request body to json, delete accountFilter and notifyHook + var mapResult map[string]interface{} + // if get request, no need to read request body + if c.Request.Method != "GET" { + err := json.NewDecoder(c.Request.Body).Decode(&mapResult) + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_request_body_failed", http.StatusInternalServerError), nullBytes, err + } + if !setting.MjAccountFilterEnabled { + delete(mapResult, "accountFilter") + } + if !setting.MjNotifyEnabled { + delete(mapResult, "notifyHook") + } + //req, err := http.NewRequest(c.Request.Method, fullRequestURL, requestBody) + // make new request with mapResult + } + if setting.MjModeClearEnabled { + if prompt, ok := mapResult["prompt"].(string); ok { + prompt = strings.Replace(prompt, "--fast", "", -1) + prompt = strings.Replace(prompt, "--relax", "", -1) + prompt = strings.Replace(prompt, "--turbo", "", -1) + + mapResult["prompt"] = prompt + } + } + reqBody, err := json.Marshal(mapResult) + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "marshal_request_body_failed", http.StatusInternalServerError), nullBytes, err + } + req, err := http.NewRequest(c.Request.Method, fullRequestURL, strings.NewReader(string(reqBody))) + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "create_request_failed", http.StatusInternalServerError), nullBytes, err + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + // 使用带有超时的 context 创建新的请求 + req = req.WithContext(ctx) + req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type")) + req.Header.Set("Accept", c.Request.Header.Get("Accept")) + auth := common.GetContextKeyString(c, constant.ContextKeyChannelKey) + if auth != "" { + auth = strings.TrimPrefix(auth, "Bearer ") + req.Header.Set("mj-api-secret", auth) + } + defer cancel() + resp, err := GetHttpClient().Do(req) + if err != nil { + common.SysError("do request failed: " + err.Error()) + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "do_request_failed", http.StatusInternalServerError), nullBytes, err + } + statusCode := resp.StatusCode + //if statusCode != 200 { + // return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "bad_response_status_code", statusCode), nullBytes, nil + //} + err = req.Body.Close() + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "close_request_body_failed", statusCode), nullBytes, err + } + err = c.Request.Body.Close() + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "close_request_body_failed", statusCode), nullBytes, err + } + var midjResponse dto.MidjourneyResponse + var midjourneyUploadsResponse dto.MidjourneyUploadResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "read_response_body_failed", statusCode), nullBytes, err + } + common.CloseResponseBodyGracefully(resp) + respStr := string(responseBody) + log.Printf("respStr: %s", respStr) + if respStr == "" { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "empty_response_body", statusCode), responseBody, nil + } else { + err = json.Unmarshal(responseBody, &midjResponse) + if err != nil { + err2 := json.Unmarshal(responseBody, &midjourneyUploadsResponse) + if err2 != nil { + return MidjourneyErrorWithStatusCodeWrapper(constant.MjErrorUnknown, "unmarshal_response_body_failed", statusCode), responseBody, err + } + } + } + //log.Printf("midjResponse: %v", midjResponse) + //for k, v := range resp.Header { + // c.Writer.Header().Set(k, v[0]) + //} + return &dto.MidjourneyResponseWithStatusCode{ + StatusCode: statusCode, + Response: midjResponse, + }, responseBody, nil +} diff --git a/service/notify-limit.go b/service/notify-limit.go new file mode 100644 index 00000000..309ea54d --- /dev/null +++ b/service/notify-limit.go @@ -0,0 +1,117 @@ +package service + +import ( + "fmt" + "github.com/bytedance/gopkg/util/gopool" + "one-api/common" + "one-api/constant" + "strconv" + "sync" + "time" +) + +// notifyLimitStore is used for in-memory rate limiting when Redis is disabled +var ( + notifyLimitStore sync.Map + cleanupOnce sync.Once +) + +type limitCount struct { + Count int + Timestamp time.Time +} + +func getDuration() time.Duration { + minute := constant.NotificationLimitDurationMinute + return time.Duration(minute) * time.Minute +} + +// startCleanupTask starts a background task to clean up expired entries +func startCleanupTask() { + gopool.Go(func() { + for { + time.Sleep(time.Hour) + now := time.Now() + notifyLimitStore.Range(func(key, value interface{}) bool { + if limit, ok := value.(limitCount); ok { + if now.Sub(limit.Timestamp) >= getDuration() { + notifyLimitStore.Delete(key) + } + } + return true + }) + } + }) +} + +// CheckNotificationLimit checks if the user has exceeded their notification limit +// Returns true if the user can send notification, false if limit exceeded +func CheckNotificationLimit(userId int, notifyType string) (bool, error) { + if common.RedisEnabled { + return checkRedisLimit(userId, notifyType) + } + return checkMemoryLimit(userId, notifyType) +} + +func checkRedisLimit(userId int, notifyType string) (bool, error) { + key := fmt.Sprintf("notify_limit:%d:%s:%s", userId, notifyType, time.Now().Format("2006010215")) + + // Get current count + count, err := common.RedisGet(key) + if err != nil && err.Error() != "redis: nil" { + return false, fmt.Errorf("failed to get notification count: %w", err) + } + + // If key doesn't exist, initialize it + if count == "" { + err = common.RedisSet(key, "1", getDuration()) + return true, err + } + + currentCount, _ := strconv.Atoi(count) + limit := constant.NotifyLimitCount + + // Check if limit is already reached + if currentCount >= limit { + return false, nil + } + + // Only increment if under limit + err = common.RedisIncr(key, 1) + if err != nil { + return false, fmt.Errorf("failed to increment notification count: %w", err) + } + + return true, nil +} + +func checkMemoryLimit(userId int, notifyType string) (bool, error) { + // Ensure cleanup task is started + cleanupOnce.Do(startCleanupTask) + + key := fmt.Sprintf("%d:%s:%s", userId, notifyType, time.Now().Format("2006010215")) + now := time.Now() + + // Get current limit count or initialize new one + var currentLimit limitCount + if value, ok := notifyLimitStore.Load(key); ok { + currentLimit = value.(limitCount) + // Check if the entry has expired + if now.Sub(currentLimit.Timestamp) >= getDuration() { + currentLimit = limitCount{Count: 0, Timestamp: now} + } + } else { + currentLimit = limitCount{Count: 0, Timestamp: now} + } + + // Increment count + currentLimit.Count++ + + // Check against limits + limit := constant.NotifyLimitCount + + // Store updated count + notifyLimitStore.Store(key, currentLimit) + + return currentLimit.Count <= limit, nil +} diff --git a/service/quota.go b/service/quota.go new file mode 100644 index 00000000..0f618402 --- /dev/null +++ b/service/quota.go @@ -0,0 +1,510 @@ +package service + +import ( + "errors" + "fmt" + "log" + "math" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/model" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/setting" + "one-api/setting/ratio_setting" + "strings" + "time" + + "github.com/bytedance/gopkg/util/gopool" + + "github.com/gin-gonic/gin" + "github.com/shopspring/decimal" +) + +type TokenDetails struct { + TextTokens int + AudioTokens int +} + +type QuotaInfo struct { + InputDetails TokenDetails + OutputDetails TokenDetails + ModelName string + UsePrice bool + ModelPrice float64 + ModelRatio float64 + GroupRatio float64 +} + +func calculateAudioQuota(info QuotaInfo) int { + if info.UsePrice { + modelPrice := decimal.NewFromFloat(info.ModelPrice) + quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + groupRatio := decimal.NewFromFloat(info.GroupRatio) + + quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio) + return int(quota.IntPart()) + } + + completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(info.ModelName)) + audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(info.ModelName)) + audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(info.ModelName)) + + groupRatio := decimal.NewFromFloat(info.GroupRatio) + modelRatio := decimal.NewFromFloat(info.ModelRatio) + ratio := groupRatio.Mul(modelRatio) + + inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens)) + outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens)) + inputAudioTokens := decimal.NewFromInt(int64(info.InputDetails.AudioTokens)) + outputAudioTokens := decimal.NewFromInt(int64(info.OutputDetails.AudioTokens)) + + quota := decimal.Zero + quota = quota.Add(inputTextTokens) + quota = quota.Add(outputTextTokens.Mul(completionRatio)) + quota = quota.Add(inputAudioTokens.Mul(audioRatio)) + quota = quota.Add(outputAudioTokens.Mul(audioRatio).Mul(audioCompletionRatio)) + + quota = quota.Mul(ratio) + + // If ratio is not zero and quota is less than or equal to zero, set quota to 1 + if !ratio.IsZero() && quota.LessThanOrEqual(decimal.Zero) { + quota = decimal.NewFromInt(1) + } + + return int(quota.Round(0).IntPart()) +} + +func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.RealtimeUsage) error { + if relayInfo.UsePrice { + return nil + } + userQuota, err := model.GetUserQuota(relayInfo.UserId, false) + if err != nil { + return err + } + + token, err := model.GetTokenByKey(strings.TrimLeft(relayInfo.TokenKey, "sk-"), false) + if err != nil { + return err + } + + modelName := relayInfo.OriginModelName + textInputTokens := usage.InputTokenDetails.TextTokens + textOutTokens := usage.OutputTokenDetails.TextTokens + audioInputTokens := usage.InputTokenDetails.AudioTokens + audioOutTokens := usage.OutputTokenDetails.AudioTokens + groupRatio := ratio_setting.GetGroupRatio(relayInfo.UsingGroup) + modelRatio, _, _ := ratio_setting.GetModelRatio(modelName) + + autoGroup, exists := ctx.Get("auto_group") + if exists { + groupRatio = ratio_setting.GetGroupRatio(autoGroup.(string)) + log.Printf("final group ratio: %f", groupRatio) + relayInfo.UsingGroup = autoGroup.(string) + } + + actualGroupRatio := groupRatio + userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) + if ok { + actualGroupRatio = userGroupRatio + } + + quotaInfo := QuotaInfo{ + InputDetails: TokenDetails{ + TextTokens: textInputTokens, + AudioTokens: audioInputTokens, + }, + OutputDetails: TokenDetails{ + TextTokens: textOutTokens, + AudioTokens: audioOutTokens, + }, + ModelName: modelName, + UsePrice: relayInfo.UsePrice, + ModelRatio: modelRatio, + GroupRatio: actualGroupRatio, + } + + quota := calculateAudioQuota(quotaInfo) + + if userQuota < quota { + return fmt.Errorf("user quota is not enough, user quota: %s, need quota: %s", common.FormatQuota(userQuota), common.FormatQuota(quota)) + } + + if !token.UnlimitedQuota && token.RemainQuota < quota { + return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", common.FormatQuota(token.RemainQuota), common.FormatQuota(quota)) + } + + err = PostConsumeQuota(relayInfo, quota, 0, false) + if err != nil { + return err + } + common.LogInfo(ctx, "realtime streaming consume quota success, quota: "+fmt.Sprintf("%d", quota)) + return nil +} + +func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, modelName string, + usage *dto.RealtimeUsage, preConsumedQuota int, userQuota int, priceData helper.PriceData, extraContent string) { + + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + textInputTokens := usage.InputTokenDetails.TextTokens + textOutTokens := usage.OutputTokenDetails.TextTokens + + audioInputTokens := usage.InputTokenDetails.AudioTokens + audioOutTokens := usage.OutputTokenDetails.AudioTokens + + tokenName := ctx.GetString("token_name") + completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(modelName)) + audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName)) + audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(modelName)) + + modelRatio := priceData.ModelRatio + groupRatio := priceData.GroupRatioInfo.GroupRatio + modelPrice := priceData.ModelPrice + usePrice := priceData.UsePrice + + quotaInfo := QuotaInfo{ + InputDetails: TokenDetails{ + TextTokens: textInputTokens, + AudioTokens: audioInputTokens, + }, + OutputDetails: TokenDetails{ + TextTokens: textOutTokens, + AudioTokens: audioOutTokens, + }, + ModelName: modelName, + UsePrice: usePrice, + ModelRatio: modelRatio, + GroupRatio: groupRatio, + } + + quota := calculateAudioQuota(quotaInfo) + + totalTokens := usage.TotalTokens + var logContent string + if !usePrice { + logContent = fmt.Sprintf("模型倍率 %.2f,补全倍率 %.2f,音频倍率 %.2f,音频补全倍率 %.2f,分组倍率 %.2f", + modelRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), groupRatio) + } else { + logContent = fmt.Sprintf("模型价格 %.2f,分组倍率 %.2f", modelPrice, groupRatio) + } + + // record all the consume log even if quota is 0 + if totalTokens == 0 { + // in this case, must be some error happened + // we cannot just return, because we may have to return the pre-consumed quota + quota = 0 + logContent += fmt.Sprintf("(可能是上游超时)") + common.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+ + "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, preConsumedQuota)) + } else { + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + logModel := modelName + if extraContent != "" { + logContent += ", " + extraContent + } + other := GenerateWssOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio, + completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) + model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + PromptTokens: usage.InputTokens, + CompletionTokens: usage.OutputTokens, + ModelName: logModel, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: relayInfo.TokenId, + UserQuota: userQuota, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) +} + +func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, + usage *dto.Usage, preConsumedQuota int, userQuota int, priceData helper.PriceData, extraContent string) { + + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + promptTokens := usage.PromptTokens + completionTokens := usage.CompletionTokens + modelName := relayInfo.OriginModelName + + tokenName := ctx.GetString("token_name") + completionRatio := priceData.CompletionRatio + modelRatio := priceData.ModelRatio + groupRatio := priceData.GroupRatioInfo.GroupRatio + modelPrice := priceData.ModelPrice + cacheRatio := priceData.CacheRatio + cacheTokens := usage.PromptTokensDetails.CachedTokens + + cacheCreationRatio := priceData.CacheCreationRatio + cacheCreationTokens := usage.PromptTokensDetails.CachedCreationTokens + + if relayInfo.ChannelType == constant.ChannelTypeOpenRouter { + promptTokens -= cacheTokens + if cacheCreationTokens == 0 && priceData.CacheCreationRatio != 1 && usage.Cost != 0 { + maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, priceData) + if promptTokens >= maybeCacheCreationTokens { + cacheCreationTokens = maybeCacheCreationTokens + } + } + promptTokens -= cacheCreationTokens + } + + calculateQuota := 0.0 + if !priceData.UsePrice { + calculateQuota = float64(promptTokens) + calculateQuota += float64(cacheTokens) * cacheRatio + calculateQuota += float64(cacheCreationTokens) * cacheCreationRatio + calculateQuota += float64(completionTokens) * completionRatio + calculateQuota = calculateQuota * groupRatio * modelRatio + } else { + calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio + } + + if modelRatio != 0 && calculateQuota <= 0 { + calculateQuota = 1 + } + + quota := int(calculateQuota) + + totalTokens := promptTokens + completionTokens + + var logContent string + // record all the consume log even if quota is 0 + if totalTokens == 0 { + // in this case, must be some error happened + // we cannot just return, because we may have to return the pre-consumed quota + quota = 0 + logContent += fmt.Sprintf("(可能是上游出错)") + common.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+ + "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, modelName, preConsumedQuota)) + } else { + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + quotaDelta := quota - preConsumedQuota + if quotaDelta != 0 { + err := PostConsumeQuota(relayInfo, quotaDelta, preConsumedQuota, true) + if err != nil { + common.LogError(ctx, "error consuming token remain quota: "+err.Error()) + } + } + + other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, + cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) + model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + PromptTokens: promptTokens, + CompletionTokens: completionTokens, + ModelName: modelName, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: relayInfo.TokenId, + UserQuota: userQuota, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) + +} + +func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData helper.PriceData) int { + if priceData.CacheCreationRatio == 1 { + return 0 + } + quotaPrice := priceData.ModelRatio / common.QuotaPerUnit + promptCacheCreatePrice := quotaPrice * priceData.CacheCreationRatio + promptCacheReadPrice := quotaPrice * priceData.CacheRatio + completionPrice := quotaPrice * priceData.CompletionRatio + + cost, _ := usage.Cost.(float64) + totalPromptTokens := float64(usage.PromptTokens) + completionTokens := float64(usage.CompletionTokens) + promptCacheReadTokens := float64(usage.PromptTokensDetails.CachedTokens) + + return int(math.Round((cost - + totalPromptTokens*quotaPrice + + promptCacheReadTokens*(quotaPrice-promptCacheReadPrice) - + completionTokens*completionPrice) / + (promptCacheCreatePrice - quotaPrice))) +} + +func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, + usage *dto.Usage, preConsumedQuota int, userQuota int, priceData helper.PriceData, extraContent string) { + + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + textInputTokens := usage.PromptTokensDetails.TextTokens + textOutTokens := usage.CompletionTokenDetails.TextTokens + + audioInputTokens := usage.PromptTokensDetails.AudioTokens + audioOutTokens := usage.CompletionTokenDetails.AudioTokens + + tokenName := ctx.GetString("token_name") + completionRatio := decimal.NewFromFloat(ratio_setting.GetCompletionRatio(relayInfo.OriginModelName)) + audioRatio := decimal.NewFromFloat(ratio_setting.GetAudioRatio(relayInfo.OriginModelName)) + audioCompletionRatio := decimal.NewFromFloat(ratio_setting.GetAudioCompletionRatio(relayInfo.OriginModelName)) + + modelRatio := priceData.ModelRatio + groupRatio := priceData.GroupRatioInfo.GroupRatio + modelPrice := priceData.ModelPrice + usePrice := priceData.UsePrice + + quotaInfo := QuotaInfo{ + InputDetails: TokenDetails{ + TextTokens: textInputTokens, + AudioTokens: audioInputTokens, + }, + OutputDetails: TokenDetails{ + TextTokens: textOutTokens, + AudioTokens: audioOutTokens, + }, + ModelName: relayInfo.OriginModelName, + UsePrice: usePrice, + ModelRatio: modelRatio, + GroupRatio: groupRatio, + } + + quota := calculateAudioQuota(quotaInfo) + + totalTokens := usage.TotalTokens + var logContent string + if !usePrice { + logContent = fmt.Sprintf("模型倍率 %.2f,补全倍率 %.2f,音频倍率 %.2f,音频补全倍率 %.2f,分组倍率 %.2f", + modelRatio, completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), groupRatio) + } else { + logContent = fmt.Sprintf("模型价格 %.2f,分组倍率 %.2f", modelPrice, groupRatio) + } + + // record all the consume log even if quota is 0 + if totalTokens == 0 { + // in this case, must be some error happened + // we cannot just return, because we may have to return the pre-consumed quota + quota = 0 + logContent += fmt.Sprintf("(可能是上游超时)") + common.LogError(ctx, fmt.Sprintf("total tokens is 0, cannot consume quota, userId %d, channelId %d, "+ + "tokenId %d, model %s, pre-consumed quota %d", relayInfo.UserId, relayInfo.ChannelId, relayInfo.TokenId, relayInfo.OriginModelName, preConsumedQuota)) + } else { + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + quotaDelta := quota - preConsumedQuota + if quotaDelta != 0 { + err := PostConsumeQuota(relayInfo, quotaDelta, preConsumedQuota, true) + if err != nil { + common.LogError(ctx, "error consuming token remain quota: "+err.Error()) + } + } + + logModel := relayInfo.OriginModelName + if extraContent != "" { + logContent += ", " + extraContent + } + other := GenerateAudioOtherInfo(ctx, relayInfo, usage, modelRatio, groupRatio, + completionRatio.InexactFloat64(), audioRatio.InexactFloat64(), audioCompletionRatio.InexactFloat64(), modelPrice, priceData.GroupRatioInfo.GroupSpecialRatio) + model.RecordConsumeLog(ctx, relayInfo.UserId, model.RecordConsumeLogParams{ + ChannelId: relayInfo.ChannelId, + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + ModelName: logModel, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: relayInfo.TokenId, + UserQuota: userQuota, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) +} + +func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error { + if quota < 0 { + return errors.New("quota 不能为负数!") + } + if relayInfo.IsPlayground { + return nil + } + //if relayInfo.TokenUnlimited { + // return nil + //} + token, err := model.GetTokenByKey(relayInfo.TokenKey, false) + if err != nil { + return err + } + if !relayInfo.TokenUnlimited && token.RemainQuota < quota { + return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", common.FormatQuota(token.RemainQuota), common.FormatQuota(quota)) + } + err = model.DecreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, quota) + if err != nil { + return err + } + return nil +} + +func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (err error) { + + if quota > 0 { + err = model.DecreaseUserQuota(relayInfo.UserId, quota) + } else { + err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false) + } + if err != nil { + return err + } + + if !relayInfo.IsPlayground { + if quota > 0 { + err = model.DecreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, quota) + } else { + err = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, -quota) + } + if err != nil { + return err + } + } + + if sendEmail { + if (quota + preConsumedQuota) != 0 { + checkAndSendQuotaNotify(relayInfo, quota, preConsumedQuota) + } + } + + return nil +} + +func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int) { + gopool.Go(func() { + userSetting := relayInfo.UserSetting + threshold := common.QuotaRemindThreshold + if userSetting.QuotaWarningThreshold != 0 { + threshold = int(userSetting.QuotaWarningThreshold) + } + + //noMoreQuota := userCache.Quota-(quota+preConsumedQuota) <= 0 + quotaTooLow := false + consumeQuota := quota + preConsumedQuota + if relayInfo.UserQuota-consumeQuota < threshold { + quotaTooLow = true + } + if quotaTooLow { + prompt := "您的额度即将用尽" + topUpLink := fmt.Sprintf("%s/topup", setting.ServerAddress) + content := "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}" + err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, []interface{}{prompt, common.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink})) + if err != nil { + common.SysError(fmt.Sprintf("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error())) + } + } + }) +} diff --git a/service/sensitive.go b/service/sensitive.go new file mode 100644 index 00000000..b3e3c4d6 --- /dev/null +++ b/service/sensitive.go @@ -0,0 +1,94 @@ +package service + +import ( + "errors" + "fmt" + "one-api/dto" + "one-api/setting" + "strings" +) + +func CheckSensitiveMessages(messages []dto.Message) ([]string, error) { + if len(messages) == 0 { + return nil, nil + } + + for _, message := range messages { + arrayContent := message.ParseContent() + for _, m := range arrayContent { + if m.Type == "image_url" { + // TODO: check image url + continue + } + // 检查 text 是否为空 + if m.Text == "" { + continue + } + if ok, words := SensitiveWordContains(m.Text); ok { + return words, errors.New("sensitive words detected") + } + } + } + return nil, nil +} + +func CheckSensitiveText(text string) ([]string, error) { + if ok, words := SensitiveWordContains(text); ok { + return words, errors.New("sensitive words detected") + } + return nil, nil +} + +func CheckSensitiveInput(input any) ([]string, error) { + switch v := input.(type) { + case string: + return CheckSensitiveText(v) + case []string: + var builder strings.Builder + for _, s := range v { + builder.WriteString(s) + } + return CheckSensitiveText(builder.String()) + } + return CheckSensitiveText(fmt.Sprintf("%v", input)) +} + +// SensitiveWordContains 是否包含敏感词,返回是否包含敏感词和敏感词列表 +func SensitiveWordContains(text string) (bool, []string) { + if len(setting.SensitiveWords) == 0 { + return false, nil + } + if len(text) == 0 { + return false, nil + } + checkText := strings.ToLower(text) + return AcSearch(checkText, setting.SensitiveWords, true) +} + +// SensitiveWordReplace 敏感词替换,返回是否包含敏感词和替换后的文本 +func SensitiveWordReplace(text string, returnImmediately bool) (bool, []string, string) { + if len(setting.SensitiveWords) == 0 { + return false, nil, text + } + checkText := strings.ToLower(text) + m := InitAc(setting.SensitiveWords) + hits := m.MultiPatternSearch([]rune(checkText), returnImmediately) + if len(hits) > 0 { + words := make([]string, 0, len(hits)) + var builder strings.Builder + builder.Grow(len(text)) + lastPos := 0 + + for _, hit := range hits { + pos := hit.Pos + word := string(hit.Word) + builder.WriteString(text[lastPos:pos]) + builder.WriteString("**###**") + lastPos = pos + len(word) + words = append(words, word) + } + builder.WriteString(text[lastPos:]) + return true, words, builder.String() + } + return false, nil, text +} diff --git a/service/str.go b/service/str.go new file mode 100644 index 00000000..4390e99b --- /dev/null +++ b/service/str.go @@ -0,0 +1,101 @@ +package service + +import ( + "bytes" + "fmt" + goahocorasick "github.com/anknown/ahocorasick" + "strings" +) + +func SundaySearch(text string, pattern string) bool { + // 计算偏移表 + offset := make(map[rune]int) + for i, c := range pattern { + offset[c] = len(pattern) - i + } + + // 文本串长度和模式串长度 + n, m := len(text), len(pattern) + + // 主循环,i表示当前对齐的文本串位置 + for i := 0; i <= n-m; { + // 检查子串 + j := 0 + for j < m && text[i+j] == pattern[j] { + j++ + } + // 如果完全匹配,返回匹配位置 + if j == m { + return true + } + + // 如果还有剩余字符,则检查下一位字符在偏移表中的值 + if i+m < n { + next := rune(text[i+m]) + if val, ok := offset[next]; ok { + i += val // 存在于偏移表中,进行跳跃 + } else { + i += len(pattern) + 1 // 不存在于偏移表中,跳过整个模式串长度 + } + } else { + break + } + } + return false // 如果没有找到匹配,返回-1 +} + +func RemoveDuplicate(s []string) []string { + result := make([]string, 0, len(s)) + temp := map[string]struct{}{} + for _, item := range s { + if _, ok := temp[item]; !ok { + temp[item] = struct{}{} + result = append(result, item) + } + } + return result +} + +func InitAc(words []string) *goahocorasick.Machine { + m := new(goahocorasick.Machine) + dict := readRunes(words) + if err := m.Build(dict); err != nil { + fmt.Println(err) + return nil + } + return m +} + +func readRunes(words []string) [][]rune { + var dict [][]rune + + for _, word := range words { + word = strings.ToLower(word) + l := bytes.TrimSpace([]byte(word)) + dict = append(dict, bytes.Runes(l)) + } + + return dict +} + +func AcSearch(findText string, dict []string, stopImmediately bool) (bool, []string) { + if len(dict) == 0 { + return false, nil + } + if len(findText) == 0 { + return false, nil + } + m := InitAc(dict) + if m == nil { + return false, nil + } + hits := m.MultiPatternSearch([]rune(findText), stopImmediately) + if len(hits) > 0 { + words := make([]string, 0) + for _, hit := range hits { + words = append(words, string(hit.Word)) + } + return true, words + } + return false, nil +} diff --git a/service/task.go b/service/task.go new file mode 100644 index 00000000..c2501fe2 --- /dev/null +++ b/service/task.go @@ -0,0 +1,10 @@ +package service + +import ( + "one-api/constant" + "strings" +) + +func CoverTaskActionToModelName(platform constant.TaskPlatform, action string) string { + return strings.ToLower(string(platform)) + "_" + strings.ToLower(action) +} diff --git a/service/token_counter.go b/service/token_counter.go new file mode 100644 index 00000000..eed5b5ca --- /dev/null +++ b/service/token_counter.go @@ -0,0 +1,474 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/tiktoken-go/tokenizer" + "github.com/tiktoken-go/tokenizer/codec" + "image" + "log" + "math" + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "strings" + "sync" + "unicode/utf8" +) + +// tokenEncoderMap won't grow after initialization +var defaultTokenEncoder tokenizer.Codec + +// tokenEncoderMap is used to store token encoders for different models +var tokenEncoderMap = make(map[string]tokenizer.Codec) + +// tokenEncoderMutex protects tokenEncoderMap for concurrent access +var tokenEncoderMutex sync.RWMutex + +func InitTokenEncoders() { + common.SysLog("initializing token encoders") + defaultTokenEncoder = codec.NewCl100kBase() + common.SysLog("token encoders initialized") +} + +func getTokenEncoder(model string) tokenizer.Codec { + // First, try to get the encoder from cache with read lock + tokenEncoderMutex.RLock() + if encoder, exists := tokenEncoderMap[model]; exists { + tokenEncoderMutex.RUnlock() + return encoder + } + tokenEncoderMutex.RUnlock() + + // If not in cache, create new encoder with write lock + tokenEncoderMutex.Lock() + defer tokenEncoderMutex.Unlock() + + // Double-check if another goroutine already created the encoder + if encoder, exists := tokenEncoderMap[model]; exists { + return encoder + } + + // Create new encoder + modelCodec, err := tokenizer.ForModel(tokenizer.Model(model)) + if err != nil { + // Cache the default encoder for this model to avoid repeated failures + tokenEncoderMap[model] = defaultTokenEncoder + return defaultTokenEncoder + } + + // Cache the new encoder + tokenEncoderMap[model] = modelCodec + return modelCodec +} + +func getTokenNum(tokenEncoder tokenizer.Codec, text string) int { + if text == "" { + return 0 + } + tkm, _ := tokenEncoder.Count(text) + return tkm +} + +func getImageToken(info *relaycommon.RelayInfo, imageUrl *dto.MessageImageUrl, model string, stream bool) (int, error) { + if imageUrl == nil { + return 0, fmt.Errorf("image_url_is_nil") + } + baseTokens := 85 + if model == "glm-4v" { + return 1047, nil + } + if imageUrl.Detail == "low" { + return baseTokens, nil + } + if !constant.GetMediaTokenNotStream && !stream { + return 3 * baseTokens, nil + } + + // 同步One API的图片计费逻辑 + if imageUrl.Detail == "auto" || imageUrl.Detail == "" { + imageUrl.Detail = "high" + } + + tileTokens := 170 + if strings.HasPrefix(model, "gpt-4o-mini") { + tileTokens = 5667 + baseTokens = 2833 + } + // 是否统计图片token + if !constant.GetMediaToken { + return 3 * baseTokens, nil + } + if info.ChannelType == constant.ChannelTypeGemini || info.ChannelType == constant.ChannelTypeVertexAi || info.ChannelType == constant.ChannelTypeAnthropic { + return 3 * baseTokens, nil + } + var config image.Config + var err error + var format string + var b64str string + if strings.HasPrefix(imageUrl.Url, "http") { + config, format, err = DecodeUrlImageData(imageUrl.Url) + } else { + common.SysLog(fmt.Sprintf("decoding image")) + config, format, b64str, err = DecodeBase64ImageData(imageUrl.Url) + } + if err != nil { + return 0, err + } + imageUrl.MimeType = format + + if config.Width == 0 || config.Height == 0 { + // not an image + if format != "" && b64str != "" { + // file type + return 3 * baseTokens, nil + } + return 0, errors.New(fmt.Sprintf("fail to decode base64 config: %s", imageUrl.Url)) + } + + shortSide := config.Width + otherSide := config.Height + log.Printf("format: %s, width: %d, height: %d", format, config.Width, config.Height) + // 缩放倍数 + scale := 1.0 + if config.Height < shortSide { + shortSide = config.Height + otherSide = config.Width + } + + // 将最小变的尺寸缩小到768以下,如果大于768,则缩放到768 + if shortSide > 768 { + scale = float64(shortSide) / 768 + shortSide = 768 + } + // 将另一边按照相同的比例缩小,向上取整 + otherSide = int(math.Ceil(float64(otherSide) / scale)) + log.Printf("shortSide: %d, otherSide: %d, scale: %f", shortSide, otherSide, scale) + // 计算图片的token数量(边的长度除以512,向上取整) + tiles := (shortSide + 511) / 512 * ((otherSide + 511) / 512) + log.Printf("tiles: %d", tiles) + return tiles*tileTokens + baseTokens, nil +} + +func CountTokenChatRequest(info *relaycommon.RelayInfo, request dto.GeneralOpenAIRequest) (int, error) { + tkm := 0 + msgTokens, err := CountTokenMessages(info, request.Messages, request.Model, request.Stream) + if err != nil { + return 0, err + } + tkm += msgTokens + if request.Tools != nil { + openaiTools := request.Tools + countStr := "" + for _, tool := range openaiTools { + countStr = tool.Function.Name + if tool.Function.Description != "" { + countStr += tool.Function.Description + } + if tool.Function.Parameters != nil { + countStr += fmt.Sprintf("%v", tool.Function.Parameters) + } + } + toolTokens := CountTokenInput(countStr, request.Model) + tkm += 8 + tkm += toolTokens + } + + return tkm, nil +} + +func CountTokenClaudeRequest(request dto.ClaudeRequest, model string) (int, error) { + tkm := 0 + + // Count tokens in messages + msgTokens, err := CountTokenClaudeMessages(request.Messages, model, request.Stream) + if err != nil { + return 0, err + } + tkm += msgTokens + + // Count tokens in system message + if request.System != "" { + systemTokens := CountTokenInput(request.System, model) + tkm += systemTokens + } + + if request.Tools != nil { + // check is array + if tools, ok := request.Tools.([]any); ok { + if len(tools) > 0 { + parsedTools, err1 := common.Any2Type[[]dto.Tool](request.Tools) + if err1 != nil { + return 0, fmt.Errorf("tools: Input should be a valid list: %v", err) + } + toolTokens, err2 := CountTokenClaudeTools(parsedTools, model) + if err2 != nil { + return 0, fmt.Errorf("tools: %v", err) + } + tkm += toolTokens + } + } else { + return 0, errors.New("tools: Input should be a valid list") + } + } + + return tkm, nil +} + +func CountTokenClaudeMessages(messages []dto.ClaudeMessage, model string, stream bool) (int, error) { + tokenEncoder := getTokenEncoder(model) + tokenNum := 0 + + for _, message := range messages { + // Count tokens for role + tokenNum += getTokenNum(tokenEncoder, message.Role) + if message.IsStringContent() { + tokenNum += getTokenNum(tokenEncoder, message.GetStringContent()) + } else { + content, err := message.ParseContent() + if err != nil { + return 0, err + } + for _, mediaMessage := range content { + switch mediaMessage.Type { + case "text": + tokenNum += getTokenNum(tokenEncoder, mediaMessage.GetText()) + case "image": + //imageTokenNum, err := getClaudeImageToken(mediaMsg.Source, model, stream) + //if err != nil { + // return 0, err + //} + tokenNum += 1000 + case "tool_use": + if mediaMessage.Input != nil { + tokenNum += getTokenNum(tokenEncoder, mediaMessage.Name) + inputJSON, _ := json.Marshal(mediaMessage.Input) + tokenNum += getTokenNum(tokenEncoder, string(inputJSON)) + } + case "tool_result": + if mediaMessage.Content != nil { + contentJSON, _ := json.Marshal(mediaMessage.Content) + tokenNum += getTokenNum(tokenEncoder, string(contentJSON)) + } + } + } + } + } + + // Add a constant for message formatting (this may need adjustment based on Claude's exact formatting) + tokenNum += len(messages) * 2 // Assuming 2 tokens per message for formatting + + return tokenNum, nil +} + +func CountTokenClaudeTools(tools []dto.Tool, model string) (int, error) { + tokenEncoder := getTokenEncoder(model) + tokenNum := 0 + + for _, tool := range tools { + tokenNum += getTokenNum(tokenEncoder, tool.Name) + tokenNum += getTokenNum(tokenEncoder, tool.Description) + + schemaJSON, err := json.Marshal(tool.InputSchema) + if err != nil { + return 0, errors.New(fmt.Sprintf("marshal_tool_schema_fail: %s", err.Error())) + } + tokenNum += getTokenNum(tokenEncoder, string(schemaJSON)) + } + + // Add a constant for tool formatting (this may need adjustment based on Claude's exact formatting) + tokenNum += len(tools) * 3 // Assuming 3 tokens per tool for formatting + + return tokenNum, nil +} + +func CountTokenRealtime(info *relaycommon.RelayInfo, request dto.RealtimeEvent, model string) (int, int, error) { + audioToken := 0 + textToken := 0 + switch request.Type { + case dto.RealtimeEventTypeSessionUpdate: + if request.Session != nil { + msgTokens := CountTextToken(request.Session.Instructions, model) + textToken += msgTokens + } + case dto.RealtimeEventResponseAudioDelta: + // count audio token + atk, err := CountAudioTokenOutput(request.Delta, info.OutputAudioFormat) + if err != nil { + return 0, 0, fmt.Errorf("error counting audio token: %v", err) + } + audioToken += atk + case dto.RealtimeEventResponseAudioTranscriptionDelta, dto.RealtimeEventResponseFunctionCallArgumentsDelta: + // count text token + tkm := CountTextToken(request.Delta, model) + textToken += tkm + case dto.RealtimeEventInputAudioBufferAppend: + // count audio token + atk, err := CountAudioTokenInput(request.Audio, info.InputAudioFormat) + if err != nil { + return 0, 0, fmt.Errorf("error counting audio token: %v", err) + } + audioToken += atk + case dto.RealtimeEventConversationItemCreated: + if request.Item != nil { + switch request.Item.Type { + case "message": + for _, content := range request.Item.Content { + if content.Type == "input_text" { + tokens := CountTextToken(content.Text, model) + textToken += tokens + } + } + } + } + case dto.RealtimeEventTypeResponseDone: + // count tools token + if !info.IsFirstRequest { + if info.RealtimeTools != nil && len(info.RealtimeTools) > 0 { + for _, tool := range info.RealtimeTools { + toolTokens := CountTokenInput(tool, model) + textToken += 8 + textToken += toolTokens + } + } + } + } + return textToken, audioToken, nil +} + +func CountTokenMessages(info *relaycommon.RelayInfo, messages []dto.Message, model string, stream bool) (int, error) { + //recover when panic + tokenEncoder := getTokenEncoder(model) + // Reference: + // https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb + // https://github.com/pkoukk/tiktoken-go/issues/6 + // + // Every message follows <|start|>{role/name}\n{content}<|end|>\n + var tokensPerMessage int + var tokensPerName int + if model == "gpt-3.5-turbo-0301" { + tokensPerMessage = 4 + tokensPerName = -1 // If there's a name, the role is omitted + } else { + tokensPerMessage = 3 + tokensPerName = 1 + } + tokenNum := 0 + for _, message := range messages { + tokenNum += tokensPerMessage + tokenNum += getTokenNum(tokenEncoder, message.Role) + if message.Content != nil { + if message.Name != nil { + tokenNum += tokensPerName + tokenNum += getTokenNum(tokenEncoder, *message.Name) + } + arrayContent := message.ParseContent() + for _, m := range arrayContent { + if m.Type == dto.ContentTypeImageURL { + imageUrl := m.GetImageMedia() + imageTokenNum, err := getImageToken(info, imageUrl, model, stream) + if err != nil { + return 0, err + } + tokenNum += imageTokenNum + log.Printf("image token num: %d", imageTokenNum) + } else if m.Type == dto.ContentTypeInputAudio { + // TODO: 音频token数量计算 + tokenNum += 100 + } else if m.Type == dto.ContentTypeFile { + tokenNum += 5000 + } else if m.Type == dto.ContentTypeVideoUrl { + tokenNum += 5000 + } else { + tokenNum += getTokenNum(tokenEncoder, m.Text) + } + } + } + } + tokenNum += 3 // Every reply is primed with <|start|>assistant<|message|> + return tokenNum, nil +} + +func CountTokenInput(input any, model string) int { + switch v := input.(type) { + case string: + return CountTextToken(v, model) + case []string: + text := "" + for _, s := range v { + text += s + } + return CountTextToken(text, model) + case []interface{}: + text := "" + for _, item := range v { + text += fmt.Sprintf("%v", item) + } + return CountTextToken(text, model) + } + return CountTokenInput(fmt.Sprintf("%v", input), model) +} + +func CountTokenStreamChoices(messages []dto.ChatCompletionsStreamResponseChoice, model string) int { + tokens := 0 + for _, message := range messages { + tkm := CountTokenInput(message.Delta.GetContentString(), model) + tokens += tkm + if message.Delta.ToolCalls != nil { + for _, tool := range message.Delta.ToolCalls { + tkm := CountTokenInput(tool.Function.Name, model) + tokens += tkm + tkm = CountTokenInput(tool.Function.Arguments, model) + tokens += tkm + } + } + } + return tokens +} + +func CountTTSToken(text string, model string) int { + if strings.HasPrefix(model, "tts") { + return utf8.RuneCountInString(text) + } else { + return CountTextToken(text, model) + } +} + +func CountAudioTokenInput(audioBase64 string, audioFormat string) (int, error) { + if audioBase64 == "" { + return 0, nil + } + duration, err := parseAudio(audioBase64, audioFormat) + if err != nil { + return 0, err + } + return int(duration / 60 * 100 / 0.06), nil +} + +func CountAudioTokenOutput(audioBase64 string, audioFormat string) (int, error) { + if audioBase64 == "" { + return 0, nil + } + duration, err := parseAudio(audioBase64, audioFormat) + if err != nil { + return 0, err + } + return int(duration / 60 * 200 / 0.24), nil +} + +//func CountAudioToken(sec float64, audioType string) { +// if audioType == "input" { +// +// } +//} + +// CountTextToken 统计文本的token数量,仅当文本包含敏感词,返回错误,同时返回token数量 +func CountTextToken(text string, model string) int { + if text == "" { + return 0 + } + tokenEncoder := getTokenEncoder(model) + return getTokenNum(tokenEncoder, text) +} diff --git a/service/usage_helpr.go b/service/usage_helpr.go new file mode 100644 index 00000000..ca9c0830 --- /dev/null +++ b/service/usage_helpr.go @@ -0,0 +1,30 @@ +package service + +import ( + "one-api/dto" +) + +//func GetPromptTokens(textRequest dto.GeneralOpenAIRequest, relayMode int) (int, error) { +// switch relayMode { +// case constant.RelayModeChatCompletions: +// return CountTokenMessages(textRequest.Messages, textRequest.Model) +// case constant.RelayModeCompletions: +// return CountTokenInput(textRequest.Prompt, textRequest.Model), nil +// case constant.RelayModeModerations: +// return CountTokenInput(textRequest.Input, textRequest.Model), nil +// } +// return 0, errors.New("unknown relay mode") +//} + +func ResponseText2Usage(responseText string, modeName string, promptTokens int) *dto.Usage { + usage := &dto.Usage{} + usage.PromptTokens = promptTokens + ctkm := CountTextToken(responseText, modeName) + usage.CompletionTokens = ctkm + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + return usage +} + +func ValidUsage(usage *dto.Usage) bool { + return usage != nil && (usage.PromptTokens != 0 || usage.CompletionTokens != 0) +} diff --git a/service/user_notify.go b/service/user_notify.go new file mode 100644 index 00000000..96664007 --- /dev/null +++ b/service/user_notify.go @@ -0,0 +1,66 @@ +package service + +import ( + "fmt" + "one-api/common" + "one-api/dto" + "one-api/model" + "strings" +) + +func NotifyRootUser(t string, subject string, content string) { + user := model.GetRootUser().ToBaseUser() + err := NotifyUser(user.Id, user.Email, user.GetSetting(), dto.NewNotify(t, subject, content, nil)) + if err != nil { + common.SysError(fmt.Sprintf("failed to notify root user: %s", err.Error())) + } +} + +func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data dto.Notify) error { + notifyType := userSetting.NotifyType + if notifyType == "" { + notifyType = dto.NotifyTypeEmail + } + + // Check notification limit + canSend, err := CheckNotificationLimit(userId, data.Type) + if err != nil { + common.SysError(fmt.Sprintf("failed to check notification limit: %s", err.Error())) + return err + } + if !canSend { + return fmt.Errorf("notification limit exceeded for user %d with type %s", userId, notifyType) + } + + switch notifyType { + case dto.NotifyTypeEmail: + // check setting email + userEmail = userSetting.NotificationEmail + if userEmail == "" { + common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId)) + return nil + } + return sendEmailNotify(userEmail, data) + case dto.NotifyTypeWebhook: + webhookURLStr := userSetting.WebhookUrl + if webhookURLStr == "" { + common.SysError(fmt.Sprintf("user %d has no webhook url, skip sending webhook", userId)) + return nil + } + + // 获取 webhook secret + webhookSecret := userSetting.WebhookSecret + return SendWebhookNotify(webhookURLStr, webhookSecret, data) + } + return nil +} + +func sendEmailNotify(userEmail string, data dto.Notify) error { + // make email content + content := data.Content + // 处理占位符 + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + return common.SendEmail(data.Title, userEmail, content) +} diff --git a/service/webhook.go b/service/webhook.go new file mode 100644 index 00000000..8faccda3 --- /dev/null +++ b/service/webhook.go @@ -0,0 +1,118 @@ +package service + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "one-api/dto" + "one-api/setting" + "time" +) + +// WebhookPayload webhook 通知的负载数据 +type WebhookPayload struct { + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` + Values []interface{} `json:"values,omitempty"` + Timestamp int64 `json:"timestamp"` +} + +// generateSignature 生成 webhook 签名 +func generateSignature(secret string, payload []byte) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write(payload) + return hex.EncodeToString(h.Sum(nil)) +} + +// SendWebhookNotify 发送 webhook 通知 +func SendWebhookNotify(webhookURL string, secret string, data dto.Notify) error { + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = fmt.Sprintf(content, value) + } + + // 构建 webhook 负载 + payload := WebhookPayload{ + Type: data.Type, + Title: data.Title, + Content: content, + Values: data.Values, + Timestamp: time.Now().Unix(), + } + + // 序列化负载 + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal webhook payload: %v", err) + } + + // 创建 HTTP 请求 + var req *http.Request + var resp *http.Response + + if setting.EnableWorker() { + // 构建worker请求数据 + workerReq := &WorkerRequest{ + URL: webhookURL, + Key: setting.WorkerValidKey, + Method: http.MethodPost, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + Body: payloadBytes, + } + + // 如果有secret,添加签名到headers + if secret != "" { + signature := generateSignature(secret, payloadBytes) + workerReq.Headers["X-Webhook-Signature"] = signature + workerReq.Headers["Authorization"] = "Bearer " + secret + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send webhook request through worker: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode) + } + } else { + req, err = http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes)) + if err != nil { + return fmt.Errorf("failed to create webhook request: %v", err) + } + + // 设置请求头 + req.Header.Set("Content-Type", "application/json") + + // 如果有 secret,生成签名 + if secret != "" { + signature := generateSignature(secret, payloadBytes) + req.Header.Set("X-Webhook-Signature", signature) + } + + // 发送请求 + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send webhook request: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("webhook request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} diff --git a/setting/auto_group.go b/setting/auto_group.go new file mode 100644 index 00000000..5a87ae56 --- /dev/null +++ b/setting/auto_group.go @@ -0,0 +1,31 @@ +package setting + +import "encoding/json" + +var AutoGroups = []string{ + "default", +} + +var DefaultUseAutoGroup = false + +func ContainsAutoGroup(group string) bool { + for _, autoGroup := range AutoGroups { + if autoGroup == group { + return true + } + } + return false +} + +func UpdateAutoGroupsByJsonString(jsonString string) error { + AutoGroups = make([]string, 0) + return json.Unmarshal([]byte(jsonString), &AutoGroups) +} + +func AutoGroups2JsonString() string { + jsonBytes, err := json.Marshal(AutoGroups) + if err != nil { + return "[]" + } + return string(jsonBytes) +} diff --git a/setting/chat.go b/setting/chat.go new file mode 100644 index 00000000..53cb655a --- /dev/null +++ b/setting/chat.go @@ -0,0 +1,41 @@ +package setting + +import ( + "encoding/json" + "one-api/common" +) + +var Chats = []map[string]string{ + //{ + // "ChatGPT Next Web 官方示例": "https://app.nextchat.dev/#/?settings={\"key\":\"{key}\",\"url\":\"{address}\"}", + //}, + { + "Cherry Studio": "cherrystudio://providers/api-keys?v=1&data={cherryConfig}", + }, + { + "Lobe Chat 官方示例": "https://chat-preview.lobehub.com/?settings={\"keyVaults\":{\"openai\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\"}}}", + }, + { + "AI as Workspace": "https://aiaw.app/set-provider?provider={\"type\":\"openai\",\"settings\":{\"apiKey\":\"{key}\",\"baseURL\":\"{address}/v1\",\"compatibility\":\"strict\"}}", + }, + { + "AMA 问天": "ama://set-api-key?server={address}&key={key}", + }, + { + "OpenCat": "opencat://team/join?domain={address}&token={key}", + }, +} + +func UpdateChatsByJsonString(jsonString string) error { + Chats = make([]map[string]string, 0) + return json.Unmarshal([]byte(jsonString), &Chats) +} + +func Chats2JsonString() string { + jsonBytes, err := json.Marshal(Chats) + if err != nil { + common.SysError("error marshalling chats: " + err.Error()) + return "[]" + } + return string(jsonBytes) +} diff --git a/setting/config/config.go b/setting/config/config.go new file mode 100644 index 00000000..3af51b14 --- /dev/null +++ b/setting/config/config.go @@ -0,0 +1,259 @@ +package config + +import ( + "encoding/json" + "one-api/common" + "reflect" + "strconv" + "strings" + "sync" +) + +// ConfigManager 统一管理所有配置 +type ConfigManager struct { + configs map[string]interface{} + mutex sync.RWMutex +} + +var GlobalConfig = NewConfigManager() + +func NewConfigManager() *ConfigManager { + return &ConfigManager{ + configs: make(map[string]interface{}), + } +} + +// Register 注册一个配置模块 +func (cm *ConfigManager) Register(name string, config interface{}) { + cm.mutex.Lock() + defer cm.mutex.Unlock() + cm.configs[name] = config +} + +// Get 获取指定配置模块 +func (cm *ConfigManager) Get(name string) interface{} { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + return cm.configs[name] +} + +// LoadFromDB 从数据库加载配置 +func (cm *ConfigManager) LoadFromDB(options map[string]string) error { + cm.mutex.Lock() + defer cm.mutex.Unlock() + + for name, config := range cm.configs { + prefix := name + "." + configMap := make(map[string]string) + + // 收集属于此配置的所有选项 + for key, value := range options { + if strings.HasPrefix(key, prefix) { + configKey := strings.TrimPrefix(key, prefix) + configMap[configKey] = value + } + } + + // 如果找到配置项,则更新配置 + if len(configMap) > 0 { + if err := updateConfigFromMap(config, configMap); err != nil { + common.SysError("failed to update config " + name + ": " + err.Error()) + continue + } + } + } + + return nil +} + +// SaveToDB 将配置保存到数据库 +func (cm *ConfigManager) SaveToDB(updateFunc func(key, value string) error) error { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + for name, config := range cm.configs { + configMap, err := configToMap(config) + if err != nil { + return err + } + + for key, value := range configMap { + dbKey := name + "." + key + if err := updateFunc(dbKey, value); err != nil { + return err + } + } + } + + return nil +} + +// 辅助函数:将配置对象转换为map +func configToMap(config interface{}) (map[string]string, error) { + result := make(map[string]string) + + val := reflect.ValueOf(config) + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() != reflect.Struct { + return nil, nil + } + + typ := val.Type() + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + // 跳过未导出字段 + if !fieldType.IsExported() { + continue + } + + // 获取json标签作为键名 + key := fieldType.Tag.Get("json") + if key == "" || key == "-" { + key = fieldType.Name + } + + // 处理不同类型的字段 + var strValue string + switch field.Kind() { + case reflect.String: + strValue = field.String() + case reflect.Bool: + strValue = strconv.FormatBool(field.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + strValue = strconv.FormatInt(field.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + strValue = strconv.FormatUint(field.Uint(), 10) + case reflect.Float32, reflect.Float64: + strValue = strconv.FormatFloat(field.Float(), 'f', -1, 64) + case reflect.Map, reflect.Slice, reflect.Struct: + // 复杂类型使用JSON序列化 + bytes, err := json.Marshal(field.Interface()) + if err != nil { + return nil, err + } + strValue = string(bytes) + default: + // 跳过不支持的类型 + continue + } + + result[key] = strValue + } + + return result, nil +} + +// 辅助函数:从map更新配置对象 +func updateConfigFromMap(config interface{}, configMap map[string]string) error { + val := reflect.ValueOf(config) + if val.Kind() != reflect.Ptr { + return nil + } + val = val.Elem() + + if val.Kind() != reflect.Struct { + return nil + } + + typ := val.Type() + for i := 0; i < val.NumField(); i++ { + field := val.Field(i) + fieldType := typ.Field(i) + + // 跳过未导出字段 + if !fieldType.IsExported() { + continue + } + + // 获取json标签作为键名 + key := fieldType.Tag.Get("json") + if key == "" || key == "-" { + key = fieldType.Name + } + + // 检查map中是否有对应的值 + strValue, ok := configMap[key] + if !ok { + continue + } + + // 根据字段类型设置值 + if !field.CanSet() { + continue + } + + switch field.Kind() { + case reflect.String: + field.SetString(strValue) + case reflect.Bool: + boolValue, err := strconv.ParseBool(strValue) + if err != nil { + continue + } + field.SetBool(boolValue) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + intValue, err := strconv.ParseInt(strValue, 10, 64) + if err != nil { + continue + } + field.SetInt(intValue) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + uintValue, err := strconv.ParseUint(strValue, 10, 64) + if err != nil { + continue + } + field.SetUint(uintValue) + case reflect.Float32, reflect.Float64: + floatValue, err := strconv.ParseFloat(strValue, 64) + if err != nil { + continue + } + field.SetFloat(floatValue) + case reflect.Map, reflect.Slice, reflect.Struct: + // 复杂类型使用JSON反序列化 + err := json.Unmarshal([]byte(strValue), field.Addr().Interface()) + if err != nil { + continue + } + } + } + + return nil +} + +// ConfigToMap 将配置对象转换为map(导出函数) +func ConfigToMap(config interface{}) (map[string]string, error) { + return configToMap(config) +} + +// UpdateConfigFromMap 从map更新配置对象(导出函数) +func UpdateConfigFromMap(config interface{}, configMap map[string]string) error { + return updateConfigFromMap(config, configMap) +} + +// ExportAllConfigs 导出所有已注册的配置为扁平结构 +func (cm *ConfigManager) ExportAllConfigs() map[string]string { + cm.mutex.RLock() + defer cm.mutex.RUnlock() + + result := make(map[string]string) + + for name, cfg := range cm.configs { + configMap, err := ConfigToMap(cfg) + if err != nil { + continue + } + + // 使用 "模块名.配置项" 的格式添加到结果中 + for key, value := range configMap { + result[name+"."+key] = value + } + } + + return result +} diff --git a/setting/console_setting/config.go b/setting/console_setting/config.go new file mode 100644 index 00000000..6327e558 --- /dev/null +++ b/setting/console_setting/config.go @@ -0,0 +1,39 @@ +package console_setting + +import "one-api/setting/config" + +type ConsoleSetting struct { + ApiInfo string `json:"api_info"` // 控制台 API 信息 (JSON 数组字符串) + UptimeKumaGroups string `json:"uptime_kuma_groups"` // Uptime Kuma 分组配置 (JSON 数组字符串) + Announcements string `json:"announcements"` // 系统公告 (JSON 数组字符串) + FAQ string `json:"faq"` // 常见问题 (JSON 数组字符串) + ApiInfoEnabled bool `json:"api_info_enabled"` // 是否启用 API 信息面板 + UptimeKumaEnabled bool `json:"uptime_kuma_enabled"` // 是否启用 Uptime Kuma 面板 + AnnouncementsEnabled bool `json:"announcements_enabled"` // 是否启用系统公告面板 + FAQEnabled bool `json:"faq_enabled"` // 是否启用常见问答面板 +} + +// 默认配置 +var defaultConsoleSetting = ConsoleSetting{ + ApiInfo: "", + UptimeKumaGroups: "", + Announcements: "", + FAQ: "", + ApiInfoEnabled: true, + UptimeKumaEnabled: true, + AnnouncementsEnabled: true, + FAQEnabled: true, +} + +// 全局实例 +var consoleSetting = defaultConsoleSetting + +func init() { + // 注册到全局配置管理器,键名为 console_setting + config.GlobalConfig.Register("console_setting", &consoleSetting) +} + +// GetConsoleSetting 获取 ConsoleSetting 配置实例 +func GetConsoleSetting() *ConsoleSetting { + return &consoleSetting +} \ No newline at end of file diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go new file mode 100644 index 00000000..fda6453d --- /dev/null +++ b/setting/console_setting/validation.go @@ -0,0 +1,304 @@ +package console_setting + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" + "strings" + "time" + "sort" +) + +var ( + urlRegex = regexp.MustCompile(`^https?://(?:(?:[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])?|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?))(?:\:[0-9]{1,5})?(?:/.*)?$`) + dangerousChars = []string{" 50 { + return fmt.Errorf("API信息数量不能超过50个") + } + + for i, apiInfo := range apiInfoList { + urlStr, ok := apiInfo["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个API信息缺少URL字段", i+1) + } + route, ok := apiInfo["route"].(string) + if !ok || route == "" { + return fmt.Errorf("第%d个API信息缺少线路描述字段", i+1) + } + description, ok := apiInfo["description"].(string) + if !ok || description == "" { + return fmt.Errorf("第%d个API信息缺少说明字段", i+1) + } + color, ok := apiInfo["color"].(string) + if !ok || color == "" { + return fmt.Errorf("第%d个API信息缺少颜色字段", i+1) + } + + if err := validateURL(urlStr, i+1, "API信息"); err != nil { + return err + } + + if len(urlStr) > 500 { + return fmt.Errorf("第%d个API信息的URL长度不能超过500字符", i+1) + } + if len(route) > 100 { + return fmt.Errorf("第%d个API信息的线路描述长度不能超过100字符", i+1) + } + if len(description) > 200 { + return fmt.Errorf("第%d个API信息的说明长度不能超过200字符", i+1) + } + + if !validColors[color] { + return fmt.Errorf("第%d个API信息的颜色值不合法", i+1) + } + + if err := checkDangerousContent(description, i+1, "API信息"); err != nil { + return err + } + if err := checkDangerousContent(route, i+1, "API信息"); err != nil { + return err + } + } + return nil +} + +func GetApiInfo() []map[string]interface{} { + return getJSONList(GetConsoleSetting().ApiInfo) +} + +func validateAnnouncements(announcementsStr string) error { + list, err := parseJSONArray(announcementsStr, "系统公告") + if err != nil { + return err + } + if len(list) > 100 { + return fmt.Errorf("系统公告数量不能超过100个") + } + validTypes := map[string]bool{ + "default": true, "ongoing": true, "success": true, "warning": true, "error": true, + } + for i, ann := range list { + content, ok := ann["content"].(string) + if !ok || content == "" { + return fmt.Errorf("第%d个公告缺少内容字段", i+1) + } + publishDateAny, exists := ann["publishDate"] + if !exists { + return fmt.Errorf("第%d个公告缺少发布日期字段", i+1) + } + publishDateStr, ok := publishDateAny.(string) + if !ok || publishDateStr == "" { + return fmt.Errorf("第%d个公告的发布日期不能为空", i+1) + } + if _, err := time.Parse(time.RFC3339, publishDateStr); err != nil { + return fmt.Errorf("第%d个公告的发布日期格式错误", i+1) + } + if t, exists := ann["type"]; exists { + if typeStr, ok := t.(string); ok { + if !validTypes[typeStr] { + return fmt.Errorf("第%d个公告的类型值不合法", i+1) + } + } + } + if len(content) > 500 { + return fmt.Errorf("第%d个公告的内容长度不能超过500字符", i+1) + } + if extra, exists := ann["extra"]; exists { + if extraStr, ok := extra.(string); ok && len(extraStr) > 200 { + return fmt.Errorf("第%d个公告的说明长度不能超过200字符", i+1) + } + } + } + return nil +} + +func validateFAQ(faqStr string) error { + list, err := parseJSONArray(faqStr, "FAQ信息") + if err != nil { + return err + } + if len(list) > 100 { + return fmt.Errorf("FAQ数量不能超过100个") + } + for i, faq := range list { + question, ok := faq["question"].(string) + if !ok || question == "" { + return fmt.Errorf("第%d个FAQ缺少问题字段", i+1) + } + answer, ok := faq["answer"].(string) + if !ok || answer == "" { + return fmt.Errorf("第%d个FAQ缺少答案字段", i+1) + } + if len(question) > 200 { + return fmt.Errorf("第%d个FAQ的问题长度不能超过200字符", i+1) + } + if len(answer) > 1000 { + return fmt.Errorf("第%d个FAQ的答案长度不能超过1000字符", i+1) + } + } + return nil +} + +func getPublishTime(item map[string]interface{}) time.Time { + if v, ok := item["publishDate"]; ok { + if s, ok2 := v.(string); ok2 { + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + } + } + return time.Time{} +} + +func GetAnnouncements() []map[string]interface{} { + list := getJSONList(GetConsoleSetting().Announcements) + sort.SliceStable(list, func(i, j int) bool { + return getPublishTime(list[i]).After(getPublishTime(list[j])) + }) + return list +} + +func GetFAQ() []map[string]interface{} { + return getJSONList(GetConsoleSetting().FAQ) +} + +func validateUptimeKumaGroups(groupsStr string) error { + groups, err := parseJSONArray(groupsStr, "Uptime Kuma分组配置") + if err != nil { + return err + } + + if len(groups) > 20 { + return fmt.Errorf("Uptime Kuma分组数量不能超过20个") + } + + nameSet := make(map[string]bool) + + for i, group := range groups { + categoryName, ok := group["categoryName"].(string) + if !ok || categoryName == "" { + return fmt.Errorf("第%d个分组缺少分类名称字段", i+1) + } + if nameSet[categoryName] { + return fmt.Errorf("第%d个分组的分类名称与其他分组重复", i+1) + } + nameSet[categoryName] = true + urlStr, ok := group["url"].(string) + if !ok || urlStr == "" { + return fmt.Errorf("第%d个分组缺少URL字段", i+1) + } + slug, ok := group["slug"].(string) + if !ok || slug == "" { + return fmt.Errorf("第%d个分组缺少Slug字段", i+1) + } + description, ok := group["description"].(string) + if !ok { + description = "" + } + + if err := validateURL(urlStr, i+1, "分组"); err != nil { + return err + } + + if len(categoryName) > 50 { + return fmt.Errorf("第%d个分组的分类名称长度不能超过50字符", i+1) + } + if len(urlStr) > 500 { + return fmt.Errorf("第%d个分组的URL长度不能超过500字符", i+1) + } + if len(slug) > 100 { + return fmt.Errorf("第%d个分组的Slug长度不能超过100字符", i+1) + } + if len(description) > 200 { + return fmt.Errorf("第%d个分组的描述长度不能超过200字符", i+1) + } + + if !slugRegex.MatchString(slug) { + return fmt.Errorf("第%d个分组的Slug只能包含字母、数字、下划线和连字符", i+1) + } + + if err := checkDangerousContent(description, i+1, "分组"); err != nil { + return err + } + if err := checkDangerousContent(categoryName, i+1, "分组"); err != nil { + return err + } + } + return nil +} + +func GetUptimeKumaGroups() []map[string]interface{} { + return getJSONList(GetConsoleSetting().UptimeKumaGroups) +} \ No newline at end of file diff --git a/setting/midjourney.go b/setting/midjourney.go new file mode 100644 index 00000000..d84f5d75 --- /dev/null +++ b/setting/midjourney.go @@ -0,0 +1,7 @@ +package setting + +var MjNotifyEnabled = false +var MjAccountFilterEnabled = false +var MjModeClearEnabled = false +var MjForwardUrlEnabled = true +var MjActionCheckSuccessEnabled = true diff --git a/setting/model_setting/claude.go b/setting/model_setting/claude.go new file mode 100644 index 00000000..04983182 --- /dev/null +++ b/setting/model_setting/claude.go @@ -0,0 +1,65 @@ +package model_setting + +import ( + "net/http" + "one-api/setting/config" +) + +//var claudeHeadersSettings = map[string][]string{} +// +//var ClaudeThinkingAdapterEnabled = true +//var ClaudeThinkingAdapterMaxTokens = 8192 +//var ClaudeThinkingAdapterBudgetTokensPercentage = 0.8 + +// ClaudeSettings 定义Claude模型的配置 +type ClaudeSettings struct { + HeadersSettings map[string]map[string][]string `json:"model_headers_settings"` + DefaultMaxTokens map[string]int `json:"default_max_tokens"` + ThinkingAdapterEnabled bool `json:"thinking_adapter_enabled"` + ThinkingAdapterBudgetTokensPercentage float64 `json:"thinking_adapter_budget_tokens_percentage"` +} + +// 默认配置 +var defaultClaudeSettings = ClaudeSettings{ + HeadersSettings: map[string]map[string][]string{}, + ThinkingAdapterEnabled: true, + DefaultMaxTokens: map[string]int{ + "default": 8192, + }, + ThinkingAdapterBudgetTokensPercentage: 0.8, +} + +// 全局实例 +var claudeSettings = defaultClaudeSettings + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("claude", &claudeSettings) +} + +// GetClaudeSettings 获取Claude配置 +func GetClaudeSettings() *ClaudeSettings { + // check default max tokens must have default key + if _, ok := claudeSettings.DefaultMaxTokens["default"]; !ok { + claudeSettings.DefaultMaxTokens["default"] = 8192 + } + return &claudeSettings +} + +func (c *ClaudeSettings) WriteHeaders(originModel string, httpHeader *http.Header) { + if headers, ok := c.HeadersSettings[originModel]; ok { + for headerKey, headerValues := range headers { + httpHeader.Del(headerKey) + for _, headerValue := range headerValues { + httpHeader.Add(headerKey, headerValue) + } + } + } +} + +func (c *ClaudeSettings) GetDefaultMaxTokens(model string) int { + if maxTokens, ok := c.DefaultMaxTokens[model]; ok { + return maxTokens + } + return c.DefaultMaxTokens["default"] +} diff --git a/setting/model_setting/gemini.go b/setting/model_setting/gemini.go new file mode 100644 index 00000000..f132fec8 --- /dev/null +++ b/setting/model_setting/gemini.go @@ -0,0 +1,70 @@ +package model_setting + +import ( + "one-api/setting/config" +) + +// GeminiSettings 定义Gemini模型的配置 +type GeminiSettings struct { + SafetySettings map[string]string `json:"safety_settings"` + VersionSettings map[string]string `json:"version_settings"` + SupportedImagineModels []string `json:"supported_imagine_models"` + ThinkingAdapterEnabled bool `json:"thinking_adapter_enabled"` + ThinkingAdapterBudgetTokensPercentage float64 `json:"thinking_adapter_budget_tokens_percentage"` +} + +// 默认配置 +var defaultGeminiSettings = GeminiSettings{ + SafetySettings: map[string]string{ + "default": "OFF", + "HARM_CATEGORY_CIVIC_INTEGRITY": "BLOCK_NONE", + }, + VersionSettings: map[string]string{ + "default": "v1beta", + "gemini-1.0-pro": "v1", + }, + SupportedImagineModels: []string{ + "gemini-2.0-flash-exp-image-generation", + "gemini-2.0-flash-exp", + }, + ThinkingAdapterEnabled: false, + ThinkingAdapterBudgetTokensPercentage: 0.6, +} + +// 全局实例 +var geminiSettings = defaultGeminiSettings + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("gemini", &geminiSettings) +} + +// GetGeminiSettings 获取Gemini配置 +func GetGeminiSettings() *GeminiSettings { + return &geminiSettings +} + +// GetGeminiSafetySetting 获取安全设置 +func GetGeminiSafetySetting(key string) string { + if value, ok := geminiSettings.SafetySettings[key]; ok { + return value + } + return geminiSettings.SafetySettings["default"] +} + +// GetGeminiVersionSetting 获取版本设置 +func GetGeminiVersionSetting(key string) string { + if value, ok := geminiSettings.VersionSettings[key]; ok { + return value + } + return geminiSettings.VersionSettings["default"] +} + +func IsGeminiModelSupportImagine(model string) bool { + for _, v := range geminiSettings.SupportedImagineModels { + if v == model { + return true + } + } + return false +} diff --git a/setting/model_setting/global.go b/setting/model_setting/global.go new file mode 100644 index 00000000..de2851bb --- /dev/null +++ b/setting/model_setting/global.go @@ -0,0 +1,26 @@ +package model_setting + +import ( + "one-api/setting/config" +) + +type GlobalSettings struct { + PassThroughRequestEnabled bool `json:"pass_through_request_enabled"` +} + +// 默认配置 +var defaultOpenaiSettings = GlobalSettings{ + PassThroughRequestEnabled: false, +} + +// 全局实例 +var globalSettings = defaultOpenaiSettings + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("global", &globalSettings) +} + +func GetGlobalSettings() *GlobalSettings { + return &globalSettings +} diff --git a/setting/operation_setting/general_setting.go b/setting/operation_setting/general_setting.go new file mode 100644 index 00000000..ae0c436e --- /dev/null +++ b/setting/operation_setting/general_setting.go @@ -0,0 +1,25 @@ +package operation_setting + +import "one-api/setting/config" + +type GeneralSetting struct { + DocsLink string `json:"docs_link"` + PingIntervalEnabled bool `json:"ping_interval_enabled"` + PingIntervalSeconds int `json:"ping_interval_seconds"` +} + +// 默认配置 +var generalSetting = GeneralSetting{ + DocsLink: "https://docs.newapi.pro", + PingIntervalEnabled: false, + PingIntervalSeconds: 60, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("general_setting", &generalSetting) +} + +func GetGeneralSetting() *GeneralSetting { + return &generalSetting +} diff --git a/setting/operation_setting/operation_setting.go b/setting/operation_setting/operation_setting.go new file mode 100644 index 00000000..ef330d1a --- /dev/null +++ b/setting/operation_setting/operation_setting.go @@ -0,0 +1,32 @@ +package operation_setting + +import "strings" + +var DemoSiteEnabled = false +var SelfUseModeEnabled = false + +var AutomaticDisableKeywords = []string{ + "Your credit balance is too low", + "This organization has been disabled.", + "You exceeded your current quota", + "Permission denied", + "The security token included in the request is invalid", + "Operation not allowed", + "Your account is not authorized", +} + +func AutomaticDisableKeywordsToString() string { + return strings.Join(AutomaticDisableKeywords, "\n") +} + +func AutomaticDisableKeywordsFromString(s string) { + AutomaticDisableKeywords = []string{} + ak := strings.Split(s, "\n") + for _, k := range ak { + k = strings.TrimSpace(k) + k = strings.ToLower(k) + if k != "" { + AutomaticDisableKeywords = append(AutomaticDisableKeywords, k) + } + } +} diff --git a/setting/operation_setting/tools.go b/setting/operation_setting/tools.go new file mode 100644 index 00000000..a59090ce --- /dev/null +++ b/setting/operation_setting/tools.go @@ -0,0 +1,90 @@ +package operation_setting + +import "strings" + +const ( + // Web search + WebSearchHighTierModelPriceLow = 30.00 + WebSearchHighTierModelPriceMedium = 35.00 + WebSearchHighTierModelPriceHigh = 50.00 + WebSearchPriceLow = 25.00 + WebSearchPriceMedium = 27.50 + WebSearchPriceHigh = 30.00 + // File search + FileSearchPrice = 2.5 +) + +const ( + // Gemini Audio Input Price + Gemini25FlashPreviewInputAudioPrice = 1.00 + Gemini25FlashProductionInputAudioPrice = 1.00 // for `gemini-2.5-flash` + Gemini25FlashLitePreviewInputAudioPrice = 0.50 + Gemini25FlashNativeAudioInputAudioPrice = 3.00 + Gemini20FlashInputAudioPrice = 0.70 +) + +const ( + // Claude Web search + ClaudeWebSearchPrice = 10.00 +) + +func GetClaudeWebSearchPricePerThousand() float64 { + return ClaudeWebSearchPrice +} + +func GetWebSearchPricePerThousand(modelName string, contextSize string) float64 { + // 确定模型类型 + // https://platform.openai.com/docs/pricing Web search 价格按模型类型和 search context size 收费 + // gpt-4.1, gpt-4o, or gpt-4o-search-preview 更贵,gpt-4.1-mini, gpt-4o-mini, gpt-4o-mini-search-preview 更便宜 + isHighTierModel := (strings.HasPrefix(modelName, "gpt-4.1") || strings.HasPrefix(modelName, "gpt-4o")) && + !strings.Contains(modelName, "mini") + // 确定 search context size 对应的价格 + var priceWebSearchPerThousandCalls float64 + switch contextSize { + case "low": + if isHighTierModel { + priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceLow + } else { + priceWebSearchPerThousandCalls = WebSearchPriceLow + } + case "medium": + if isHighTierModel { + priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceMedium + } else { + priceWebSearchPerThousandCalls = WebSearchPriceMedium + } + case "high": + if isHighTierModel { + priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceHigh + } else { + priceWebSearchPerThousandCalls = WebSearchPriceHigh + } + default: + // search context size 默认为 medium + if isHighTierModel { + priceWebSearchPerThousandCalls = WebSearchHighTierModelPriceMedium + } else { + priceWebSearchPerThousandCalls = WebSearchPriceMedium + } + } + return priceWebSearchPerThousandCalls +} + +func GetFileSearchPricePerThousand() float64 { + return FileSearchPrice +} + +func GetGeminiInputAudioPricePerMillionTokens(modelName string) float64 { + if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-native-audio") { + return Gemini25FlashNativeAudioInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview-lite") { + return Gemini25FlashLitePreviewInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash-preview") { + return Gemini25FlashPreviewInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.5-flash") { + return Gemini25FlashProductionInputAudioPrice + } else if strings.HasPrefix(modelName, "gemini-2.0-flash") { + return Gemini20FlashInputAudioPrice + } + return 0 +} diff --git a/setting/payment.go b/setting/payment.go new file mode 100644 index 00000000..7fc5ad3f --- /dev/null +++ b/setting/payment.go @@ -0,0 +1,46 @@ +package setting + +import "encoding/json" + +var PayAddress = "" +var CustomCallbackAddress = "" +var EpayId = "" +var EpayKey = "" +var Price = 7.3 +var MinTopUp = 1 +var USDExchangeRate = 7.3 + +var PayMethods = []map[string]string{ + { + "name": "支付宝", + "color": "rgba(var(--semi-blue-5), 1)", + "type": "alipay", + }, + { + "name": "微信", + "color": "rgba(var(--semi-green-5), 1)", + "type": "wxpay", + }, +} + +func UpdatePayMethodsByJsonString(jsonString string) error { + PayMethods = make([]map[string]string, 0) + return json.Unmarshal([]byte(jsonString), &PayMethods) +} + +func PayMethods2JsonString() string { + jsonBytes, err := json.Marshal(PayMethods) + if err != nil { + return "[]" + } + return string(jsonBytes) +} + +func ContainsPayMethod(method string) bool { + for _, payMethod := range PayMethods { + if payMethod["type"] == method { + return true + } + } + return false +} diff --git a/setting/payment_stripe.go b/setting/payment_stripe.go new file mode 100644 index 00000000..80d877df --- /dev/null +++ b/setting/payment_stripe.go @@ -0,0 +1,7 @@ +package setting + +var StripeApiSecret = "" +var StripeWebhookSecret = "" +var StripePriceId = "" +var StripeUnitPrice = 8.0 +var StripeMinTopUp = 1 diff --git a/setting/rate_limit.go b/setting/rate_limit.go new file mode 100644 index 00000000..53b53f88 --- /dev/null +++ b/setting/rate_limit.go @@ -0,0 +1,64 @@ +package setting + +import ( + "encoding/json" + "fmt" + "one-api/common" + "sync" +) + +var ModelRequestRateLimitEnabled = false +var ModelRequestRateLimitDurationMinutes = 1 +var ModelRequestRateLimitCount = 0 +var ModelRequestRateLimitSuccessCount = 1000 +var ModelRequestRateLimitGroup = map[string][2]int{} +var ModelRequestRateLimitMutex sync.RWMutex + +func ModelRequestRateLimitGroup2JSONString() string { + ModelRequestRateLimitMutex.RLock() + defer ModelRequestRateLimitMutex.RUnlock() + + jsonBytes, err := json.Marshal(ModelRequestRateLimitGroup) + if err != nil { + common.SysError("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateModelRequestRateLimitGroupByJSONString(jsonStr string) error { + ModelRequestRateLimitMutex.RLock() + defer ModelRequestRateLimitMutex.RUnlock() + + ModelRequestRateLimitGroup = make(map[string][2]int) + return json.Unmarshal([]byte(jsonStr), &ModelRequestRateLimitGroup) +} + +func GetGroupRateLimit(group string) (totalCount, successCount int, found bool) { + ModelRequestRateLimitMutex.RLock() + defer ModelRequestRateLimitMutex.RUnlock() + + if ModelRequestRateLimitGroup == nil { + return 0, 0, false + } + + limits, found := ModelRequestRateLimitGroup[group] + if !found { + return 0, 0, false + } + return limits[0], limits[1], true +} + +func CheckModelRequestRateLimitGroup(jsonStr string) error { + checkModelRequestRateLimitGroup := make(map[string][2]int) + err := json.Unmarshal([]byte(jsonStr), &checkModelRequestRateLimitGroup) + if err != nil { + return err + } + for group, limits := range checkModelRequestRateLimitGroup { + if limits[0] < 0 || limits[1] < 1 { + return fmt.Errorf("group %s has negative rate limit values: [%d, %d]", group, limits[0], limits[1]) + } + } + + return nil +} diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go new file mode 100644 index 00000000..51d473a8 --- /dev/null +++ b/setting/ratio_setting/cache_ratio.go @@ -0,0 +1,122 @@ +package ratio_setting + +import ( + "encoding/json" + "one-api/common" + "sync" +) + +var defaultCacheRatio = map[string]float64{ + "gpt-4": 0.5, + "o1": 0.5, + "o1-2024-12-17": 0.5, + "o1-preview-2024-09-12": 0.5, + "o1-preview": 0.5, + "o1-mini-2024-09-12": 0.5, + "o1-mini": 0.5, + "o3-mini": 0.5, + "o3-mini-2025-01-31": 0.5, + "gpt-4o-2024-11-20": 0.5, + "gpt-4o-2024-08-06": 0.5, + "gpt-4o": 0.5, + "gpt-4o-mini-2024-07-18": 0.5, + "gpt-4o-mini": 0.5, + "gpt-4o-realtime-preview": 0.5, + "gpt-4o-mini-realtime-preview": 0.5, + "gpt-4.5-preview": 0.5, + "gpt-4.5-preview-2025-02-27": 0.5, + "deepseek-chat": 0.25, + "deepseek-reasoner": 0.25, + "deepseek-coder": 0.25, + "claude-3-sonnet-20240229": 0.1, + "claude-3-opus-20240229": 0.1, + "claude-3-haiku-20240307": 0.1, + "claude-3-5-haiku-20241022": 0.1, + "claude-3-5-sonnet-20240620": 0.1, + "claude-3-5-sonnet-20241022": 0.1, + "claude-3-7-sonnet-20250219": 0.1, + "claude-3-7-sonnet-20250219-thinking": 0.1, + "claude-sonnet-4-20250514": 0.1, + "claude-sonnet-4-20250514-thinking": 0.1, + "claude-opus-4-20250514": 0.1, + "claude-opus-4-20250514-thinking": 0.1, +} + +var defaultCreateCacheRatio = map[string]float64{ + "claude-3-sonnet-20240229": 1.25, + "claude-3-opus-20240229": 1.25, + "claude-3-haiku-20240307": 1.25, + "claude-3-5-haiku-20241022": 1.25, + "claude-3-5-sonnet-20240620": 1.25, + "claude-3-5-sonnet-20241022": 1.25, + "claude-3-7-sonnet-20250219": 1.25, + "claude-3-7-sonnet-20250219-thinking": 1.25, + "claude-sonnet-4-20250514": 1.25, + "claude-sonnet-4-20250514-thinking": 1.25, + "claude-opus-4-20250514": 1.25, + "claude-opus-4-20250514-thinking": 1.25, +} + +//var defaultCreateCacheRatio = map[string]float64{} + +var cacheRatioMap map[string]float64 +var cacheRatioMapMutex sync.RWMutex + +// GetCacheRatioMap returns the cache ratio map +func GetCacheRatioMap() map[string]float64 { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + return cacheRatioMap +} + +// CacheRatio2JSONString converts the cache ratio map to a JSON string +func CacheRatio2JSONString() string { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + jsonBytes, err := json.Marshal(cacheRatioMap) + if err != nil { + common.SysError("error marshalling cache ratio: " + err.Error()) + } + return string(jsonBytes) +} + +// UpdateCacheRatioByJSONString updates the cache ratio map from a JSON string +func UpdateCacheRatioByJSONString(jsonStr string) error { + cacheRatioMapMutex.Lock() + defer cacheRatioMapMutex.Unlock() + cacheRatioMap = make(map[string]float64) + err := json.Unmarshal([]byte(jsonStr), &cacheRatioMap) + if err == nil { + InvalidateExposedDataCache() + } + return err +} + +// GetCacheRatio returns the cache ratio for a model +func GetCacheRatio(name string) (float64, bool) { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + ratio, ok := cacheRatioMap[name] + if !ok { + return 1, false // Default to 1 if not found + } + return ratio, true +} + +func GetCreateCacheRatio(name string) (float64, bool) { + ratio, ok := defaultCreateCacheRatio[name] + if !ok { + return 1.25, false // Default to 1.25 if not found + } + return ratio, true +} + +func GetCacheRatioCopy() map[string]float64 { + cacheRatioMapMutex.RLock() + defer cacheRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(cacheRatioMap)) + for k, v := range cacheRatioMap { + copyMap[k] = v + } + return copyMap +} diff --git a/setting/ratio_setting/expose_ratio.go b/setting/ratio_setting/expose_ratio.go new file mode 100644 index 00000000..8fca0bcb --- /dev/null +++ b/setting/ratio_setting/expose_ratio.go @@ -0,0 +1,17 @@ +package ratio_setting + +import "sync/atomic" + +var exposeRatioEnabled atomic.Bool + +func init() { + exposeRatioEnabled.Store(false) +} + +func SetExposeRatioEnabled(enabled bool) { + exposeRatioEnabled.Store(enabled) +} + +func IsExposeRatioEnabled() bool { + return exposeRatioEnabled.Load() +} \ No newline at end of file diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go new file mode 100644 index 00000000..9e5b6c30 --- /dev/null +++ b/setting/ratio_setting/exposed_cache.go @@ -0,0 +1,55 @@ +package ratio_setting + +import ( + "sync" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +const exposedDataTTL = 30 * time.Second + +type exposedCache struct { + data gin.H + expiresAt time.Time +} + +var ( + exposedData atomic.Value + rebuildMu sync.Mutex +) + +func InvalidateExposedDataCache() { + exposedData.Store((*exposedCache)(nil)) +} + +func cloneGinH(src gin.H) gin.H { + dst := make(gin.H, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func GetExposedData() gin.H { + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + rebuildMu.Lock() + defer rebuildMu.Unlock() + if c, ok := exposedData.Load().(*exposedCache); ok && c != nil && time.Now().Before(c.expiresAt) { + return cloneGinH(c.data) + } + newData := gin.H{ + "model_ratio": GetModelRatioCopy(), + "completion_ratio": GetCompletionRatioCopy(), + "cache_ratio": GetCacheRatioCopy(), + "model_price": GetModelPriceCopy(), + } + exposedData.Store(&exposedCache{ + data: newData, + expiresAt: time.Now().Add(exposedDataTTL), + }) + return cloneGinH(newData) +} \ No newline at end of file diff --git a/setting/ratio_setting/group_ratio.go b/setting/ratio_setting/group_ratio.go new file mode 100644 index 00000000..86f4a8d1 --- /dev/null +++ b/setting/ratio_setting/group_ratio.go @@ -0,0 +1,122 @@ +package ratio_setting + +import ( + "encoding/json" + "errors" + "one-api/common" + "sync" +) + +var groupRatio = map[string]float64{ + "default": 1, + "vip": 1, + "svip": 1, +} +var groupRatioMutex sync.RWMutex + +var ( + GroupGroupRatio = map[string]map[string]float64{ + "vip": { + "edit_this": 0.9, + }, + } + groupGroupRatioMutex sync.RWMutex +) + +func GetGroupRatioCopy() map[string]float64 { + groupRatioMutex.RLock() + defer groupRatioMutex.RUnlock() + + groupRatioCopy := make(map[string]float64) + for k, v := range groupRatio { + groupRatioCopy[k] = v + } + return groupRatioCopy +} + +func ContainsGroupRatio(name string) bool { + groupRatioMutex.RLock() + defer groupRatioMutex.RUnlock() + + _, ok := groupRatio[name] + return ok +} + +func GroupRatio2JSONString() string { + groupRatioMutex.RLock() + defer groupRatioMutex.RUnlock() + + jsonBytes, err := json.Marshal(groupRatio) + if err != nil { + common.SysError("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateGroupRatioByJSONString(jsonStr string) error { + groupRatioMutex.Lock() + defer groupRatioMutex.Unlock() + + groupRatio = make(map[string]float64) + return json.Unmarshal([]byte(jsonStr), &groupRatio) +} + +func GetGroupRatio(name string) float64 { + groupRatioMutex.RLock() + defer groupRatioMutex.RUnlock() + + ratio, ok := groupRatio[name] + if !ok { + common.SysError("group ratio not found: " + name) + return 1 + } + return ratio +} + +func GetGroupGroupRatio(userGroup, usingGroup string) (float64, bool) { + groupGroupRatioMutex.RLock() + defer groupGroupRatioMutex.RUnlock() + + gp, ok := GroupGroupRatio[userGroup] + if !ok { + return -1, false + } + ratio, ok := gp[usingGroup] + if !ok { + return -1, false + } + return ratio, true +} + +func GroupGroupRatio2JSONString() string { + groupGroupRatioMutex.RLock() + defer groupGroupRatioMutex.RUnlock() + + jsonBytes, err := json.Marshal(GroupGroupRatio) + if err != nil { + common.SysError("error marshalling group-group ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateGroupGroupRatioByJSONString(jsonStr string) error { + groupGroupRatioMutex.Lock() + defer groupGroupRatioMutex.Unlock() + + GroupGroupRatio = make(map[string]map[string]float64) + return json.Unmarshal([]byte(jsonStr), &GroupGroupRatio) +} + +func CheckGroupRatio(jsonStr string) error { + checkGroupRatio := make(map[string]float64) + err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio) + if err != nil { + return err + } + for name, ratio := range checkGroupRatio { + if ratio < 0 { + return errors.New("group ratio must be not less than 0: " + name) + } + } + return nil +} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go new file mode 100644 index 00000000..8a1d6aae --- /dev/null +++ b/setting/ratio_setting/model_ratio.go @@ -0,0 +1,665 @@ +package ratio_setting + +import ( + "encoding/json" + "one-api/common" + "one-api/setting/operation_setting" + "strings" + "sync" +) + +// from songquanpeng/one-api +const ( + USD2RMB = 7.3 // 暂定 1 USD = 7.3 RMB + USD = 500 // $0.002 = 1 -> $1 = 500 + RMB = USD / USD2RMB +) + +// modelRatio +// https://platform.openai.com/docs/models/model-endpoint-compatibility +// https://cloud.baidu.com/doc/WENXINWORKSHOP/s/Blfmc9dlf +// https://openai.com/pricing +// TODO: when a new api is enabled, check the pricing here +// 1 === $0.002 / 1K tokens +// 1 === ¥0.014 / 1k tokens + +var defaultModelRatio = map[string]float64{ + //"midjourney": 50, + "gpt-4-gizmo-*": 15, + "gpt-4o-gizmo-*": 2.5, + "gpt-4-all": 15, + "gpt-4o-all": 15, + "gpt-4": 15, + //"gpt-4-0314": 15, //deprecated + "gpt-4-0613": 15, + "gpt-4-32k": 30, + //"gpt-4-32k-0314": 30, //deprecated + "gpt-4-32k-0613": 30, + "gpt-4-1106-preview": 5, // $10 / 1M tokens + "gpt-4-0125-preview": 5, // $10 / 1M tokens + "gpt-4-turbo-preview": 5, // $10 / 1M tokens + "gpt-4-vision-preview": 5, // $10 / 1M tokens + "gpt-4-1106-vision-preview": 5, // $10 / 1M tokens + "chatgpt-4o-latest": 2.5, // $5 / 1M tokens + "gpt-4o": 1.25, // $2.5 / 1M tokens + "gpt-4o-audio-preview": 1.25, // $2.5 / 1M tokens + "gpt-4o-audio-preview-2024-10-01": 1.25, // $2.5 / 1M tokens + "gpt-4o-2024-05-13": 2.5, // $5 / 1M tokens + "gpt-4o-2024-08-06": 1.25, // $2.5 / 1M tokens + "gpt-4o-2024-11-20": 1.25, // $2.5 / 1M tokens + "gpt-4o-realtime-preview": 2.5, + "gpt-4o-realtime-preview-2024-10-01": 2.5, + "gpt-4o-realtime-preview-2024-12-17": 2.5, + "gpt-4o-mini-realtime-preview": 0.3, + "gpt-4o-mini-realtime-preview-2024-12-17": 0.3, + "gpt-image-1": 2.5, + "o1": 7.5, + "o1-2024-12-17": 7.5, + "o1-preview": 7.5, + "o1-preview-2024-09-12": 7.5, + "o1-mini": 0.55, + "o1-mini-2024-09-12": 0.55, + "o3-mini": 0.55, + "o3-mini-2025-01-31": 0.55, + "o3-mini-high": 0.55, + "o3-mini-2025-01-31-high": 0.55, + "o3-mini-low": 0.55, + "o3-mini-2025-01-31-low": 0.55, + "o3-mini-medium": 0.55, + "o3-mini-2025-01-31-medium": 0.55, + "gpt-4o-mini": 0.075, + "gpt-4o-mini-2024-07-18": 0.075, + "gpt-4-turbo": 5, // $0.01 / 1K tokens + "gpt-4-turbo-2024-04-09": 5, // $0.01 / 1K tokens + "gpt-4.5-preview": 37.5, + "gpt-4.5-preview-2025-02-27": 37.5, + //"gpt-3.5-turbo-0301": 0.75, //deprecated + "gpt-3.5-turbo": 0.25, + "gpt-3.5-turbo-0613": 0.75, + "gpt-3.5-turbo-16k": 1.5, // $0.003 / 1K tokens + "gpt-3.5-turbo-16k-0613": 1.5, + "gpt-3.5-turbo-instruct": 0.75, // $0.0015 / 1K tokens + "gpt-3.5-turbo-1106": 0.5, // $0.001 / 1K tokens + "gpt-3.5-turbo-0125": 0.25, + "babbage-002": 0.2, // $0.0004 / 1K tokens + "davinci-002": 1, // $0.002 / 1K tokens + "text-ada-001": 0.2, + "text-babbage-001": 0.25, + "text-curie-001": 1, + //"text-davinci-002": 10, + //"text-davinci-003": 10, + "text-davinci-edit-001": 10, + "code-davinci-edit-001": 10, + "whisper-1": 15, // $0.006 / minute -> $0.006 / 150 words -> $0.006 / 200 tokens -> $0.03 / 1k tokens + "tts-1": 7.5, // 1k characters -> $0.015 + "tts-1-1106": 7.5, // 1k characters -> $0.015 + "tts-1-hd": 15, // 1k characters -> $0.03 + "tts-1-hd-1106": 15, // 1k characters -> $0.03 + "davinci": 10, + "curie": 10, + "babbage": 10, + "ada": 10, + "text-embedding-3-small": 0.01, + "text-embedding-3-large": 0.065, + "text-embedding-ada-002": 0.05, + "text-search-ada-doc-001": 10, + "text-moderation-stable": 0.1, + "text-moderation-latest": 0.1, + "claude-instant-1": 0.4, // $0.8 / 1M tokens + "claude-2.0": 4, // $8 / 1M tokens + "claude-2.1": 4, // $8 / 1M tokens + "claude-3-haiku-20240307": 0.125, // $0.25 / 1M tokens + "claude-3-5-haiku-20241022": 0.5, // $1 / 1M tokens + "claude-3-sonnet-20240229": 1.5, // $3 / 1M tokens + "claude-3-5-sonnet-20240620": 1.5, + "claude-3-5-sonnet-20241022": 1.5, + "claude-3-7-sonnet-20250219": 1.5, + "claude-3-7-sonnet-20250219-thinking": 1.5, + "claude-sonnet-4-20250514": 1.5, + "claude-3-opus-20240229": 7.5, // $15 / 1M tokens + "claude-opus-4-20250514": 7.5, + "ERNIE-4.0-8K": 0.120 * RMB, + "ERNIE-3.5-8K": 0.012 * RMB, + "ERNIE-3.5-8K-0205": 0.024 * RMB, + "ERNIE-3.5-8K-1222": 0.012 * RMB, + "ERNIE-Bot-8K": 0.024 * RMB, + "ERNIE-3.5-4K-0205": 0.012 * RMB, + "ERNIE-Speed-8K": 0.004 * RMB, + "ERNIE-Speed-128K": 0.004 * RMB, + "ERNIE-Lite-8K-0922": 0.008 * RMB, + "ERNIE-Lite-8K-0308": 0.003 * RMB, + "ERNIE-Tiny-8K": 0.001 * RMB, + "BLOOMZ-7B": 0.004 * RMB, + "Embedding-V1": 0.002 * RMB, + "bge-large-zh": 0.002 * RMB, + "bge-large-en": 0.002 * RMB, + "tao-8k": 0.002 * RMB, + "PaLM-2": 1, + "gemini-1.5-pro-latest": 1.25, // $3.5 / 1M tokens + "gemini-1.5-flash-latest": 0.075, + "gemini-2.0-flash": 0.05, + "gemini-2.5-pro-exp-03-25": 0.625, + "gemini-2.5-pro-preview-03-25": 0.625, + "gemini-2.5-pro": 0.625, + "gemini-2.5-flash-preview-04-17": 0.075, + "gemini-2.5-flash-preview-04-17-thinking": 0.075, + "gemini-2.5-flash-preview-04-17-nothinking": 0.075, + "gemini-2.5-flash-preview-05-20": 0.075, + "gemini-2.5-flash-preview-05-20-thinking": 0.075, + "gemini-2.5-flash-preview-05-20-nothinking": 0.075, + "gemini-2.5-flash-thinking-*": 0.075, // 用于为后续所有2.5 flash thinking budget 模型设置默认倍率 + "gemini-2.5-pro-thinking-*": 0.625, // 用于为后续所有2.5 pro thinking budget 模型设置默认倍率 + "gemini-2.5-flash-lite-preview-06-17": 0.05, + "gemini-2.5-flash": 0.15, + "text-embedding-004": 0.001, + "chatglm_turbo": 0.3572, // ¥0.005 / 1k tokens + "chatglm_pro": 0.7143, // ¥0.01 / 1k tokens + "chatglm_std": 0.3572, // ¥0.005 / 1k tokens + "chatglm_lite": 0.1429, // ¥0.002 / 1k tokens + "glm-4": 7.143, // ¥0.1 / 1k tokens + "glm-4v": 0.05 * RMB, // ¥0.05 / 1k tokens + "glm-4-alltools": 0.1 * RMB, // ¥0.1 / 1k tokens + "glm-3-turbo": 0.3572, + "glm-4-plus": 0.05 * RMB, + "glm-4-0520": 0.1 * RMB, + "glm-4-air": 0.001 * RMB, + "glm-4-airx": 0.01 * RMB, + "glm-4-long": 0.001 * RMB, + "glm-4-flash": 0, + "glm-4v-plus": 0.01 * RMB, + "qwen-turbo": 0.8572, // ¥0.012 / 1k tokens + "qwen-plus": 10, // ¥0.14 / 1k tokens + "text-embedding-v1": 0.05, // ¥0.0007 / 1k tokens + "SparkDesk-v1.1": 1.2858, // ¥0.018 / 1k tokens + "SparkDesk-v2.1": 1.2858, // ¥0.018 / 1k tokens + "SparkDesk-v3.1": 1.2858, // ¥0.018 / 1k tokens + "SparkDesk-v3.5": 1.2858, // ¥0.018 / 1k tokens + "SparkDesk-v4.0": 1.2858, + "360GPT_S2_V9": 0.8572, // ¥0.012 / 1k tokens + "360gpt-turbo": 0.0858, // ¥0.0012 / 1k tokens + "360gpt-turbo-responsibility-8k": 0.8572, // ¥0.012 / 1k tokens + "360gpt-pro": 0.8572, // ¥0.012 / 1k tokens + "360gpt2-pro": 0.8572, // ¥0.012 / 1k tokens + "embedding-bert-512-v1": 0.0715, // ¥0.001 / 1k tokens + "embedding_s1_v1": 0.0715, // ¥0.001 / 1k tokens + "semantic_similarity_s1_v1": 0.0715, // ¥0.001 / 1k tokens + "hunyuan": 7.143, // ¥0.1 / 1k tokens // https://cloud.tencent.com/document/product/1729/97731#e0e6be58-60c8-469f-bdeb-6c264ce3b4d0 + // https://platform.lingyiwanwu.com/docs#-计费单元 + // 已经按照 7.2 来换算美元价格 + "yi-34b-chat-0205": 0.18, + "yi-34b-chat-200k": 0.864, + "yi-vl-plus": 0.432, + "yi-large": 20.0 / 1000 * RMB, + "yi-medium": 2.5 / 1000 * RMB, + "yi-vision": 6.0 / 1000 * RMB, + "yi-medium-200k": 12.0 / 1000 * RMB, + "yi-spark": 1.0 / 1000 * RMB, + "yi-large-rag": 25.0 / 1000 * RMB, + "yi-large-turbo": 12.0 / 1000 * RMB, + "yi-large-preview": 20.0 / 1000 * RMB, + "yi-large-rag-preview": 25.0 / 1000 * RMB, + "command": 0.5, + "command-nightly": 0.5, + "command-light": 0.5, + "command-light-nightly": 0.5, + "command-r": 0.25, + "command-r-plus": 1.5, + "command-r-08-2024": 0.075, + "command-r-plus-08-2024": 1.25, + "deepseek-chat": 0.27 / 2, + "deepseek-coder": 0.27 / 2, + "deepseek-reasoner": 0.55 / 2, // 0.55 / 1k tokens + // Perplexity online 模型对搜索额外收费,有需要应自行调整,此处不计入搜索费用 + "llama-3-sonar-small-32k-chat": 0.2 / 1000 * USD, + "llama-3-sonar-small-32k-online": 0.2 / 1000 * USD, + "llama-3-sonar-large-32k-chat": 1 / 1000 * USD, + "llama-3-sonar-large-32k-online": 1 / 1000 * USD, + // grok + "grok-3-beta": 1.5, + "grok-3-mini-beta": 0.15, + "grok-2": 1, + "grok-2-vision": 1, + "grok-beta": 2.5, + "grok-vision-beta": 2.5, + "grok-3-fast-beta": 2.5, + "grok-3-mini-fast-beta": 0.3, +} + +var defaultModelPrice = map[string]float64{ + "suno_music": 0.1, + "suno_lyrics": 0.01, + "dall-e-3": 0.04, + "imagen-3.0-generate-002": 0.03, + "gpt-4-gizmo-*": 0.1, + "mj_video": 0.8, + "mj_imagine": 0.1, + "mj_edits": 0.1, + "mj_variation": 0.1, + "mj_reroll": 0.1, + "mj_blend": 0.1, + "mj_modal": 0.1, + "mj_zoom": 0.1, + "mj_shorten": 0.1, + "mj_high_variation": 0.1, + "mj_low_variation": 0.1, + "mj_pan": 0.1, + "mj_inpaint": 0, + "mj_custom_zoom": 0, + "mj_describe": 0.05, + "mj_upscale": 0.05, + "swap_face": 0.05, + "mj_upload": 0.05, +} + +var ( + modelPriceMap map[string]float64 = nil + modelPriceMapMutex = sync.RWMutex{} +) +var ( + modelRatioMap map[string]float64 = nil + modelRatioMapMutex = sync.RWMutex{} +) + +var ( + CompletionRatio map[string]float64 = nil + CompletionRatioMutex = sync.RWMutex{} +) + +var defaultCompletionRatio = map[string]float64{ + "gpt-4-gizmo-*": 2, + "gpt-4o-gizmo-*": 3, + "gpt-4-all": 2, + "gpt-image-1": 8, +} + +// InitRatioSettings initializes all model related settings maps +func InitRatioSettings() { + // Initialize modelPriceMap + modelPriceMapMutex.Lock() + modelPriceMap = defaultModelPrice + modelPriceMapMutex.Unlock() + + // Initialize modelRatioMap + modelRatioMapMutex.Lock() + modelRatioMap = defaultModelRatio + modelRatioMapMutex.Unlock() + + // Initialize CompletionRatio + CompletionRatioMutex.Lock() + CompletionRatio = defaultCompletionRatio + CompletionRatioMutex.Unlock() + + // Initialize cacheRatioMap + cacheRatioMapMutex.Lock() + cacheRatioMap = defaultCacheRatio + cacheRatioMapMutex.Unlock() + + // initialize imageRatioMap + imageRatioMapMutex.Lock() + imageRatioMap = defaultImageRatio + imageRatioMapMutex.Unlock() + +} + +func GetModelPriceMap() map[string]float64 { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + return modelPriceMap +} + +func ModelPrice2JSONString() string { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + + jsonBytes, err := json.Marshal(modelPriceMap) + if err != nil { + common.SysError("error marshalling model price: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateModelPriceByJSONString(jsonStr string) error { + modelPriceMapMutex.Lock() + defer modelPriceMapMutex.Unlock() + modelPriceMap = make(map[string]float64) + err := json.Unmarshal([]byte(jsonStr), &modelPriceMap) + if err == nil { + InvalidateExposedDataCache() + } + return err +} + +// GetModelPrice 返回模型的价格,如果模型不存在则返回-1,false +func GetModelPrice(name string, printErr bool) (float64, bool) { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + + if strings.HasPrefix(name, "gpt-4-gizmo") { + name = "gpt-4-gizmo-*" + } + if strings.HasPrefix(name, "gpt-4o-gizmo") { + name = "gpt-4o-gizmo-*" + } + price, ok := modelPriceMap[name] + if !ok { + if printErr { + common.SysError("model price not found: " + name) + } + return -1, false + } + return price, true +} + +func UpdateModelRatioByJSONString(jsonStr string) error { + modelRatioMapMutex.Lock() + defer modelRatioMapMutex.Unlock() + modelRatioMap = make(map[string]float64) + err := json.Unmarshal([]byte(jsonStr), &modelRatioMap) + if err == nil { + InvalidateExposedDataCache() + } + return err +} + +// 处理带有思考预算的模型名称,方便统一定价 +func handleThinkingBudgetModel(name, prefix, wildcard string) string { + if strings.HasPrefix(name, prefix) && strings.Contains(name, "-thinking-") { + return wildcard + } + return name +} + +func GetModelRatio(name string) (float64, bool, string) { + modelRatioMapMutex.RLock() + defer modelRatioMapMutex.RUnlock() + + name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*") + name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") + if strings.HasPrefix(name, "gpt-4-gizmo") { + name = "gpt-4-gizmo-*" + } + ratio, ok := modelRatioMap[name] + if !ok { + return 37.5, operation_setting.SelfUseModeEnabled, name + } + return ratio, true, name +} + +func DefaultModelRatio2JSONString() string { + jsonBytes, err := json.Marshal(defaultModelRatio) + if err != nil { + common.SysError("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func GetDefaultModelRatioMap() map[string]float64 { + return defaultModelRatio +} + +func GetCompletionRatioMap() map[string]float64 { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + return CompletionRatio +} + +func CompletionRatio2JSONString() string { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + + jsonBytes, err := json.Marshal(CompletionRatio) + if err != nil { + common.SysError("error marshalling completion ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateCompletionRatioByJSONString(jsonStr string) error { + CompletionRatioMutex.Lock() + defer CompletionRatioMutex.Unlock() + CompletionRatio = make(map[string]float64) + err := json.Unmarshal([]byte(jsonStr), &CompletionRatio) + if err == nil { + InvalidateExposedDataCache() + } + return err +} + +func GetCompletionRatio(name string) float64 { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + if strings.HasPrefix(name, "gpt-4-gizmo") { + name = "gpt-4-gizmo-*" + } + if strings.HasPrefix(name, "gpt-4o-gizmo") { + name = "gpt-4o-gizmo-*" + } + if strings.Contains(name, "/") { + if ratio, ok := CompletionRatio[name]; ok { + return ratio + } + } + hardCodedRatio, contain := getHardcodedCompletionModelRatio(name) + if contain { + return hardCodedRatio + } + if ratio, ok := CompletionRatio[name]; ok { + return ratio + } + return hardCodedRatio +} + +func getHardcodedCompletionModelRatio(name string) (float64, bool) { + lowercaseName := strings.ToLower(name) + if strings.HasPrefix(name, "gpt-4") && !strings.HasSuffix(name, "-all") && !strings.HasSuffix(name, "-gizmo-*") { + if strings.HasPrefix(name, "gpt-4o") { + if name == "gpt-4o-2024-05-13" { + return 3, true + } + return 4, true + } + // gpt-4.5-preview匹配 + if strings.HasPrefix(name, "gpt-4.5-preview") { + return 2, true + } + if strings.HasPrefix(name, "gpt-4-turbo") || strings.HasSuffix(name, "gpt-4-1106") || strings.HasSuffix(name, "gpt-4-1105") { + return 3, true + } + // 没有特殊标记的 gpt-4 模型默认倍率为 2 + return 2, false + } + if strings.HasPrefix(name, "o1") || strings.HasPrefix(name, "o3") { + return 4, true + } + if name == "chatgpt-4o-latest" { + return 3, true + } + + if strings.Contains(name, "claude-3") { + return 5, true + } else if strings.Contains(name, "claude-sonnet-4") || strings.Contains(name, "claude-opus-4") { + return 5, true + } else if strings.Contains(name, "claude-instant-1") || strings.Contains(name, "claude-2") { + return 3, true + } + + if strings.HasPrefix(name, "gpt-3.5") { + if name == "gpt-3.5-turbo" || strings.HasSuffix(name, "0125") { + // https://openai.com/blog/new-embedding-models-and-api-updates + // Updated GPT-3.5 Turbo model and lower pricing + return 3, true + } + if strings.HasSuffix(name, "1106") { + return 2, true + } + return 4.0 / 3.0, true + } + if strings.HasPrefix(name, "mistral-") { + return 3, true + } + if strings.HasPrefix(name, "gemini-") { + if strings.HasPrefix(name, "gemini-1.5") { + return 4, true + } else if strings.HasPrefix(name, "gemini-2.0") { + return 4, true + } else if strings.HasPrefix(name, "gemini-2.5-pro") { // 移除preview来增加兼容性,这里假设正式版的倍率和preview一致 + return 8, false + } else if strings.HasPrefix(name, "gemini-2.5-flash") { // 处理不同的flash模型倍率 + if strings.HasPrefix(name, "gemini-2.5-flash-preview") { + if strings.HasSuffix(name, "-nothinking") { + return 4, false + } + return 3.5 / 0.15, false + } + if strings.HasPrefix(name, "gemini-2.5-flash-lite") { + if strings.HasPrefix(name, "gemini-2.5-flash-lite-preview") { + return 4, false + } + return 4, false + } + return 2.5 / 0.3, true + } + return 4, false + } + if strings.HasPrefix(name, "command") { + switch name { + case "command-r": + return 3, true + case "command-r-plus": + return 5, true + case "command-r-08-2024": + return 4, true + case "command-r-plus-08-2024": + return 4, true + default: + return 4, false + } + } + // hint 只给官方上4倍率,由于开源模型供应商自行定价,不对其进行补全倍率进行强制对齐 + if lowercaseName == "deepseek-chat" || lowercaseName == "deepseek-reasoner" { + return 4, true + } + if strings.HasPrefix(name, "ERNIE-Speed-") { + return 2, true + } else if strings.HasPrefix(name, "ERNIE-Lite-") { + return 2, true + } else if strings.HasPrefix(name, "ERNIE-Character") { + return 2, true + } else if strings.HasPrefix(name, "ERNIE-Functions") { + return 2, true + } + switch name { + case "llama2-70b-4096": + return 0.8 / 0.64, true + case "llama3-8b-8192": + return 2, true + case "llama3-70b-8192": + return 0.79 / 0.59, true + } + return 1, false +} + +func GetAudioRatio(name string) float64 { + if strings.Contains(name, "-realtime") { + if strings.HasSuffix(name, "gpt-4o-realtime-preview") { + return 8 + } else if strings.Contains(name, "gpt-4o-mini-realtime-preview") { + return 10 / 0.6 + } else { + return 20 + } + } + if strings.Contains(name, "-audio") { + if strings.HasPrefix(name, "gpt-4o-audio-preview") { + return 40 / 2.5 + } else if strings.HasPrefix(name, "gpt-4o-mini-audio-preview") { + return 10 / 0.15 + } else { + return 40 + } + } + return 20 +} + +func GetAudioCompletionRatio(name string) float64 { + if strings.HasPrefix(name, "gpt-4o-realtime") { + return 2 + } else if strings.HasPrefix(name, "gpt-4o-mini-realtime") { + return 2 + } + return 2 +} + +func ModelRatio2JSONString() string { + modelRatioMapMutex.RLock() + defer modelRatioMapMutex.RUnlock() + + jsonBytes, err := json.Marshal(modelRatioMap) + if err != nil { + common.SysError("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +var defaultImageRatio = map[string]float64{ + "gpt-image-1": 2, +} +var imageRatioMap map[string]float64 +var imageRatioMapMutex sync.RWMutex + +func ImageRatio2JSONString() string { + imageRatioMapMutex.RLock() + defer imageRatioMapMutex.RUnlock() + jsonBytes, err := json.Marshal(imageRatioMap) + if err != nil { + common.SysError("error marshalling cache ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateImageRatioByJSONString(jsonStr string) error { + imageRatioMapMutex.Lock() + defer imageRatioMapMutex.Unlock() + imageRatioMap = make(map[string]float64) + return json.Unmarshal([]byte(jsonStr), &imageRatioMap) +} + +func GetImageRatio(name string) (float64, bool) { + imageRatioMapMutex.RLock() + defer imageRatioMapMutex.RUnlock() + ratio, ok := imageRatioMap[name] + if !ok { + return 1, false // Default to 1 if not found + } + return ratio, true +} + +func GetModelRatioCopy() map[string]float64 { + modelRatioMapMutex.RLock() + defer modelRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(modelRatioMap)) + for k, v := range modelRatioMap { + copyMap[k] = v + } + return copyMap +} + +func GetModelPriceCopy() map[string]float64 { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + copyMap := make(map[string]float64, len(modelPriceMap)) + for k, v := range modelPriceMap { + copyMap[k] = v + } + return copyMap +} + +func GetCompletionRatioCopy() map[string]float64 { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + copyMap := make(map[string]float64, len(CompletionRatio)) + for k, v := range CompletionRatio { + copyMap[k] = v + } + return copyMap +} diff --git a/setting/sensitive.go b/setting/sensitive.go new file mode 100644 index 00000000..86f9be9a --- /dev/null +++ b/setting/sensitive.go @@ -0,0 +1,43 @@ +package setting + +import "strings" + +var CheckSensitiveEnabled = true +var CheckSensitiveOnPromptEnabled = true + +//var CheckSensitiveOnCompletionEnabled = true + +// StopOnSensitiveEnabled 如果检测到敏感词,是否立刻停止生成,否则替换敏感词 +var StopOnSensitiveEnabled = true + +// StreamCacheQueueLength 流模式缓存队列长度,0表示无缓存 +var StreamCacheQueueLength = 0 + +// SensitiveWords 敏感词 +// var SensitiveWords []string +var SensitiveWords = []string{ + "test_sensitive", +} + +func SensitiveWordsToString() string { + return strings.Join(SensitiveWords, "\n") +} + +func SensitiveWordsFromString(s string) { + SensitiveWords = []string{} + sw := strings.Split(s, "\n") + for _, w := range sw { + w = strings.TrimSpace(w) + if w != "" { + SensitiveWords = append(SensitiveWords, w) + } + } +} + +func ShouldCheckPromptSensitive() bool { + return CheckSensitiveEnabled && CheckSensitiveOnPromptEnabled +} + +//func ShouldCheckCompletionSensitive() bool { +// return CheckSensitiveEnabled && CheckSensitiveOnCompletionEnabled +//} diff --git a/setting/system_setting.go b/setting/system_setting.go new file mode 100644 index 00000000..c37a6123 --- /dev/null +++ b/setting/system_setting.go @@ -0,0 +1,10 @@ +package setting + +var ServerAddress = "http://localhost:3000" +var WorkerUrl = "" +var WorkerValidKey = "" +var WorkerAllowHttpImageRequestEnabled = false + +func EnableWorker() bool { + return WorkerUrl != "" +} diff --git a/setting/system_setting/oidc.go b/setting/system_setting/oidc.go new file mode 100644 index 00000000..aed52ae0 --- /dev/null +++ b/setting/system_setting/oidc.go @@ -0,0 +1,25 @@ +package system_setting + +import "one-api/setting/config" + +type OIDCSettings struct { + Enabled bool `json:"enabled"` + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` + WellKnown string `json:"well_known"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + UserInfoEndpoint string `json:"user_info_endpoint"` +} + +// 默认配置 +var defaultOIDCSettings = OIDCSettings{} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("oidc", &defaultOIDCSettings) +} + +func GetOIDCSettings() *OIDCSettings { + return &defaultOIDCSettings +} diff --git a/setting/user_usable_group.go b/setting/user_usable_group.go new file mode 100644 index 00000000..0ae132d0 --- /dev/null +++ b/setting/user_usable_group.go @@ -0,0 +1,76 @@ +package setting + +import ( + "encoding/json" + "one-api/common" + "sync" +) + +var userUsableGroups = map[string]string{ + "default": "默认分组", + "vip": "vip分组", +} +var userUsableGroupsMutex sync.RWMutex + +func GetUserUsableGroupsCopy() map[string]string { + userUsableGroupsMutex.RLock() + defer userUsableGroupsMutex.RUnlock() + + copyUserUsableGroups := make(map[string]string) + for k, v := range userUsableGroups { + copyUserUsableGroups[k] = v + } + return copyUserUsableGroups +} + +func UserUsableGroups2JSONString() string { + userUsableGroupsMutex.RLock() + defer userUsableGroupsMutex.RUnlock() + + jsonBytes, err := json.Marshal(userUsableGroups) + if err != nil { + common.SysError("error marshalling user groups: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateUserUsableGroupsByJSONString(jsonStr string) error { + userUsableGroupsMutex.Lock() + defer userUsableGroupsMutex.Unlock() + + userUsableGroups = make(map[string]string) + return json.Unmarshal([]byte(jsonStr), &userUsableGroups) +} + +func GetUserUsableGroups(userGroup string) map[string]string { + groupsCopy := GetUserUsableGroupsCopy() + if userGroup == "" { + if _, ok := groupsCopy["default"]; !ok { + groupsCopy["default"] = "default" + } + } + // 如果userGroup不在UserUsableGroups中,返回UserUsableGroups + userGroup + if _, ok := groupsCopy[userGroup]; !ok { + groupsCopy[userGroup] = "用户分组" + } + // 如果userGroup在UserUsableGroups中,返回UserUsableGroups + return groupsCopy +} + +func GroupInUserUsableGroups(groupName string) bool { + userUsableGroupsMutex.RLock() + defer userUsableGroupsMutex.RUnlock() + + _, ok := userUsableGroups[groupName] + return ok +} + +func GetUsableGroupDescription(groupName string) string { + userUsableGroupsMutex.RLock() + defer userUsableGroupsMutex.RUnlock() + + if desc, ok := userUsableGroups[groupName]; ok { + return desc + } + return groupName +} diff --git a/types/channel_error.go b/types/channel_error.go new file mode 100644 index 00000000..f2d72bf5 --- /dev/null +++ b/types/channel_error.go @@ -0,0 +1,21 @@ +package types + +type ChannelError struct { + ChannelId int `json:"channel_id"` + ChannelType int `json:"channel_type"` + ChannelName string `json:"channel_name"` + IsMultiKey bool `json:"is_multi_key"` + AutoBan bool `json:"auto_ban"` + UsingKey string `json:"using_key"` +} + +func NewChannelError(channelId int, channelType int, channelName string, isMultiKey bool, usingKey string, autoBan bool) *ChannelError { + return &ChannelError{ + ChannelId: channelId, + ChannelType: channelType, + ChannelName: channelName, + IsMultiKey: isMultiKey, + AutoBan: autoBan, + UsingKey: usingKey, + } +} diff --git a/types/error.go b/types/error.go new file mode 100644 index 00000000..5c8b37d2 --- /dev/null +++ b/types/error.go @@ -0,0 +1,210 @@ +package types + +import ( + "errors" + "fmt" + "net/http" + "strings" +) + +type OpenAIError struct { + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` + Code any `json:"code"` +} + +type ClaudeError struct { + Message string `json:"message,omitempty"` + Type string `json:"type,omitempty"` +} + +type ErrorType string + +const ( + ErrorTypeNewAPIError ErrorType = "new_api_error" + ErrorTypeOpenAIError ErrorType = "openai_error" + ErrorTypeClaudeError ErrorType = "claude_error" + ErrorTypeMidjourneyError ErrorType = "midjourney_error" + ErrorTypeGeminiError ErrorType = "gemini_error" + ErrorTypeRerankError ErrorType = "rerank_error" +) + +type ErrorCode string + +const ( + ErrorCodeInvalidRequest ErrorCode = "invalid_request" + ErrorCodeSensitiveWordsDetected ErrorCode = "sensitive_words_detected" + + // new api error + ErrorCodeCountTokenFailed ErrorCode = "count_token_failed" + ErrorCodeModelPriceError ErrorCode = "model_price_error" + ErrorCodeInvalidApiType ErrorCode = "invalid_api_type" + ErrorCodeJsonMarshalFailed ErrorCode = "json_marshal_failed" + ErrorCodeDoRequestFailed ErrorCode = "do_request_failed" + ErrorCodeGetChannelFailed ErrorCode = "get_channel_failed" + + // channel error + ErrorCodeChannelNoAvailableKey ErrorCode = "channel:no_available_key" + ErrorCodeChannelParamOverrideInvalid ErrorCode = "channel:param_override_invalid" + ErrorCodeChannelModelMappedError ErrorCode = "channel:model_mapped_error" + ErrorCodeChannelAwsClientError ErrorCode = "channel:aws_client_error" + ErrorCodeChannelInvalidKey ErrorCode = "channel:invalid_key" + ErrorCodeChannelResponseTimeExceeded ErrorCode = "channel:response_time_exceeded" + + // client request error + ErrorCodeReadRequestBodyFailed ErrorCode = "read_request_body_failed" + ErrorCodeConvertRequestFailed ErrorCode = "convert_request_failed" + ErrorCodeAccessDenied ErrorCode = "access_denied" + + // response error + ErrorCodeReadResponseBodyFailed ErrorCode = "read_response_body_failed" + ErrorCodeBadResponseStatusCode ErrorCode = "bad_response_status_code" + ErrorCodeBadResponse ErrorCode = "bad_response" + ErrorCodeBadResponseBody ErrorCode = "bad_response_body" + + // sql error + ErrorCodeQueryDataError ErrorCode = "query_data_error" + ErrorCodeUpdateDataError ErrorCode = "update_data_error" + + // quota error + ErrorCodeInsufficientUserQuota ErrorCode = "insufficient_user_quota" + ErrorCodePreConsumeTokenQuotaFailed ErrorCode = "pre_consume_token_quota_failed" +) + +type NewAPIError struct { + Err error + RelayError any + ErrorType ErrorType + errorCode ErrorCode + StatusCode int +} + +func (e *NewAPIError) GetErrorCode() ErrorCode { + if e == nil { + return "" + } + return e.errorCode +} + +func (e *NewAPIError) Error() string { + if e == nil { + return "" + } + if e.Err == nil { + // fallback message when underlying error is missing + return string(e.errorCode) + } + return e.Err.Error() +} + +func (e *NewAPIError) SetMessage(message string) { + e.Err = errors.New(message) +} + +func (e *NewAPIError) ToOpenAIError() OpenAIError { + switch e.ErrorType { + case ErrorTypeOpenAIError: + return e.RelayError.(OpenAIError) + case ErrorTypeClaudeError: + claudeError := e.RelayError.(ClaudeError) + return OpenAIError{ + Message: e.Error(), + Type: claudeError.Type, + Param: "", + Code: e.errorCode, + } + default: + return OpenAIError{ + Message: e.Error(), + Type: string(e.ErrorType), + Param: "", + Code: e.errorCode, + } + } +} + +func (e *NewAPIError) ToClaudeError() ClaudeError { + switch e.ErrorType { + case ErrorTypeOpenAIError: + openAIError := e.RelayError.(OpenAIError) + return ClaudeError{ + Message: e.Error(), + Type: fmt.Sprintf("%v", openAIError.Code), + } + case ErrorTypeClaudeError: + return e.RelayError.(ClaudeError) + default: + return ClaudeError{ + Message: e.Error(), + Type: string(e.ErrorType), + } + } +} + +func NewError(err error, errorCode ErrorCode) *NewAPIError { + return &NewAPIError{ + Err: err, + RelayError: nil, + ErrorType: ErrorTypeNewAPIError, + StatusCode: http.StatusInternalServerError, + errorCode: errorCode, + } +} + +func NewOpenAIError(err error, errorCode ErrorCode, statusCode int) *NewAPIError { + openaiError := OpenAIError{ + Message: err.Error(), + Type: string(errorCode), + } + return WithOpenAIError(openaiError, statusCode) +} + +func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int) *NewAPIError { + return &NewAPIError{ + Err: err, + RelayError: nil, + ErrorType: ErrorTypeNewAPIError, + StatusCode: statusCode, + errorCode: errorCode, + } +} + +func WithOpenAIError(openAIError OpenAIError, statusCode int) *NewAPIError { + code, ok := openAIError.Code.(string) + if !ok { + code = fmt.Sprintf("%v", openAIError.Code) + } + return &NewAPIError{ + RelayError: openAIError, + ErrorType: ErrorTypeOpenAIError, + StatusCode: statusCode, + Err: errors.New(openAIError.Message), + errorCode: ErrorCode(code), + } +} + +func WithClaudeError(claudeError ClaudeError, statusCode int) *NewAPIError { + return &NewAPIError{ + RelayError: claudeError, + ErrorType: ErrorTypeClaudeError, + StatusCode: statusCode, + Err: errors.New(claudeError.Message), + errorCode: ErrorCode(claudeError.Type), + } +} + +func IsChannelError(err *NewAPIError) bool { + if err == nil { + return false + } + return strings.HasPrefix(string(err.errorCode), "channel:") +} + +func IsLocalError(err *NewAPIError) bool { + if err == nil { + return false + } + + return err.ErrorType == ErrorTypeNewAPIError +} diff --git a/types/set.go b/types/set.go new file mode 100644 index 00000000..db6b0272 --- /dev/null +++ b/types/set.go @@ -0,0 +1,42 @@ +package types + +type Set[T comparable] struct { + items map[T]struct{} +} + +// NewSet 创建并返回一个新的 Set +func NewSet[T comparable]() *Set[T] { + return &Set[T]{ + items: make(map[T]struct{}), + } +} + +func (s *Set[T]) Add(item T) { + s.items[item] = struct{}{} +} + +// Remove 从 Set 中移除一个元素 +func (s *Set[T]) Remove(item T) { + delete(s.items, item) +} + +// Contains 检查 Set 是否包含某个元素 +func (s *Set[T]) Contains(item T) bool { + _, exists := s.items[item] + return exists +} + +// Len 返回 Set 中元素的数量 +func (s *Set[T]) Len() int { + return len(s.items) +} + +// Items 返回 Set 中所有元素组成的切片 +// 注意:由于 map 的无序性,返回的切片元素顺序是随机的 +func (s *Set[T]) Items() []T { + items := make([]T, 0, s.Len()) + for item := range s.items { + items = append(items, item) + } + return items +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000..2b5bba76 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,26 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.idea +package-lock.json +yarn.lock \ No newline at end of file diff --git a/web/.prettierrc.mjs b/web/.prettierrc.mjs new file mode 100644 index 00000000..5140bc3e --- /dev/null +++ b/web/.prettierrc.mjs @@ -0,0 +1 @@ +module.exports = require('@so1ve/prettier-config'); diff --git a/web/bun.lock b/web/bun.lock new file mode 100644 index 00000000..b78c149b --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,2010 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "react-template", + "dependencies": { + "@douyinfe/semi-icons": "^2.63.1", + "@douyinfe/semi-ui": "^2.69.1", + "@lobehub/icons": "^2.0.0", + "@visactor/react-vchart": "~1.8.8", + "@visactor/vchart": "~1.8.8", + "@visactor/vchart-semi-theme": "~1.8.8", + "axios": "^0.27.2", + "clsx": "^2.1.1", + "country-flag-icons": "^1.5.19", + "dayjs": "^1.11.11", + "history": "^5.3.0", + "i18next": "^23.16.8", + "i18next-browser-languagedetector": "^7.2.0", + "katex": "^0.16.22", + "lucide-react": "^0.511.0", + "marked": "^4.1.1", + "mermaid": "^11.6.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", + "react-fireworks": "^1.0.4", + "react-i18next": "^13.0.0", + "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.3.0", + "react-telegram-login": "^1.1.2", + "react-toastify": "^9.0.8", + "react-turnstile": "^1.0.5", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "sse.js": "^2.6.0", + "unist-util-visit": "^5.0.0", + "use-debounce": "^10.0.4", + }, + "devDependencies": { + "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", + "@so1ve/prettier-config": "^3.1.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "prettier": "^3.0.0", + "tailwindcss": "^3", + "typescript": "4.4.2", + "vite": "^5.2.0", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@ant-design/colors": ["@ant-design/colors@7.2.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6" } }, "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ=="], + + "@ant-design/cssinjs": ["@ant-design/cssinjs@1.23.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-7GAg9bD/iC9ikWatU9ym+P9ugJhi/WbsTWzcKN6T4gU0aehsprtke1UAaaSxxkjjmkJb3llet/rbUSLPgwlY4w=="], + + "@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@1.1.3", "", { "dependencies": { "@ant-design/cssinjs": "^1.21.0", "@babel/runtime": "^7.23.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg=="], + + "@ant-design/fast-color": ["@ant-design/fast-color@2.0.6", "", { "dependencies": { "@babel/runtime": "^7.24.7" } }, "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA=="], + + "@ant-design/icons": ["@ant-design/icons@5.6.1", "", { "dependencies": { "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.4.0", "@babel/runtime": "^7.24.8", "classnames": "^2.2.6", "rc-util": "^5.31.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg=="], + + "@ant-design/icons-svg": ["@ant-design/icons-svg@4.4.2", "", {}, "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="], + + "@ant-design/react-slick": ["@ant-design/react-slick@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.10.4", "classnames": "^2.2.5", "json2mq": "^0.2.0", "resize-observer-polyfill": "^1.5.1", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": ">=16.9.0" } }, "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA=="], + + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + + "@antfu/utils": ["@antfu/utils@8.1.1", "", {}, "sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ=="], + + "@astrojs/compiler": ["@astrojs/compiler@2.10.3", "", {}, "sha512-bL/O7YBxsFt55YHU021oL+xz+B/9HvGNId3F9xURN16aeqDK9juHGktdkCSXz+U4nqFACq6ZFvWomOzhV+zfPw=="], + + "@babel/code-frame": ["@babel/code-frame@7.26.2", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="], + + "@babel/compat-data": ["@babel/compat-data@7.26.3", "", {}, "sha512-nHIxvKPniQXpmQLb0vhY3VaFb3S0YrTAwpOWJZh1wn3oJPjJk9Asva204PsBdmAE8vpzfHudT8DB0scYvy9q0g=="], + + "@babel/core": ["@babel/core@7.26.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.0", "@babel/generator": "^7.26.0", "@babel/helper-compilation-targets": "^7.25.9", "@babel/helper-module-transforms": "^7.26.0", "@babel/helpers": "^7.26.0", "@babel/parser": "^7.26.0", "@babel/template": "^7.25.9", "@babel/traverse": "^7.25.9", "@babel/types": "^7.26.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg=="], + + "@babel/generator": ["@babel/generator@7.26.3", "", { "dependencies": { "@babel/parser": "^7.26.3", "@babel/types": "^7.26.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.25.9", "", { "dependencies": { "@babel/compat-data": "^7.25.9", "@babel/helper-validator-option": "^7.25.9", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ=="], + + "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.4", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", "debug": "^4.1.1", "lodash.debounce": "^4.0.8", "resolve": "^1.14.2" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.25.9", "", { "dependencies": { "@babel/traverse": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.26.0", "", { "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9", "@babel/traverse": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.25.9", "", {}, "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.25.9", "", {}, "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.25.9", "", {}, "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.25.9", "", {}, "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw=="], + + "@babel/helpers": ["@babel/helpers@7.26.0", "", { "dependencies": { "@babel/template": "^7.25.9", "@babel/types": "^7.26.0" } }, "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw=="], + + "@babel/parser": ["@babel/parser@7.26.3", "", { "dependencies": { "@babel/types": "^7.26.3" }, "bin": "./bin/babel-parser.js" }, "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.25.9", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg=="], + + "@babel/plugin-transform-runtime": ["@babel/plugin-transform-runtime@7.27.1", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "babel-plugin-polyfill-corejs2": "^0.4.10", "babel-plugin-polyfill-corejs3": "^0.11.0", "babel-plugin-polyfill-regenerator": "^0.6.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TqGF3desVsTcp3WrJGj4HfKokfCXCLcHpt4PJF0D8/iT6LPd9RS82Upw3KPeyr6B22Lfd3DO8MVrmp0oRkUDdw=="], + + "@babel/runtime": ["@babel/runtime@7.26.0", "", { "dependencies": { "regenerator-runtime": "^0.14.0" } }, "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw=="], + + "@babel/template": ["@babel/template@7.25.9", "", { "dependencies": { "@babel/code-frame": "^7.25.9", "@babel/parser": "^7.25.9", "@babel/types": "^7.25.9" } }, "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg=="], + + "@babel/traverse": ["@babel/traverse@7.26.4", "", { "dependencies": { "@babel/code-frame": "^7.26.2", "@babel/generator": "^7.26.3", "@babel/parser": "^7.26.3", "@babel/template": "^7.25.9", "@babel/types": "^7.26.3", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w=="], + + "@babel/types": ["@babel/types@7.26.3", "", { "dependencies": { "@babel/helper-string-parser": "^7.25.9", "@babel/helper-validator-identifier": "^7.25.9" } }, "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA=="], + + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.1", "", {}, "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw=="], + + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.0.3", "", { "dependencies": { "@chevrotain/gast": "11.0.3", "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ=="], + + "@chevrotain/gast": ["@chevrotain/gast@11.0.3", "", { "dependencies": { "@chevrotain/types": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q=="], + + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.0.3", "", {}, "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA=="], + + "@chevrotain/types": ["@chevrotain/types@11.0.3", "", {}, "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ=="], + + "@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="], + + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@7.0.2", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.0", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.0.7", "react": ">=16.8.0" } }, "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + + "@douyinfe/semi-animation": ["@douyinfe/semi-animation@2.72.2", "", { "dependencies": { "bezier-easing": "^2.1.0" } }, "sha512-MM2We1Nzvqa6uOrrWurUR+r5klOtOucpBHSjN13plVfZrd1VW8aIlwAyvqEntjOutOoVgnVwkeJHN1P56UV6dQ=="], + + "@douyinfe/semi-animation-react": ["@douyinfe/semi-animation-react@2.72.2", "", { "dependencies": { "@douyinfe/semi-animation": "2.72.2", "@douyinfe/semi-animation-styled": "2.72.2", "classnames": "^2.2.6" } }, "sha512-Iz2mDHDg8Gbur4pzqAyptkA6SK3LB5coGm5r/hevVNWif8Q7gDH9/UR/E9PAx1zORwlxov7BJxUMhrmgaHx7uw=="], + + "@douyinfe/semi-animation-styled": ["@douyinfe/semi-animation-styled@2.72.2", "", {}, "sha512-RKiHV71nWqpp/FiLDLNyw2CNrkR9W7qNnF/zkRosxRs5t4qRCtukdDaTNruuD2exekmCuejs+ClQi4AAwgkIYw=="], + + "@douyinfe/semi-foundation": ["@douyinfe/semi-foundation@2.72.2", "", { "dependencies": { "@douyinfe/semi-animation": "2.72.2", "@douyinfe/semi-json-viewer-core": "2.72.2", "@mdx-js/mdx": "^3.0.1", "async-validator": "^3.5.0", "classnames": "^2.2.6", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.8", "fast-copy": "^3.0.1 ", "lodash": "^4.17.21", "lottie-web": "^5.12.2", "memoize-one": "^5.2.1", "prismjs": "^1.29.0", "remark-gfm": "^4.0.0", "scroll-into-view-if-needed": "^2.2.24" } }, "sha512-pIJIz5rrayVyx2Dk4ntCifet5ZL9bEeTRSnauQtKRxq15ZqT10IETeUha235NAXZr+qA8YGhY+v9dhCXM9SMNA=="], + + "@douyinfe/semi-icons": ["@douyinfe/semi-icons@2.72.2", "", { "dependencies": { "classnames": "^2.2.6" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-Jbq/U+/R+UWQyp6Wz19ZnSSwDob0f2m/7ZfXBLUgRGGaYrtDTW+RNY1yp7Y4JEvYIcYThPzrNa8WDga7eK//Ng=="], + + "@douyinfe/semi-illustrations": ["@douyinfe/semi-illustrations@2.72.2", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-Rp1JBcZaEFyIJ+LYfESIp4Xf0rv4h6Es+XOVHtjzuZLD+cSmlhf8zT8WJpLT/9RA16YUFKXTNUFDMjTc/VQKTQ=="], + + "@douyinfe/semi-json-viewer-core": ["@douyinfe/semi-json-viewer-core@2.72.2", "", { "dependencies": { "jsonc-parser": "^3.3.1" } }, "sha512-h87OKEgvWAzqu9XBmc1Y0v6+QiETFFZx5wJbEqMSXuPx3EpW8Q6oYiKKSyaaLNcVC6ytBB2Jf71GFvM8HpcuSg=="], + + "@douyinfe/semi-theme-default": ["@douyinfe/semi-theme-default@2.72.2", "", {}, "sha512-XaMXl9hPtgNF0h8SIptJQSUzQdk78wB5AlAAnOIY0i+27S/3CT5WGBCj27wZqgz/FZcT8xK1sG29B0oXdRhgew=="], + + "@douyinfe/semi-ui": ["@douyinfe/semi-ui@2.72.2", "", { "dependencies": { "@dnd-kit/core": "^6.0.8", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.1", "@douyinfe/semi-animation": "2.72.2", "@douyinfe/semi-animation-react": "2.72.2", "@douyinfe/semi-foundation": "2.72.2", "@douyinfe/semi-icons": "2.72.2", "@douyinfe/semi-illustrations": "2.72.2", "@douyinfe/semi-theme-default": "2.72.2", "async-validator": "^3.5.0", "classnames": "^2.2.6", "copy-text-to-clipboard": "^2.1.1", "date-fns": "^2.29.3", "date-fns-tz": "^1.3.8", "fast-copy": "^3.0.1 ", "jsonc-parser": "^3.3.1", "lodash": "^4.17.21", "prop-types": "^15.7.2", "react-resizable": "^3.0.5", "react-window": "^1.8.2", "scroll-into-view-if-needed": "^2.2.24", "utility-types": "^3.10.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-okV/9mwSjsGEw5iBOGt2Z/cNIp/VQmgFjQRgwzagoXPmXU5NVSUyxYLdgQXUZXa6po4ZmVZB5IZpyddGGUeDiA=="], + + "@douyinfe/vite-plugin-semi": ["@douyinfe/vite-plugin-semi@2.74.0-alpha.6", "", { "dependencies": { "sass": "^1.85.1" }, "peerDependencies": { "vite": "5.1.0" } }, "sha512-juyKSG0onVBG29FLdGPBA0yHT9Kh7P8e0FDtwhp0DuMk6drd45bDQZuU171gzx0ahv9rJaojnD6CgcBiggtQ3A=="], + + "@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="], + + "@emoji-mart/react": ["@emoji-mart/react@1.1.1", "", { "peerDependencies": { "emoji-mart": "^5.2", "react": "^16.8 || ^17 || ^18" } }, "sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g=="], + + "@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="], + + "@emotion/cache": ["@emotion/cache@11.14.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="], + + "@emotion/css": ["@emotion/css@11.13.5", "", { "dependencies": { "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.13.5", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2" } }, "sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w=="], + + "@emotion/hash": ["@emotion/hash@0.8.0", "", {}, "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="], + + "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], + + "@emotion/react": ["@emotion/react@11.14.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "hoist-non-react-statics": "^3.3.1" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="], + + "@emotion/serialize": ["@emotion/serialize@1.3.3", "", { "dependencies": { "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/unitless": "^0.10.0", "@emotion/utils": "^1.4.2", "csstype": "^3.0.2" } }, "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="], + + "@emotion/sheet": ["@emotion/sheet@1.4.0", "", {}, "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="], + + "@emotion/unitless": ["@emotion/unitless@0.7.5", "", {}, "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="], + + "@emotion/use-insertion-effect-with-fallbacks": ["@emotion/use-insertion-effect-with-fallbacks@1.2.0", "", { "peerDependencies": { "react": ">=16.8.0" } }, "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="], + + "@emotion/utils": ["@emotion/utils@1.4.2", "", {}, "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="], + + "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.0", "", { "dependencies": { "@floating-ui/core": "^1.7.0", "@floating-ui/utils": "^0.2.9" } }, "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg=="], + + "@floating-ui/react": ["@floating-ui/react@0.27.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.1.2", "@floating-ui/utils": "^0.2.9", "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-EQJ4Th328y2wyHR3KzOUOoTW2UKjFk53fmyahfwExnFQ8vnsMYqKc+fFPOkeYtj5tcp1DUMiNJ7BFhed7e9ONw=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.2", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="], + + "@giscus/react": ["@giscus/react@3.1.0", "", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@2.3.0", "", { "dependencies": { "@antfu/install-pkg": "^1.0.0", "@antfu/utils": "^8.1.0", "@iconify/types": "^2.0.0", "debug": "^4.4.0", "globals": "^15.14.0", "kolorist": "^1.8.0", "local-pkg": "^1.0.0", "mlly": "^1.7.4" } }, "sha512-GmQ78prtwYW6EtzXRU1rY+KwOKfz32PD7iJh6Iyqw68GiKuoZ2A6pRtzWONz5VQJbp50mEjXh/7NkumtrAgRKA=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "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" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.3.0", "", {}, "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ=="], + + "@lit/reactive-element": ["@lit/reactive-element@2.1.0", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.2.0" } }, "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA=="], + + "@lobehub/emojilib": ["@lobehub/emojilib@1.0.0", "", {}, "sha512-s9KnjaPjsEefaNv150G3aifvB+J3P4eEKG+epY9zDPS2BeB6+V2jELWqAZll+nkogMaVovjEE813z3V751QwGw=="], + + "@lobehub/fluent-emoji": ["@lobehub/fluent-emoji@2.0.0", "", { "dependencies": { "@lobehub/emojilib": "^1.0.0", "@lobehub/ui": "^2.0.0", "antd-style": "^3.7.1", "emoji-regex": "^10.4.0", "lodash-es": "^4.17.21", "lucide-react": "^0.469.0", "react-layout-kit": "^1.9.1", "url-join": "^5.0.0" }, "peerDependencies": { "antd": "^5.23.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-bKjU3sf0+7NppvcdqD/raWvKGJIw8HDJVporNQ7oR8pIPoLeb9IUu/vqIYClOlwfu9qntji7FFySfbdNqXSiJw=="], + + "@lobehub/icons": ["@lobehub/icons@2.1.0", "", { "dependencies": { "@lobehub/ui": "^2.0.0", "antd-style": "^3.7.1", "lucide-react": "^0.469.0", "polished": "^4.3.1", "react-layout-kit": "^1.9.1" }, "peerDependencies": { "antd": "^5.23.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-iHtIp8a05/YHxTDlOFXCTfvYXUjKi1Mbq5a9qsEN+zwJ5U+mR2WgKz5zUausIzZiMZo+P3pgxbhh3/eHf7Q1pw=="], + + "@lobehub/ui": ["@lobehub/ui@2.1.10", "", { "dependencies": { "@ant-design/cssinjs": "^1.23.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@floating-ui/react": "^0.27.5", "@giscus/react": "^3.1.0", "@lobehub/fluent-emoji": "^2.0.0", "@lobehub/icons": "^2.0.0", "@mdx-js/mdx": "^3.1.0", "@mdx-js/react": "^3.1.0", "@radix-ui/react-slot": "^1.1.2", "@shikijs/transformers": "^3.2.1", "@splinetool/runtime": "0.9.526", "ahooks": "^3.8.4", "antd-style": "^3.7.1", "chroma-js": "^3.1.2", "class-variance-authority": "^0.7.1", "dayjs": "^1.11.13", "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "framer-motion": "^12.6.3", "immer": "^10.1.1", "katex": "^0.16.9", "leva": "^0.10.0", "lodash-es": "^4.17.21", "lucide-react": "^0.484.0", "mermaid": "^11.6.0", "numeral": "^2.0.6", "polished": "^4.3.1", "query-string": "^9.1.1", "rc-collapse": "^4.0.0", "rc-footer": "^0.6.8", "rc-image": "^7.11.1", "rc-menu": "^9.16.1", "re-resizable": "^6.11.2", "react-avatar-editor": "^13.0.2", "react-error-boundary": "^5.0.0", "react-hotkeys-hook": "^5.1.0", "react-layout-kit": "^1.9.1", "react-markdown": "^10.1.0", "react-merge-refs": "^3.0.2", "react-rnd": "^10.5.2", "react-zoom-pan-pinch": "^3.7.0", "rehype-katex": "^7.0.1", "rehype-raw": "^7.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "remark-math": "^6.0.0", "shiki": "^3.2.1", "swr": "^2.3.3", "ts-md5": "^1.3.1", "unified": "^11.0.5", "url-join": "^5.0.0", "use-merge-value": "^1.2.0", "uuid": "^11.1.0" }, "peerDependencies": { "antd": "^5.25.0", "react": "^19.0.0", "react-dom": "^19.0.0" } }, "sha512-R1/t5I8UAjvd5xoEDJXg6RzHmwhdOU45JQN297MlYB/sGqcvySfQL9POpDmySSs+QMyjkhwhum254cfXFKJIZA=="], + + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw=="], + + "@mdx-js/react": ["@mdx-js/react@3.1.0", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-QjHtSaoameoalGnKDT3FoIl4+9RwyTmo9ZJGBdLOks/YOiWHoRDI3PUwEzOE7kEmGcV3AFcp9K6dYu9rEuKLAQ=="], + + "@mermaid-js/parser": ["@mermaid-js/parser@0.4.0", "", { "dependencies": { "langium": "3.3.1" } }, "sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.1", "", { "os": "linux", "cpu": "arm" }, "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-primitive": "1.0.2" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-fqYwhhI9IarZ0ll2cUSfKuXHlJK0qE4AfnRrPBbRwEH/4mGQn04/QFGomLi8TXWIdv9WJk//KgGm+aDxVIr1wA=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.0.3", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/primitive": "1.0.0", "@radix-ui/react-compose-refs": "1.0.0", "@radix-ui/react-primitive": "1.0.2", "@radix-ui/react-use-callback-ref": "1.0.0", "@radix-ui/react-use-escape-keydown": "1.0.2" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-nXZOvFjOuHS1ovumntGV7NNoLaEp9JEvTht3MBjP44NSW5hUKj/8OnfN3+8WmB+CEhN44XaGhpHoSsUIEl5P7Q=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-layout-effect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.1.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@floating-ui/react-dom": "0.7.2", "@radix-ui/react-arrow": "1.0.2", "@radix-ui/react-compose-refs": "1.0.0", "@radix-ui/react-context": "1.0.0", "@radix-ui/react-primitive": "1.0.2", "@radix-ui/react-use-callback-ref": "1.0.0", "@radix-ui/react-use-layout-effect": "1.0.0", "@radix-ui/react-use-rect": "1.0.0", "@radix-ui/react-use-size": "1.0.0", "@radix-ui/rect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-keYDcdMPNMjSC8zTsZ8wezUMiWM9Yj14wtF3s0PTIs9srnEPC9Kt2Gny1T3T81mmSeyDjZxsD9N5WCwNNb712w=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-primitive": "1.0.2" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-swu32idoCW7KA2VEiUZGBSu9nB6qwGdV6k6HYhUoOo3M1FFpD+VgLzUqtt3mwL1ssz7r2x8MggpLSQach2Xy/Q=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0", "@radix-ui/react-use-layout-effect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-slot": "1.0.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-zY6G5Qq4R8diFPNwtyoLRZBxzu1Z+SXMlfYpChN7Dv8gvmx9X3qhDqiLWvKseKVJMuedFeU/Sa0Sy/Ia+t06Dw=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.0.5", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/primitive": "1.0.0", "@radix-ui/react-compose-refs": "1.0.0", "@radix-ui/react-context": "1.0.0", "@radix-ui/react-dismissable-layer": "1.0.3", "@radix-ui/react-id": "1.0.0", "@radix-ui/react-popper": "1.1.1", "@radix-ui/react-portal": "1.0.2", "@radix-ui/react-presence": "1.0.0", "@radix-ui/react-primitive": "1.0.2", "@radix-ui/react-slot": "1.0.1", "@radix-ui/react-use-controllable-state": "1.0.0", "@radix-ui/react-visually-hidden": "1.0.2" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-cDKVcfzyO6PpckZekODJZDe5ZxZ2fCZlzKzTmPhe4mX9qTHRfLcKgqb0OKf22xLwDequ2tVleim+ZYx3rabD5w=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-callback-ref": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-DXGim3x74WgUv+iMNCF+cAo8xUHHeqvjx8zs7trKf+FkQKPQXLk2sX7Gx1ysH7Q76xCpZuxIJE7HLPxRE+Q+GA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/rect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-TB7pID8NRMEHxb/qQJpvSt3hQU4sqNPM1VCTjTRjEOa7cEop/QMuq8S6fb/5Tsz64kqSvB9WnwsDHtjnrM9qew=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-use-layout-effect": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-imZ3aYcoYCKhhgNpkNDh/aTiU05qw9hX+HHI1QDBTyIlcFjgeFlKKySNGMwTp7nYFLQg/j0VA2FmCY4WPDDHMg=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.0.2", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-primitive": "1.0.2" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0", "react-dom": "^16.8 || ^17.0 || ^18.0" } }, "sha512-qirnJxtYn73HEk1rXL12/mXnu2rwsNHDID10th2JGtdK25T9wX+mxRmGt7iPSahw512GbZOc0syZX1nLQGoEOg=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" } }, "sha512-d0O68AYy/9oeEy1DdC07bz1/ZXX+DqCskRd3i4JzLSTXwefzaepQrKjXC7aNM8lTHjFLDO0pDgaEiQ7jEk+HVg=="], + + "@rc-component/async-validator": ["@rc-component/async-validator@5.0.4", "", { "dependencies": { "@babel/runtime": "^7.24.4" } }, "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg=="], + + "@rc-component/color-picker": ["@rc-component/color-picker@2.0.1", "", { "dependencies": { "@ant-design/fast-color": "^2.0.6", "@babel/runtime": "^7.23.6", "classnames": "^2.2.6", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q=="], + + "@rc-component/context": ["@rc-component/context@1.4.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w=="], + + "@rc-component/mini-decimal": ["@rc-component/mini-decimal@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0" } }, "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ=="], + + "@rc-component/mutate-observer": ["@rc-component/mutate-observer@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw=="], + + "@rc-component/portal": ["@rc-component/portal@1.1.2", "", { "dependencies": { "@babel/runtime": "^7.18.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg=="], + + "@rc-component/qrcode": ["@rc-component/qrcode@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.24.7", "classnames": "^2.3.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg=="], + + "@rc-component/tour": ["@rc-component/tour@1.15.1", "", { "dependencies": { "@babel/runtime": "^7.18.0", "@rc-component/portal": "^1.0.0-9", "@rc-component/trigger": "^2.0.0", "classnames": "^2.3.2", "rc-util": "^5.24.4" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ=="], + + "@rc-component/trigger": ["@rc-component/trigger@2.2.6", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/9zuTnWwhQ3S3WT1T8BubuFTT46kvnXgaERR9f4BTKyn61/wpf/BvbImzYBubzJibU707FxwbKszLlHjcLiv1Q=="], + + "@remix-run/router": ["@remix-run/router@1.21.0", "", {}, "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA=="], + + "@resvg/resvg-js": ["@resvg/resvg-js@2.4.1", "", { "optionalDependencies": { "@resvg/resvg-js-android-arm-eabi": "2.4.1", "@resvg/resvg-js-android-arm64": "2.4.1", "@resvg/resvg-js-darwin-arm64": "2.4.1", "@resvg/resvg-js-darwin-x64": "2.4.1", "@resvg/resvg-js-linux-arm-gnueabihf": "2.4.1", "@resvg/resvg-js-linux-arm64-gnu": "2.4.1", "@resvg/resvg-js-linux-arm64-musl": "2.4.1", "@resvg/resvg-js-linux-x64-gnu": "2.4.1", "@resvg/resvg-js-linux-x64-musl": "2.4.1", "@resvg/resvg-js-win32-arm64-msvc": "2.4.1", "@resvg/resvg-js-win32-ia32-msvc": "2.4.1", "@resvg/resvg-js-win32-x64-msvc": "2.4.1" } }, "sha512-wTOf1zerZX8qYcMmLZw3czR4paI4hXqPjShNwJRh5DeHxvgffUS5KM7XwxtbIheUW6LVYT5fhT2AJiP6mU7U4A=="], + + "@resvg/resvg-js-android-arm-eabi": ["@resvg/resvg-js-android-arm-eabi@2.4.1", "", { "os": "android", "cpu": "arm" }, "sha512-AA6f7hS0FAPpvQMhBCf6f1oD1LdlqNXKCxAAPpKh6tR11kqV0YIB9zOlIYgITM14mq2YooLFl6XIbbvmY+jwUw=="], + + "@resvg/resvg-js-android-arm64": ["@resvg/resvg-js-android-arm64@2.4.1", "", { "os": "android", "cpu": "arm64" }, "sha512-/QleoRdPfsEuH9jUjilYcDtKK/BkmWcK+1LXM8L2nsnf/CI8EnFyv7ZzCj4xAIvZGAy9dTYr/5NZBcTwxG2HQg=="], + + "@resvg/resvg-js-darwin-arm64": ["@resvg/resvg-js-darwin-arm64@2.4.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-U1oMNhea+kAXgiEXgzo7EbFGCD1Edq5aSlQoe6LMly6UjHzgx2W3N5kEXCwU/CgN5FiQhZr7PlSJSlcr7mdhfg=="], + + "@resvg/resvg-js-darwin-x64": ["@resvg/resvg-js-darwin-x64@2.4.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-avyVh6DpebBfHHtTQTZYSr6NG1Ur6TEilk1+H0n7V+g4F7x7WPOo8zL00ZhQCeRQ5H4f8WXNWIEKL8fwqcOkYw=="], + + "@resvg/resvg-js-linux-arm-gnueabihf": ["@resvg/resvg-js-linux-arm-gnueabihf@2.4.1", "", { "os": "linux", "cpu": "arm" }, "sha512-isY/mdKoBWH4VB5v621co+8l101jxxYjuTkwOLsbW+5RK9EbLciPlCB02M99ThAHzI2MYxIUjXNmNgOW8btXvw=="], + + "@resvg/resvg-js-linux-arm64-gnu": ["@resvg/resvg-js-linux-arm64-gnu@2.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-uY5voSCrFI8TH95vIYBm5blpkOtltLxLRODyhKJhGfskOI7XkRw5/t1u0sWAGYD8rRSNX+CA+np86otKjubrNg=="], + + "@resvg/resvg-js-linux-arm64-musl": ["@resvg/resvg-js-linux-arm64-musl@2.4.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-6mT0+JBCsermKMdi/O2mMk3m7SqOjwi9TKAwSngRZ/nQoL3Z0Z5zV+572ztgbWr0GODB422uD8e9R9zzz38dRQ=="], + + "@resvg/resvg-js-linux-x64-gnu": ["@resvg/resvg-js-linux-x64-gnu@2.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-60KnrscLj6VGhkYOJEmmzPlqqfcw1keDh6U+vMcNDjPhV3B5vRSkpP/D/a8sfokyeh4VEacPSYkWGezvzS2/mg=="], + + "@resvg/resvg-js-linux-x64-musl": ["@resvg/resvg-js-linux-x64-musl@2.4.1", "", { "os": "linux", "cpu": "x64" }, "sha512-0AMyZSICC1D7ge115cOZQW8Pcad6PjWuZkBFF3FJuSxC6Dgok0MQnLTs2MfMdKBlAcwO9dXsf3bv9tJZj8pATA=="], + + "@resvg/resvg-js-win32-arm64-msvc": ["@resvg/resvg-js-win32-arm64-msvc@2.4.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-76XDFOFSa3d0QotmcNyChh2xHwk+JTFiEQBVxMlHpHMeq7hNrQJ1IpE1zcHSQvrckvkdfLboKRrlGB86B10Qjw=="], + + "@resvg/resvg-js-win32-ia32-msvc": ["@resvg/resvg-js-win32-ia32-msvc@2.4.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-odyVFGrEWZIzzJ89KdaFtiYWaIJh9hJRW/frcEcG3agJ464VXkN/2oEVF5ulD+5mpGlug9qJg7htzHcKxDN8sg=="], + + "@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.4.1", "", { "os": "win32", "cpu": "x64" }, "sha512-vY4kTLH2S3bP+puU5x7hlAxHv+ulFgcK6Zn3efKSr0M0KnZ9A3qeAjZteIpkowEFfUeMPNg2dvvoFRJA9zqxSw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.30.0", "", { "os": "android", "cpu": "arm" }, "sha512-qFcFto9figFLz2g25DxJ1WWL9+c91fTxnGuwhToCl8BaqDsDYMl/kOnBXAyAqkkzAWimYMSWNPWEjt+ADAHuoQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.30.0", "", { "os": "android", "cpu": "arm64" }, "sha512-vqrQdusvVl7dthqNjWCL043qelBK+gv9v3ZiqdxgaJvmZyIAAXMjeGVSqZynKq69T7062T5VrVTuikKSAAVP6A=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.30.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-617pd92LhdA9+wpixnzsyhVft3szYiN16aNUMzVkf2N+yAk8UXY226Bfp36LvxYTUt7MO/ycqGFjQgJ0wlMaWQ=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.30.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Y3b4oDoaEhCypg8ajPqigKDcpi5ZZovemQl9Edpem0uNv6UUjXv7iySBpGIUTSs2ovWOzYpfw9EbFJXF/fJHWw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.30.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3REQJ4f90sFIBfa0BUokiCdrV/E4uIjhkWe1bMgCkhFXbf4D8YN6C4zwJL881GM818qVYE9BO3dGwjKhpo2ABA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.30.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZtY3Y8icbe3Cc+uQicsXG5L+CRGUfLZjW6j2gn5ikpltt3Whqjfo5mkyZ86UiuHF9Q3ZsaQeW7YswlHnN+lAcg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.30.0", "", { "os": "linux", "cpu": "arm" }, "sha512-bsPGGzfiHXMhQGuFGpmo2PyTwcrh2otL6ycSZAFTESviUoBOuxF7iBbAL5IJXc/69peXl5rAtbewBFeASZ9O0g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.30.0", "", { "os": "linux", "cpu": "arm" }, "sha512-kvyIECEhs2DrrdfQf++maCWJIQ974EI4txlz1nNSBaCdtf7i5Xf1AQCEJWOC5rEBisdaMFFnOWNLYt7KpFqy5A=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.30.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-CFE7zDNrokaotXu+shwIrmWrFxllg79vciH4E/zeK7NitVuWEaXRzS0mFfFvyhZfn8WfVOG/1E9u8/DFEgK7WQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.30.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-MctNTBlvMcIBP0t8lV/NXiUwFg9oK5F79CxLU+a3xgrdJjfBLVIEHSAjQ9+ipofN2GKaMLnFFXLltg1HEEPaGQ=="], + + "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.30.0", "", { "os": "linux", "cpu": "none" }, "sha512-fBpoYwLEPivL3q368+gwn4qnYnr7GVwM6NnMo8rJ4wb0p/Y5lg88vQRRP077gf+tc25akuqd+1Sxbn9meODhwA=="], + + "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.30.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hiHPV6dUaqIMXrIjN+vgJqtfkLpqHS1Xsg0oUfUVD98xGp1wX89PIXgDF2DWra1nxAd8dfE0Dk59MyeKaBVAw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.30.0", "", { "os": "linux", "cpu": "none" }, "sha512-U0xcC80SMpEbvvLw92emHrNjlS3OXjAM0aVzlWfar6PR0ODWCTQtKeeB+tlAPGfZQXicv1SpWwRz9Hyzq3Jx3g=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.30.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-VU/P/IODrNPasgZDLIFJmMiLGez+BN11DQWfTVlViJVabyF3JaeaJkP6teI8760f18BMGCQOW9gOmuzFaI1pUw=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.30.0", "", { "os": "linux", "cpu": "x64" }, "sha512-laQVRvdbKmjXuFA3ZiZj7+U24FcmoPlXEi2OyLfbpY2MW1oxLt9Au8q9eHd0x6Pw/Kw4oe9gwVXWwIf2PVqblg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.30.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3wzKzduS7jzxqcOvy/ocU/gMR3/QrHEFLge5CD7Si9fyHuoXcidyYZ6jyx8OPYmCcGm3uKTUl+9jUSAY74Ln5A=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.30.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jROwnI1+wPyuv696rAFHp5+6RFhXGGwgmgSfzE8e4xfit6oLRg7GyMArVUoM3ChS045OwWr9aTnU+2c1UdBMyw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.30.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-duzweyup5WELhcXx5H1jokpr13i3BV9b48FMiikYAwk/MT1LrMYYk2TzenBd0jj4ivQIt58JWSxc19y4SvLP4g=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.30.0", "", { "os": "win32", "cpu": "x64" }, "sha512-DYvxS0M07PvgvavMIybCOBYheyrqlui6ZQBHJs6GqduVzHSZ06TPPvlfvnYstjODHQ8UUXFwt5YE+h0jFI8kwg=="], + + "@shikijs/core": ["@shikijs/core@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-1/adJbSMBOkpScCE/SB6XkjJU17ANln3Wky7lOmrnpl+zBdQ1qXUJg2GXTYVHRq+2j3hd1DesmElTXYDgtfSOQ=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-zcZKMnNndgRa3ORja6Iemsr3DrLtkX3cAF7lTJkdMB6v9alhlBsX9uNiCpqofNrXOvpA3h6lHcLJxgCIhVOU5Q=="], + + "@shikijs/langs": ["@shikijs/langs@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2" } }, "sha512-H6azIAM+OXD98yztIfs/KH5H4PU39t+SREhmM8LaNXyUrqj2mx+zVkr8MWYqjceSjDw9I1jawm1WdFqU806rMA=="], + + "@shikijs/themes": ["@shikijs/themes@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2" } }, "sha512-qAEuAQh+brd8Jyej2UDDf+b4V2g1Rm8aBIdvt32XhDPrHvDkEnpb7Kzc9hSuHUxz0Iuflmq7elaDuQAP9bHIhg=="], + + "@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="], + + "@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@so1ve/prettier-config": ["@so1ve/prettier-config@3.1.0", "", { "dependencies": { "@so1ve/prettier-plugin-toml": "3.1.0", "prettier-plugin-astro": "^0.14.0", "prettier-plugin-curly-and-jsdoc": "3.1.0", "prettier-plugin-pkgsort": "^0.2.1" }, "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-9GJ1yXKBC4DzqCTTaZoBf8zw7WWkVuXcccZt1Aqk4lj6ab/GiNUnjPGajUVYLjaqAEOKqM7jUSUfTjk2JTjCAg=="], + + "@so1ve/prettier-plugin-toml": ["@so1ve/prettier-plugin-toml@3.1.0", "", { "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-8WZAGjAVNIJlkfWL6wHKxlUuEBY45fdd5qY5bR/Z6r/txgzKXk/r9qi1DTwc17gi/WcNuRrcRugecRT+mWbIYg=="], + + "@splinetool/runtime": ["@splinetool/runtime@0.9.526", "", { "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" } }, "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ=="], + + "@stitches/react": ["@stitches/react@1.2.8", "", { "peerDependencies": { "react": ">= 16.3.0" } }, "sha512-9g9dWI4gsSVe8bNLlb+lMkBYsnIKCZTmvqvDG+Avnn69XfmHZKiaMrx7cgTaddq7aTPPmXiTsbFcUy0xgI4+wA=="], + + "@turf/boolean-clockwise": ["@turf/boolean-clockwise@6.5.0", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0" } }, "sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw=="], + + "@turf/clone": ["@turf/clone@6.5.0", "", { "dependencies": { "@turf/helpers": "^6.5.0" } }, "sha512-mzVtTFj/QycXOn6ig+annKrM6ZlimreKYz6f/GSERytOpgzodbQyOgkfwru100O1KQhhjSudKK4DsQ0oyi9cTw=="], + + "@turf/flatten": ["@turf/flatten@6.5.0", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/meta": "^6.5.0" } }, "sha512-IBZVwoNLVNT6U/bcUUllubgElzpMsNoCw8tLqBw6dfYg9ObGmpEjf9BIYLr7a2Yn5ZR4l7YIj2T7kD5uJjZADQ=="], + + "@turf/helpers": ["@turf/helpers@6.5.0", "", {}, "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw=="], + + "@turf/invariant": ["@turf/invariant@6.5.0", "", { "dependencies": { "@turf/helpers": "^6.5.0" } }, "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg=="], + + "@turf/meta": ["@turf/meta@6.5.0", "", { "dependencies": { "@turf/helpers": "^6.5.0" } }, "sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA=="], + + "@turf/rewind": ["@turf/rewind@6.5.0", "", { "dependencies": { "@turf/boolean-clockwise": "^6.5.0", "@turf/clone": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "@turf/meta": "^6.5.0" } }, "sha512-IoUAMcHWotBWYwSYuYypw/LlqZmO+wcBpn8ysrBNbazkFNkLf3btSDZMkKJO/bvOzl55imr/Xj4fi3DdsLsbzQ=="], + + "@types/acorn": ["@types/acorn@4.0.6", "", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.6.8", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.20.6", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg=="], + + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.6", "", {}, "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + + "@types/estree": ["@types/estree@1.0.6", "", {}, "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], + + "@types/ms": ["@types/ms@0.7.34", "", {}, "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g=="], + + "@types/parse-author": ["@types/parse-author@2.0.3", "", {}, "sha512-pgRW2K/GVQoogylrGJXDl7PBLW9A6T4OOc9Hy9MLT5f7vgufK2GQ8FcfAbjFHR5HjcN9ByzuCczAORk49REqoA=="], + + "@types/parse-json": ["@types/parse-json@4.0.2", "", {}, "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="], + + "@types/react": ["@types/react@19.1.5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.2.1", "", {}, "sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA=="], + + "@use-gesture/core": ["@use-gesture/core@10.3.1", "", {}, "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw=="], + + "@use-gesture/react": ["@use-gesture/react@10.3.1", "", { "dependencies": { "@use-gesture/core": "10.3.1" }, "peerDependencies": { "react": ">= 16.8.0" } }, "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g=="], + + "@visactor/react-vchart": ["@visactor/react-vchart@1.8.11", "", { "dependencies": { "@visactor/vchart": "1.8.11", "@visactor/vgrammar-core": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-wHnCex9gOpnttTtSu04ozKJhTveUk8Ln2KX/7PZyCJxqlXq+eWvW4zvM6Ja8T8kGXfXtFYVVNh9zBMQ7y2T/Sw=="], + + "@visactor/vchart": ["@visactor/vchart@1.8.11", "", { "dependencies": { "@visactor/vdataset": "~0.17.3", "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-hierarchy": "0.10.11", "@visactor/vgrammar-projection": "0.10.11", "@visactor/vgrammar-sankey": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vgrammar-wordcloud": "0.10.11", "@visactor/vgrammar-wordcloud-shape": "0.10.11", "@visactor/vrender-components": "0.17.17", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3", "@visactor/vutils-extension": "1.8.11" } }, "sha512-RdQ822J02GgAQNXvO1LiT0T3O6FjdgPdcm9hVBFyrpBBmuI8MH02IE7Y1kGe9NiFTH4tDwP0ixRgBmqNSGSLZQ=="], + + "@visactor/vchart-semi-theme": ["@visactor/vchart-semi-theme@1.8.8", "", { "dependencies": { "@visactor/vchart-theme-utils": "1.8.8" }, "peerDependencies": { "@visactor/vchart": "~1.8.8" } }, "sha512-lm57CX3r6Bm7iGBYYyWhDY+1BvkyhNVLEckKx2PnlPKpJHikKSIK2ACyI5SmHuSOOdYzhY2QK6ZfYa2NShJ83w=="], + + "@visactor/vchart-theme-utils": ["@visactor/vchart-theme-utils@1.8.8", "", { "peerDependencies": { "@visactor/vchart": "~1.8.8" } }, "sha512-RdCey3/t0+82EYyFZvx210rgJJWti9rsgcL3ROZS7o9CtRW1CMj9u9LKLDNIcPLNcLNACFC0aoT03jpdD1BCpA=="], + + "@visactor/vdataset": ["@visactor/vdataset@0.17.5", "", { "dependencies": { "@turf/flatten": "^6.5.0", "@turf/helpers": "^6.5.0", "@turf/rewind": "^6.5.0", "@visactor/vutils": "0.17.5", "d3-dsv": "^2.0.0", "d3-geo": "^1.12.1", "d3-hexbin": "^0.2.2", "d3-hierarchy": "^3.1.1", "eventemitter3": "^4.0.7", "geobuf": "^3.0.1", "geojson-dissolve": "^3.1.0", "path-browserify": "^1.0.1", "pbf": "^3.2.1", "point-at-length": "^1.1.0", "simple-statistics": "^7.7.3", "simplify-geojson": "^1.0.4", "topojson-client": "^3.1.0" } }, "sha512-zVBdLWHWrhldGc8JDjSYF9lvpFT4ZEFQDB0b6yvfSiHzHKHiSco+rWmUFvA7r4ObT6j2QWF1vZAV9To8Ml4vHw=="], + + "@visactor/vgrammar-coordinate": ["@visactor/vgrammar-coordinate@0.10.11", "", { "dependencies": { "@visactor/vgrammar-util": "0.10.11", "@visactor/vutils": "~0.17.3" } }, "sha512-XSUvEkaf/NQHFafmTwqoIMZicp9fF3o6NB2FDpuWrK4DI1lTuip/0RkqrC+kBAjc5erjt0em0TiITyqXpp4G6w=="], + + "@visactor/vgrammar-core": ["@visactor/vgrammar-core@0.10.11", "", { "dependencies": { "@visactor/vdataset": "~0.17.3", "@visactor/vgrammar-coordinate": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-components": "0.17.17", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-VL9vcLPDg1LrHl7EOx0Ga9ATsoaChKIaCGzxjrPEjWiIS5VPU9Rs0jBKP+ch8BjamAoSuqL5mKd0L/RaUBqlaA=="], + + "@visactor/vgrammar-hierarchy": ["@visactor/vgrammar-hierarchy@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3" } }, "sha512-0r3k51pPlJHu63BduG3htsV/ul62aVcKJxFftRfvKkwGjm1KeHoOZEEAwIf78U2puio0BkLqVn2Ek2L4FYZaIg=="], + + "@visactor/vgrammar-projection": ["@visactor/vgrammar-projection@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vutils": "~0.17.3", "d3-geo": "^1.12.1" } }, "sha512-yEiKsxdfs5+g60wv5xZ1kyS/EDrAsUzAxCMpFFASVUYbQObHvW+elm+UPq2TBX6KZqAM0gsd1inzaLvfsCrLSg=="], + + "@visactor/vgrammar-sankey": ["@visactor/vgrammar-sankey@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3" } }, "sha512-BbJTPuyydsL/L5XtQv59Q82GgJeePY7Wleac798usx3GnDK0GAOrPsI3bubSsOESJ4pNk3V4HPGEQDG1vCPb4w=="], + + "@visactor/vgrammar-util": ["@visactor/vgrammar-util@0.10.11", "", { "dependencies": { "@visactor/vutils": "~0.17.3" } }, "sha512-cJZLmKZvN95Y+yGhX+28+UpZu3bhYYlXDlHJNvXHyonI76ZYgtceyon2b3lI6XIsUsBGcD4Uo777s949X5os3g=="], + + "@visactor/vgrammar-wordcloud": ["@visactor/vgrammar-wordcloud@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vutils": "~0.17.3" } }, "sha512-JWDqjGhr9JlYkKVBeEkiOqLQk7C1x1BtnsZ+E8oN541gzUqHwfS9qZyhwI3OyoSLewJlsSSPu1vXLKSQzLzKPA=="], + + "@visactor/vgrammar-wordcloud-shape": ["@visactor/vgrammar-wordcloud-shape@0.10.11", "", { "dependencies": { "@visactor/vgrammar-core": "0.10.11", "@visactor/vgrammar-util": "0.10.11", "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-NsQOYJp+9WHnIApMvkcUOaajxIg5U/r6rD8LKnoXW/HqAN2TFYXcRR3Daqmk9rrpM5VztQimKOsA1yZWyzozrA=="], + + "@visactor/vrender-components": ["@visactor/vrender-components@0.17.17", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-7gYFQrozvBkyGF7s/JHXdWDZnATzymxzug63CZd4EB7A0OXKatVDImXRePqwzlPD3QamF7QMVWn0CuIx3gQ2gA=="], + + "@visactor/vrender-core": ["@visactor/vrender-core@0.17.17", "", { "dependencies": { "@visactor/vutils": "~0.17.3", "color-convert": "2.0.1" } }, "sha512-pAZGaimunDAWOBdFhzPh0auH5ryxAHr+MVoz+QdASG+6RZXy8D02l8v2QYu4+e4uorxe/s2ZkdNDm81SlNkoHQ=="], + + "@visactor/vrender-kits": ["@visactor/vrender-kits@0.17.17", "", { "dependencies": { "@resvg/resvg-js": "2.4.1", "@visactor/vrender-core": "0.17.17", "@visactor/vutils": "~0.17.3", "roughjs": "4.5.2" } }, "sha512-noRP1hAHvPCv36nf2P6sZ930Tk+dJ8jpPWIUm1cFYmUNdcumgIS8Cug0RyeZ+saSqVt5FDTwIwifhOqupw5Zaw=="], + + "@visactor/vscale": ["@visactor/vscale@0.17.5", "", { "dependencies": { "@visactor/vutils": "0.17.5" } }, "sha512-2dkS1IlAJ/IdTp8JElbctOOv6lkHKBKPDm8KvwBo0NuGWQeYAebSeyN3QCdwKbj76gMlCub4zc+xWrS5YiA2zA=="], + + "@visactor/vutils": ["@visactor/vutils@0.17.5", "", { "dependencies": { "@turf/helpers": "^6.5.0", "@turf/invariant": "^6.5.0", "eventemitter3": "^4.0.7" } }, "sha512-HFN6Pk1Wc1RK842g02MeKOlvdri5L7/nqxMVTqxIvi0XMhHXpmoqN4+/9H+h8LmJpVohyrI/MT85TRBV/rManw=="], + + "@visactor/vutils-extension": ["@visactor/vutils-extension@1.8.11", "", { "dependencies": { "@visactor/vrender-core": "0.17.17", "@visactor/vrender-kits": "0.17.17", "@visactor/vscale": "~0.17.3", "@visactor/vutils": "~0.17.3" } }, "sha512-Hknzpy3+xh4sdL0iSn5N93BHiMJF4FdwSwhHYEibRpriZmWKG6wBxsJ0Bll4d7oS4f+svxt8Sg2vRYKzQEcIxQ=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.3.4", "", { "dependencies": { "@babel/core": "^7.26.0", "@babel/plugin-transform-react-jsx-self": "^7.25.9", "@babel/plugin-transform-react-jsx-source": "^7.25.9", "@types/babel__core": "^7.20.5", "react-refresh": "^0.14.2" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug=="], + + "abs-svg-path": ["abs-svg-path@0.1.1", "", {}, "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA=="], + + "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ahooks": ["ahooks@3.8.5", "", { "dependencies": { "@babel/runtime": "^7.21.0", "dayjs": "^1.9.1", "intersection-observer": "^0.12.0", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "react-fast-compare": "^3.2.2", "resize-observer-polyfill": "^1.5.1", "screenfull": "^5.0.0", "tslib": "^2.4.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y+MLoJpBXVdjsnnBjE5rOSPkQ4DK+8i5aPDzLJdIOsCpo/fiAeXcBY1Y7oWgtOK0TpOz0gFa/XcyO1UGdoqLcw=="], + + "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + + "ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + + "antd": ["antd@5.25.2", "", { "dependencies": { "@ant-design/colors": "^7.2.0", "@ant-design/cssinjs": "^1.23.0", "@ant-design/cssinjs-utils": "^1.1.3", "@ant-design/fast-color": "^2.0.6", "@ant-design/icons": "^5.6.1", "@ant-design/react-slick": "~1.1.2", "@babel/runtime": "^7.26.0", "@rc-component/color-picker": "~2.0.1", "@rc-component/mutate-observer": "^1.1.0", "@rc-component/qrcode": "~1.0.0", "@rc-component/tour": "~1.15.1", "@rc-component/trigger": "^2.2.6", "classnames": "^2.5.1", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.11", "rc-cascader": "~3.34.0", "rc-checkbox": "~3.5.0", "rc-collapse": "~3.9.0", "rc-dialog": "~9.6.0", "rc-drawer": "~7.2.0", "rc-dropdown": "~4.2.1", "rc-field-form": "~2.7.0", "rc-image": "~7.12.0", "rc-input": "~1.8.0", "rc-input-number": "~9.5.0", "rc-mentions": "~2.20.0", "rc-menu": "~9.16.1", "rc-motion": "^2.9.5", "rc-notification": "~5.6.4", "rc-pagination": "~5.1.0", "rc-picker": "~4.11.3", "rc-progress": "~4.0.0", "rc-rate": "~2.13.1", "rc-resize-observer": "^1.4.3", "rc-segmented": "~2.7.0", "rc-select": "~14.16.8", "rc-slider": "~11.1.8", "rc-steps": "~6.0.1", "rc-switch": "~4.1.0", "rc-table": "~7.50.5", "rc-tabs": "~15.6.1", "rc-textarea": "~1.10.0", "rc-tooltip": "~6.4.0", "rc-tree": "~5.13.1", "rc-tree-select": "~5.27.0", "rc-upload": "~4.9.0", "rc-util": "^5.44.4", "scroll-into-view-if-needed": "^3.1.0", "throttle-debounce": "^5.0.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-7R2nUvlHhey7Trx64+hCtGXOiy+DTUs1Lv5bwbV1LzEIZIhWb0at1AM6V3K108a5lyoR9n7DX3ptlLF7uYV/DQ=="], + + "antd-style": ["antd-style@3.7.1", "", { "dependencies": { "@ant-design/cssinjs": "^1.21.1", "@babel/runtime": "^7.24.1", "@emotion/cache": "^11.11.0", "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.4", "@emotion/serialize": "^1.1.3", "@emotion/utils": "^1.2.1", "use-merge-value": "^1.2.0" }, "peerDependencies": { "antd": ">=5.8.1", "react": ">=18" } }, "sha512-CQOfddVp4aOvBfCepa+Kj2e7ap+2XBINg1Kn2osdE3oQvrD7KJu/K0sfnLcFLkgCJygbxmuazYdWLKb+drPDYA=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "array-source": ["array-source@0.0.4", "", {}, "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw=="], + + "assign-symbols": ["assign-symbols@1.0.0", "", {}, "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw=="], + + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + + "async-validator": ["async-validator@3.5.2", "", {}, "sha512-8eLCg00W9pIRZSB781UUX/H6Oskmm8xloZfr09lz5bikRpBVDlJ3hRVuxxP1SxcwsEYfJ4IU8Q19Y8/893r3rQ=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], + + "author-regex": ["author-regex@1.0.0", "", {}, "sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g=="], + + "autoprefixer": ["autoprefixer@10.4.21", "", { "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" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], + + "axios": ["axios@0.27.2", "", { "dependencies": { "follow-redirects": "^1.14.9", "form-data": "^4.0.0" } }, "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ=="], + + "babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="], + + "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.13", "", { "dependencies": { "@babel/compat-data": "^7.22.6", "@babel/helper-define-polyfill-provider": "^0.6.4", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g=="], + + "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.11.1", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.3", "core-js-compat": "^3.40.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ=="], + + "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.4", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.4" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bezier-easing": ["bezier-easing@2.1.0", "", {}, "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.24.5", "", { "dependencies": { "caniuse-lite": "^1.0.30001716", "electron-to-chromium": "^1.5.149", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001718", "", {}, "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chevrotain": ["chevrotain@11.0.3", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.0.3", "@chevrotain/gast": "11.0.3", "@chevrotain/regexp-to-ast": "11.0.3", "@chevrotain/types": "11.0.3", "@chevrotain/utils": "11.0.3", "lodash-es": "4.17.21" } }, "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw=="], + + "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], + + "chokidar": ["chokidar@3.6.0", "", { "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" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "chroma-js": ["chroma-js@3.1.2", "", {}, "sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "colord": ["colord@2.9.3", "", {}, "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "compute-scroll-into-view": ["compute-scroll-into-view@1.0.20", "", {}, "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], + + "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "copy-text-to-clipboard": ["copy-text-to-clipboard@2.2.0", "", {}, "sha512-WRvoIdnTs1rgPMkgA2pUOa/M4Enh2uzCwdKsOMYNAJiz/4ZvEJgmbF4OmninPmlFdAWisfeh0tH+Cpf7ni3RqQ=="], + + "copy-to-clipboard": ["copy-to-clipboard@3.3.3", "", { "dependencies": { "toggle-selection": "^1.0.6" } }, "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA=="], + + "core-js-compat": ["core-js-compat@3.42.0", "", { "dependencies": { "browserslist": "^4.24.4" } }, "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + + "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], + + "country-flag-icons": ["country-flag-icons@1.5.19", "", {}, "sha512-D/ZkRyj+ywJC6b2IrAN3/tpbReMUqmuRLlcKFoY/o0+EPQN9Ev/e8tV+D3+9scvu/tarxwLErNwS73C3yzxs/g=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "cytoscape": ["cytoscape@3.32.0", "", {}, "sha512-5JHBC9n75kz5851jeklCPmZWcg3hUe6sjqJvyk3+hVqFaKcHwHgxsjeN1yLmggoUc6STbtm9/NQyabQehfjvWQ=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@2.0.0", "", { "dependencies": { "commander": "2", "iconv-lite": "0.4", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json", "csv2tsv": "bin/dsv2dsv", "dsv2dsv": "bin/dsv2dsv", "dsv2json": "bin/dsv2json", "json2csv": "bin/json2dsv", "json2dsv": "bin/json2dsv", "json2tsv": "bin/json2dsv", "tsv2csv": "bin/dsv2dsv", "tsv2json": "bin/dsv2json" } }, "sha512-E+Pn8UJYx9mViuIUkoc93gJGGYut6mSDKy2+XaPwccwkRGlR+LO97L2VCCRjQivTwLHkSnAJG7yo00BWY6QM+w=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-geo": ["d3-geo@1.12.1", "", { "dependencies": { "d3-array": "1" } }, "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg=="], + + "d3-hexbin": ["d3-hexbin@0.2.2", "", {}, "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.11", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw=="], + + "date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="], + + "date-fns-tz": ["date-fns-tz@1.3.8", "", { "peerDependencies": { "date-fns": ">=2.0.0" } }, "sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ=="], + + "dayjs": ["dayjs@1.11.13", "", {}, "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg=="], + + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.0.2", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg=="], + + "decode-uri-component": ["decode-uri-component@0.4.1", "", {}, "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ=="], + + "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "dompurify": ["dompurify@3.2.6", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.157", "", {}, "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w=="], + + "emoji-mart": ["emoji-mart@5.6.0", "", {}, "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow=="], + + "emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], + + "entities": ["entities@6.0.0", "", {}, "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw=="], + + "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], + + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], + + "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + + "esbuild": ["esbuild@0.21.5", "", { "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" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], + + "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "estree-util-scope": ["estree-util-scope@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ=="], + + "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], + + "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "exsolve": ["exsolve@1.0.5", "", {}, "sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="], + + "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "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" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], + + "file-source": ["file-source@0.6.1", "", { "dependencies": { "stream-source": "0.3" } }, "sha512-1R1KneL7eTXmXfKxC10V/9NeGOdbsAXJ+lQ//fvvcHUgtaZcZDWNJNblxAoVOyV1cj45pOtUrR3vZTBwqcW8XA=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "filter-obj": ["filter-obj@5.1.0", "", {}, "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng=="], + + "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], + + "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + + "for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.1", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw=="], + + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], + + "framer-motion": ["framer-motion@12.12.2", "", { "dependencies": { "motion-dom": "^12.12.1", "motion-utils": "^12.12.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-qCszZCiGWkilL40E3VuhIJJC/CS3SIBl2IHyGK8FU30nOUhTmhBNWPrNFyozAWH/bXxwzi19vJHIGVdALF0LCg=="], + + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "geobuf": ["geobuf@3.0.2", "", { "dependencies": { "concat-stream": "^2.0.0", "pbf": "^3.2.1", "shapefile": "~0.6.6" }, "bin": { "geobuf2json": "bin/geobuf2json", "json2geobuf": "bin/json2geobuf", "shp2geobuf": "bin/shp2geobuf" } }, "sha512-ASgKwEAQQRnyNFHNvpd5uAwstbVYmiTW0Caw3fBb509tNTqXyAAPMyFs5NNihsLZhLxU1j/kjFhkhLWA9djuVg=="], + + "geojson-dissolve": ["geojson-dissolve@3.1.0", "", { "dependencies": { "@turf/meta": "^3.7.5", "geojson-flatten": "^0.2.1", "geojson-linestring-dissolve": "0.0.1", "topojson-client": "^3.0.0", "topojson-server": "^3.0.0" } }, "sha512-JXHfn+A3tU392HA703gJbjmuHaQOAE/C1KzbELCczFRFux+GdY6zt1nKb1VMBHp4LWeE7gUY2ql+g06vJqhiwQ=="], + + "geojson-flatten": ["geojson-flatten@0.2.4", "", { "dependencies": { "get-stdin": "^6.0.0", "minimist": "1.2.0" }, "bin": { "geojson-flatten": "./geojson-flatten" } }, "sha512-LiX6Jmot8adiIdZ/fthbcKKPOfWjTQchX/ggHnwMZ2e4b0I243N1ANUos0LvnzepTEsj0+D4fIJ5bKhBrWnAHA=="], + + "geojson-linestring-dissolve": ["geojson-linestring-dissolve@0.0.1", "", {}, "sha512-Y8I2/Ea28R/Xeki7msBcpMvJL2TaPfaPKP8xqueJfQ9/jEhps+iOJxOR2XCBGgVb12Z6XnDb1CMbaPfLepsLaw=="], + + "get-stdin": ["get-stdin@6.0.0", "", {}, "sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g=="], + + "get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="], + + "giscus": ["giscus@1.6.0", "", { "dependencies": { "lit": "^3.2.1" } }, "sha512-Zrsi8r4t1LVW950keaWcsURuZUQwUaMKjvJgTCY125vkW6OiEBkatE7ScJDbpqKHdZwb///7FVC21SE3iFK3PQ=="], + + "glob": ["glob@10.4.5", "", { "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" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-to-estree": ["hast-util-to-estree@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "style-to-object": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-IWtwwmPskfSmma9RpzCappDUitC8t5jhAynHhc1m2+5trOgsrp7txscUSavc5Ic8PATyAjfrCK1wgtxh2cICVQ=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.2", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "style-to-object": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + + "history": ["history@5.3.0", "", { "dependencies": { "@babel/runtime": "^7.7.6" } }, "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ=="], + + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "i18next": ["i18next@23.16.8", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg=="], + + "i18next-browser-languagedetector": ["i18next-browser-languagedetector@7.2.2", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-6b7r75uIJDWCcCflmbof+sJ94k9UQO4X0YR62oUfqGI/GjCLVzlCwu8TFdRZIqVLzWbzNcmkmhfqKEr4TLz4HQ=="], + + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "immer": ["immer@10.1.1", "", {}, "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw=="], + + "immutable": ["immutable@5.1.2", "", {}, "sha512-qHKXW1q6liAk1Oys6umoaZbDRqjcjgSrbnrifHsfsttza7zcvRAsL7mMV6xWcyhwQy7Xj5v4hhbr6b+iDYwlmQ=="], + + "import-fresh": ["import-fresh@3.3.0", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "inline-style-parser": ["inline-style-parser@0.2.4", "", {}, "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "intersection-observer": ["intersection-observer@0.12.2", "", {}, "sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-extendable": ["is-extendable@1.0.1", "", { "dependencies": { "is-plain-object": "^2.0.4" } }, "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="], + + "isarray": ["isarray@0.0.1", "", {}, "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "isobject": ["isobject@3.0.1", "", {}, "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-cookie": ["js-cookie@3.0.5", "", {}, "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json2mq": ["json2mq@0.2.0", "", { "dependencies": { "string-convert": "^0.2.0" } }, "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], + + "katex": ["katex@0.16.22", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg=="], + + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + + "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], + + "langium": ["langium@3.3.1", "", { "dependencies": { "chevrotain": "~11.0.3", "chevrotain-allstar": "~0.3.0", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.0.8" } }, "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + + "leva": ["leva@0.10.0", "", { "dependencies": { "@radix-ui/react-portal": "1.0.2", "@radix-ui/react-tooltip": "1.0.5", "@stitches/react": "^1.2.8", "@use-gesture/react": "^10.2.5", "colord": "^2.9.2", "dequal": "^2.0.2", "merge-value": "^1.0.0", "react-colorful": "^5.5.1", "react-dropzone": "^12.0.0", "v8n": "^1.3.3", "zustand": "^3.6.9" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-RiNJWmeqQdKIeHuVXgshmxIHu144a2AMYtLxKf8Nm1j93pisDPexuQDHKNdQlbo37wdyDQibLjY9JKGIiD7gaw=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lit": ["lit@3.3.0", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw=="], + + "lit-element": ["lit-element@4.2.0", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.2.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q=="], + + "lit-html": ["lit-html@3.3.0", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw=="], + + "local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], + + "lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lottie-web": ["lottie-web@5.12.2", "", {}, "sha512-uvhvYPC8kGPjXT3MyKMrL3JitEAmDMp30lVkuq/590Mw9ok6pWcFCwXJveo0t5uqYw1UREQHofD+jVpdjBv8wg=="], + + "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="], + + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], + + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.1.3", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-newline-to-break": ["mdast-util-newline-to-break@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-find-and-replace": "^3.0.0" } }, "sha512-MbgeFca0hLYIEx/2zGsszCSEJJ1JSCdiY5xQxRcLDDGa8EPvlLPupJ4DSajbMPAnC0je8jfb9TiUATnxxrHUog=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "memoize-one": ["memoize-one@5.2.1", "", {}, "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="], + + "merge-value": ["merge-value@1.0.0", "", { "dependencies": { "get-value": "^2.0.6", "is-extendable": "^1.0.0", "mixin-deep": "^1.2.0", "set-value": "^2.0.0" } }, "sha512-fJMmvat4NeKz63Uv9iHWcPDjCWcCkoiRoajRTEO8hlhUC6rwaHg0QCF9hBOTjZmm4JuglPckPSTtcuJL5kp0TQ=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "mermaid": ["mermaid@11.6.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.0.4", "@iconify/utils": "^2.1.33", "@mermaid-js/parser": "^0.4.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.11", "dayjs": "^1.11.13", "dompurify": "^3.2.4", "katex": "^0.16.9", "khroma": "^2.1.0", "lodash-es": "^4.17.21", "marked": "^15.0.7", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg=="], + + "micromark": ["micromark@4.0.1", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-eBPdkcoCNvYcxQOAKAlceo5SNdzZWfF+FcSupREAzdAh9rRmE239CEQAiTwIgblwnoM8zzj35sZ5ZwvSEOF6Kw=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.2", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-FKjQKbxd1cibWMM1P9N+H8TwlgGgSkWZMmfuVucLCHaYqeSvJ0hFeHsIa65pA2nYbes0f8LDHPMrd9X7Ujxg9w=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], + + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ=="], + + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.1", "", { "dependencies": { "@types/acorn": "^4.0.0", "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg=="], + + "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], + + "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], + + "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-5E5I2pFzJyg2CtemqAbcyCktpHXuJbABnsb32wX2U8IQKhhVFBqkcZR5LRm1WVoFqa4kTueZK4abep7wdo9nrw=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.2", "", { "dependencies": { "@types/acorn": "^4.0.0", "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-Fk+xmBrOv9QZnEDguL9OI9/NQQp6Hz4FuQ4YmCb/5V7+9eAh1s6AYSvL20kHkD67YIg7EpE54TiSlcsf3vyZgA=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.0.3", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VXJJuNxYWSoYL6AJ6OQECCFGhIU2GGHMw8tahogePBrjkG8aCCas3ibkp7RnVOSTClg2is05/R7maAhF1XyQMg=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.1", "", {}, "sha512-534m2WhVTddrcKVepwmVEVnUAmtrx9bfIjNoQHRqfnvdaHQiFytEhJoTgpWJvDEXCO5gLTQh3wYC1PgOJA4NSQ=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "mixin-deep": ["mixin-deep@1.3.2", "", { "dependencies": { "for-in": "^1.0.2", "is-extendable": "^1.0.1" } }, "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA=="], + + "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], + + "motion-dom": ["motion-dom@12.12.1", "", { "dependencies": { "motion-utils": "^12.12.1" } }, "sha512-GXq/uUbZBEiFFE+K1Z/sxdPdadMdfJ/jmBALDfIuHGi0NmtealLOfH9FqT+6aNPgVx8ilq0DtYmyQlo6Uj9LKQ=="], + + "motion-utils": ["motion-utils@12.12.1", "", {}, "sha512-f9qiqUHm7hWSLlNW8gS9pisnsN7CRFRD58vNjptKdsqFLpkVnX00TNeD6Q0d27V9KzT7ySFyK1TZ/DShfVOv6w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.8", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], + + "numeral": ["numeral@2.0.6", "", {}, "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "on-change": ["on-change@4.0.2", "", {}, "sha512-cMtCyuJmTx/bg2HCpHo3ZLeF7FZnBOapLqZHr2AlLeJ5Ul0Zu2mUJJz051Fdwu/Et2YW04ZD+TtU+gVy0ACNCA=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "package-manager-detector": ["package-manager-detector@1.3.0", "", {}, "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-author": ["parse-author@2.0.0", "", { "dependencies": { "author-regex": "^1.0.0" } }, "sha512-yx5DfvkN8JsHL2xk2Os9oTia467qnvRgey4ahSm2X8epehBLx/gWLcy5KI+Y36ful5DzGbCS6RazqZGgy1gHNw=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse-json": ["parse-json@5.2.0", "", { "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" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse-svg-path": ["parse-svg-path@0.1.2", "", {}, "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "path-source": ["path-source@0.1.3", "", { "dependencies": { "array-source": "0.0", "file-source": "0.6" } }, "sha512-dWRHm5mIw5kw0cs3QZLNmpUWty48f5+5v9nWD2dw3Y0Hf+s01Ag8iJEWV0Sm0kocE8kK27DrIowha03e1YR+Qw=="], + + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pbf": ["pbf@3.3.0", "", { "dependencies": { "ieee754": "^1.1.12", "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "pkg-types": ["pkg-types@2.1.0", "", { "dependencies": { "confbox": "^0.2.1", "exsolve": "^1.0.1", "pathe": "^2.0.3" } }, "sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A=="], + + "point-at-length": ["point-at-length@1.1.0", "", { "dependencies": { "abs-svg-path": "~0.1.1", "isarray": "~0.0.1", "parse-svg-path": "~0.1.1" } }, "sha512-nNHDk9rNEh/91o2Y8kHLzBLNpLf80RYd2gCun9ss+V0ytRSf6XhryBTx071fesktjbachRmGuUbId+JQmzhRXw=="], + + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + + "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], + + "postcss": ["postcss@8.5.3", "", { "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.0.1", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw=="], + + "postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prettier": ["prettier@3.4.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ=="], + + "prettier-package-json": ["prettier-package-json@2.8.0", "", { "dependencies": { "@types/parse-author": "^2.0.0", "commander": "^4.0.1", "cosmiconfig": "^7.0.0", "fs-extra": "^10.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4", "parse-author": "^2.0.0", "sort-object-keys": "^1.1.3", "sort-order": "^1.0.1" }, "bin": { "prettier-package-json": "bin/prettier-package-json" } }, "sha512-WxtodH/wWavfw3MR7yK/GrS4pASEQ+iSTkdtSxPJWvqzG55ir5nvbLt9rw5AOiEcqqPCRM92WCtR1rk3TG3JSQ=="], + + "prettier-plugin-astro": ["prettier-plugin-astro@0.14.1", "", { "dependencies": { "@astrojs/compiler": "^2.9.1", "prettier": "^3.0.0", "sass-formatter": "^0.7.6" } }, "sha512-RiBETaaP9veVstE4vUwSIcdATj6dKmXljouXc/DDNwBSPTp8FRkLGDSGFClKsAFeeg+13SB0Z1JZvbD76bigJw=="], + + "prettier-plugin-curly-and-jsdoc": ["prettier-plugin-curly-and-jsdoc@3.1.0", "", { "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-4QMOHnLlkP2jTRWS0MFH6j+cuOiXLvXOqCLKbtwwVd8PPyq8NenW5AAwfwqiTNHBQG/DmzViPphRrwgN0XkUVQ=="], + + "prettier-plugin-pkgsort": ["prettier-plugin-pkgsort@0.2.1", "", { "dependencies": { "prettier-package-json": "^2.8.0" }, "peerDependencies": { "prettier": "^3.0.0" } }, "sha512-/k5MIw84EhgoH7dmq4+6ozHjJ0VYbxbw17g4C+WPGHODkLivGwJoA6U1YPR/KObyRDMQJHXAfXKu++9smg7Jyw=="], + + "prismjs": ["prismjs@1.29.0", "", {}, "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], + + "protocol-buffers-schema": ["protocol-buffers-schema@3.6.0", "", {}, "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw=="], + + "quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="], + + "query-string": ["query-string@9.2.0", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-YIRhrHujoQxhexwRLxfy3VSjOXmvZRd2nyw1PwL1UUqZ/ys1dEZd1+NSgXkne2l/4X/7OXkigEAuhTX0g/ivJQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "rc-cascader": ["rc-cascader@3.34.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "^2.3.1", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag=="], + + "rc-checkbox": ["rc-checkbox@3.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.25.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg=="], + + "rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="], + + "rc-dialog": ["rc-dialog@9.6.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/portal": "^1.0.0-8", "classnames": "^2.2.6", "rc-motion": "^2.3.0", "rc-util": "^5.21.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg=="], + + "rc-drawer": ["rc-drawer@7.2.0", "", { "dependencies": { "@babel/runtime": "^7.23.9", "@rc-component/portal": "^1.1.1", "classnames": "^2.2.6", "rc-motion": "^2.6.1", "rc-util": "^5.38.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg=="], + + "rc-dropdown": ["rc-dropdown@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.6", "rc-util": "^5.44.1" }, "peerDependencies": { "react": ">=16.11.0", "react-dom": ">=16.11.0" } }, "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA=="], + + "rc-field-form": ["rc-field-form@2.7.0", "", { "dependencies": { "@babel/runtime": "^7.18.0", "@rc-component/async-validator": "^5.0.3", "rc-util": "^5.32.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-hgKsCay2taxzVnBPZl+1n4ZondsV78G++XVsMIJCAoioMjlMQR9YwAp7JZDIECzIu2Z66R+f4SFIRrO2DjDNAA=="], + + "rc-footer": ["rc-footer@0.6.8", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-JBZ+xcb6kkex8XnBd4VHw1ZxjV6kmcwUumSHaIFdka2qzMCo7Klcy4sI6G0XtUpG/vtpislQCc+S9Bc+NLHYMg=="], + + "rc-image": ["rc-image@7.12.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "@rc-component/portal": "^1.0.2", "classnames": "^2.2.6", "rc-dialog": "~9.6.0", "rc-motion": "^2.6.2", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q=="], + + "rc-input": ["rc-input@1.8.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-util": "^5.18.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA=="], + + "rc-input-number": ["rc-input-number@9.5.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/mini-decimal": "^1.0.1", "classnames": "^2.2.5", "rc-input": "~1.8.0", "rc-util": "^5.40.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag=="], + + "rc-mentions": ["rc-mentions@2.20.0", "", { "dependencies": { "@babel/runtime": "^7.22.5", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.6", "rc-input": "~1.8.0", "rc-menu": "~9.16.0", "rc-textarea": "~1.10.0", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ=="], + + "rc-menu": ["rc-menu@9.16.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/trigger": "^2.0.0", "classnames": "2.x", "rc-motion": "^2.4.3", "rc-overflow": "^1.3.1", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg=="], + + "rc-motion": ["rc-motion@2.9.5", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA=="], + + "rc-notification": ["rc-notification@5.6.4", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.9.0", "rc-util": "^5.20.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw=="], + + "rc-overflow": ["rc-overflow@1.4.1", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-resize-observer": "^1.0.0", "rc-util": "^5.37.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-3MoPQQPV1uKyOMVNd6SZfONi+f3st0r8PksexIdBTeIYbMX0Jr+k7pHEDvsXtR4BpCv90/Pv2MovVNhktKrwvw=="], + + "rc-pagination": ["rc-pagination@5.1.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.3.2", "rc-util": "^5.38.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ=="], + + "rc-picker": ["rc-picker@4.11.3", "", { "dependencies": { "@babel/runtime": "^7.24.7", "@rc-component/trigger": "^2.0.0", "classnames": "^2.2.1", "rc-overflow": "^1.3.2", "rc-resize-observer": "^1.4.0", "rc-util": "^5.43.0" }, "peerDependencies": { "date-fns": ">= 2.x", "dayjs": ">= 1.x", "luxon": ">= 3.x", "moment": ">= 2.x", "react": ">=16.9.0", "react-dom": ">=16.9.0" }, "optionalPeers": ["date-fns", "dayjs", "luxon", "moment"] }, "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg=="], + + "rc-progress": ["rc-progress@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.6", "rc-util": "^5.16.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw=="], + + "rc-rate": ["rc-rate@2.13.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.5", "rc-util": "^5.0.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q=="], + + "rc-resize-observer": ["rc-resize-observer@1.4.3", "", { "dependencies": { "@babel/runtime": "^7.20.7", "classnames": "^2.2.1", "rc-util": "^5.44.1", "resize-observer-polyfill": "^1.5.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ=="], + + "rc-segmented": ["rc-segmented@2.7.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "classnames": "^2.2.1", "rc-motion": "^2.4.4", "rc-util": "^5.17.0" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA=="], + + "rc-select": ["rc-select@14.16.8", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/trigger": "^2.1.1", "classnames": "2.x", "rc-motion": "^2.0.1", "rc-overflow": "^1.3.1", "rc-util": "^5.16.1", "rc-virtual-list": "^3.5.2" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg=="], + + "rc-slider": ["rc-slider@11.1.8", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.5", "rc-util": "^5.36.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ=="], + + "rc-steps": ["rc-steps@6.0.1", "", { "dependencies": { "@babel/runtime": "^7.16.7", "classnames": "^2.2.3", "rc-util": "^5.16.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g=="], + + "rc-switch": ["rc-switch@4.1.0", "", { "dependencies": { "@babel/runtime": "^7.21.0", "classnames": "^2.2.1", "rc-util": "^5.30.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg=="], + + "rc-table": ["rc-table@7.50.5", "", { "dependencies": { "@babel/runtime": "^7.10.1", "@rc-component/context": "^1.4.0", "classnames": "^2.2.5", "rc-resize-observer": "^1.1.0", "rc-util": "^5.44.3", "rc-virtual-list": "^3.14.2" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-FDZu8aolhSYd3v9KOc3lZOVAU77wmRRu44R0Wfb8Oj1dXRUsloFaXMSl6f7yuWZUxArJTli7k8TEOX2mvhDl4A=="], + + "rc-tabs": ["rc-tabs@15.6.1", "", { "dependencies": { "@babel/runtime": "^7.11.2", "classnames": "2.x", "rc-dropdown": "~4.2.0", "rc-menu": "~9.16.0", "rc-motion": "^2.6.2", "rc-resize-observer": "^1.0.0", "rc-util": "^5.34.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-/HzDV1VqOsUWyuC0c6AkxVYFjvx9+rFPKZ32ejxX0Uc7QCzcEjTA9/xMgv4HemPKwzBNX8KhGVbbumDjnj92aA=="], + + "rc-textarea": ["rc-textarea@1.10.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "^2.2.1", "rc-input": "~1.8.0", "rc-resize-observer": "^1.0.0", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA=="], + + "rc-tooltip": ["rc-tooltip@6.4.0", "", { "dependencies": { "@babel/runtime": "^7.11.2", "@rc-component/trigger": "^2.0.0", "classnames": "^2.3.1", "rc-util": "^5.44.3" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g=="], + + "rc-tree": ["rc-tree@5.13.1", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.0.1", "rc-util": "^5.16.1", "rc-virtual-list": "^3.5.1" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A=="], + + "rc-tree-select": ["rc-tree-select@5.27.0", "", { "dependencies": { "@babel/runtime": "^7.25.7", "classnames": "2.x", "rc-select": "~14.16.2", "rc-tree": "~5.13.0", "rc-util": "^5.43.0" }, "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww=="], + + "rc-upload": ["rc-upload@4.9.0", "", { "dependencies": { "@babel/runtime": "^7.18.3", "classnames": "^2.2.5", "rc-util": "^5.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-pAzlPnyiFn1GCtEybEG2m9nXNzQyWXqWV2xFYCmDxjN9HzyjS5Pz2F+pbNdYw8mMJsixLEKLG0wVy9vOGxJMJA=="], + + "rc-util": ["rc-util@5.44.4", "", { "dependencies": { "@babel/runtime": "^7.18.3", "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w=="], + + "rc-virtual-list": ["rc-virtual-list@3.18.6", "", { "dependencies": { "@babel/runtime": "^7.20.0", "classnames": "^2.2.6", "rc-resize-observer": "^1.0.0", "rc-util": "^5.36.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-TQ5SsutL3McvWmmxqQtMIbfeoE3dGjJrRSfKekgby7WQMpPIFvv4ghytp5Z0s3D8Nik9i9YNOCqHBfk86AwgAA=="], + + "re-resizable": ["re-resizable@6.11.2", "", { "peerDependencies": { "react": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A=="], + + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-avatar-editor": ["react-avatar-editor@13.0.2", "", { "dependencies": { "@babel/plugin-transform-runtime": "^7.12.1", "@babel/runtime": "^7.12.5", "prop-types": "^15.7.2" }, "peerDependencies": { "react": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", "react-dom": "^0.14.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, "sha512-a4ajbi7lwDh98kgEtSEeKMu0vs0CHTczkq4Xcxr1EiwMFH1GlgHCEtwGU8q/H5W8SeLnH4KPK8LUjEEaZXklxQ=="], + + "react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="], + + "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], + + "react-draggable": ["react-draggable@4.4.6", "", { "dependencies": { "clsx": "^1.1.1", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.3.0", "react-dom": ">= 16.3.0" } }, "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw=="], + + "react-dropzone": ["react-dropzone@14.3.5", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ=="], + + "react-error-boundary": ["react-error-boundary@5.0.0", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ=="], + + "react-fast-compare": ["react-fast-compare@3.2.2", "", {}, "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="], + + "react-fireworks": ["react-fireworks@1.0.4", "", {}, "sha512-jj1a+HTicB4pR6g2lqhVyAox0GTE0TOrZK2XaJFRYOwltgQWeYErZxnvU9+zH/blY+Hpmu9IKyb39OD3KcCMJw=="], + + "react-hotkeys-hook": ["react-hotkeys-hook@5.1.0", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-GCNGXjBzV9buOS3REoQFmSmE4WTvBhYQ0YrAeeMZI83bhXg3dRWsLHXDutcVDdEjwJqJCxk5iewWYX5LtFUd7g=="], + + "react-i18next": ["react-i18next@13.5.0", "", { "dependencies": { "@babel/runtime": "^7.22.5", "html-parse-stringify": "^3.0.1" }, "peerDependencies": { "i18next": ">= 23.2.3", "react": ">= 16.8.0" } }, "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA=="], + + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-layout-kit": ["react-layout-kit@1.9.1", "", { "dependencies": { "@babel/runtime": "^7", "@emotion/css": "^11" }, "peerDependencies": { "react": ">=18" } }, "sha512-tQO5J+Ajppu2JCdhgFaFbWCg01WJXXaQ5vg8cxzsv8vVeogJKGFgoJm9OI2saDFchfKP3RABd+aRY5vB++poqw=="], + + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "react-merge-refs": ["react-merge-refs@3.0.2", "", { "peerDependencies": { "react": ">=16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["react"] }, "sha512-MSZAfwFfdbEvwkKWP5EI5chuLYnNUxNS7vyS0i1Jp+wtd8J4Ga2ddzhaE68aMol2Z4vCnRM/oGOo1a3V75UPlw=="], + + "react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], + + "react-resizable": ["react-resizable@3.0.5", "", { "dependencies": { "prop-types": "15.x", "react-draggable": "^4.0.3" }, "peerDependencies": { "react": ">= 16.3" } }, "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w=="], + + "react-rnd": ["react-rnd@10.5.2", "", { "dependencies": { "re-resizable": "6.11.2", "react-draggable": "4.4.6", "tslib": "2.6.2" }, "peerDependencies": { "react": ">=16.3.0", "react-dom": ">=16.3.0" } }, "sha512-0Tm4x7k7pfHf2snewJA8x7Nwgt3LV+58MVEWOVsFjk51eYruFEa6Wy7BNdxt4/lH0wIRsu7Gm3KjSXY2w7YaNw=="], + + "react-router": ["react-router@6.28.1", "", { "dependencies": { "@remix-run/router": "1.21.0" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-2omQTA3rkMljmrvvo6WtewGdVh45SpL9hGiCI9uUrwGGfNFDIvGK4gYJsKlJoNVi6AQZcopSCballL+QGOm7fA=="], + + "react-router-dom": ["react-router-dom@6.28.1", "", { "dependencies": { "@remix-run/router": "1.21.0", "react-router": "6.28.1" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-YraE27C/RdjcZwl5UCqF/ffXnZDxpJdk9Q6jw38SZHjXs7NNdpViq2l2c7fO7+4uWaEfcwfGCv3RSg4e1By/fQ=="], + + "react-telegram-login": ["react-telegram-login@1.1.2", "", { "dependencies": { "react": "^16.13.1" } }, "sha512-pDP+bvfaklWgnK5O6yvZnIwgky0nnYUU6Zhk0EjdMSkPsLQoOzZRsXIoZnbxyBXhi7346bsxMH+EwwJPTxClDw=="], + + "react-toastify": ["react-toastify@9.1.3", "", { "dependencies": { "clsx": "^1.1.1" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg=="], + + "react-turnstile": ["react-turnstile@1.1.4", "", { "peerDependencies": { "react": ">= 16.13.1", "react-dom": ">= 16.13.1" } }, "sha512-oluyRWADdsufCt5eMqacW4gfw8/csr6Tk+fmuaMx0PWMKP1SX1iCviLvD2D5w92eAzIYDHi/krUWGHhlfzxTpQ=="], + + "react-window": ["react-window@1.8.11", "", { "dependencies": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" }, "peerDependencies": { "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ=="], + + "react-zoom-pan-pinch": ["react-zoom-pan-pinch@3.7.0", "", { "peerDependencies": { "react": "*", "react-dom": "*" } }, "sha512-UmReVZ0TxlKzxSbYiAj+LeGRW8s8LraAFTXRAxzMYnNRgGPsxCudwZKVkjvGmjtx7SW/hZamt69NUmGf4xrkXA=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], + + "recma-jsx": ["recma-jsx@1.0.0", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" } }, "sha512-5vwkv65qWwYxg+Atz95acp8DMu1JDSqdGkA2Of1j6rCreyFUE/gp15fC8MnGEuG1W68UKjM6x6+YTWIh7hZM/Q=="], + + "recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="], + + "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + + "regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], + + "regex": ["regex@6.0.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype-highlight": ["rehype-highlight@7.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-text": "^4.0.0", "lowlight": "^3.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA=="], + + "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + + "remark-breaks": ["remark-breaks@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-newline-to-break": "^2.0.0", "unified": "^11.0.0" } }, "sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], + + "remark-mdx": ["remark-mdx@3.1.0", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "resize-observer-polyfill": ["resize-observer-polyfill@1.5.1", "", {}, "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="], + + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], + + "rollup": ["rollup@4.30.0", "", { "dependencies": { "@types/estree": "1.0.6" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.30.0", "@rollup/rollup-android-arm64": "4.30.0", "@rollup/rollup-darwin-arm64": "4.30.0", "@rollup/rollup-darwin-x64": "4.30.0", "@rollup/rollup-freebsd-arm64": "4.30.0", "@rollup/rollup-freebsd-x64": "4.30.0", "@rollup/rollup-linux-arm-gnueabihf": "4.30.0", "@rollup/rollup-linux-arm-musleabihf": "4.30.0", "@rollup/rollup-linux-arm64-gnu": "4.30.0", "@rollup/rollup-linux-arm64-musl": "4.30.0", "@rollup/rollup-linux-loongarch64-gnu": "4.30.0", "@rollup/rollup-linux-powerpc64le-gnu": "4.30.0", "@rollup/rollup-linux-riscv64-gnu": "4.30.0", "@rollup/rollup-linux-s390x-gnu": "4.30.0", "@rollup/rollup-linux-x64-gnu": "4.30.0", "@rollup/rollup-linux-x64-musl": "4.30.0", "@rollup/rollup-win32-arm64-msvc": "4.30.0", "@rollup/rollup-win32-ia32-msvc": "4.30.0", "@rollup/rollup-win32-x64-msvc": "4.30.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-sDnr1pcjTgUT69qBksNF1N1anwfbyYG6TBQ22b03bII8EdiUQ7J0TlozVaTMjT/eEJAO49e1ndV7t+UZfL1+vA=="], + + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + + "s.color": ["s.color@0.0.15", "", {}, "sha512-AUNrbEUHeKY8XsYr/DYpl+qk5+aM+DChopnWOPEzn8YKzOhv4l2zH6LzZms3tOZP3wwdOyc0RmTciyi46HLIuA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "sass": ["sass@1.89.1", "", { "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-eMLLkl+qz7tx/0cJ9wI+w09GQ2zodTkcE/aVfywwdlRcI3EO19xGnbmJwg/JMIm+5MxVJ6outddLZ4Von4E++Q=="], + + "sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="], + + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + + "screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="], + + "scroll-into-view-if-needed": ["scroll-into-view-if-needed@2.2.31", "", { "dependencies": { "compute-scroll-into-view": "^1.0.20" } }, "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "semver-compare": ["semver-compare@1.0.0", "", {}, "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow=="], + + "set-value": ["set-value@2.0.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-extendable": "^0.1.1", "is-plain-object": "^2.0.3", "split-string": "^3.0.1" } }, "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw=="], + + "shapefile": ["shapefile@0.6.6", "", { "dependencies": { "array-source": "0.0", "commander": "2", "path-source": "0.1", "slice-source": "0.4", "stream-source": "0.3", "text-encoding": "^0.6.4" }, "bin": { "dbf2json": "bin/dbf2json", "shp2json": "bin/shp2json" } }, "sha512-rLGSWeK2ufzCVx05wYd+xrWnOOdSV7xNUW5/XFgx3Bc02hBkpMlrd2F1dDII7/jhWzv0MSyBFh5uJIy9hLdfuw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "simple-statistics": ["simple-statistics@7.8.7", "", {}, "sha512-ed5FwTNYvkMTfbCai1U+r3symP+lIPKWCqKdudpN4NFNMn9RtDlFtSyAQhCp4oPH0YBjWu/qnW+5q5ZkPB3uHQ=="], + + "simplify-geojson": ["simplify-geojson@1.0.5", "", { "dependencies": { "concat-stream": "~1.4.1", "minimist": "1.2.6", "simplify-geometry": "0.0.2" }, "bin": { "simplify-geojson": "cli.js" } }, "sha512-02l1W4UipP5ivNVq6kX15mAzCRIV1oI3tz0FUEyOsNiv1ltuFDjbNhO+nbv/xhbDEtKqWLYuzpWhUsJrjR/ypA=="], + + "simplify-geometry": ["simplify-geometry@0.0.2", "", {}, "sha512-ZEyrplkqgCqDlL7V8GbbYgTLlcnNF+MWWUdy8s8ZeJru50bnI71rDew/I+HG36QS2mPOYAq1ZjwNXxHJ8XOVBw=="], + + "slice-source": ["slice-source@0.4.1", "", {}, "sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg=="], + + "sort-object-keys": ["sort-object-keys@1.1.3", "", {}, "sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg=="], + + "sort-order": ["sort-order@1.1.2", "", {}, "sha512-Q8tOrwB1TSv9fNUXym9st3TZJODtmcOIi2JWCkVNQPrRg17KPwlpwweTEb7pMwUIFMTAgx2/JsQQXEPFzYQj3A=="], + + "source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "split-on-first": ["split-on-first@3.0.0", "", {}, "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA=="], + + "split-string": ["split-string@3.1.0", "", { "dependencies": { "extend-shallow": "^3.0.0" } }, "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw=="], + + "sse.js": ["sse.js@2.6.0", "", {}, "sha512-eGEqOwiPX9Cm+KsOYkcz7HIEqWUSOFeChr0sT515hDOBLvQy5yxaLSZx9JWMhwjf75CXJq+7cgG1MKNh9GQ36w=="], + + "stream-source": ["stream-source@0.3.5", "", {}, "sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g=="], + + "string-convert": ["string-convert@0.2.1", "", {}, "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "style-to-object": ["style-to-object@1.0.8", "", { "dependencies": { "inline-style-parser": "0.2.4" } }, "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g=="], + + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + + "sucrase": ["sucrase@3.35.0", "", { "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" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="], + + "suf-log": ["suf-log@2.5.3", "", { "dependencies": { "s.color": "0.0.15" } }, "sha512-KvC8OPjzdNOe+xQ4XWJV2whQA0aM1kGVczMQ8+dStAO6KfEB140JEVQ9dE76ONZ0/Ylf67ni4tILPJB41U0eow=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "swr": ["swr@2.3.3", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A=="], + + "tabbable": ["tabbable@6.2.0", "", {}, "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="], + + "tailwindcss": ["tailwindcss@3.4.17", "", { "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" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="], + + "text-encoding": ["text-encoding@0.6.4", "", {}, "sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "throttle-debounce": ["throttle-debounce@5.0.2", "", {}, "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A=="], + + "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toggle-selection": ["toggle-selection@1.0.6", "", {}, "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ=="], + + "topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="], + + "topojson-server": ["topojson-server@3.0.1", "", { "dependencies": { "commander": "2" }, "bin": { "geo2topo": "bin/geo2topo" } }, "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "ts-md5": ["ts-md5@1.3.1", "", {}, "sha512-DiwiXfwvcTeZ5wCE0z+2A9EseZsztaiZtGrtSaY5JOD7ekPnR/GoIVD5gXZAlK9Na9Kvpo9Waz5rW64WKAWApg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + + "typescript": ["typescript@4.4.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ=="], + + "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + + "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], + + "use-debounce": ["use-debounce@10.0.4", "", { "peerDependencies": { "react": "*" } }, "sha512-6Cf7Yr7Wk7Kdv77nnJMf6de4HuDE4dTxKij+RqE9rufDsI6zsbjyAxcH5y2ueJCQAnfgKbzXbZHYlkFwmBlWkw=="], + + "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], + + "use-merge-value": ["use-merge-value@1.2.0", "", { "peerDependencies": { "react": ">= 16.x" } }, "sha512-DXgG0kkgJN45TcyoXL49vJnn55LehnrmoHc7MbKi+QDBvr8dsesqws8UlyIWGHMR+JXgxc1nvY+jDGMlycsUcw=="], + + "use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], + + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "v8n": ["v8n@1.5.1", "", {}, "sha512-LdabyT4OffkyXFCe9UT+uMkxNBs5rcTVuZClvxQr08D5TUgo1OFKkoT65qYRCsiKBl/usHjpXvP4hHMzzDRj3A=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw=="], + + "vite": ["vite@5.4.11", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "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" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q=="], + + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-uri": ["vscode-uri@3.0.8", "", {}, "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="], + + "zustand": ["zustand@3.7.2", "", { "peerDependencies": { "react": ">=16.8" }, "optionalPeers": ["react"] }, "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@babel/helper-compilation-targets/browserslist": ["browserslist@4.24.3", "", { "dependencies": { "caniuse-lite": "^1.0.30001688", "electron-to-chromium": "^1.5.73", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" } }, "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA=="], + + "@babel/helper-define-polyfill-provider/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + + "@babel/plugin-transform-runtime/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], + + "@douyinfe/semi-foundation/remark-gfm": ["remark-gfm@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA=="], + + "@emotion/babel-plugin/@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/babel-plugin/convert-source-map": ["convert-source-map@1.9.0", "", {}, "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="], + + "@emotion/babel-plugin/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "@emotion/babel-plugin/source-map": ["source-map@0.5.7", "", {}, "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="], + + "@emotion/babel-plugin/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + + "@emotion/cache/stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + + "@emotion/serialize/@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], + + "@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="], + + "@lobehub/icons/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="], + + "@lobehub/ui/@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@lobehub/ui/lucide-react": ["lucide-react@0.484.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oZy8coK9kZzvqhSgfbGkPtTgyjpBvs3ukLgDPv14dSOZtBtboryWF5o8i3qen7QbGg7JhiJBz5mK1p8YoMZTLQ=="], + + "@lobehub/ui/rc-collapse": ["rc-collapse@4.0.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-SwoOByE39/3oIokDs/BnkqI+ltwirZbP8HZdq1/3SkPSBi7xDdvWHTp7cpNI9ullozkR6mwTWQi6/E/9huQVrA=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + + "@radix-ui/react-popper/@floating-ui/react-dom": ["@floating-ui/react-dom@0.7.2", "", { "dependencies": { "@floating-ui/dom": "^0.5.3", "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1T0sJcpHgX/u4I1OzIEhlcrvkUN8ln39nz7fMoE/2HDHrPiMFoOGR7++GYyfUmIQHkkrTinaeQsO3XWubjSvGg=="], + + "@radix-ui/react-popper/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + + "@radix-ui/react-presence/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw=="], + + "@radix-ui/react-tooltip/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + + "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.0.1", "", { "dependencies": { "@babel/runtime": "^7.13.10", "@radix-ui/react-compose-refs": "1.0.0" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-avutXAFL1ehGvAXtPquu0YK5oz6ctS474iM3vNGQIkswrVhdrS52e3uoMQBzZhNRAIE0jBnUyXWNmSjGHhCFcw=="], + + "@visactor/vrender-kits/roughjs": ["roughjs@4.5.2", "", { "dependencies": { "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-2xSlLDKdsWyFxrveYWk9YQ/Y9UfK38EAMRNkYkMqYBJvPX8abCa9PN0x3w02H8Oa6/0bcZICJU+U95VumPqseg=="], + + "antd/scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "cosmiconfig/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3/d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3/d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-dsv/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "d3-fetch/d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-geo/d3-array": ["d3-array@1.2.4", "", {}, "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="], + + "d3-sankey/d3-array": ["d3-array@1.2.4", "", {}, "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "extend-shallow/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "geojson-dissolve/@turf/meta": ["@turf/meta@3.14.0", "", {}, "sha512-OtXqLQuR9hlQ/HkAF/OdzRea7E0eZK1ay8y8CBXkoO2R6v34CsDrWYLMSo0ZzMsaQDpKo76NPP2GGo+PyG1cSg=="], + + "geojson-flatten/minimist": ["minimist@1.2.0", "", {}, "sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw=="], + + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "hast-util-from-parse5/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "hast-util-to-html/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "hastscript/property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "leva/react-dropzone": ["react-dropzone@12.1.0", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8" } }, "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog=="], + + "mermaid/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "prettier-package-json/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "prettier-package-json/glob": ["glob@7.2.3", "", { "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" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-draggable/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + + "react-rnd/tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + + "react-telegram-login/react": ["react@16.14.0", "", { "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2" } }, "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g=="], + + "react-toastify/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="], + + "sass/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "set-value/is-extendable": ["is-extendable@0.1.1", "", {}, "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw=="], + + "shapefile/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "simplify-geojson/concat-stream": ["concat-stream@1.4.11", "", { "dependencies": { "inherits": "~2.0.1", "readable-stream": "~1.1.9", "typedarray": "~0.0.5" } }, "sha512-X3JMh8+4je3U1cQpG87+f9lXHDrqcb2MVLg9L7o8b1UZ0DzhRrUpdn65ttzu10PpJPPI3MQNkis+oha6TSA9Mw=="], + + "split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="], + + "string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "topojson-client/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "topojson-server/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "vite/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@babel/helper-compilation-targets/browserslist/caniuse-lite": ["caniuse-lite@1.0.30001690", "", {}, "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w=="], + + "@babel/helper-compilation-targets/browserslist/electron-to-chromium": ["electron-to-chromium@1.5.76", "", {}, "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ=="], + + "@babel/helper-compilation-targets/browserslist/update-browserslist-db": ["update-browserslist-db@1.1.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.0" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse": ["@babel/traverse@7.27.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.1", "@babel/parser": "^7.27.1", "@babel/template": "^7.27.1", "@babel/types": "^7.27.1", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="], + + "@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom": ["@floating-ui/dom@0.5.4", "", { "dependencies": { "@floating-ui/core": "^0.7.3" } }, "sha512-419BMceRLq0RrmTSDxn8hf9R3VCJv2K9PUfugh5JyEFmdjzDo+e8U5EdR8nzKq8Yj1htzLm3b6eQEEam3/rrtg=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot/@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.0.0", "", { "dependencies": { "@babel/runtime": "^7.13.10" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0" } }, "sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA=="], + + "antd/scroll-into-view-if-needed/compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="], + + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-fetch/d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-fetch/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + + "d3/d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="], + + "leva/react-dropzone/file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="], + + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "simplify-geojson/concat-stream/readable-stream": ["readable-stream@1.1.14", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ=="], + + "simplify-geojson/concat-stream/typedarray": ["typedarray@0.0.7", "", {}, "sha512-ueeb9YybpjhivjbHP2LdFDAjbS948fGEPj+ACAMs4xCMmh72OCOMQWBQKlaN4ZNQ04yfLSDLSx1tGRIoWimObQ=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/generator": ["@babel/generator@7.27.1", "", { "dependencies": { "@babel/parser": "^7.27.1", "@babel/types": "^7.27.1", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/parser": ["@babel/parser@7.27.2", "", { "dependencies": { "@babel/types": "^7.27.1" }, "bin": "./bin/babel-parser.js" }, "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@radix-ui/react-popper/@floating-ui/react-dom/@floating-ui/dom/@floating-ui/core": ["@floating-ui/core@0.7.3", "", {}, "sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg=="], + + "simplify-geojson/concat-stream/readable-stream/string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], + + "@babel/plugin-transform-runtime/@babel/helper-module-imports/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..1e75f3d7 --- /dev/null +++ b/web/index.html @@ -0,0 +1,19 @@ + + + + + + + + + New API + + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..a313e0f5 --- /dev/null +++ b/web/package.json @@ -0,0 +1,85 @@ +{ + "name": "react-template", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "@douyinfe/semi-icons": "^2.63.1", + "@douyinfe/semi-ui": "^2.69.1", + "@lobehub/icons": "^2.0.0", + "@visactor/react-vchart": "~1.8.8", + "@visactor/vchart": "~1.8.8", + "@visactor/vchart-semi-theme": "~1.8.8", + "axios": "^0.27.2", + "clsx": "^2.1.1", + "country-flag-icons": "^1.5.19", + "dayjs": "^1.11.11", + "history": "^5.3.0", + "i18next": "^23.16.8", + "i18next-browser-languagedetector": "^7.2.0", + "katex": "^0.16.22", + "lucide-react": "^0.511.0", + "marked": "^4.1.1", + "mermaid": "^11.6.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", + "react-fireworks": "^1.0.4", + "react-i18next": "^13.0.0", + "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.3.0", + "react-telegram-login": "^1.1.2", + "react-toastify": "^9.0.8", + "react-turnstile": "^1.0.5", + "rehype-highlight": "^7.0.2", + "rehype-katex": "^7.0.1", + "remark-breaks": "^4.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "sse.js": "^2.6.0", + "unist-util-visit": "^5.0.0", + "use-debounce": "^10.0.4" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "prettier . --check", + "lint:fix": "prettier . --write", + "preview": "vite preview" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "devDependencies": { + "@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6", + "@so1ve/prettier-config": "^3.1.0", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "prettier": "^3.0.0", + "tailwindcss": "^3", + "typescript": "4.4.2", + "vite": "^5.2.0" + }, + "prettier": { + "singleQuote": true, + "jsxSingleQuote": true + }, + "proxy": "http://localhost:3000" +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/public/azure_model_name.png b/web/public/azure_model_name.png new file mode 100644 index 0000000000000000000000000000000000000000..36828db217203c57aa811839731d53b39ac9e633 GIT binary patch literal 256912 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 pfqL?~2!P?;pggm-BNwKBY%G$& zpqs-mDh(;uVd?Y`_)}nurVEZb5nnu7$oI4bQ``M8MlH$(0P^PU4IpTqHm!)1(E}W@3BELD-?eWGoeVCFq%vpN zmjS@{I@ji&bvd)}j}Nrh!5>(`@oQU}M-xuF8+3aa_D_lb0N9hA&@1XdU)~mm5wJpab3o2$8Be1iDF#ZPa+}}0;AeluSfV3@aRShz8 zqDn=3)_6_#KfgQ>jWE5OnjkF;JT3}zENk{;^G^B9a!S8xcJtSaRL{g?jH>YaTKn;Y znc2$iKe`rF?r6G6NhA|Y4t6PeCimTMr~b#9()2_=tD{1#+0`O#O2yEsWsJ1vaDeww z2yK)3+10A7tp74Q3w`E(lSfoa#fYj40Muxhd%K(2WW zOT!(o_c9z-1r!E-(C(kn$n7YK>@BQ&mMG(GB7PXoU_%955+C6?02{_Jqf>9*9%wV| zLWnt4E$y_$rOZkr{QKH~r7*99Vja`R&+_Yob!P0|i*CHJZX@+$7Weu)2|PSOz($Kg ziOnw37A7}M4Lh-?&R!V5$FMwGGYrsdNneqzOT(8gFBfS#31+OwGW zpnevRH|5{7{U4KV4|o-P1X!4(7!?w9JrTrov*L1N;|-wMBxc@^s&V=xbzkp=$uj;U zTO|dh$j<}!vN8mp#QstS05B4T?=7+1)^YsN2)Y=hon?QmqjpeJ7T|og8g#R|a9kD? z0M@f{rr4@>T?6Qk3@w+lyEF!xKAvme!ej!~M5Z`XjJ3u$a}(5g;0cAS*}MjzCtq~$ zeR2wF+G*G%BFK^79=yF8q>bx+k&OwHy;@k1oIgkJUGE09f(obb(BRjB2MJkH&)!^T z+~R)*f1Ylk7*f^!0|1WJ-O6s%CAX`&;nh6ZI*@=3?JOSWd)($0rYNXD&h|gT*dq|2 zrgnz&=mNNzULWmJWu3hMe;%O7zWRrn>RI1A-@zbcZhE^% zcB|>s^$)_vqwgr7Au^_&b@9h7gSmCnWS2)y56QmB%Mi@%4Q~~j_dm?Jkp}_BSq4KE zlpu>FiGB5WPu1v75;uzrfV^7=Y%YL>9vw8?B|7x`Y{2q5Jy0AGFnAeutzPdiJc*olZe5sk zG!ru3->X$gzsy1gra}#!>&K9JF1ilfGe<+P@!235SBz(SVBP#b0NDk+@fwaDB1{b+RIPeJk&CkQ7<0M5jUI0pw!IS2_AGX>C*!F}e4 zy1iO2XJ00Nxf6?Phx?hXRRRJCN1J#re1g8n4LYLO1%wbHA24M|a_qG8CWE=-}+n>L^p0|$LN-h+2Tr>4QH9j)&8^^Uf|3{we%bCA9sm=T6 zk8XK$gLFe^jbXoX&SRy=e66s6`>{>~2+N`LI74@X_LQLI4zG~+?21{gD+WQjt?4oI z1D6H>=`ZJ|li)$ss7x4{t<+`3LP&~#CCC6j>+sLQ=ZOG6@k5DShUmI2iMH2zvy#T= zdtRYSF8(hI;FYuF-YfJd%)VGt6I}N&L^1(hm2o1S15%x6Jxuv$97kXmRr3a=8sCX` zZ~I|mo40SpcR#T(dCqLcLJ;?ZaIQ(8UaknS;2vo&;K3v>Gh6SSNS=RwY3aT)wG)2Km;l1rL% zS34ZZl!{~Q@nRABewhiZb4ZbhBHZ_HDtUeIU=odM$wu!U5@vJIud5tvU$Mg>d%Z~B zh&{9GF|#BqEf&hQ)23DFNP=!Jz`e)3qe>6!f0-4J{)Z>z>8Uwaof<#=p*#w76z=Nq zLFz!<@F+nnNtv!4SOHAE%(iM}-~zB$61th;{f2hWUoKMX8NWt48FITFqP<$GbZUK= zf8xFnmH`ldUx+pLzmWyl@xW{t$IV{e&HjB2H@55(e-3(6d088DSqn|{IjPDrffpHj z$=;KE!~(+;A8GNii$r7Ig#Y-rUQ}KpJasg|Dewn!6u`K+Z=o9@tXcwOd*9JU75+2p zddn8Nhnzbjmx-rmvYT6Z%f9LPA-&nhtncU>6H^Km$2oFj0j+pl zgR;7#3OfMsYU$p{l!2muS+KCs%bV^Gn=1E_+8uO0_#Z2{|E*G^6;=MJ&x%4h4zEA4 zm#cg#%&*4?t|sd-FK7scGTQpui}Hs&y(~CY(~Kwf&tFgBF1R!bm6>r|=!er+AGqhC)%e9iP&lUehl&fXz*XKK9b*VYX`8GGcL3=8igA>{)!wg&1|t4t-mj({ zKbbA5z@Bo(ZjPkOsv?|z`m$lQEAn{V4`y@m!RYP>a7<*lXNlpIeJgvIWm$8O_${!6 zQd&=T!-`n0S1Y&o4<>GAVQrSJJY(90^`(JFS-r{KDyHXRZ3bSgWpF1y7QY+VdeM#N z!p&~VAxgb|TP<=e*NFMCu89mDmgEUMemjb161YWAW@xXDVP|<>NzPHoh@9x32*vE{b~n_=6Yv#Vq%3~!4RuxzIL!U#zvc9VtBQJ8uw0?joiAyl&PFyb`X z4+wXI-A;_o+174lNfGp68$#6Yy5`S%KW(N+gEhSZb}jiISax+?Yt;Kb*ERg_Ji|4` z=vSZ_Jl;w95e@<@&f$%YaC=8jF8?w#m$^XdFz?xf8(rgzsbs}%B{-NBl!)NPJtM%3 z+Y?U!QDjguhfz+!h6*&6gB<&2K89nK(xRszh-q&YZOL5eeD@V(C^iu%)X80F?qp7u zQ>*9053~U!-q6gE+d%J*<(FXd6jNV&Oy<9SiF!+`&adDv)!;{3e01>dT+?(t;j7(m zWnN^PRAy7&Sp!=I3`d&kOmP{Zh~UA_DfCtIfQSQnUTgg)f8{EIZ`J6&5ziNG>anI5 zrWtQ-98HWWUWLH}t#pG5%OIh{oQc~$-ua8sj1BYBJA=Z3!ZMCSA%Yq-;Phh7A@Xos zpf;9Hivf$W1&!8%#=OIBZ|0}I))v-)vL*2E=qqr+U-FdtdxP}z*aj7e;O~J3(`1zu zGOX4!6|(+e@pXd0s1hnvU%mCRsAw2EjZAYa)!B8dGzJMW0e#Z&>D5-AKKm>6#C;@_ z4?rwm!N5h}SRU}SjL{Uv$4E?C2P8O68CTU@>en?G`lIQzw(ZOAjT(3mWM5y(v)!I< zCd?Q(yxpejaux`lEDe}$r8Y0&bO!f$&RoX6E&D#mgswZRSXk8auLr%HYQCv3dk0!( zzETDcA+D{bf0TTM^RhhJvmUfxW~}68zCp8L`|n@zEWphajeS?kuXip}K0lUAy8$*= z!w0W%ryuhBtvTjx6mhXGh8~W#w60A-i7gL{PmV!~4D+9$27061t`xtS2rLUt7F#`HC{?DbF#hMo@SWAfwd|ca3 zo65D&r#B1eYXzW0Cgge8;kKEhF}SpJ_;(XCjeS=N$Gh72?2|P0U05595n9NxlqFL8@hH2nZaUkTC;b$O!y=m zX3O4`K5sbF_&RQ&sKu}i#metwa6Rxm^Ak3x|z*idyE4EK9jGT z@F{T`yTP*p>Xw=jgmTWc8NTSbhSXnXmF#b)8fkrkN`VEyb?Uwwj>P8BA>ZE18~3_>=~ntupTig;_)?SL zmR;qf44welgYL_vxpy8_P2cBE+Tg|iI}O=TO>QU4LIKP9tRSTE=v$ezdP?)fW}9`{xy`pLyh$wzTgi1`e|{wn!z&(5 zcK;pSwCL03*|@FO(m+T8nZd9%3=*c0gdzO;Wf41)2eBz`^tkI*I9cJidy#BH1L7&+~0 zFH{6xUkKsDRRDn{lDw}v4U=Ct9u?Z>LU1Q7dD&Qdh0kP8?&F-+tuUXCpV~do+5Xz% zoHQod!kQ#8t|Lzhg3aE8FbdHOLWDannAwf)aGrqMi=m+2Q+0J*S` zRZ_OxE1h|tY?+{3&JzSQO@M3xj|}3pf`YOkVOIr{_tQI_o8d%qQYbJ?*J%866NmwH zABES7qb(E)XaqV+;iT&O;n(4yqrN;^VP100ygP?aP()a^&oYExhdMoB9>n<^@QEr_ zP(jX8D5mQi1x|anTSO@EJ%@D9(PTPMjLU%i(BW@J)WS33yJIgpo&P>j1W{c(?iNJc z6qwfJjrzRZB4afHBAk(X3HY9h9avy)XDz)xo)*j6m=7pBoWyM^IWLkKJy&0Uw#*L_ z3D}nmfaH#dVOz>vM)+Gtl~AR^3!n*^dst>EsAko6+~4!=bF)U$%Pe1ybIQDucpmgW zt4bq400$`z3BXF#p;sV8nbXwXro4{kee3CGe-e0P3U@aLToa`{O5KhfAPV zd^G$`O4FiBOhN^ysx)A;H4*15qin^CbyP{iZ#ML1+}RuRjn7B`Y%2o;HC0(3{VX!o z1l2CR^FA+cV`h*$|JwVe=ab)H;G7vXjLba$snb?e<&dc4jw!0LXzuJ9B2_q|wXnkb z4H*idaSlFy25EKqmy~*DJFAZjE_9MS`fZy$UsD5QoI_{HWS#5Qth1ON`>)%Ewb7K; zTo0(AJ*StttB5os6lJ2#5tkYM*5I2c{!_@o)lLIn6*qtXbVE8-+N5U+_dD6$|H?f{ z{L`yrg;#mQnNWB=X&hR|q1#D-CV{T>&6@%|9lIV;_R|6`!^beZ?pV4F;#cv{JEtYY z%}ppD{z`oCqbx6W`Cj%n*=$l&uQ=xdoE{bTqBQ{R`69wGVBAopFqNY+-oI%$rXHNA z6JCuCasJQ=pD&DoLnO$$Jb(KWl56HcI^IajMY=)?LE?q!I(}e>gO^H6vEB@2pMUxc zhP1hxC%J;t7-v{OcQjZ01t(^32=1$l%P(r=cY|WpM85bG$v`?6>N(760I>!T4r}NY zXkbszI!R*APoW9$||bVZ_cs zY{yAh6*7ZGkcLR9k?4KMjt(g}X4(%VHBLHT+(8#dn@t=@@5pmB1>gr*zAGQ*M3bVLV)qWs>=i}N6$+Ssg+wL*h#{Vuph|Y2?0tEuefwfIzvZ- zbzZG()d=QXS|u~5s$}M+%MiVlS#fGCO2JwFN=jemhvBVM#>Yr|#+HI+x+{TEXih1)?^p!?L; zay3-%e8(Qxh1x)Dcdt5N2PH0#vSS>-eJ=}qRxsmslY(qUJK(^4Yaiq?sI0F%# zIfFhZ1!NnqrQp>F2gO>BgXEb8P!$vWVr#sT;gXa<{2U zM@{K0_?a9XHKQC|#7~KdFl<ew zwW44&=+0U4=O&KL3-Dci;pq<0b4rJ7T=QQI$~l~iQ#GD)67UhN+H52XzUDsu$8z0A zWB2Ykt90~a2BI{Tc6~5l zj$y^df7yNOs`!hNoT`8H%&37B2Lq}AKCnVMRMVOH48(5EGDITaV&ZlO^I3lrA)=?G zFxZnU(=BKkN9K0N4q93Y04G*BS-7%)z#lab79$^nu=d&XY*m=vLWd!Q&Yw7G+uHM2 zACbCAg2rG73%&pq119zOcMX)O{%_nuf8(4siZ44>xR11P&7iHJ1jz@aI53ZWvBcKX z*3)Ko%vh(bB@j=S&UNCMP~+>RyUp*Ah->;G4M<5|tjI4Q18a;RZ1tNQ_R*>(p8 zjX>&v_pV>XX}hy~;elE49WV+KGCuD9&(?nO=hO9*Q)OYZE33t=o?bnR3ltofouWd?j5OIR$cj!eFS)*Nl&iyjGmUJgd1&0Iu%wqG570 zKt|Sfw!(VJFr%P~JC8tSE)^4G`xxp5jR+_DyUp;e(6x*s)DV5jYDUjojW zf)*$6f>K3;fe0Zm4%|SZdH4(ZR9OSAF~y1lSf>7YKp?KLb28?n#XdKLlFnw}OWLCa zY79p$a)`lpnN0tV{&()>@j7W2)B4sMhb5_8NZ*OH5!)b;ELVO>i+usb04aJau*r$k zMzK}_?u^9RRyuL{3M{}jLE=_fH@-~uUF#*dZLZ5i-1rE!szN;l6r-wtwrbu%U@Ytz z?PA!`SJJSPzLDs|cDVO_Bjyb0{LlR!X=0pIYlU@MIGrVxwq9rvOXey0ms|&@!~Nw1r;T*GFn9&MTfhP~ z;u-Fpc){Ng(3XsDCNoIwi&RPa`niVJ?3V0PZKvI({q$-RuDH8tTGhIztbbeD_XL7) zhbSOD$Cu|oPFJ`3diCy{=YMqwj5-W|#X-x*!*uVH&gkj7_X~C4b z7MS0JtRi@hqY&Hsa(4vc8Sj0l|9cNa$co?H=I1zcP{IX@5djh0OA(B(GQfS%(N!#q zBG3MVytSe*sa8KT#j))q*!wN%7GJN=FV*e8Q4jeDl5=LUO3MI(mrZMD3>%zUI=dCl zfo();f9sU_`SE?UKAQCT_%w}4Wg{h4l?7>RIDL|7a2Fe;9*=KXfwoGE!WlLGK5EK3 zdx83Q3iK$^f! zP7}Bn+vTgzp!zs&;70#E?aP1V0myIgrSaf|L>8*c=iZvSdiEhu^F{lFm)THOTOBLU=S7cTsO>Zy0@VW1*)Am7S@fAw7~$(ql> zsF^I{c&7CMYe7kwcfm%n&e?X8{h-#?|A}3Xx|AGg#EHRhI4~+YMg|woegm%p(IdOT zZHAD}R}MciT$9@twOvEp0x1AdP>CL)Sk~1{rQTra&UI$ikE@(fd|1IRIz&oqhc7iz zssXc}+@O??M!yZS#Kz<2o%eCMl-}>Z@<@3hb6O1KMs-juQaanoFXp8wtyndAvfzXg zt3_N*?_*wEMoaF{Nw+#v)&QD|2{`5{&dulHD4^KGuj>uQhahSK3VI=IpuvqYhp#y0 ze?;K&1OzF>Zl=*#|%EM$&~YPQ!<4goS%gqANj3)<}H1Y?J_Gv zuc4b$@(s+h{>ede)84s;xVx9utS49bk?rp|;($U`T&ZDQp}LXJRluNMf0(ntioe0w zQwCnlv6y6g^boy2+Q8e1yFO;CE5C3Ap$o1LMz|o~>DLaNo1v%N z0#nNr*{~794biRpf1%-1w~0le?q+)n3n8>YZsIoA0Z^-kMUVL@*xpU%F~-j*@ep}b zb?hmbQYJN}K#1G8Kig`M9mG=VeI^M#JFrGG(A?Z20K16dGrJ^_SiU)!xSECa@U{vu zdd;e+d@?QtZ<9(LhC<}Q0VmS?2as4W2FRyrggMTi@V9xm+}1*RXZLo@Qk?Ti`(?L& zPxm6V%n(xV-kNsF{!RM3nR5Z21J65gLn#|dD+eaTY4_cpsq5BkSMSFCh22U=i(3jZ z`PhJe>bMn@8og&?3iJi#G7tAcf51(a!_Xie76VXdR_GOKfP9O zBF3M<@~4!ANSC$J*ulwn3pe(}u#c#v3l(+vYH&aHBAWrZPtb3g=P0dLT6Z)@0G^Uk zYCyQ0QM}5wWQuAY*&YFQfj(@mu`FYhqtY6vVWFP0?fBG&LYd?=h4*tm@B4k{`)scRt_!Y}kc>6vm}8FdpT7wMfdWC= z2vsA**mnzHKPgkZOI(q*>(}O^n5ThyAnHI+P7r~#!_;qln*#cKl#`WEa~-(M54N0bk%-0gsvxlwD#U$wn3-0Hq=UY3&y_TmKRg8S6^u zVgLSo)57r@&_g1waHJWqhlsUjl-5Q`u7|CF^d*8y!laF%8ZvcgjBxnKJUPq&Wl zW4G4uaPH)e9wn+sbEEDKTvORA9iqk{QcEBogI<6HJ!1ToCt8@Ek*AR`9$Y?8Q5@8& zOF9jZD=?I3shCxKCD7n3s^zI73KUZfDQZt2=2M`4oHx0yHkF14imIf3xfBifdXp@W zzUn*GGh$i-+-60m>Z9oSx0L?1&e4qElyL4x?DqNQ zXT|{Ii)M@axxNY2OWatU_UzJRB;8$V-IgE$#uRC|GCAhWNngDku-iBz zVBc6?CC1K#+QRB(A91KzPbZ$^oY25z6+fC#2K18U^X8eDHZF9qUZapFLqovGsFpDT z^;KQ%i9hr^Yoenu3q1OkyYW=2aQKO_v~7P2vs=>YSVR{ewc*z zXlXONAIM92*U5IWjD+j&T|q$Ky!AJxnbRRI!8<1IA&A|H@54*4A&U8`_-cLu1ROA8 zuD(+GXzEu@Ji$2rQ>U8U$rGI!qt#kH5|EO35E z$Ww@aH9@ojPuI)|c8qXZygFS!r)Hs2|#RzSUiek+=>0r$E3 zr(P>}nmK#ps+r-E)>2fRbNUj<7PGDDbhq5j+8^DYt9^Rva6A_%9Irn&k;zUXrZi^k zF`-uYBLie5(CypSA~Q%Z&{*kgOmyue;M`(w&a~G|)U!Lkr%v12x(QPVE7_#E^O&rt zx_7ksID)9c?TPtqUd7E?A{MuuOE1wgWT8!Qd5-_-)UCHuzvIt~GJh}IlDa70dJPa- zd%2t9J{bf}oz(uXBK*C3)h+4Q*o@`z5}c*`IzeZpFfvc>T|$`la%MFV5-(GmBkMPz zCy4yRfa(^t@+NR7i#oN+TnwQ(^{<_0k>!r!kyUO59iTMvM9i&GJR1=C!0AVB4vp&t zB#*TX*FB7e3m)T1y_4u_#B*@jtGLm^)P(oSxx3_jPy_E={Q9w4fg_FY4Nf<4z4f6z zRLnF=ao37H5S|+XCkWu$1)AdDT|xMV>6~!4axWrGQ6aERFo3$cSG2h?(+$u_Lt7G0 zOPTJd8r%4>7D`1aj7pKo5)36e1g7jC;m@-^Yr!L06w_2VbT1s))diE7?A5mjWWkcMS4fTGA@e1Aj>W`C69`K#Q?9G#-k-G0bBWlv-|8MHJRU+sV;@d3f_B`snq$J)5zNE ztu(JLe6b|kHy<-H7hYNb#VecQp#nSZKJhu&jb~H1*bVujFB)<2Dx~yvKUHx#`?C}G zr7zb6wq!`=bd6t2&VpXA*ByU}Fz8}B-fyM-o@F;iCL?ONs(Aj?&*Fb+j33{|N7WT; z`{F%HRJqhK;tor^GZ*0~7?>1FUw|itFJ za_6+&7f34cWuA0!N7 zw0odnOXm83wEiA&_L{)rYF@1&_heZ}ZKdU?!20)@r1Ez|qTabf*R1WBr8pvmjxnM8 zzd#Q`t-dVQ!a%<}L!8O2E>HhN76i||e+i>( z$&CGEm=*s=#MQJ`{Px5VP&bP<%Wup~nARD|o%x?CW}D$%quOr`beg>w4)bYJ-nbGd zj>_v^?jX;Nzk*PJY@YG&J>VAMdSI% zWKPvRNdFREZo($F;k^_2c5(yIVbN~*N56O619I`YYS;Mi+9vWcJe&-(2rGIftUHKSyED0#i?#wew#3;$ z=2N_TAMMyyp^>KAh6Idt@%Z5&$-poG6LZ17`o=Gi+`n&fMm8Gsw27M3Xy~2SiNtH^ zL`HM{Zg@+pFQ7o{C~F|z;rp4`$DHF8k4F!zOn6jO)rb{1UexK}v*ED`IIET6F;}@T zf()#*0$E+B|45U`N>b7(1ya}o>1-l56_;in5J}{!i#@FezjP%32 z%BkeG_rSBSuL2Y-E*hFe?=uS~(WHptSCnNiY@i?N7%qy9mrnB9zunxlUWerbGxpdNAuSmx9C>kqX~N>K?Sb(P294hi=ua)U+^F z+<|GBRFw+36x(r9Qj1>R3;0gfLhftm5=tqM2omztmFcFioq+84FiI%a^4X0!PAsF?X)>sb)RWK^X!2B|vZ$=|lXmnED5T>$f^f7*xnq zWO&NU((_AgRMb~4OS$J_HY#5nsk{eu9B3_F{{~6Z(;8=5U&(e((DWz4;&BkDEoXRD z0wSyT?Dx$OWFGcEWaPJ4-X>(5B8v3#ME3|P(>~rC@7Q~j75T_52!=GL22J|BS{>nAyi}n zFbqtig2QAakU|6EqyD40Bx^fsyDwYv#Tu#Gmxh7r?DT1$E&kJukGwuV4*g!}-B$S7 zWQ9v&J95zrz+E?!t}VyuWyVh14p{69mY3D<4b}{F7rPlT5?yH1ExOZl^QF6QEWve3 z)iYxmQ%pzfTJg9z_SOlCa|Tu-(`==0Ay~+WSbTqm)N}aP?wng=Bw;^OXHK%7&mB9- zsTTbrJ#5Ng;K*&d(OKT)KW-eq(vBCd6I=b`UH{kl8i9UBn&ZA<-s2LTLB+`@bhUy zG`0FYamWWip||&b4NO{=6p8nlqF((uV`qNHJ#e7_LKZqZPPzSXsMp5^f)~oQy>OmM zSuPIQE|w+W+0sp(AiZ@hFUR0S^Tm9O-~NQ^yJzKjD#`|aHI(x&y`Q`PDk2DLiYl3R z3uAeAiB>M~LEy}c`3~LDpbow=I*4Ws`}U*e#(0129F)RCA+^c&arKW*SStehpY}R8XGY`JKA@3H-$e#@s<6@|C zXw{(1-#umh-JD?Y;I77c8xt9!(1*o@=Y3hqIzBniA7uEQ8JmBGkZK_;UKGpe$)9Zq z9RrW@o=tW9M;OEOtkbV{ho0cOLuu(`u3XbTFTAZX-k@C52dtd|XXI?;H`rtZDLgou z54Nbm-;Y_e&g*Q8J(NgL2PdqV(4h>5F}5bE$p|B)v_Sabo{J34R^Vlgq&fHJ;0#lQ zb8QBTYbj0;nn?A4B`4CKpIyh3oyHwzf{a(&5JNwzYEiRM4YY708aqZm-3hq| zl}k;u3G*sGP-ZGaBnBCZ4uz~?&Ch;*o=!c0oY1=H5L#slCYV|9o+tS2veAR*4&|_1 zNNvM0EH8N4m086*)v%lvRe8b-xi?8F<|wUFR{bT zx&^N|ZDoxTDC}6by7`4#rZ^k=K@47|vLV&asVrH}&8p;%kV;vVz+_k+M$VdcQtJjp zqeJHvn16`-P<~n&qh^RMTZ_)4H zy>jsb#^?^6BqS3KB4|+6Ou8S}f7uT~nJQ-g89yX2hwU~|)8Cl$%QBRHF(Yk78C-sE zA$Fg`G0qsSO`?%oI=dYccOIJ|D;Rw<>VW;lnErlUJ^jSdC%L+bN7FUa*T08><-W^) zT<3*$q4hp9-w7MHU?ik!P6MJu);wkmykn-0z<9b@g*pSqN8xn_aE2Q%oI_KNQ?x{D}U8phB#wL|HO)M>;-kR4RT<@i)7VP zImoH(xHm|Szu=Vnle%9Z;=~FG^RRgrz!_%wQnz`Mbk!g%jq#T|G`ru2PeG9pSnHv$ z(ol(Uk|Z?GOsVyJ92dq9dJ{Gjc1VyHQul~Db|lJU!LlthUPko>)muc3L5)7?Vff)7 zk1h^Eo~gwW(Jfo(ru)rDh;fDlTYw~sYey$o>#i>N8>?Evo?$JJltX`z18;JuC=j%B zAJ7YOo9u&{T+D;_+`m!&GCgR=k}t_1?n9G%Fja<`gIcTm;DjyAcN1U_#3ngR$WonU z&=jNgO8&)@vdxYa%*CybRPZ*BX{XTJ6H4yiZc@1qqA)CWm(#cGST@55-|1@wz#6E# zW52!jJIds|^rm4NyMJT>Nk_gye}%q;dw5ZXG(}0`9C2-~0dGV(@sEXuM6sy{s*_2Z zA8tC|_h$>i&fxI(&7HaXVSCBzTthq@u9V%qyQ@}y5Y$GJ&lj^6RK_WdG{}J&$CksJ z%{fn3Kb9=3&b29JV>jt_5Gu8asje22|7okN&+C77^H)W2Q<}zg!8m%fW(aho9o{hvfxUyQ{MSDgmbmMJzTE@!!G<&Pyix7fCR>zEjR+Vr4v zDMfdGSIN^fV>f7jw05&zQDWtYvh?G&{?d1Sh_8j!#!GZ)^W8IHyXnR4>yV_FpNJue ze(_~@I6DNmyvBxQ=7k&+?np*-3l8cbWDtH?$Xj+*$Oh;oo;`kai^?Q$t85^7QP!DbQl5E(=h zA*%84L}IDpI!`qcG#PyeS^A>Sb6qhgtyojGQnW0=uU+OWPIvCcc(z|sk69_H<|~ei z@l>Pkt-SWfqBEVR6O`1|3VI0QBJ^D%CkEQzVwZS2xtS9^7bfGIxR-uLGu#5#ry@p%ZjUdyk|CY zw}@wea|vS?b0d~2;mrXv!u`i*njflDMvr8z7{A$rrMWoru;OsT)gbZrr0BYJ+(doZ z_|SbzJ>KcVeW6?`Qyi_~UGkq{&n6%BjaY0u`Z+GY0DQtf!Rh&Jxz#q7R)pQ%jeWmu z_LtFnobU8b+MW)77F23hy6xJxDD}HS((U)Ou|($PbbD4m z^Hiz3l=6aOJo3k{-w*8`Hl@Zjqg;fA9=9~DwJ|{+mR!>@VkX1mIFlu|*})X&^hkG@ z9R&td#|M5sr`=IL9}c*cue7()xT^a5_rkW9v~c4dS$)U7&dQ=_0tMh?vEm?;nQH&$ zr^^Rpjk6J#X;b;b>y+n8%}*=nNkEIMz-h?#q){b^xFLE))i#zvUt1J=Y8WrI$e(Uh zT!Cqa(TSKrwr@6L(or*>RgZKf)j#R2vI}Q_ng!H0Z3BYWwiu0Mv19l}H&P3842@)= zk>e@U^@Kb`kcpe4K6eu)c7gMRsSGNga)H~pvbuTsZEzzHx{y131BKV)2Ds8OS<+6U zcCD%zbc5=MZK~fL>mmFExNWf@Ub*m7RU;bLl$sGaOlDpH?}z!IBi-c!9e(6?NOCdLsa$UA>wG|T36VKrz)h*5727j3R{WTA(Gco*f&5R4E5&3p4Z z2DWlP+Y7$yN;t7& z@G+nCRP^ibE~RJZlIAv0d*rU~Fe9Ea?!+g1NAkk0X z@{?uMgFopOFJce+*6OjAI%w4ldZAUJU*V4?JX_$0 z%eK($p2OdSht@xra&Bq;IM|f(+pTwac^SLN%!gDQ5`B?_zN5#mH0={y#{~D+q|aZv zI3nK-0539;x$J}yEKpcQiO=jl(s{9GaW&1){HTeQB})s{JfNYusgf&>CqhI(RoWI1 z1#uaOmKMgp+`&w0crs$}kXE$+AasZ7^KftblZY#STQT&q)e)L4_wS$BPP z(J~&|sA0KB1WL$Sl2NUVeayb)CHS-L=nDfWMy5j%Bg(>(5HplaaN1yoNNrXuncs_@eX8xBb>32zw7jSR^6;G%fj2n3cQ{>oj>L`b z(6=AM^CS1;zuLq)6^^pnDvvCy?cKKWwX`%&GHOTruxD#-boi~`_o)pw8u`ijqPI9+ zPw}9J<&o)=VVW*sdh)a;PmQvhro5%$H_jASsv+}63HAr&H(2|H;GqYPtv{*32arlO~b3F``58HDkHkE;r==S8kx6qloW4gNb#RC z0HWA25}I~%^?&1p9bb@?xdS9bsK11lcHId9Ui$-}^;Dhx@Y()U zt-DZLRZhthRUQ6sQ17}E#Vq%1;m(biXnP{V!A3)R5iC4bxY}lyk zttwC_?vTOs0EK1|#iO~9(41!7wxUpPY+>{Db$+=faG1JNP7Y^! z(GuWgejo}ND^MB02@T|{3m4qBdw*+ zX01>o<{)Z+A>{d9H*fw^|NU3yYXF!+898R*cAMdK_2tEBzCM63@p=V32KHZXUs~z_ zR0Y+17wl8u{~rzmTd8vn0JZ|RAHZhnhLt(XbI}pBC+RDtPi>b6oI5FP0Z^Q*R8gz! z(Gv5dE0T-5L%s>en&(q5A&#i6*AZ+w<;@q~N|!4+0ZLka#&3R(ho2q~=Uhmg1E>{! zr?IlHHcn!B;Do2s`jZ=*?`Qi@S5D&O)(RS{bm~9-jm>=hb_*KlQKt3@P5i8ry2LUPh+<-ljcD=$9w^=^0?&wDOSE8q5dSr-TBr#k-l5u-F1g*n1w`G^_^A zpuK|Z1+cJoRUN1jhJ@B|yGE^RWkX^$*47>Jvr?PRmPA+FM5YSqYFc|z1V6HnLBku> zEvSFB`&}`Ma>&F}At-<<=Ijk1hxR?DY3!y|Nmxg?xohP=JVpG5q(n%aZJ?B2{q(#- z=D6C~Otb&FoR1b=Mz)V%S33Wtzv8j@Vd+BSY7UvX4cO22U;8;kbqOPk0~JnIlrPgC zAra=#n0a@uKE=c7q%DA=a{&AqPR0i7!eG4tSF1w)gd=DZ^uR)fuv_R34(WCO5# zC|9IzZ_xn}_Gjt)nT&q;N@m>VTpNvkU*XH?{IyDqS|dPzk|VSaV6$L$iBJ(L|7)tn zAEEQQGA6XBEQ3rJE{L(ln7zNJxYj|BsW;`#S-*rrZcEUa-?%UrlbNTr>%&Qsm{xMCRy56qkiRp-c z^oTJb3`SO{#k>gsyXr9k#+j8lIhSWE@Yp1$>s}r8et_ThCz6?W*M5(z24*^$9Of!Y z2)#ti;coTyKVLm+L^lCa#(7Dv3{nHfmGg3K7wEyb%{RHmKh8IK77M8__-6t)*Z&wL z>3EY<(p6jI$cE~E zIYCjw7B~kV1cgW*v;@P~LX0`GEWSIf$6vIQX$g#Jhfvnm8XbJu11g%K zv<9{#V*>3=>>Kx=)N@C(5cXb?*c2)_;$-QiTacUxOXwJKLFggYj$@(Kx~~ZnF7Uqi zGurG|;z9e~3HBb&Ztv&Y(Xj+AwsaDu&{6A1$-JJ&WRsM5n#>7ok;yn#{<(O*H+ADG z?M%OWv4+S3jFBz#kW@klF6GZlQQx0rzb2i^Zv)BR`bhYrHYVkIQABcWa2^ zj)6NW%?Yh%XDM#Q{UH<%eH4~vn|d0U^E})jz9m;_dN*yB=0>g{D%f^Y`;r4a})}v+YH%9KES1 zD_>l|+Q|B!9cA9=1|fR5stT_CZf9)63bVxXiN~4$2%kjZtRxB~JfVJ9Bs+i=W)?av za<7I@OOL3;xqB^_+?hNaBZ**dKaI>KGlM3M=)&ebpRsn;ugQ>GBX6obPq1e{?$X-x zk^s@vYV~lD@m5gb4RDSeX>vBDGht*Gq%U%@-b|Erub(jUS7P{^;ICtkQuA(B2(R3l z+9?G?yS`r-@b=3pb-EBuwbk_BM2A+kJ>GYiHUPzS0c(^o}# z*?K9RWdBRC2VATK&k?V}WNqKxRf{?{3EAg%scF2}vc8`;&$!dugab*pFV}isZCI7Vhrh5t1eloX?`@-Taeh`h*ORVe8wNx4>ag8#TGXIt6qHW9_mh6v@7&d-He_DUG z*DG&H0;_r~Khw+6#nI`tGpD$Fn~*Yl<%4f@;GJlr+r$p88}*%NA;i3a7HbNbP3n9M zAN)iWQD@8!?Z+mU9Ccgk-LHJ`)cXFPUiO_mOI71c^i%CGh&nXo{Z{sRv$Ov!WUI10 z=umyPyV(S+o)YadF6;XV?+r8t!AF2tvu}aVn`Wy~$hORLX4hKpF#K)_gumQ`v(sFL zxUnxw^p$qW=J^f+`%KgNWfx+6FVo0UJFkHs&v}z>SVM_|h0`t*v^B8EK3m%-T`XxU zN0FJFUBO<^s`xs=L*?YZY4<3{bD7X<2Dd^V@2P6cBu`P$%CS^M7l_z_hoUKks;<%W zTL&9OevU`n-Bm^T5Q`a#ALkOx61Np4G_ohO&l$1NVD(-;@G#ed+kF%m_t@|Wt*o-* zBIn6QiV;)CF|>2Sm4_%GUYLp|jN~HMtQ^NVV{*__{0~iLji3d6#X3(`#xCr0#M*8| z$jW2p$;ce?Kl6*N&!qmnGx?Sk=8GvA*~k!dF^c)*uiqF_|mSuNiwI<;?+O zqjshN zLq`1#x2U~Z6Y^Bq=skE}dRK0g7n^eWC3R3oEzt6+1bXl<9#&rQR)tCKiLt9sayZ}9 zmgHCsgDX0e66T0emiE&eYiCIFRuu7b!Ik9U*6HYO4!ZSv173u z5AWm=GZ<)tc|r$(WVgCfbavQ^@t|u~^LZExnK-q98dS-%Z68c)MVyqlc>u?UKYJ#jf3UEyqS~ z#Jky9g-#gSQm8W6h!3&WXCHNhkPvVW#jVHZok06nd(T=U`9vn+j_<-`If+u8IPKC@ z8)87~*S8j~l3dda{!{SCTSwOi7W#rz&FHv)BS#~18&iVeNC?lJzNT}D77iX)}s|d8d=i#O|B- zx*-HF{VBeu7-p9O1hFE&K`!?_D6W<8n05uxc0>B}hi!E1big$c*jpWR2T8`W`+vTw z5Rn&E2J=x8i{$msyIZr9=S@pCx#a#~3}H0HI@$Zgg;l6~xKipXpC4RDkG|BxNKR5Y z=pb$ohzEcettYIc=VMg4E@V?&rdXY$Rcs#yR>iBN7ilYkh&>H&-EWm~F8(JgJyxDH z&NR>qhchfW>XbE|tSRt5)N5@FLrR*o+db(DsV8H)NyDSt1+f^hf2ZeOpA&dY+KNv= zhZ@1W`=;!sQ}xnDuUj9VjmoT#n{ zT``RjrG*#%5&@4c+Px&+bDcWw&bPxeN)?vxsQq1tas;YSrYOhurM*rmXC$4AaD*MLWkN#qaXh50)rDf zXNgypv;<`Pc|vijS+az{BU}vAA*9-;U)F*=Z<(%})(=#eSG=~> z-)rB!`g;H;CRK~}Vq58+rZ3cz0oG0uC7y9DF(!97UwP3=P_49_QIl$cz@78FE?>?t zwwf6{{27@vYB64SlXYz53Ay(*CSS=|v-cIJlcbK_9JzB@gFcm;`owBta|awMJ5`*p!CI6Y^sB|~keckrf7`@mXJ2Duud0+QH!?bX8( z{y>ZrrvO=@+y2*^D<>|`04DI#`tmCu8njiG{ofC#-n7$>rC{SsTKdZR~mNogj zNU37J5QF^W7%RC}4KX$ul@;7(21fb35Ud{?fRt;3|Dtfd<;WY**t-C#9r!Q?m4@4= zfW6AXLDw@}ph>M@|8g0n4)6A~rgNqWUW-1Ac`09p}rxbs#ihz=U+ znT>xs@+$l0Q_Gj#MZH89Vnyt&QgA8*&ar z5Y)D3lS&###O8M}XijgiAC2dbyBCA^s^VUZ9Knse0y^9uaVoLZ6=5`mv<^7*%y$+b z$F!ZrsB`ff_yCfR(E_FxcuKKj>S3u~=HMUlf=rrIrOUH@pY|v^!95=&f9bjpVi~f; zNGA0y8;}3v!7DKL@{1wM5${@hp1e@L_6IiBhU{T%NFbLo&y9+Uo$gM+Q#Fc}qShc&8XK)xD3 zVYp+L6Q`!bML@Y1BX#SuTCa-_8T*qzbo}9pibp$Dx}sIUH-v0zbn!#(l3yc_2`zSI zkvjq^9!X@!oMRYQ8?nXvB!3L(?7wT#-a+&mwN6T6IbLHQF4Y3TV?iNu=899l&!NR- z)N))>YHnQJKmqMS^j0$B4ccE-NZDwaRzKLsg>i9aL@$VQaCR|rsApDU@k1QlYjx^0 zBwU5hA*ch26N1Dd?N`(#Y)gbvK5MFC;l5{eA=qS%*Q4u|%qy@8CkT13P3`b;@LVlWcfsF@CnG%w1- zl&}l%5r;5Mp(c)XUl_;IDuBolH*LeCGVk;ykL;{Wi*Wta_de|lY*to(xwr3Adgr|b zUGyke-5?X%DYlCvOniqq;83g;BZ6%3)oO>a{c!v%}PPk~iA5<1aw`R4g z5HE+;OlBOY4CaRD;~q$0I1(78kb`#akP^AVG;#@OMiC`3Cvk+~MskHdaVpraef0%H zw-KOGD%*7-;m->F7@oPd9^x_2f=N{uLbGeX=GI7*Q4NPB7pRBF*zBkzid)O@_pcIw zKLKgt%drW4)<*iNX8{SezWZM;5^B1H9VgM27->Srv51N#rDvaz1{dSs*ednQl2({T zAcdjMG^HJ0uG`0>k^X88&R!4x+M8LPxms3G6(8EqclU17XjD*?1Lt8Pm6r?qeA4$h8qz{w-F&rQn)vnGSSX&{XO8m~VQ{KO$y2`(#+i5y5y%`zM_4e1QY?O}jmaU&wbnjj6(3mN zj`j3m?&rqpc(Ef$VH-hVa4AaVG0{qN!LMRj^-<`7S#Dq}`P6_o(-A(QA^CF!Sa3-L zg~J2G{BBt__fJ^!M>)y5c)jJ9ivkR`BO@rt`820Dew#lgAvVarW~sbUPM z3x-Zx5xojC;FIM_KHMf>Kvkwd==hzILX)aMj#Ew)NaGZv*Lq zz(LCy6(aktlt<#vDg%f!Oxnaxfb{|+L>GgJGe_N5(2L(YW{-JuelrZ^W!;5w_dlk$vp`f4<6D%i0@g3rW% zD$zJ}G}o6Kqh)|*=kx{QExJEb9zSK$?Z7P?h1Dw9vkm}T+1dWrC!?yfnF?e%Cu2Y) z%VvEK8EA{7*_VbU&bPv; zBir2}4ZXlF&UZlXsnpi^cHZ#g_%#`okCK6jALBV}av$BX6I+-f&#MN1)R3qYy@=&l z94DVdltG7%m}-AKmLxX){7ilNw`rz$0cXjF<{!cwtvC4~aTau_18oyk?pVg|?)6Oh z!^}EB|IVQoiw|2zvhxV1DEOt?y?cNSF^GtxHE$CLP*Fp=D&X#WS^hJof!fTK{cJA= zf}i&}q{WMAJ=$X2i-A+@8%Lc;Dsu-0FKA$iRKd4Wg}P7I;$@48*~DhKhYLkZ9(TUNrMDK^S9sb7>qMjtQ3O4VX^cz?fVPB67={CxmjyxhI+b z!4_Va0OTRGpUN3QoO()ZLdqRWH`xBC=JxN_Q_|Ad^?5l!I^=y|nu-3DDW6~kq+DLB z)OPH!Dq0k#;!$tvqsWNW`N>rpoaNqElGFThj&@7|r{gwIjQpx0O&YQs4-4S16(_8F zgk70pi$aG};Y>jDRgHf&1_`n0FOp8xEVFFXC!Fph9uLEWtQUpUTVoX4nD*ixfB2soEGfB77d?W6{f@uH9je>3 zJKI!FXLjFIG9eG!oX80=RK#ywFBMg1WDWE;e^Sv*oo{w3#xx_Rs^}c*DalH;0XPHGvBeoD;_$pGu5Pj?soMLEuzm8KNzRref zkvSeX;f0s-12q1S9p_!q zXT9mzSQ$vTy|0x9lmdUKar-r z;7Fpoar(FU-^l#*f_RB|9~D zl23{4C=BetQp8Przo}111KK|D{Lz2yv-q(7fz|6QrV{uV+-=#eJIq4xJ;7QTbb%35 z&(Kxm%4|0ZNF!X^*bW2Vra~S}-#U>WfZ$!V30NXKc6C1J*})Ez{h%8S^7#TaoMWHa zeP;&P3AeQ9U|Xw_7-U_!4&q8Ul<-1wlczt;=T24FjSgS9UN|}Ti?nER8A2Beucegw zj=lW><(vS~fqdl}Y7`-q;|m7NDHs7FTz_Mm>gb@L^N&IO}TzUcq#b>iS8v>gW**V zyZThI(26afe`w*BE+J>94EY6Q|4*0po%fSE%w0n|} z*Imiq{pEC>;|(7@2}V~GUv8Y8id-#F!z)&u^l!`M@2mrZ`Qd)FnXCM;wWBdGTEa(# zP`Kywcizobw}M8+y2^~au}^@vF$BlZ3DwiLjYNw!rG?SCcVJBc$iNeN$IAE_jAYMm zth;(WYY*Xrfb-vQo%kvQqOvl;UrVXXy!st|503$HFN}kL%a+9MHOKl^m9WMN|9RYfV(XLSm+t3;$Go{-fnYvOAcf zN8_;bo`C*jCBwd}8qk(-GW^W8B}r;l^i5U7RjMWflpeR9* zBuHZuBuZ95a?bFrjlNIa=lg$e)vdZ!=lt-T682tutu@CSbIdWRV+jsA?der+VsB^v zCDLH91(pcGVf{1e@SkqASCQ1Z@16Rpz!M7FCujKb?EIq9Tk)jU!J0=VkMg7g*avh` zI|2VAwL%^c5-h^3Srnq?1p+8+AI$eCRf3Lo&xOt4|5A17Py-BBf)W9;Lf*{>@t&a+ zDzN6Q#sA2F{{Es44fDOVDQ{f`)(eg46&%aN$5iq1Rl_;Ti5mquR#{Bk|D`W$na5@K zK?kpV5MAm2}y!zh3#}w8YENyS2dhmI8+c{2GwbUSKX=nEYS>i zL; zuVJRFWtK=(63n4F{0oRrDLI!LX-@#VZ@y_Uz;n=^*S)tg!XNAlG((eLJRlV=6>ODmU51o&o zh}d^Say@+O7hiz5ADBth?F)u~Ed&N^j#x`>zO1qvC~*Ii|L!fM>?P^{)o`Z5LFS16 zJM2SX(qb<--n~H3*#}0%#6zs()>fbTT-NTGn|ciIr#}USt|2tc1CZ#$eJ=CKncgo_ zh?Q2;7RdY_K{vs;wKx6Wy+z{-elGtDpu*%$cdRi`gN@o17H_DZ7&{F*6)j#dV0KYO z86&q{>VMU!k}h|OOFPGOFJ_3Y2BVRjtEz>^Es|bL?Y|_B>vukiEG_KGg*MF(qK>~0 z?(ST~qFT_f1vE>H5+um!gJPRJ^+IYG<5Qav&r!!uj`k+$w>|7WZgg_>Q8lcE9*qMV z)VszmO#}w7gi#HD<7Y2aFe7FI%~fDB9oxO+oKfdS3H3T?JfCl0*YZd-Sam+d#ke0k zvx(|&FERPv8Vtes>(n}4k7AU#{q84>#~r8z-w9$tzifFsqjAlZIH9pCS}SqvV34A; zakT_q8ib0DWbb>ILzIq*J zy(OMw_@r0$3~wVZUi!NjA^>i=0}Vqi7)z$O0LE@F&Uy z{xK@Ji00;v3>VpCWgz=qb*&$JaA&0!4gVkbK%= zGsJmb3ft>B39j%pTL5gnz{0ezbO7Y$DPZ1Y(pJ8@A7^*EXld8U1q6nY&m=>hYGWob z>*&$#UoW(KuZoVvnE;7GjJ)iBR%~A(irwa zmD#P@9zR|N*A95S+6)WkGAiW0A6Vjz^#CycD){Huxa*^2^(uMt4@}%n z;7Dx&t)W0y3!uOdoKxZrhzEyzlRJIwa*d`e3RWlFv(a%uy>JfgyGL#row4 zAjN;vRr7Z<3V1<8%FS>=Cjp!Wn0cq(2XWt@$}gFV6gEdNuF$)_~qwCwz}9D ztbf5Fn@Tt#BVyf9raONVdxuZ)AdXV`5jD(0DINT+32{uGE-MpG4uXgWU^G->57ks_ zM8p!+ew&|+ldI`9siQS+jEUd&0C%<6#npW<`6sb}q$G3Biw|!;!wvftQFzyr0bL1+ z&hIhg&>Zf87svog(GQa0f+I7(QcTqX_P#J}NM5{-y@K=kVPKsbe*MO8pL>~n*m!@m z#t|5&mOT1wISc3(>5tp(KOZwEMD-xZ$ix*Ie@O)FcbU45uA7Xc_jaEFCXP_%PY%2XT_%So(6*)9@)!0ft!iWlw>&4ezP}-U zEnWm2dOMlND7p3827%@p*l4Ujy=J1pIUaiOvo@Aal-JnxE87CNnZH%o++tLUjP+Zx z6feko%8gHjvqXBM)R12Bt}4+tu+Q?+p`mmPy&*y(CE1EE0;Leh&oR?8M-g)2HJ$MXG%iTy`Om5us{-$Z#c&=aNi`3kx z=WAfHYvapt@JRHyp@{i_yEcL)`D1T8q3`RfhBn2tk_LAmO%4}LPe4o;I9EwI2jn1Gk%bVaH0!QjTR=9E7=L$#imeC{bc7-1(DWk7EIn>9l zVEMY@?S6*e!AADr;p{sEF~8Bs(Qi-xwM|987PCiR1qKaxcYrF~ZHof)<^>6yrU1i* zdC|Fyhe?T}_*E{FBPM57);zgb+W0CIhPi-i-sy-Jz~jO!coknmyhrz%dwltW8j;CZ zzpEKk)U0GIxN zCE0H5bi6QScim?(Rtu?S6J9bk~28<8gObD&Cbbt}!o4Uz!hkV&L{|3ZI zF8_83IqfNsZl|)ukrD;3?}4EDGTW!qGHH5Av~gdR_oljIao}xjWR*NC^>on4{Q<|4 z+TUpzw>z`%pcHt7YvlgC;D|W(X6}g-MV9Es%Goj6dJJq9&HyKWtj4>Dsynk3nkGvr zHO$LfV32Q5DFGolKC_<%^K_KR-Q}vjjRyS}fh4>^Z`D%Q?heZ7^n#*m0u+%A#hz%< zg_CDbxLgI~lWo%ICFEn2)0Q(PCr*_+B2cMrt@%nHh$)#=cW%lA;rL{Q5M^juN;37~l%-FJo?Mo<6A!QYrK7L$9({Jvlms%*bokzc-vh(|jVILUQdbwrIv z2qIp@spWRq5^#8B*mI$NP$}I*dcssg>fk|~m@FG9q|WvpQdN^G+Mc78Cf@!1nCra; zPs9+U_S+=bp2qo%Fl1cXw3`F&zkk?;=Q) z!-=!&<7<(*@L5XUhp7bCTf&r@X62#EiR>P>?M;E`TXqfQy*CP9Y#2<9r;H}uBJDd> zW>cu!e!;0y#k_qq--E=$-A2vXbk6Wmyr&$TBzZ1t&Sj8T8; zs8Nh+Yp8AbvpDd=B1Wb~k49PEkn=e3B!(JU7Vm!#$=K+Y1$)FzAIQw|)LAZEkd?_c zrGtBAxi|No4_g&!)|SixyQZ&4dlHicXR!G5xFcvPr0z9OoZ21Ri%j-I8b+op8ILtDg1b~khV<(S~N?|#DcrKdSwzY z)w?Pv1=T_T+rajP0%0 zesQVfIg0lgK}U=DbU6Bhk}-Y@eGq4iX=l~O6C~wo{z|ZaTWux)&$X$3W2FURk%w5j z^d6~G-`}BBCLOkK(4sL-4Yi`=D{~(Hy*1?{R^Y}g%0^2DQ*xx1)@`VSu}Xr$(XJ6U z{X%vWE3|fF$rUKuUQ2j?cZS(*J=+kZbBRKaFLH4c1?EMLIOcpy9_^PoofP~Wk%hdH zd_})srdw39GAD%WrD7+lHu`TiKuip9TV3KbG3xUwl6#wy{XppoQq>fJC8ejYynkBw zgEjJI?n=!J7q~|SzR}w!MCrAJ3(}8}@ zDa!Xx1FF7?3#Wp#E+;8Df4+4Q{+2YfU%+zUt7`z82h2KW!S$6i5vQL}2vz$HqG-<< z4@!Z_i7EqQPy&6g)M5A0T0J(eU7>{~7M+uQy!hIgj6>n0mA7C0vmS|fr~Z#}uXpr5 zZ!2cRC)_)}+cFG^V=@ zSk9y`xArVK&i>HPQCWb`c~xNDMAnDH^o2RCZ0DD=%Qj=~>VoE45 z1WiFv(~K_BN_Qi=QV#KYN%9v%Gk^-VwgKwBQZ$tR!*OV>iHG?prnqwqB+aukuvWd5 z_bR#>>x9Dw^-L%xIg6yy%~h{@(Dd<5lQhdmp3X%|Kk&RcwE!`$-7o4;o<~T_A^g@% z6F=!wLXDBE0Jpm#-0ha8W2NH?5lP4v8jnS>-SL%oQqxg{g7}57f4HXm&A*Gr=tqfh zI*awe6^o$e&0pm}Y=??d9&k)E)Jmy1>6P7nN4q$!^n#?3-nD)wrmDUim!gF zH$zLgh4DDtr`zr&a=5(I^%T}`ZW5qIOzfycPC3}R4JYBKP;c_fA&OTUK=TsyvpgMQ zG(g^o;|=@Z4N_{Oh)Oi`%!nAJrbggrnLlO5pfHxIl!g2bPbIa_(IB&G^Zy`%eb3t* z?xQ@4u!_4kMum+2W=!0Sv&+fCW2M1!)r0_6e>Qi51bzKf)Y6^)J*91ktg0dO8l3| zzwGW7phC*wtu*b1-{`3fP--K3=VO?sx#CZ0;SHjRT{mo&H;k${BD!C8MYV!yHaTGbvLC z@gvra(7a+V64%SPHerN!oN-4Mxb8;69Az=*Euzo@*z}L4V2Q%y>3b>1tTD}upuNSH zK1SWB_tGCdy_^Qx4MKB@ikQQDk0?C8fC$<`zCk?ob@Q$b)e4+TA^EVh#$0%s7_Kyd zmXs%_n=??2Me4)&TNjUJO+v@GT4|1-`7{exG98E?$GSz;J=L% zU6NR_LJP)3Dsh$OmoooU8e|(GeiKSq^yvm0&@?$HGQD2j%cPL#a{RZ$%%GH5E<#YN z3SAU_@R0D5o)vP0Insiyt0AyVCXdugM}o2yy9(%qYqd=^IiKLHc=%Vzo+}cPv8p^7 z-O|fINuWtXOq|VuhR_QXdytpmwAon7giFs_RAO>|!efR==9D`T0{G=E9w-K+G90tV z3>**#BjQ*HuA>wlktVQXT86H{I~ry{VvK$~<7;C&61OQ+z#JSW*P-0YZ0j>ZMf2c6 zn8S2S_(CUF=@Oc8hT0+0i{)t?x(w(sR7E1j+7XxF6-tq;Pj5ZaOR8w2S&dXGW8R_g zW3kIvH&hqK#>M8aR-D>3mNbMNKZtFg3zQ5b{MBR~vh%t)jnrDYn16+d%O3Hd%imbh z6i{g{IV3C(;)4kFv_kTSA2@OS-Cq|VC`ktpQ>s#*oC4fF?}9=LjQ}g+CF#|UEIsB& zaW~*Xse6?wmfQMDH)fUk-$bA}YDfxPLlo7ab-KER%M_Ed(VS;eZb8Lqf`(SNWSWdV zITDF}c!$j(gshc>LasYrtOH|{ce|Nb^;%gSll|a!O53x+3eWt3@7nbDi*?U^S*!%? z3PZ(a_LihZBDUmSDF&`Pvsx5UN4U>y+db<0o$mZ<(H$NvHus@a=|+k5LT|1?Mtnqs zeuPi#IbsK?h$1-J=Ch2HlFTYVVWi24ylfVXV{-!0> zL)YDY1vsZMlbp1QfReBirCB=Mlm60~8;8*+@{K#v zmz3n$M-opI7-|6#SKj5RtKXW~fz*RrtzX{Jbh_^1tUQoA!Hgw+wv=V_-qq-T38h1E z(@{!Hm_$;F_+GR-A?i6fO9G)xFVXB(i)ViBpo}hp8GnG>cOhmah}S+ZfY2fyem+wf zWbQ$i14}?FI=31)6PQud%=s0!41-2**8`5Z77w_^&7;^OX?%DY;+XWjTmi>+8Ky%D zkMN~j8atO1*>RZI^oXrGlCE7Q1IAzNxjb?tiBAHC^a9dim1&M@nwUd&3pR(XA!ODf zj_1#?ylWu^L?QEWJZOqP&tN~>#1XSzWu^MUWZuY;dC&~trbD?KxU3|>rb3k8B2p#g zcVh+k<15MaqZ(!Hz`Y#!!M~ThwrA`CHrfy8RE0v4^p-rlLsok2B0K(r>JO4I5Av~h z^>O!eID1Vp?i^!|P?Gy)BFu)x9Op~53A;)v>AzYn@ubBh zcw(006Mjb1b(EWbd7f)uS}zjNW;+2kn9%B{F};7#4G6g4XDkAg!HTC{dL}Kwjfs%W zfM0sX`|=KrDHX zFRy}JQx@JpDqGL?CvbgUEdy>FM+&>z>Pg5L!W5&W zJzzZwLw5=y#L-ocM&jIF^C@KIX6oMz^zT|7%Yym}6_whKU#lwTIszBIYhzbpzW9O2 z)Pi0syoVwvNhFlap@zl%EN&48A}?;Z^3Pu%4IZ9xvSB|wgJ}kz zWA2PNct8m5l$^@=4ZVib{4RYDocT-M}lk5y#z+ zS9smJ=ygX#PQL?70(T3klpJfQ3&z-%N}3aQF+;Qz?wm)^UjOzYy=`&_$2gOw6+KDQ zv+q$t3R*YAk+Nd|a0aa+-=dgotPx|R{7@ICe?W$P|9LrS`F@ydsAa;Bui~J^*`{Lv zjlZ{Y73;A(66Ajv zdw>)gY&t=U=%)Q81%(tx!z(>*6pIx~uRm>3N1sO$xjsi{lUm)>mhqZ_wLFn#Eb8R44n(2M9Ff zT_Fx066dbOfaxZ^RetLN$xiUiT!~r|Lb?`WO>3kP%GYG>31<2OpAruoIGY9s4(ceO#H zCZA^EJzgxG#;oJ@VwxLZP_7LRSQ3S8s_2-DTuEcAxXW<{odrRBn|Gfij37({(M!x* zJDWfsE&oX|M4Q~ldO)>TTbzWI`O0nP)oYS_lh;ts&&j&O@)bU<(KoW7 za&~N*`p6tjX(rUg{?6%)`~_jqz`;E)CWToGtg&ib%9<%tN0Jo^AxwrZUY0*xGil)% zl$A2mRs7=^Z=v)Qjz?+`Mrdi&a&4IjulzvT2KPKCwSwW$k~9qzJf7d=M2isU6+EKD zXLMxT?o%@V^$G;&gk*~&Le3W2UXofG*%!<426VtAx8bc?W?E0!yqKgm+9`(@ku)!@ zB~-|f-=Qe!T*ZHdmwDs^lhVCeI^jR0I0+sx=6?X--Ezyz{It_cc<7)-Bm-PK?vfIx z9Y8Y%0gRy668Y*TLyl(ASa^=~cerrpCg@zoiFB~xGugT+5n<9~ZBQqK@vf%1Vs!I> zylnduuSCwuieQQOkOGrIS6OHI)nu!RpQf1liz|wvuA*sTV7*ZytlJd)ZFu;|d+-e} z#y7$Dr}RNaT!$bd!O`?4-V_aC-%Yu+s#w>ml6BXH3G;>282v_U?vF>y>Ur14<8|H! zZQt9LB0)j_n(gynJGww~c6%hWp~;&?B>S(dd_&kWH!`5@( zykH*PVo#7fntsSwgvb6EvrhcW3?GBTf0eH^83;Rp6l;d`LcASSp^Vm-d?}co=hWQ|GnGzuEthcv zz(998j07(~1{s*t%&lK$CeuKPfAug&f>bO=8exqI^sizfTfju}c6v0TYd!(Q&*&LMi98`I+z?W2kug^GoXnVL6vkQ8L-4pu})r(0<-S{{)C+!%3c zzji#BCrEXW+PHR`;JC*}ImU6(RoC<1b|4N6AFBUkSrIOi8VRO?JLl-#~nnP z%`|-Xtm5fS>Jhdru9A7;r}HmtQvn9b0rb>89fQ0!veWW(DXLrY!rX_gQmm!|b7bzr zYGog3aK$<19KnU-!gXq_SMfqNc^4nXA1ET#iX6g+W(?!K>O+^~x#$xuS-3wWZ)Esf;EL^KRN_Ts z##wX_>k+aAMRnXj5?c^1C>b)h%kly)U2S3Q3rWxPwJ8P8n;RF;%Z8A{j53%UW2xl? z5)zcEfY~9M0nsBCpd}d|CC8ocTS!&3(FGX(|{qc$as7!PN52mbS8kwim%Ofs{7uO&?F@0Rc!d!1d za*+eydV8oyPvw9!|1CXGe|ES&DNnC%uRF2&m$=`4Nwe)dc`VNsCbtujJbvwzlObFE>2oxaRjxL_up%( z*aj7&Ia#mTdi0;4c}i+QNYX1AE-(rzr?4)Jpa69>S&ri{_av(-p!aV z_2su@v)F_Ax4R_r}hr|q3krXbDSD|x(GWWR`Q|K1jE(^M1Gug|Aed# z+#U9TyoT@nr4X*&3?KE7X^wXp38SF{atnf6ne$SF#UB?f%!zxM!u4joGA;yFe7p)Q z`DX=#d?OB@Jwz60&O|rJGsy%cx7(171TIbsDm1<>bt9n>-K+Z^@PR}jFGBA z>Fr!2-w>lJGF-K|{dXzQXks#mL5!Ft)~xBC7q@*^udbGTt&u(2poTqKYBAEbH{xC2 z>}Hm6yPI6%<}2mv$pIYE5(`aLiYZc)d-0xKn&-lk-YAk{Rl(9jkF@H8C$>9R8<{>9`3rW}orr=; zj_JKVJ%2v<{*KrTZtH|i^xf3qtN0oki93oI$BKNI&EHsvIIE>kXHr@tQylpn?jL03 zNA~5(59Wr?-A&s0xRYWa>jBet?EGeLs-DQ_CAnO)RkJl-AERotEFfNQUAX+ID)cY8 zj#dizSH;S^Tr+@8>_uPTQ_|-3g)1f{_1aom{3sCZ-QqG)W+7>0cdR@d@BE*ALqiVu zo^`bd!CBnA<*JHcsgK|MJM(LlIc_Gqi$W_e)Zd=IbVa88$9ivp3oNzQyO!b8`?rOb z{f{JU50XmVlkTnJ<&BE0^sDDGSTkguNcrqevFX0tUOzdx8f*~H+HouB0)kKco*pYT z>92x^EOMcA&4}~z{MPM(5uh>6jkYgU;pgD$X2ZJ&+xZtizA4OI;+bNgrIfvC^wJ99(t$~{Kq1Xl2mZdj8+c)y2Bo68ZUu_E%F9B<@#Hxn+R7o8Lo zZoxfAH)$#+H!*{SvR*%qJB+4R{o0V(zemY`a-IG4oIkh3xZ*z&WAzDN+m15g7AEgu zPM01;2IaW&vsN4VXOz6@`E~=-j@wm8`6?>kj(WgqeC?CuNA>zUPt^m2ic7?8PQnTu z#KrpnolRIzUTdD9Y7J_E7bzDvkdi5wGjGalQ!6U347fxTZYIx`Pc0U z3Vh>e+G1lP+eCI%jCz(NYATef4QRK>p!>`nC`4>N{h2-(vIg2LdxAs5QTs$_X%~7P zyXYZ2C^Cc{`dJNk##lVX)?ZS)?pVt=Y1YmQF6IyUH_GGcdWME_Y!`=IHrdOY+R_z+ zuZl!83fsg94VithDZ{-jpL!f^nayK_>~s30_=$frd$1}={;+H7&aYOR%1u~4Cttwo zkBeDzK*|NFY|71=g7iPQOx*_Fd|xm=yDReT1=oT~#_N6Bfk=~*$!|+@3|RE>1LY59 zxga>O5NLDxEn3h+;6GY|H(7K5=7P9dw>gaE*%#KiMvPU-DG924Jh~mebGk~yzqh2` z*O{j1%u8uy;dor@L09@P}~RakWm0qkJ`_@s3pnrZ>^;~FfarV|6XRzwUN^w?VBXZw-} zd&0l=dfsCx0`obKo>+1*acC-M&SLbZdd&0FrR#JKbq1PZHS9XC9e-5F9a+w0W9CpK zEwA^P$QjE6A7^bn|DW`Jc? z3{Z6!&g;MCaUXSjA8qV8%*L<_o{rmfZz`-E@|ctTz}K*c0ChJSL2GOjYNn0CF%oFP zsyjWgKZ=dofq0e<>In4(BHQ(W2d25VVghe{;gpFOXCb|jK61Xq&fM@~9)z2&j^RR22%zQ12)Q~v~;Vhao0vFwTE8C@Zz zbNXndc~j3A-znX(-tWjQRy!M<&qzvQv!-Xf*Gr;!(yOC*XnzzDam9K+((!^Mz5GC1 zywzPPLYN5NvSANYp7iK4`1b-p;UEfdh3!Bc9T$`jRNC1p_{R7q_(p{zF%eZz)mb8a z-;&~@HNK0w#I{aarbxiaQ7Y)7c7+6InO(#7t2?z90&2%*}g)>A%`W|l&uAdyP z(q)`&zRqOmn9IBTsOyWtSxr|=YZMS`IR2PPJ;W%#gOyMHj*e_DOS3DRYRey}>D<%fi4*7B$*C6D0? z+1s3R*TiSzA_fJFKY6N}(;Rw%QYA4KI{3;~U6Vz~KM1kW#~mT_-rwrw;hN&}KGT&r z&z@Ra*tzE?bHQb__TQOB#)>rUhL=2!zR0(aPPM$7$@-)g%k$n&*PY28$R}$eroM!| z+fm|ddaGXQO?R_6W#B3nC?4N?flW12*&}%`pwg9n)jB@tk3~};u!p@tU=u5OU}G4^ z98l!z;t8rN?Qw89Esa#izQPBkA0~sYXt}h}R|S(uez1`O<7ZWNTPE*AyGLgQI_11U zmCHLBnRnq?V*FfB zk9XDKluh4(-piz_JFQ67MS--n>=^QqN&}6qRm*=!y{=v64_20KTNY{uHx27pcs=-0BE!)Q<;L_Av z@E|SviE3w9(8r3M-Rwz%f6rRcV$NdUZM8?iQ(i{CIa zSH%ze=6#yAaeGV*5i6Fezk&rhYi%Zq!lCY<(0js{u{Hye{`&pPneeKXDnq~C2YuUNw>}q0D zA$G+DUn^e_Scm9wUz1K}r7#z6@~LP&UvNFc?___j-1S$d7)YtK){?|$q6D?B+?b*s z0L~f@bP?zYNc5Cp!e^vzJv~|vWsVbCI=}eQFy2HXuVo&qhpySSQG5YkagFy z>k%>UXPWu?GAc)|VF@eq@O^-9ru{6W6kN@AE&SL-Bhz14+Cclj*hBXrkmI4&Td7JZ zF766$Nd*)gMOD%{eSq(bx%JG0_5|8J5<|)1OOY48SBQK3=?CXfF6^4BE^6rujWx8T zb6n4&FD%1DYC4&^VzQD`7PCF!d6;pX&Iwl}wr8kF=k!DDgnug!d?tf6ZxB#J4N0&K zAax=~)SnzI3U7cb>Me5-J9sMc0Y^yYy9AO;ey_F3>fU;v-64?oM8xP)XW%TnLG~-3 zCF%E2SNrTVIsf}E#_YV2GMf{8U@@9b>3wY-#tYoeHi#pR_oTLc2{XfgrDS(2EE`q0 z90|4uxGhb6*X8d5NpL>+K9DLl=!PKzczhlV0eNp^%!OCE++r->FsccThue!(euq2J z44y-8+6Ws*Y8;KA`zq>lWT!yTnM2`7m2IMiD&$d?I~k>i{hD~=L0$5~L}LIX-o5yD zkt(bqhyoUAXti!$>Tm!Pmq+jZ(p^qyL8$KLp5X(dnq{id)%SVxUKe`2ZhU0$*A_bQ z`2_8w{nqn89_w~9N<3`=dT2VRGiSgc#BAU3bES}7f1XfE6sIsHxtS#G=`k?RYy+oU z#}eODk;%^;S56cRYOAkBQPZF_Cv~z{Mcq7T8jx8M035gEN540CL*ZUeCs^=rC>Mkv zCL#8uc5!Q7aHbr3#I3X)v#OExwX==BTK&ZR&h!t=%(m2a?vVck0WME|5qiXGSVnmT zyRND6NB3}0PLT&S_sE*_=9J5z0rk|Es1h9g5`dM=Z}W0J5Ljz`3w~{8Dv*BQ%EhuS zUuVw|w==l%2Sg3y%U@1x`K^RCYToz>HC{ERiDWPP^S01V1^U>XXWL%hWoNhH5V_0o zo+1?7ou?Ut(*f5E$qjexncc^x(`ng#(G&M^V5i@B0+YIf`R{M!=%0+CIP_UaeWt^2OoJCJoCVl9*7}t&ML<(cZ^!D8D!~5{s`*HZhtX~F*t0%m1*{!Ek8qsuwDO6-6VeP zsJMLVo>vqUHg^>vwQ<21CUgUgoX;b~LT#5-8q)p&x^D`WA&tK|ow=TF6U~4#h|Etv zvd0Jm*g*c9V)`y$6_%@D1y4FE#pthA1zw{FvaU_Hn@P@!n|zHwrGdL;tD zy@KV*B7lnXJrd%=J-H%T1+CW;SMc>@eSh#TDU6!1%K47nu~M} z74kw3qoO0LpKetLTr*gc$Dy7w`l&kzK9a@N8yQtIWLyR1-DF9Ybj{&vY5#_oKi8p{TR779%t3*3o-#M^P2fC=-7 zAq;LL)KJ$JxGWdoTWUz^S`>MCgWj1o;6~8r%h&Mc$0Gpl)J7SBco+o`QgWw*A)e|N_qA2^2Byz_d4Ba?WtZ16FlA9S z9|xY>+V0E$Zs3C!2;Oju9m-`FzmL{C-i?$LWQ=aI_IpUqm~@3woZ4k1okM!mDT@F~ zsQQj7+Zdj+s(xCbDp`n(pY0wHj}4-*M}=y2{_;5lWajE+3G$+JBc zfAE8#>IH0yr$IB#{b#gqXmrgCT-pu^M~B+epqwnfH;OdIjfWUZ_$SZ7Qs&%_^5Vj4 znc#>Ly8TH9(DJw7d+8cIHE0)!jb~AOJ8T1_WNH-d^eIWVHxJ{_&BLt%j@HaSm2vR|Kl@!PkquwwEpu5Y&~vySf=py)cOsy44J=`+ zwz@Et zgtfQb=i{1-&R@7#R1Fvn#aKp(UePJ0o+l%sT#mh+-oPFFEfF6c7v+_CkPq5j@%#PK zdiqacsag*;Pm+Cawf$g(ft^lkd55$o!#C^fkITS@AsXt(uQn{PGohRM3EDNauW-oF z>psrB^F(c~LtN8*=U}n49da>$+=#m0K=ohGo_Wdo^qs*c6P)KR4eoF+ECMh0ATZ+7 z@~*gG%|Vt7$Ng^;S+24JRF(ZU!H;C{8rmx5zk`2U>16ay{B+HE>COW`_2s03VWLZ? z*AqMzdfB?gEhik(0|MTHE_;p;%M;qsjo@H6y1QO3OCWR1e;S=6X%H2t5zTZ9-+%vD z=?p1B?80mh5%&95*3X$Nme}Z5?PJ$3s0ZA6&b?Q6)9iqY#uZD&zxRzKdv~47@{@%1 zsk4nBYO__ zL~)UUB)aW%Uc;Ra+J4FjKb`}?dNkZLOnH?~;fsQ5wq{pAo76>mYU6A5w=ce0Uoo|t z3jCW?I7NzliHTAuu7Q%u6hEIc_^oUh0CsRk7Gs80jRSdf&A3Qf^(q~YPjrlDwi;S( zhWDOh#<7~eE!kDjb--DizwU`aOp@DNyCEnHd4)m%c?%|Zi&lL;(59QO}gtbdOk&qmTzRf=p zE$Mw+-L8!De7}%i5%s4QjDMq59o+%x{K@KP-?Nv^(SjI}ZT`OrjI!1?fT-xEmuD-X z@N;T$k~8!^WV#fRryypxSjrGwF#Z+V8N*WUdOCSqTtc~=f`n6U$kbkaA80D zRXXF~qy9@QS@Y}u@A*aVE3RNq#BBd91ZxsRGXCdvq5tVH@Z#@+8H2_A-??B)#uu%l z=|p27W+?Fd1q(os#WV$9QcU4Bj;OH*z#fo$*ZAG%ze7wI#PTgH8adZ{9Eh6E9D}4l z)wHXzQ3MtH(Q`Qs>oNKH84fg$tF+HPG^(~sV!XdbOTslL-$ihK|pLM$0 zK&j+q z*>1kV*8&Z0fcTzi@Q3@QFa>BZ21n{gK(I^$yIiGFKx&GvgI?Np&mk?A1E@cVe-b@DaNHz0*pueYuV%F64_JSDDpvnt<5)gNN9|EU_uUiX!mVgaK zfmY(28PJXQ00qU0TRflMf}Bva1Q3sqSwf$9md6hRjx_*L!lnV-iUCZ}O&~|61e|J= z+%<7lFh+tA#Q!IEfgk&Vp;$ZcN#LkIh3GAStOWqm_X)5{Ow)j?jD-en)Qs2_VtfoJ zf+}T#FShDFFizUp?BZ+8@gwWdtWTaWIlG^Zh5yBH;2d*Ck#gKbpfQ|~qvt|3aQeCB z^kM3`7x*YMfDTfg!huW{U7!eKRDX~a@+W(zy30zk5 zt5JZAMnPT2%LRvF=OR^#0cFz$uwpV~>;bXdAHYKOii8;WIs$V8ia{i`Ct#x90YdKb zk30>Z*?7g?_53uJ!0cWp(2AOG7&EyFa9Y8Km}V)Ea`=37S|J&*0PEy$clfPfK`+cP zuhXc;!Qi9y(j53Ws#B)AJmqKaxKP>z?!X@@Acs~oB+q|=AZ0-#kO+OmBLv~}VqI+M zI<4uA5X9_Lpi$^;sAYfbqq)BgD4TbaXg`=1Zd~~jTNC|&!%|q_N-LFV5#d*-y7jgy z38?#&{NaFti$dxXFo>cZ;0#mL!-6J4{Cw`2hqeAO%3g|H?4#;n7SF-fg5|%vRqyLx zGR)FTvfbQ-syM7li0mos&-dR$zzMtmtv4V{Af7)NkJZGIt~n2UZ_yPnsGD2^_7wKY z$}x`XLCO8%7vEG^ZrS57@Gw zzlsYr0F?|FxB#JGY%AE#uP86we3%g!#~pC`K}7HC?3j&J?oT(}57q|hT)U}mlLz@u zUCu`6f;*2}VHUq*{~q@rP&Ct`YN9z0Yoa#C7My&S{rWn9^5Pz7vDGc-WHqN7FnB-y z)Sf7BPgI6f_mKm*>vnVWTENBm_IvmMOFn%z-g26Ki86(iK`1SthRw^*@5Jjqg5|-y zP!5l?iyd9sPo2P}atovF}4rk_{j!ht5pn<%>^1hSVR^ z=}|c`@YT>@Kat?YzT<|NI;D=;E5j4tvvut~fI>7No3lqEc9ir=S@$}dS%TO(D^k7N z3X<`4za1iG3wVQAn>qD*q1ujNW+MFb+cg&QuE#H}+Gp%T1^f5XYwfC>dJz%Tm$7+E zcvmPi>g7YJ+GqDdY(l)2_&#~YDBk-8D7Q$BeR=egPL6juVGTy_oosd0IF|^?Hg^MO z9?KX|CIj{=^pu@-W@O`V(rwc4GV^9u)_;F`=(clAE#g#OL*^m9t2C};0aOu(rRnTP^Y-9S} zjPf(Ml}cb~U`6u=F2ktn8G2ZI@TXAQikZS^zeoKq=$bgY?Sm?b-wd2K`)_!f1spGghw-Pq+%_h}PGErYRLrLHi%zj*7_zvxu()+YN= zgT5Ytf-c2*ippxpA0eNEXUfsIWH~QR-;sDiF4b zYqZK2vYJyRnN>dSR!OqEYxZq_nqHcKUe{#cGM2EhqHGxyv+m37T#2R;Wam-Qpawo^ zRVEdj^B}-!%QGgKK4cm^1w>Q(JmB=A=cQ+r3N&pw*%3#_E6ICo8}18$iSOdiV0s3RujhAEPgA< z6R6VFD%R6=c@7bdQ8a?Rh;zet`8dB*w>5%fwB5iuGf9@iWX7v1e8UX(0Tn~MVT~OE zV1)`WUyo^LO*1FCjdUvuG$PE(Ty#O3nEeAbu~ASuv;aqfV{FFW;Y-lvD)ryHq3o(@ zNC*l5*u(MgTTokTBvm_zvkzby>mwV#y85=SFRcr6F6FHl8MCFIZEsE{=(>2&WU%<7 zSleYAu*P~-!erO1Hd_ci_2G#-DtP>|ufSXgJ^~|dMh*0z{31J^iGpE}(UGS~002XA z<$}xoW`TyHD>C5>Nob@LY>BZ|TA0|n9th0=g0;Fq6iv1h; zWS=(iG0^0)KxgMM;BdC%^Y8v6i0&*`Y1_rKR(*Z!1tWz8!g6py%53DtiNhK`4GN!I z2Z>17Rg}a1UXiPxW{5cAXrPY3IbyahHP&mz& zkEC39H3mLK{&aL)h^tydtTUXJ8 zjwc%*J$zpTn?4}Bxtm~5ecRn;BzY}*SzNTBn{XsgmWvn*O&>x<9~Jl4^(dP5VC+9*#z13#lu4Y~I3942V=P8pdOvUAxBSVq-83Q-8r z12j!!am@&4JphcGI!W@NE&hfKCA<$*Ga12HMnk*JM{n?cr)v&_BeSTJ340VxFJ7Vb z{e>z0>jEkINCMEd$y^wetrewrA5Xeu3am;bq<$v|ty8HbyUAJSZbO!kzDQW zdR6D6=5U)|9;et$x;2Ql6q|k09!zixPv^6D(DMFA$X+C-jsx!;Oms^65Q>D=SGD3> zU;A)o=l$s<^@WwKhsA~O8;2MUFXBsGVsO`R|I?p8?e&c_8?gwIgh+t=-!4KE6N!>- z!ZPl9yAs7i!>A$x!`EW=tpLp&3HgzApJVx8(MHoQwvdwu)BrFXxI0t^+xTs+u!&tT zCqEBAZ$|kRh1Gdd9>Cd4_(|V9(IvJ(Fh-q#e!%%5%c>8!vMm9MyC-8hwVvn6GHF>J zyp$T^@{r^iQ<9%9fpuN%xpiPKc(2roC%x*4Yw_~vd%lCQe=mAgt0%<9i2qKl+F(i2 zrIBSY!8pXF>TZktTtHSQ3xU=ZTO9z7mZ*=6w)){T&BWqc9nTE^EE&BXCHy#M<`P7i<@(X!} z|H3z!K8#Xr=iYorM5ci-?Hjtmmzi{PM*w-`>fu&?exma8JUd;s$N!2fnzgN@{&?_a z0nQ#m|2k5pa&YE+%VG=pxgGoxaCrv2S(ZlnFg$*3PHlv1xpqBh~w*_|;- z$Vzdm;c}ZRxhhfnmh{1TohJ29bH7LKV3(eHk+@9`Ngci+i)CJiSV=g2sdm#2nDxe$ zNmA<8SlNTH_x_a{5&#WK!)^bi5Vo_RyIS4_8jsilwV=v!b9NAZ%JTwIkI*A^wbd@| zr9+(G35n@UwZoleI43R@m~WRig4#=N&Aoz7!qL1UPNYkVPv!BZ=xO)h=BPi&W^6Cg z$~}D$Y1v)U59h126KJ$yQ zHzA5M=x=;FDJrgf8$j4k_@lxlS}NmuaW+(^!_D0|d*c59IkzUDVu*kI5*W_#g=*|i zM%nQiQrV*LW$1W9@gM9<`9>Gy7ey!N3iSX9Rh7VFk?TbUm5*^C`Va(m!%&vX3NOm?~&Y`CnR8>pU=)<9N3wpieP-*}nv z^2N%szgnjx5{USYoQz81{RHy)-wyourP6uys5AJ-0zhg{v56(D_R5qF|AA~~(}1-F zfQolJhuCcpd!apQeY1d2>irfi*m)$yYdt<%CoTNKiZ$7WW;! zrIO5-Vvl!Le%|TUU>Td1SPI@mM9~8B4v58WiTQvWp^sZ|gfo9_8r0%!>RIbJi;fV1 zmTOXOy$8FiOn|LW+l}q{1GE$Od0s(Q4MhJRdv6{M<@?8tmxhMK*o`G?)I@fM>^o(j z5wa%6`iVqj*P!f??E4a9$sQ#_mh356QV31e|~>G=Q+Rg`=@j2 zG~9FF*L_{@_iKAkoFrNeL=B$9)Km(e-XE~fuDna4Ic{COjAkNHP;P-TX})QYbhMH( z{@n@o{@n>g@j9jSd%&!Zh2d%hHgafF*H2fs^5oFjP`4OKZbp)xzWQdV${PR&M)j(~ zs-a9)SwLxECYZ1m)h$4h10GwUwF?%W&0IAgva9m$y^C;D=42hB?by`D12l`p7ZVT> zJED_O-BPXmVSkv&aBShueC9lyGmVasvv0>rD@#4&7lg`F>Uv4fG3Ey!fqGnLCwA?r zuj=BJ;rsaeeqNzm97^2F6_~k7^r@T+#9q1XIvX<9=?go~gV{>O8)bltzMS0?X#Zau zK%keK`kDm5Y|x9P)hI$$)tEi(POn-XA(BCqyrk$^u8v4aQFn-h@$-DA2rm^gp6V2v z+Qaa}a`Oh!vfC+w58pMoWtLigaH;ikQzu0-+#8EXh2h3X$YG8y8nrBsj>lqNuFIpA zo(q`LLg%;}vZlSf05W7Ql0KpFvC)+2;0&Fjj$gkq@4aE+HVQWinA7olvMkUJtA>FY9F zNMer!?ZHQu)VJydtSWmx3OhU{H*C`YlP}ZvkJ!J6W65oZX-1~k{_{z4g1iGAM-R) zwK@)j8M%vGmy)DidkykD?}78PcsagXI7aUc&on~`zABY}8ksW%#9D@G$Yi|^%2$AQ zybAf6F(!_0usX}6$Jd^VjDWb) zJuXa*VoD3#rw>Na`os%U?S|4a*3SfyW>?O8}JMC-*&mD@pALQYtcw+*XAEJW0TU z=l15;om8tZT$x#I1ZXY9AgLgerMHcMW#eBh+`P~-gc*X$vmGeMX(18{L_aaw@zu8f z?Uh-j{xMH?YbCD)#I72sfoy^5@^SOw^4*g=sTF9->RMwU4x%WjK!N@&67(B|m&A$% z|H1-e;Jjpq<|rrPfFQ-@)%!qB+k9(3TKOt z|8k)6pZ{J5hSqjM;NHqagIbD~>j`HJ?}xTFG?;c9d4cWkvHK@zYAH&r|5Eo2r^x?C zq=7ijjtocoFFJ4N4{&>+ML7L0e2vLMrencBz5B}n^A6+4aEZut2H}6#$8Yd||8Mv> zCJRgxm;CtZ?&L2p5FLs+RC{?dba~ejnlc_?+4J>Ig3i#+3e~kG#Nqx@Mpqu_ z#+WrT|K|(N-EKQC6$J*6s{@=0O4hT?18ayr)b{KJZ|e!5m~#hph6Xr2Z-X)~Hd=DL z=$DBD*r;6q5;uR=eD(X=JTMRYvg}vuN9QBz`dn&btO@!-1;E)fdwst4PZEl?0AZo2~%vI7Vt5fB2L z4kBbPYl0r}*2hXkhc5sNL4)Ln86cjCgd}kgI8EO*_o@#J=M)74p_zbO^tMbVxF<~i z`}Ymu5QgLtkbfG=v}p&RGxJSzceO?k3b_touMU8{B0vEB6Nng<2N4Nde?Y41mE}hi z6~LD%0R`~YgAQ^AjC+ns(3QCi2^=ss#`S`Uc9=MJP(eTgV>>~qO%G*3QgqaTNoBd4 zXIjQ~*JoxRbRVFV3P7cIt0o${Eslp^a|i1wc$~5ySm2(UjRPp487iG-it=K#l}NGj zcyi3Kn_$hv+~jpn>Sa%mY^&J|I(7miRQk<01St5u;s8 zWgZf)kZf?9ICUepKzK*HK2pY z7ZyCuB&;=Nf-QF){JKKlhd@eQFd)$UEfpcY0vbd25sL=}M0>y+h=3O}Vk(LlYyznMP75fx9Qi5s@NqE@`~)&~ zCJ0eX98lAFB(|~xjVNwaY+@oe+4LBL)@rf&`u?@%o{fFJ=AD~ZO(P@v#I=2{{n?%2 z@k@Ln=n|kdRdj{nWb;c6y&;~ZB4Y4~^`ogrT=OtFzhyu}J%PraV@K$ln!))}38`_m zuaPi?Fog)CeBo|@7&^|+eiCE@@B%_nP;R^n5S3gGx_yjisad2+MxguwM86Igh5`?1 zQ0{+{>x)ULouWxaRDE3NTq;q6me<=Fk(>< zg~b5U+uoS!{5^!S>q4sK{r|iVAw6eUp@JBMc6(6vi2CiWVVdw<@zWqwU0+jEv;8|E;4xx+@TjYSmJGa6$7P9AQPxl1B@D(pp%H5aAB{nS?W?x- zo>+4ga05u!1lTQD4j?t}wI5UhmK&_rMe9u0sxFHK7x{fbZ3>--M`2FlHn_+<;!_Sq;Kx=gCrjmZg zWA1OMF?9r zg&bxEh5UAy6#jg#bPx3mKO4^tt~f69DZlDdE}rmD-fQIA@NaN^Zxf&BZT%2B5_)Ap zv)PSRtDB~C^qY0?HA1RwhcwNEyVYpeph)w11^8}@vhe%8D*rDX_2n(dV75e`P2zyw z#d3TkOc!Piu=XOvj2N#Q&(`!Nmp9Motu(L`hHw;On4p2ksp5`?>t@qGPC=Q66tvQ0 zL@fy(B?g9DH9l46v5v4Zm}~FFGt&aDMK#BU>`DF^%hG|FX9%bFxO6#trUH}&*Y1*7 zp~!AxkzGU$c+XK0xLYrH7hjA>xwrSl;>z}4^nAeN+QLXUZmdH0dbgmN~A5w`3FabixEzRr3}xe zN(dFP6%jexm>=s8Yzk-;IP1L#Cz*Av%w{%YG0p_=oDf-bcI7pS#{6iWHLlM*z1+ex z@*Af2$|0vrG!Pi;1!)Ae*RDscMcKdHJi~YHnomrXm!|x!m`*t^*%@&1Q?s7_bAUrOsWFL& z+Mg&9JBTN`gU0i+@a(v$r$qBfDU>L7?=;1E_`t4z!sIb?j*5mcb{c7An@-gnXaS;@ z=Xc~#eayC)Tg_*2l>I2sqUi&k@run5iAJ{aFmnr~N4oc5u^acq)FS0E?sw0@{+@jb z0Bs5N+)LKaSw7qkcq1G0;ovF>f`ptfR%NWYBGh79m;J8ZeJ0|2b2(a18Tr%AJ+_1* z8&t#3Q7;_$?FRjAUP@&RKt>P-2BMaA2Pz70w5S4H8eOPjzpLi_3SDr{5kBl6B|AP!TP^cC;aB|#CdggKd?qzSEP&ZiNlP>Q+|_zcxlhgu4KJ!&=F z>QJZEjcjImN-b=gO2rkBHM7GXaJFP&$1K9Nib_0y-@Xw+%6PD1f*q{8`af?K7K6F? zkyhh3b+PDaMrxTB>MIV05~`X8SS;hEHeO1udXuU~7oQz* zdPl(R9W^cC+2~mX)uL=}j4dCl70)0*OvD0$+*e>0_>|r<+bN#u&*L!M1kV2J`Xk|6 zrCiOW!ov?_ufuYCW6yNXHmytFlMQ&h;mMe8-9lnNX19u~?HACTJ2Du?VijbMW65lo z6tboHg-fCsKSPH_#_Hu2@~>5jr-^S02ObmSoq2fYeDSytMB;w>r19UomLX$m0LxH- z86SMd?(S1J^B8Eh-f#-wFN~XwQ=uxZ8ZDFrz0vmZf%^~{Q2BjsLAJnb$Xe)wNfT8u zI(9F`j3R=aFk%mdQztIUF#I@zHM>?Ewp@KzaRev-mgL}*Y9BGjK2 zGq~M3i3y;2xE8JTOG#~*K(_soVsfffR2$yu`SFp?DJ=Tsl>|~8M#sSMJ+V(NyALp1 z)H*g?)swq6E!4EAT=?wK(o`wXJ&HV7nYf>Yz8mms{ZSC0Z$reiy;3;TKU~cs6-?Q4 zg~|y%a+~f1YZB_Jt5m zGya79QG0YirDI_i-C?ds9mKB!emoa8H@dDJ0#*JC0k1-vUa%CAc)*C5v zu||o&1m#Dy6iWxwmZkf}vG zl8f2q%a8U*BUa1lMVL&@>-F>PQr`d*k{oiWKbYa%=Kd{qvqOiOSrfbnMubLvohA5d zGYCQ78V8P0V{$7X7N9%>_PvNpu304|Xr5C)ENQlu8i<+m9y!XfzA94**q*Y>=sfKw z)=!kS_cnR-xmC@viCE7NF%pAJEoHg>Ue8o z%Og>3$r);|4*X#C&t|bmIV7A>eCvaxGdx2NrdJRGc`Y1{rasyM@AmSrigj;2Vy?k$(Hn`JG z9nN@f*&V+CwntOXBH9QcpJe|+Q@_cZ2aI@?zVc;A^R-lRTgL3HSTlL0wxAP~XPThi!?>_-cqFXdBKLk{1V5DTc#4_2G%kjwE)^it|q z2n?V!zQZcNbKqkQHlE3;8uoiS=^TL`E2V`g8CH@b(GgP0JeEYHwE~tjJO#W~uz&;M zyn^Rz&t^WF^-yD1&Q`b7bKncDEtwUP;m+h7pS0&M<1^Isf|ZyQ+gc{Y@P! z_|q8mZ#`Q!73EJ8bLWb@p5Z%5GZ)X#xb&SzE zcQMfdGq2Cp@$La(U?b60LTybokag+$0c&BCCv67`Uifs791W z!MI(*AeFX*TDFD+n-bMd%ntT}6_%St>N;=ZAn zi0v!8Yz-U-kpJg?R#73((#+oMF=@`jcwC&MeyeI7k18MioGG_SkHS~vkrg9q0lrbs z7b4Z4fs<|i_tg{$c(NjN4348?7y3)afMy* zIi@K1i*%D|Rf7>;y7wSgE>4>-gsN4vKV$h4adr{UiIFV3TvS5U8ZB^%3&!n`qb33n z4Si$nI2BgDVT~FC`*ZYe7MMbVMD;Zl6V>|nzY(tyU$CJl^oj_BF-$a_XaA?az>PSC z@Kh5czz(&$7kz>5mt)HlivLjLpl# z5iXtsXNbDPfJgBTOJ?dcd>?uxr|Ou7M?JsFg3)qn?BaVK;kiwc)5zsg^D`xeLl=5{ z>lw{tiXZ2v#@RySV&+qRh)p5QzwH67PZVuFZHQgT1KL^9NFe7>7H`sZ6bTj{5XRar zW#Q5WlJ`?4rMxeo^$2rtD)Q-i{$bO4MW{Nf`RCtam9+)gJZ040W*uO*31>Fhi$~<4 z@K&W2DWTkuL>puwFe|-Nbmp;9mHZTGtgg&}YOb<5hVfv;Kh|j`vbwtO+1kOm;pHZn zAZcs-KQR7ueykPc?-x!_mK_rE-o|rnz&vUc@4eg@dCSL!JJ>zJY+ux={e0=(3>^2L z?^72AaQAY3&MEW2dQ4H2Il2dVRu+Q(_KTN$P$~AQ@t{iwQ{*-JI>`-x#dg+6L4jL! z0)6P2O@7UM!$>bp5=AGJROidAIo2Ldp=a4-t@_|Ql~J(TIR`Se0|DE!^- zg&dh^MvQ$jB=T~MQxI&#d@^wD6zg^;7pq>7X$p57o0X6kQ?_euKM)zs+!2|KUYrT{ z3SfmWd_%{7H~qR0G3Be{=TqLavz5DCj3NxCYVIbGTEoOJUlH*%sTDesn5=G8$a!TE z<+xUxCCndv&)>k$^CCNJq2i!Y6datT`hDsQwA2DEbguU?Xc`mY$jG2a@1hWK6nv;K z_`8h}a|;YXCqqVpJ#v#%cKT2C;++*nB{P5=M|{7P*zDv+)+!bvV#d8EYx4G)lY}LY z{t|$XA5y^$iG68Ikk<%niOF`?+fAboCNT^VBz%Drs#4r#7sr%OEyZ$}HF9dP)kz*yy|ko1|UA?)gyS51Uriz?~(yWlf7zc$^p z@O!So=B_GW@mf5T8zzJqHt16)#pwL3qFF$UuUVFd?^hL~Y8QT(?NOdM@wrlF)Eu6L z-W-0~SDLpG$$)Mc*mE@wmoz0bJKW@TL7Bge_tH|nN&-epbjGl!g^227)%81wz15mQ z?bRy3855%p`bL2D06XuUryi|6?1WE--SgAk8y1pg9Dpyk%BBg3zr%HGcA}E;$r$aS z)b1VUqtqha*-^h0Q@7szDhjFbr(;Z~IZm_LTsUnR@W0(8Lsu^5*|UrcI6O{3wN| zhfnx@%&Wg|ytP~5Z*uHi*v-=CTJ@^K%)=%a@{L2q-%MCHOg2rCfqQ4*rkt__(kd?_ z5tn8i-Qd|9YikfnFfPiMd$yzB*U@;*{@2_sk1?j@w|;uGHR~5z#TY%T`7R{l)5Q|) zN=o$a)QtmP(UA1w<`}#Wy$V7^3f@H-+m5S|4{fX_LbYdN%zs$DnkGj;Y&*H$@zf=> zJ1iD>KAnnOB{kDXSycCK*Y`JLFLAng(cHF!ZBnD;T+OIZ=hC+~J1lK4m0>P&zS}p3 z@9tH&V&PNp4m2ZCZ%Tbl-IIOzAG|*vzqbRux$)Yap!s`LO+tn<;{Z~|tAc*-B8nhZ z^vurY*8_$@GHondS*n27)2x{jF%C-ZKg`+5$MgRHp#AUXp`XTEEH8Fa90&dDNg@pV z=cfKO6)k|sg}=nx|H+S6c(4CR27W-7x-ok->KN!kwT7&aV_kp|z71>z|3%w+qapno zFjKM{dVc{#aBldC!;N?T-g1PLS^hP;Y`UfbLI5yWieg9C`U)M06PIKg?&&Qr)=t1R9An0c5J~fVuhm zdk^iN22R}T5p-`Q=T`+Px!ZZslBj4ac=rA$*M4$ES}6Aa&zZjdkJ{LZ@3xo%k!cr<{YkyVu!zU{0tPrvCMC~eI;t&Kv`S4xHfeVy|P%;V+RQ8VoIx7z-lSG`qltQsl zkkd)`J6QN8fRcsWT%@j{z?4o8jaq!T45XORnR32`Yh^%MG;eaZab5iY5Zmj3PVE3p z9ulJE&B0ZkShav_<)(X7>Mr0Xe?U1kkb&*t?*;7rjFY=w902Jv6vWWZK-5f8vTI!l zsFfF-kHkqbn1DHb+n*Icht8@CDFX#ab)$TxJ+X*g; z31WMp_@^f?oBp6kJ;9$&6z{wh0_lFxK#>ADZsDC&=&2w~Fp%3vL{MDqx5GZ#bqvh+j7Lz^2tbsq+Sf9Q$TKw#AGsk* zzu83v5Jg3APLmFW=|Fml7j6-Q3kt`3dK9k}gFpv93A+i|`6zmJ?;iO9N7MY_#K4k? z`I*jCQ9JDkaCeLCHbJVA_OL77gH5W$!50|p4R7&JvmXC!ta=v}w*)!Q0Iw(D+9vNX zmcN`pO1Uv|>14NG0ICb1R0|P9^%Ny@yLBJw&#Aqu0gI#{QD}3uT5k{yT#&}&yiB>SG<_4|H+~gzBO^KRjx0i~N z?SKfBnc~-x5n_bb4vVr2MR`BKJ5*K#nE@!$0Du3GTCCT50Md9sfiFOyPWXkkwmEHP zWU6TmUg8q^bPJ%EfFeMWuRZuDJ@9C6J{5dA*`@91Zo}O38X4k=J)-7BXJOdGp>eXH zhZTUFX#+=!14=jZR55j7)vr9RQ8n_0!CpX3qxwQOBNz0-YOA+^^X*WCIvVM?6z(Ku z{HhV?8H`H!1miI&m3o$WiO(t`5+(N9*5TC--)1L#I3mdq_LYLzdzfl>0g@_6p9P7i zBAi4CZRoRven1T=OSRU6)hSk65&+2Xc@tCTOFMI5Ybsv@=Fvh@a&XwDH9&ugz*0{*0!5V24WWq_Qh7);fCxpO`PWlM?di`w)3Mg0pP=gujK_kQkcfs%zE z)NMx~V2R@!7_J5BaZch>gd;~BQhEtJ$=9Cusa*qRQpf-T34K2oYPBPY8Q4U3O8a6F zVpK#8aLQ~B6*cA!#T?q7gG1qKz!ZiGZ!Q!wiP z%)>H(p)ls3pvjeW?Lfjp0|~=RJZLkKe|_5d%Ku(mb&m-)-sTT*F1gZBejL)|RQFb8 zV8NO_I!k@^aGJuKi^46S*}%tMB-=0$JdZ^ z1yie@thUzPP^I(bpYEoPC}aZJfk){{1exuaB+Lr35s7sd{nKOoOMtS!${)}2d9(!L@brS2gD(V+fm9J1a4J{=f#_l{zpcUn za7#J&^6~uQ-n~t#YrHVqRCRuJ#uhR~({Z$S2W4{@4??sRN z8$4#n1s#e#Q&EVhsmQ^{5m!-fl0Bcl)txhCyhLM8lMk5lmqYRZbMJ{6sO#lr1tP^i zQq#s^+GxUihmZMo@%pg~6|JtKHMX=tG!NG=D2t>zb>KndFK+-SPgjs2!1=Bf zKIUm3dFDkkNChQENnb4cv9 zeDON!i#nmt;rg9^Cago~ei6VL7pef}`u6tIpjx%H+B+ZOq4V&gh$u*3egSOAHzIiu z7w)+bI;+}gl5N%9IwTBCJ3`L5oH5?KBgy*m&EN1Gt?m=GEjXck_--V}nbDGm!=saN z+u$j`@X~+|l64S$QQ#!5e3^vXOd6QqEh@9dffp)+H|_^|DSXE8quTY^1z8}Fb-ZPU z@>vDnF!5($*;TaddN8z?Z)QRsaNyos2VW;YLX7c}^~HgcfF#?c1m8AiAH4m+e}WA{ zA^j#uzx0zZ^*pL*<+T|Hvv)Dz_fhqunUpIacTb^SRwg4H+IgX1&2?ye@bqyb6syt> z)N-Hvs-W~hQv1dSo`A7nCK2l$jd>S&C`uPS7#9@B=?>gF?kf$%LPj0J$knFZ9}#Xb z&iVccra04jZ?F&Nhn{yh?&Ubi72`eGvVqeK`Dh*Ktv5%{fTf&#!H|%6<($aQ5-5{& zbC50cEko(j9z#ygmtN4fjDfqw1StgxF3!N$PXR*4J$Av|$04U=%ERLsF`dnhBF>pb zXNvy%7h@r3@ptfHm@)$k5cASu;-0XtC-EJ);nT}Q)yUvq(PV;UFmJpUq}bcrB)-^l z>5X&-b;(DOzmc1Vw$SU$kZyogV_+zITxzBjipAQ{wG4wf^FM$LOWTG{?0wO$6X;4Q z%C*_k4UighOeGc@EMgsyZw$&@H3R}1XVcgB9Vy7k&!rftr}6Fciq9}p%dRQDKAFk< zjfv0$XNDYZKq9u;IA54%*v`_zbB??hFdp9$4*r)*0qI}Lvw&s#G`KIIm>-_g-N3caA(Jiic~LyLixu23yXwyTBx=b-;7%163)jac=ZdQeZc`| zRDdL2Pww(RIXJsCE}9>WpPUf1?3dgbyS z#(Xg(Q6i~2ILv#v44!#8{9Lh&FGvR%(hoF~++*QFjwquwyfxa9VuJv0U)TWMy0Bs> zE3wlzvDcHOSn4Atx(wkZRW7YrTL z(CXKD7)+pUG!f@amu`^KuA=(;E2yMw!2w$dhK3d99OLid&9VaiI8rQBSw~1-|CuHS z6tI_L%kZ)`hm;)*GOtPj(0n!!PXW7hSmi8 zeB9XY0)e8I>H1mINf>vUcmr}kUK|@B)py%lPf2~dZzOwRzkMKJyLv6s>VUf+piDTJ z^%8%XA`hGgKl7@FzfGLHdDKc~HX))8gs648J|%k$#7kD{aP>>-o~CBO9Zh==NNR!A zp&ccMS_e^YtRVVXdhVH-#ha7oE7Js|?D@Z@mL^GUEP!^w7OsXxGFaZI8)&q-F**qu zw^ZYE((!jNJ##xs$(^#!l(D?#wgKXVks>PFQQpM6Y~eYnu-o`sm`;&EmMfv6z0Vkw zk$<%8p#FSDp8Pkw9a18LNoEWsy+yZey)W6YoyCpfq0A@(QLvRPQNjcecDFu`T1Dn0e!2_=>&?kC9S;Nfoe%?^$2gS3nWoi{Y|Mv3lh5?jTO1k;~3i{{0 zF@z zo5R&`sGNsN4;x?~vjJR-dm0#J9tNyP;lNTE3PjxD9EZO&urY_O=3r;c0U6=z=z~qr zfWEBK`3^jVbWjS1Fo+Hv1pXd3(D@pV70PV+ltX>VH1IW&v&-CvLj`3O`v?>T7N(ma z6ADxsVIYU{{Wjz=XoDpEKmkY#-m0w7RsU6AY(PogVt#@y1!Qq|0LMGDUYG$hrZzbn zFnAY|r@Mi$2F%_G^~nwZRf>DEWVl9gDVI^<3BVtSd`1IJd5MchzAk_v-9S$t4%T}p zNT}H;TG@AS1P%$xH&6og8W<2=nGX%BpB4(-yS__QI7S}r1RhJ3n$@Bpgdj}y_rmZw zP@hvkYVBQcuC@ULh6VzPb_XC;E|eH&1JKWD5R37||B|oEAG)k+p>blk!hucV=9N(2 zFU^6+6%SUwd&>(ot|`}plbzslw*TpjIp}O{z=hcCZ!fRQW<6Xeb^}N1&9NpKCUZ9> z7yj}^FqYoKvC}EI%qRZwzAbWEtDV4Z)e2hD3vHjXKoT(5-ZgVO z4(NryFAHqhUF#tA)c`Wu1?;?;Y}XIfEnK*Y{ZgL;5cPj+3kRJHr=rbV7p z1VOmp1H9Lj=QrT(+pCitNBhmk*T|?aD}%Zi7tfUmX{a<1i35*Bs&%~9xeYP}Rj4|s zuWf_-!_^B+t5CTbj;CY4=0yq22Ty`&mp6N?j;gx%=4d1#M_uWyS62@J&`~CW=IP31 ziP3%uop@oE_&{?F3xJy>{o)5RMGbeej`yf!5jI8-!Kd z?y3jDI%`mBTwPuUh@WNamsu>WpFF7SMo!ZyUE28x7$lp$jkz4Vp6A%QYsSE}RmYtA z@$Amh7@g!l6U?im{?WcF&G~wRIC9y*jf5=BSU9wGvB&Xo0K#5Y z^*zlU4;6mG^q{pN582Hwh#{vDKKwJ%97BEMGQbe;jyC#uG{!*QJ>#DS>>ZCf;#5Ou z2@ORowh9>b!-bp86yT+NKzRjT55MZL=z`t`qO0Q@b@vwMQ4Gx%4D4ueWm~5^mkLH2#4Elm1>b4q-4Q0#aWkpun zAJExrJWZoa1(fJi34I(T>F%pvYaHoRS|Rui*o1#x1IWWxz7^!i6Jee?_+^V}F<{WB z(3qSF0BC-VuF3;wi-y9Q8D_mH`->iqzx91Uc?~`oWA6rK7IiuagrtHK>*y94P6B{& z6~TKsa1{{E`pCq!1A%VB)pL1h(g;Aas6{?^A7xrQ0e6{*0C%=ERSmGvzWm|4eSKg5 zw+i%y^Kzfqc;YQ7-`aM4Q#e|WbpnCY^P>tX8(<_V$D{bIfX|X>o?R&M$JSbNt)mu^ z4I-(x+b3}8bi7zLqW}xV$W&$}Ub)#pQG2l7jSO^aP^b|ZHphog0`mo; zL)c|p`j$?|A*TSJKprZdLMhB;C~0#a0z~p>o`Pd42Bfz1Tp50vl-vV*eiCIrvt-v%{qY1{$d$UX>uw$97Ul}mtj%+y7$b1MkEYg;Z6ae*ox1yW z5dk`$7h1joAs5@eQ$X4{H~o+Ag9AhI?`mlo@3Cd(9q8+aX#EjS^q${rsaDP6{*@QK zF@2HY$rd)#H)P6Xk~ms(Es*-x1HXa8=}f~!jY|?hUFl7^JrJETm{o%wE$-VX1Ky$mS0=FYr?t~uU^e06VXrGID0IY@5 zi~!W4+(gjS>3>U5E9jpD(DPd0@lN2nT@nR%>siY}X@1KpBCuaV5PZZkxy$G_m|YNk zgVIm`gE=+0a0If$t5hixezqh!3 zkM@p!fm%gN6WqlqH9d0`&RNuLgTAQ3WBRnBE(ccCf+W6^7V&$!78)17o#)Sd)TOUo zH~p|)J|yA30_8_ppjoh0Y+Z$>P?%JzWn+!po@=<7e4UDtS3^U%8@l&d-3!--mzXZ_ z+>FQa2iyjVEm2qE#4^atbDUg zvm-vUeJt3q$u1ysC|xahxsaejjorUIAtwL@{K94T|XIw=D8;x4#yk($&@@sYKyn5}f^L zIb|)gBDA5RNWhj<)3V9b?kOiU#$?@>&{~AL?TtK5B1FWn8qAA>+J#vXu~Koqg-U1~ z@+MNghgYK|zlt@Ffaad1qy&zG^X_IgyiC57skKpDn!EAhYQj_8Be7N~WnsQyq8~ZZ zdyjZ6rqg{lb1_#t%H!fLcd2H0fxCh;!7F#@aYHuz@hJa@GU+YXn@uq6-BMMpWaTbp zQks#z+KSh+G3?iFYih-*p*4PUxa#>DRVJ>4SHwtOY@Gf+#8e-PN2!tG%zb4(Kle%p z=uT;u_M_*1ZXLCig3H%{thavLAs`E{4{EQNW%7`**HqN`FX>Q2FB`II1XZHu7bl!U zG(ceSMv;6ERTeuay_c9AeK%QAWuwoVRKE6S%Du^5mS#NM1&KV7{#jnu{W5>p1~>+| zxr7Gz~GhEQEC&r&96YuYYON!_w%xmTdpx5BoOMfd|&J@>Xk=x zW)M?}PlC3*(>zWB9tZ$8fgTr4yxR<%p1CTK8$Df+k{!Y1WjR#5z}3YSmpz0}vPRuv z4(pgu$4sml>e*2b3*%WDe;Ki>6bwM%a!D5ao%cun*@s(CWl&?nw$#J>>wnJy-@i+S&EabKS#+C^e;+RZW@2$(SA!t>HtjdTq& z8+Co9Q<}-GwTN`#7tAV{;__)Rf(QH}dM6e29jy^CAW!~nR3XP&2kT@?sbZ(eS;BJf zu*Mg-Z)ZF`%*TWMX#?Ek@FJAq1FLv5q zy3)olADOPf^0Tm+e z8Yt z#Lh`r<`v~>?&ER@3>j($A=DvkkyEAgmaoa(y;Jz`Hpv&Hl9}8s(5zWEzb%Kok}!iuZ}J)w?DKUq)U|N^XH%v)G(qNDB;v*Z7>)s7>x7+pB^h z`hRFn8x~JR#XXBqSGM@0RNU|eT*6VSERI^?z|Dnt;59srypt(OC~=@}wO!#F4))V~ zeB&!(<6ahdiuXT5lOAvJag#hOxfXj?dAQs0QYjt@cNtA7^4$$zBGV- zyB+}f9qo;FW-hAWGeWOnobweZ%jZZkfDAaac!3RjfU78i>xOkQ%U5B6E8hZvf57b! zMhT;C9Q{Ovr=|4eb;}lg!oXV`r4?udRF#)h5h|D7-Pzsq;0u~Enb&_Kj<)T(oUmZ@ z_PJcMxF&bpv<&&{qKWQ*R2)>U(QFvVC6)h4)~J66+ngU%Y1RBm?7o|nR9^L=fi^;z zNWX90LQ0Z*aNq#E8hHPji>Eo1&gCFSG%j-4TwXR-b?exp({;DR>*0+s$PEji4i3J+ z#npx+ABX#H?)Q`7Fn4HP54`~3s#BxkVUXf8pMzc74zDe#{>VDJ(Ypb@s3vCZUTH(Y zE}w!l0AP$o$bi%BM9*nMn`rB4f{g#m{fbD`=lsFEfa4$c%GOCf`wzOh0 zjAJ(B4#VqmvWQH|mqM3+r9oPNz_ai;q-Z)7DFpcB(}hCC8JPDKDKC`}fw25ljNsbp z?mySA!$;2(#fkY5nvN)yS$f1o+}a60*AHKT3wXES;1^V=$Z6nO%-db7MI=0zl7R$T zEs@Cy;Kq_+A8FnRV?+Wom$DGT=dFJY&a>gng>uZcWs7rd0)+dYPze1j?!GyQ$VD)M z3e}dGV??`NWmkDklXdo054R>4vo^muF~?c*CVN6aAPB27xC9HGVV=yB}`?( z=?@->T3NR$D2G=8bx-;}4m75zf=sP+you zbq4zm3oJGVP7cVkO#HPWh(x%e}0*Jwjig^ zE_c2E?bq3OYXU^|iqlJvw;=r^2E((jT2KZr_V=H;Y2@|=cyhf3x6Y5})Pb2Q%|l$x z=6X#Wh(gOa72>mN`m%r%o|Hz;t5+qUb6%ER**4Uf5(q~QW(Skd9nCz3;a-G+ptroCvTy~B3?@l(oMQbR} zOuwY2(GdxB&&~cENpNTAN4jLqFuxHbi7Z3O-1$@H%>5q@j4*FR4x%N0DDnFB9`j)> z+|*4}zJB~E;PpHbX^VVu1(uSH!n34`pjYQ%%@2oMmWZi zr$4yq{VJO3F#Fe(fE-7wNLU^1FN0zY1uj|Jf1$2{3z&f;4#zT#;euQF}b?#^FP>i_9Y(x^S75)?1bJvJ2%!ei_5m0h< z$#=4{pCtXGEK2fLobtz9nqr7`>|joLi>U?lW1N4;eA;`RY#Vv@ax**pLeaR*E?bML zS?K#lqZ*5VecACC-|N3pK)-zZcBV)_FeOay%5;CjLe;sifDo~Y85Wfpg|d&AL|xS>eVd= z@q)X8o6p<_cIB-uce(Gfs=(E65g%34+i)HxA)@4y!k*WhBX%9mu9A+V_Sb4Se0YG# zo(uPY4y>UkV0r^*C#Yx0K#wUA#@yIzG;H|ITs8gNY09s(Rr>A9y9pTr=|Mr9;Y{;2 z5BU$vfrLXumFvcIs64B^TOS^SC&w$__>&oSzOi0R_UQt*<>>YE!}-eLOg}CR1>T~>uFU7h%m$=e7{kYa-Co1|eAg^-fz!av>CU(BR{a$f817~*=nsUWBjf+%T@>ODsB1tA^-l=1^bs$83U32FG>n& zdGD3ih>hrAP5!W=zW=)Br}ry~47U`%inebOjdXeqc$SXEY?M2giXq}R;{!7320l?2 zyYvX%Zm9&(DfCcp5EgI$NVhzkl*<8!+OP6RcaH@riOQ#7Lp7xS&)+2xpKVUdAVqsO zJKs)EY&8Ep!v2FVoDP!KS0&+&K~+8{p<47Q z8w#Nnm>`DBZl}-lOTogN2{@EAFjMY*=GR72Ci&VCa^)$5af|s6W8(^B%ze$uk}gC} z$H8P^HtBlf^24E?#d&g_rmmmGJCSYI$w|m->nHIpegnS;am;32ci|6;`gtnK%HvN+WGAa*#7oSr+k4nlO*l0w^h_WS2uKSo3cibg^?wz@tjtl0h##`g#=6{^>>jbCG z7~fk!*Z;Jek-|Ti*uVeuOpKa|h85?UeG}T4LT4b_SFf3ua?Xcm_BQiHE{^>Q*eV(s4dJ^7U7w5^8MvI!6ZfGLL# zGXY?k<7`c?Nexwu+N*}QF)Jr??@rq#C;7ILgmsHxV!7_)<$HD17$~s%Ks*?s(hQG= z4-`dc$96h}V(8YnsY%$5(iRG!*w&dUtEQP9FkQHGl>ao`MpZ`12VidZt>e8~*-7XE z+A0jU`w(^Gl(-wvlJ^2P_f&{iAwi&7C&Jl|N=BKjm0bBEN$P@dl<2klvCg;GPme#} z@ALe)O-ahj`GG@daZ(V?^L{Q9k?byq55_FH%WWlzbLJaHAODQ~G~L=F8^W(FI{wsG z(~6k2Wf~$H#!X8?_mh@D6IVd&F!#ieZru2oB`p-7vR^Hy#N>Y^Vb zTzF7KzzvSPm+lsrdj;5Z%8~qq1m}VQF~3BVe5Tq8ax<33rSdUsCpe%(WK?J;!8#Uf z%he3HEfoT}qfv+jir(=o$l=I3EGP-s@bl1x-KXRyZO(Ds@TZl{+mZ`Q*-*H!=aqB< zphf=$(9|!U<6;`-Yea8y5A;X`t1vQHm!dT<>X}KmA%rYH`xx+b-Tp3y3W5)LBZ(JN zw6pGOa{l6-Kj*{Zt*@mrOsTR6&xi752A&+!u2twM!}+B7Ri{h}exmbQ3jlrResq!K zoaIsjBVpcKYoL788x3|co~Wrm@S7%mB43StZd&@-xk#Z(;T84js8xbWm$G-_Q6?hP z>0($*;WV<-{(>;!_eN*Wp9kmq6>qI06CZ9-*X14G)7iRGp3w9jF*#0!>*H~81`IGI z6M2e*$Da#_X#v++hdPZw+%=&Cv6<_f@?OJQcTP6Z<>cFfB+rurm4aqOM%oVt5<*aH8p=1klDJ^628n=fKFmuL>` zh-lk@|A)P|3X8IR+rAYM2LUN5>6Vg)K}refZYco)DJdx_fsyWx0R%xQDJePF@+G$I@JM?Zh?<~;FL)LvUK&-{1`LUF=DOUSv|i;_}(9{fTY_uPz}^>yB= z$&)9n*2$g)04Wu;nHe3>jT(;}@Sj!lh${}14UQI1TIW)X_>lwrqx|CH#F9%;u~9_> zn|#_tIGfgMO=@nb$`Nk{2=YB?Vh?Myfv->6!)BV}4>mosnd%VFJoKLJfDchea+8Un zA`__&EWY*Z%cZ7y%#*`UDlwZO{k42U=Ld{x2W>O zy)}IUO95R`^#ix!B$Kqwq;>9zq$_Fhg_*LK=B|$)OFmlbhx@*7I-w*=fYqTVh`3F8 zo?%R)LY2W=eu_)E5_=VSWFf{c!7Cw>feVgb-n@2#S2FbJB!BB5kpNJ3 zQ*rz!lO#42JQciuS!RDTN&e@D|62w`+qGHG36KYFb`b_SRuH4|{67&g`r~&kAN(7m zGF0~J|CX-=ERg^6{{5fE;HHA`M)_nIfUuWdLUE@ z``?x2T{f^Cy>>`6`8Quf`5iD$Vi#Oxh9;*|%&ZGweA9i=U@R2dxxH_bw)~`tg-Z{9>U1cDv^zAW|jGP=3{>jW@ z@CJRto6iWUIIa`muaFqJKd)KqNaW@szh3=Q>OR;~`gUu*)Z}u4RiN0IM4Mgz(HG^6 zhi6A?lrLvpZQeMXnj?E>Gt|sy|CyjNz)k|bV@DY_!pF0N>;_wod0b&n@QI+is z>7DP4D(v^aTPWf#bVlHqy@#vb_4+n_`f20WA*jkQa2(Emz>)+BI3@ zhe#mnYAGQ4)w*x4(2^HJ-{PW_22*&__!#c8Y+rc6v=Kh6f<*%NHG1QE>5`?>Pi3+e z+itS8u4OVY!h)PR+QRNHaO@!a8ub1Ufw?kTqO(+2TeIF1==g?XtH0r_FaN8iS7#GS z?{i`CeK6uC3A6H@0mI7YN>EUelmKo279g_MTk^bl^OySnj_8ZQ=5hOesHk>4e*8DR zN*?x?H8j{11Dt$z@9m(X9%Oy?#)QlKb=l2JSqdx4CP%$wCZjz|$g=#nRwcozV5EW1 zIpOd8uWbXH#lkz-xKU4`n+ejS){`&B49EBmHU~uycE$t^oDWB{b^>q%Q+U60W_`an zw&z9t!lwfiNxE=q@h9P)?`)lVg$p)!-n@YyWKnes9eS6lycxU*RBf91vFCh-o@Lvg zAu5sdB)t`knD(h1742raTSbI-xo36pUh4kj>qC z7U1K&t*N7I3-{smA_PNA)#8I~_e;McumPLeS+`D%&K%YzEPra3y0o-rBoJpw|ha*)Gx4OEKBb@Bg!F_8~hoB@7 zuwgs0Uu!)MsHr@neX^jWErtn!w z{3-X)_>%}<0rsMTIp+z7<_iyG^Gri4uo|_5F8yi+&4ZRmN);;8;f@HCj)OI%KNcct zO!AdiD^8#38|b^$x!uLM$yPvEBPM>5Ouh3wjJQt{wOE(-)|zahvQ8L-l~Q<0M?DA~ zg}vWv;G)$^TChKRJTrWyZzhf&Fooi6!n|~UB4jTydT~@K-Sy69=qu*eyl3W3mQ23X zcBr6ulJkJlIGZCpQH4p^A)Y!_ZDnHN zwDYq`C7nkIdv%r*q+PinHy4cvrqqS%Y}e-hPzw|pQ-iNW{+s#yE9a|)4xBw+ThPD# z-X}LJFaEg%b$Yfq>p^AUc(W{=9sRS?12Lo8$7Tf%WLp(ST1ywQ)f&4G;tjzhed)d} zWXaqle&TO%pL0O8)(wj-krRttuqaGRxDqGOI>6a-@Agr*f)z_WyJewwp45E(Nu;j@ z+jr}hohOZ}Eur32R=2vm94g37Mhg!CE+|Ce zq{WgPuVhYA-U<2PysU|CsX2MDKFX_@DaKKxTP6ZzQ#=^9J59%RqyzX*fbX7P?hnu{yxflTEE%u8J%{f74yvAg z&wF&QxT%(veVb*ht#WJnTp38k%8(t291nhcJw4Y>n=3M`t7~cbh%)W^=*O01pu>B;Ba5e?xM^CTmJ;xGfKk0am8)xQNKhi0upWAf(Sg3l_ZS8oc!IMVhhqwOzagGwU`Z#C*@|}kF z?TvcVr@pD8O^dUrSu^7yTy5sdlX^uWGNi#7dw`hCpR+v~YR3NNFQFT)?cE-HO<8)R z;@g9}b#Ai}{S?O-6iHRS%s-Bvqsv;~w#*C}Yrd2_ z$DHw6I6NV{3=){*&A5X|^XZr!eW7Ehm#cD93M?T1cU8i$#3`u}5#cNK^aI{nMR$2e|!W-`Te9 z&kj0G549$iFXDPW$Iu4_kz~wLw$_zJg@(B>2 zwCLFpCeUmhx6e+O*fPK?Izr~Qj@*s={ z5byH?<&4ZP$QjRxcGl!U-|XCkRJs4{^>YAR@dkfW@>m$=EAnDj>i+eRusMur_9mhg(Ip zZ_wB0qLnG7O`5fn9k1l)_@XvE#&)EQ^ z7TNhni{IZG)^)55{yo<|egB`ej{ABD?5D8N?q55*CX!|}>eF~n@x%^%=B~KBCq}rS z9?h2OXQ>6BpVaPN)IfipRTj1mwX3w<`f^nbhlsa_t#;wJnpI+rQo^J)D|1Qj%Rrp> zq!Vayo&E41?l~!$_vC~S0_g6j$O zIP#hlTI%~It-k1ZZCF?&)8G#6Ey+5nM^G-l#^uydT6+)BPb7W+;?v30_x?7a5eRRd zt7+bHZKaKw7G5HUA%XD;*}5L+wcMRwlp}1-sGbdN<-o)l_Gkc^C&5!a3BMM85Jo#( zR7|jk(T0NaVdNzKPqm0-ZIRqnmEPkQmBfCXLyRhI*0Rg?3XY<7<_!@>M2{ykeFa+!LTRMnCXM)->&Rb^9)CmoKcb?-rl_B_eQAU* zs~h>XMf@(+)4O=(s2LaKcJvK**>h;ufc4qbocQv1==HT#9i9XJ*#KvL;(Diu3rDKV z@wBIXGQaZ+>}jaV1mES8!#|n@D5KEZlhba~dO#O)tMf=MBWoSud$Mb?H}w+*zovp2 z^hzgsQ@#1DCTILfd^od;VWrRG%jN6QP@?Wr5key`BJ zhj*0F_yQr{cYx-BCwk8Dut2MOw!5>Xhv=6X_2+V@$=@6%^80YV##T!a1?<@+-ibKC zRWhFuM+nYF-Q4I#L0uBuox-z$pw!Pj-`5n}G#r}i9mdk_7Q){9)UmfDkf$1w1SF@u ztb%S@M`K&rPUJWyi#orDOGf3r;3&DaGOydje!SOT+o8i8sB;XaZW}JH-Vuw-riZtf%Di+x!J>iDFdQ8?yF68A7 zrw;{6ljtn}De+ysN9>v;@M*UlQIo!Pt-X3`GTx^cv|b1y{CAFqB@IW`*p??5LQV2p zVL#(b!yTe}1OuOx+ZX&CvV&-+)I|u;49rnrmgm6~r+s}(NWD~-4lbPgrD0^5K2D|9 zYom*g^10@n3l=R9$@uHugo~ApTUB6Un%$FZ%kd^nO|E3I@wNAlOW%MGLc76OpD5#4SmVV+pfi9HE8Lb)BvT z;I@!}bJ+?u8xhs(!AMO({DWPdek>@FX1y(;_Tk3yE8Ut`&x__B2_MA_@OgxM&AtQaQ$K?Hh>r_F`{E;>3DI%nTsVmn+GjxFZ~w`jp2>!=tMbw-dN9x1LcwZ5 zYSa;SwNcVQTyh&`r*MpI*Exan?l&)fx^i__R?^ilD>dKKsNWXjADv{j| zb)seS>&t02jn#^-{XIby%tVU4VZV=#e$Qg#iZRYmGifp_(k;@6WVdig2fyGSFHRR` zTV|jyq8T}zNA)jUdY~c%O{1TC4hL3f)V9!R$!Ox|qV#FgH^M_{m!B+9DZ#n2dVW{} zxgsFk30B_+dc4qt4=N1D9AVOEkgQYT{l%(p+`&ps^lcdlQ*|G^xs%PJv!>b6Tb$#> zeQ$2oE}$w5F8-f`LCe~KH3d=*K?LGZ8knc4L!hKX-S!<3-VA&{jwY;EpbFfxnk&kb zaU`f*Zdi8c`cn@l zxQfl}j3_sa>0`RAyM>P)kcm8g;KHD58d>`8if#F+S-Vu=95>%_`${HmYan*W=xfmN zA?J6Jsy_k?Omcfc3JaQH;Di!|xESKHeOJt%_l*BI_7;DXb1#M^j52Ya@7Apkx8$Ur zYx-2kRTmqY;>BT@!V+wcyE~~nqTiZ0iD-X|K)H&3XL~^O<$j4y)V3p!=Q8f=Vs5kn zpt-!l41gS&rVR+_%~s%hry`5i=%`&lFf25)G{zxh;~8AiVMiDl#wixnIafQh1MWHp zg=UZ7jK@J@&I&^^Nt}iRY!I^hz0L|mEGCTS6z5&5?%vqmKs(jTE8{}n-`t8DRkTX4 z*Xz-FKONXC+Grt&8X0crWaC!a9Q*0p!E90*T+#u~UU-VOw-_NvbVJ0R-&7#_Ulxz}s^+d(OOPL;R3FQ3ZVNs@$INpMjr(J_X>xMiugZf5u564-XYB==&qM z(#)_oW;6+{8e2*-BwlfS8SO<}_ z;dBiU>lR4V|`%R=akewq%jp+maPdk^21N$Ab=Hz^pWf1`{u;x`5tq zBv)ZYx34oaQS-J1{*>c3=)1|bJ4hGopTGsS2S0maI}kIDo%sySLUc*B6=sq3f2us4 zink`GDSsCm5@xoFHBbkS$&hL>LKV&)AQ`=v=5h|x%-T)YySFCIJiYe^WgP#kRz{SB zGzn7$d_OxxL|i6q2{YUEewXxp?epWo@eJV|J_&_w{xA+z{!WK+IYPD-=mOQAC{YTw z5=Z6Z`&rBLi&x@t20Gf0A_;7V1=n!Blt;-Wn~mi%c+srHzEbZOtz`a9>~;C4=KDEs zuE^uNg(IEIGH3#R`}e6L#w~-$^{c|!4EFseD-~8)Cn+$|=p=9o#Eg6oN1>hggkCCX zN4oG=VmJ@V!CvQJ)=~p@*$~d5D9&)_FS&8K8kJ58`#(h*oGszJVVGT*c3@qggx}jf z+g06fx$3$xW6%zmp6K}K7xcQ9&D5Ak!o$LxKV^sXuvb|IGnw3X4#FYhfogxouU#U! z*Hui{4cm-jIu48ux6$XaHxj`>eZl{rn*W09?y{L_o_YS>=q_rdGy21aSTmHz zFR&?dfooAoC!RS_LLq_RI{Y@8mP=(b{7uEBb6sW#5{>r7OGla2mJ1C~tr4rjdXAq( zmNmBZ%^r(B5C=;*Zq}!6&elgaV@d^0n00+0Y@WuOycjgyd*dnskF(!LJu5MF{E(*q zvGa>^$e098$5qW2C<*!VU%xopX}@Sr4hou9PDE1fJ;HpKOdWQ5q|4wh^hX|uB8q$C zUU=Fn6^A?v{>Z3~P0eVKD-m;^Ia5*#*mci)S30@$3RZslS9yL~EdO^*-fuq|MUKZ9hJ& zxL&yo%#6+&C%d7@1HHgVg56wSotvYqxA4Df!F@#XfS1L-M6LVa0=H_Hy+%(A_~5F7 z)z(DBjGTp7%l#3p0x0mx6nJ6aK6*$juBL|{|DfM=o6V>^1;rvP#fpPC+VN%AL!oDa!CVLcQQnP6PKEqnx6-(+~cbt4^U0{e6?^=P$yCbV}q zX7IBa(MWbDiFSoJDgxg^-BI#Y+_w=r$O8N{BV_-pVg_jdS#i*(=*E^Qha&6o3b{B^ zZGtX5WmUtrUDJX)m|r4App!)XNzR@ZdjSbDFhg{0?2uf}qL)8-ihsJlnr+l|th8Ml zf&#FM$MBawu9MaFVSd_iOc9b%RMYc#`V$$f5))^rezAx*lB@usQp`aP&wY-KT>8zS zZ%qUTr4I5kQAl+8Wdv6U>gr%2DiR8vtXr8Y_%!ZK4YV)llk64GT&6N=td}!lNngtQ z-U4@UF==1A5eC`iAFrUSmmhY+xGG+Pv)5!QtD^rA5c=wFiey~p^yAH_t;td^+TTJt zBq7r0CCE<)ur-yO8sMm&oarx$84u4#(z+W=R(VFZ#+%! zUjKXF#)otJj#kbperkBVm(Aty>{@p%e)h?ZtpDEyE~_(^nF`n0vOknS-OK5!1B-9Bu;5XlWwkCAtB)ML2wYt_HP#3{YxwLg69&u+QfKIV4?ACI+ z+XD=A_%KqnOycCW(LSy=EB{jvw5tT8wN?301*#NK%u8^goS(8TLhs>{^8Ywy&#(Qxc=!C9T%i9S~m#s(FqDl8MyLeJ%rZx7MLCL(@Dvhw@?+C0X_ z&EPZf!rs#^)-^KEf)L}rGFRHhY9<+Vge74+Gt`^tveMqYjg#;}6GuJ&g%%;<{;<(S zqN@9TjvO)~hk6kRUAYT-GrV1Ol6lP4v30E!Cj5Se5eb<&2soNj_}4r=J@%Za6kZgm zbK4;X?oUcKBFuF63`kfWX^yYPrEKmafb;*dx1)JjR?8yk3T|x$+{GcN*Kb%Qy_$Z3 zr5J=`A&-XwR0tT$esA4$U{ug*sn z7aIwAcn7Z%aL7TxD|daFZKtG$87}e*e~H&a5{Ag^e;B5q*GKd`k=?c-PAYO@)}7IwHy8;kQA-+ z^vg;zE@InmR!q^;)*4$DqBMj|ufHHP!h48ESE01M2Nof84+CM8&L9FCSA-()n(~jE zLxOBz=u84!!RJsN@ZfW1eGj#;vxX9AF~uN8#r5STHQCMPdw+yM&jE0VhDm9-(e2+m zHPjCfZmZ^NgKm%3vKw9eC;z}#k;r<%Dk$Hr!UtJ*njaELEKqs#fj&|*=DG&9-qHmK z4Eqckg5c=KMuKs25C;uEIprg!0I^VH#$ zuWrgg+Ckkzt&}44G#tc?Yij`C|ZN(Wdcao zYiS1xtdqK%^i6j0n+tyDo*kB~*MGrE|Lrb|E~nXzN5!FE4t+4ra6p6CWuNB4&^*4B zWK0ZL;NJ`>J48fSg%B=URYIvX7)FUHq?sNyq=B+E&2C`49Q;CwSkoXF5LJl%N#Bg9 zeJ3(bvB-CWqnbhuh+y0C6pVKB{jFUu5BW{^omvv0dl!++3#FwBoDDhi7a9ilBuA11 zHnRNgEL>Rj;z~b$_R-)eE+O)fAEqQ#e({g*c4=H5^RQhtr*E2PT=l%Z2yE_#>no!& z|6r~dp|y3eZC-0oHG#&hCH6N6hv(rY83}3E+tzwVtd)M;Os{=MU65Bm(UE$YQI$Or zmE}&6gt?P{L0e;@TgcJmkB6oQ5ykTHX}oyZRqad` zCSHF~N0WQU2kotQEwL+?W;8rHRhJ&#=E|f7?gBQQqTsMQyPjv%L>4sc^sv{J+AVUd zdEhRwMq%D*?RcwHx8;M+CF}B;+3H&sY{4%>;aBmvh}BGVdu&UKM$yRrg31H83}LiY z;hlQ=D??rQ1;^Y@8yoOowyOE-OYoj~*1AIy!dbQtK@@w-9o&QafFYy>U4ehM)*8K0 zr{}-_&s9P_{pk$Ja(_By;uE8Xg#uteg=q3^96F9P5AYa@j4DMAl#S>xZvlg#Qtxa> zX%Ta^9gcm|sYNosjYTC$t(eBA>Qgatz%+zpCbAn*NU{Zb^2A5lMe;#^nO!j~KlnTp zVPaxcs@dezFi<=_?RY{>xI%cul|%@9c>AZ9fc;3L{euhtX_iXSVyYO2w!of$zjaE{ zzT2zoD|gEJHs@K=?HZHmiIbLoRv3CJHtuWp*9H_o0Kl~3CP`87;;}T5`mdMTX_Q4w zX~e*=->S>s5#4qn@XfxCKZCk;0y6ND&) O|v0~lV|P1ri99%-tTgF3?lGD1xDm0 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ℑ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 literal 0 HcmV?d00001 diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ab5f17bcdb35e96cf7673ca8fa7ba8d6a33bd7ce GIT binary patch literal 15406 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 literal 0 HcmV?d00001 diff --git a/web/public/logo.png b/web/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..851556f62db58d4267e096a39d2e9de88e6688c5 GIT binary patch literal 9597 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 aG3a-DviBvqQ0G6M3ZNjPDqSUM7WzNIJEblF literal 0 HcmV?d00001 diff --git a/web/public/ratio.png b/web/public/ratio.png new file mode 100644 index 0000000000000000000000000000000000000000..9c7e02c86093d1d373aec66530cdaf77f5100d5d GIT binary patch literal 143438 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%0DOxK%}YD8q@w2psRVK>Nr!8pA{rvzobN?cUBB_ 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�FipuSKBx$5 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 z zc0}`=*rmhe^<37|`=b<^;pH(OZ<0a;b&vm0q|%?I6BJ%riXII4bGu0=%NntxZ(I#o zWk0-$r06!y4nDlke&1IF+_WDR%s1--O5}>Vj(xG3A=eNbNwR^P1@qMxBoQiywbD%m z2sVg`VGEAmN8*(1V(vJ=BEn*c_0ps~IkX{1C*C&w3XlEc0a;|BQ&4Lz%#2F_I!J8nXfsw!I!f&7Oa$gs=+*4Zqg$sMiq5SkmLd ziCZ#>vOcERKqH^M;PrI*!#fos4lz|F(&6rpq|GJ6Pfx(XQ}|zO0Y;>TDP(e+tah?X zn&4y0MqU}j4uoe0_&m@?9W+$r_95qL=?sQ!7zc%aT^Jd)X?3zbt$k{O1-@8^}}UAQ}uiY&iEV4re>>78sIF5 z_w4AIIKs#|`_S|^(kOc#@5Dvtvb5MfQR$39+s@7M7<|NF`;r*3b(m2#a72HVcR6r{ zG7@bbrSqG(7z{vKh(Y_!Kv0q`o*Zu9>uN<2=QwAk{Ow4M)=0_fkHsf~cIP_vDJ(Yy zQA{?@N|>|nIz@553}nW?AqZe+iqR&0-Ku4L=@MtEE0vyk-sVjol?D4RXCel=)$lBTT-jbc}sX}W+WiJy<(K-u17B_NCDaErw7nb97l^$or0@*l%l%!IPrwH zAYcQynWkcQBQUdla6!N36w$6w?<(3g%82gGVp>YXjjtwB3yrh_*h*1hiL1R%K9Fe7 z!1CKLbH$)%{jdUZc$Q=tv_>c@@GM=51Lv^c<#W_HZAPgyih(e9nWkoH!J+V?g$hrL zVo&Z8^aq_v2lPa&5nRqD=k{40J4Nb)Hn!?mm+dC!MU3&I%rH{-MYi%&R0n;%Mynm8 z$n>`iSp*IWsiW@BZ$UlNOLEHyx8c$L#U4sh1`-%zKpA}7WaIQMov%?!uc{~@@q*)m!KnJt=Ua}FS$H;l zYG-^S7onH)a7c0(b)6E(Fb9&s_HO-af&{z=p#7)WpGQ;hnoY}sz4119wMENKnvoscIguCl(`UUjt;Mqn7jLjC-a`|p{@e@TR5-QmVaZjbo3?+;Q}zq*cL2@)hG$XT zV%dwkF3Yi~ZM@}|Yng(=@3);e`S8C3u#3xPQHRT$_z##}PWVD=x07ITd_8sQqp9o< z72NBGg2;9zdLvB&XGiesA+FsO zAb8Ek;&X|l%L1(j?<1#R-^6ppDx2}4K9mh6KDCi7z6I5JLoZ?A%aIz{cefy5J^Kua zc3)A3mu2c$=oxa0Y0Bkpa5VhYgs*|+ilkH+jFvh4#3}qUnprMrLjv4=tHZ0HM}e@l z7kQx_@_~!NW!g%iuu!kfvNl8r&~ra5?2G0;M#MkixF}}kPCka)mEmb5&r5c5@3!NE zD-qT7hQr9IKTnYw?G~wTh2$8!B^B48-Q4W$jL@J%8=8g-IhI(xASs%Qz<*h#wwuv1 zjV3rr=a5BO8I))1M96(bhJYk?D{gLi=~Ah5QinIN&$Z(G|FQShL2i2!R zPSxpS&->`-lLf3%SjHlsT0n@~3^ehqdS2?5aTXW-$NkM%0`HLeB4`-q7QY+&een}` z|A*p1F1~&YgY=bKis;!Ee~Nb6!$(8-qJ-J{kM?B2jzE|7$m3`b$RHgrGE?2s=FGJjXu>qU3!|UOhRlh@{G19CG{mg zsreS`Xagk{70rA&!BMrSZI>kD=iXxFoI`%Reco|0CE<+o?i-tvZ=UDXiL2MM+FP~pcn^rT6ygG;NAVHC+^~S2AmBXGO z2d7dORvZ0P7RaYvO2ev4aJ#C3KLF1226>fa`P z?VMMTYb1mTwv*+cGn&29uCDtjf1T#;a*nlLrnMUUA#YYT-4>4dj&00HhgAkw+P+$) z^esk3xPf9*OfSS<(c87wHDzgwsGkO}Oh>oIhY&UFs~_qzn-&s3)_gJ69(-ppR}N^L z*I>KRnccQxv{2O7WR~oL)Qq;YSb)_kl1i#<*iYo$VlsIY_32)C_PT4~-G?&Gl)b_s zzF{W=H;Dc|FWOO-b#hT@IAhdX+?J7Ce&$86KUf;4M=HVeTE$oEI#V$X&IP*jO#NCs zs_jd-NrJ;l?2|gf`Ap5w+ zE}xgkVC8gH9}5uCBBAPZkoEKJ$k5REIST=EP>?Jr=&JM@gp3>Yf~QE={{p^L$Jbzs zQ8R-#qy~EkcgL{v-9X}R6bo}wa3(mNQxle3Mz_k-Pb8&MZh!BY&5saxk>pTX)#I?= zv~<~9Zun3e#^hdA5QVCIw$9#j#x~j4j#%$l5E1HkIkqyU5MA;Vp{XaLb{+?vZZK8S zMDFO43_4ZC9F6)UuqR@twDfvP-ptRupnNxd1;fmD@tgjoi5U~vg;|dV=hMXa)I}dF zq2Yx7&?;(sXF)he(e1?yIwRjKv96G5fA>}!bX6m;O;C}7E&KiFj%zj>yy-^P1`WL|z4ydR+`DDZ`D zTahHz`3Vy*4lwVm6uQ-0v5E?CzMSJJBU~bI1=SC`A|S98V!!JQ(=v7OUaw32KVPef z;L<+E)6ty7cOj5yxxux4ftxRZ`;4Js5{4XJnUz``te8pg{g^6XQ|3aqFHOYRJr5D% z!N^idXSJmEe5@F)tUdY+;&L2ihUEJUOY7o5uC^UD!iXb}EIWkhj5gZsFXfCGasUS+?3;4Y#(gkb*fG z?X40ro*%J+3~5t4qX|*%ZKvL>(@H#7e#i;ZZh;wwgAWIL`glqV7p313Ss7A&->w$Ppc@g_3CO6iu}eXj$tKl=BUhvWuCXcweu8 zXfJCTJFZZm-|izRdChUq%OS!(8x|Pd_QpviXO(F$`$QF;GMTD~Q3p0= ziOa9lAMYP^*dD_;1^O|D2BK7J&3wheYrTH}nP++PSLZ{{YX3#2{6$^Y$N@P@g$!{`~JZ0!1D^7cYC!XkngcmC$8dbZUK=KU(RDRHe{>p|7E38Ropz(vz zWP~ln>*3fJ*$~~ZdXr`w^j`=k7t?Z#ThH|c2CYX@5NCg^L-0hI6$#|QN^Z!Q2b@5Y zeQf?mZ+!$U-38X!Zs0T{)qNNg2r#zD44wwSBDmO=Xx|#KYb&%CMpFcZDi>@nZKE5h z8{NGzU0Ntxski@!yoRfmy=#apbbkg?X9HCL4mv8*-H0#DbIxXj-=$e((I4L&e-~jP z)b?PW4l9<`8LBI4SKf{@-99#9QEt`S&Q&fhVjE$~15K!PLy!{+x_ebuP-*XX(Q(@7 zSEaskyyoLW)ut{Hh1pM+7Yc5;gk77 zIgYqrHGg!0JYxt~O`xZVsWlDn=>uG0-Wk;ie)L-l-EMQDb{ZB2$&=06RIVM-ns3g@ zRkg>83{#^dBDtkNL642ik}#T?T<`Pq;&nOX5uF#Jih4hkZ1pzUbg*kd=kpPN(I7Xt zt!;r=u%JPLs!(P$;+>b>Z&|{Ax9FuFhcm4ry5n$Q+CktIaK*T`Bi;V_$SZ#xBDUmc z9zD4#hl}j=4(EgSmi{FQZ-Q6+TwN};)q5}6&(-WyDdofM^r@dXfpy%)=*Y=3iZgg& zru$kTAXOe>4I0U{a1>} zrMeE{bw93RI7R?abv&5sTMaY?B$}_J<<&fz1?+JNWptuIWgypW$0NUS z>V4p(gjb@&11P!%1|!^V+zdDU&?u=7WQzW}QyIBDmM zFO3s31}%t~Pr+hge8oWvy=mgt2>7R(g9@Hac<}7I_hN}MhNWjc2cwSoGeSr_h8uhY z{W4fBeAk{rps;wt(| zc&toye8j#WI&)uU5Q4oj7AEl)=B$+k#zTy-_DuE9VRpn4;<1xcMD>mYGaFYKH?#ci z3>0RfXlSz@&K##XwL47p2WNHcwJK}IEqZ>$t!%w;P^s{7sp(RZ!UqOLsrxIJi=UW~ z3p|$t=GVfq0X=@0v03O>L#2#<6p#fUrA~0vEz@r%9fob@mUfiylau)fOQU?~aS)xt zp4J}Ruc8OCRqM}SSg5ItqHKq3_}2_VWNy79BJ>cq*UDWICQ|Qmsv|WN6;okh+SaQ; z+3M(HLB53#_z$_ZZMRkaGI8~P0tWls|DSIzs5U0jkb07UR~8E*pnfdqSbx)l{q*?YD)*sS3Wctycgq%fee#3x!QVU{8MB*Plgvqan<;wRV$a?~~TnA5aXIYt+Y7`w8FK|;z>)99i5L{k8 zcF(K`J48Pr3Tmi=2ExMQ8(B#*07S*6){=eqM$sM_j}A3f*I26iw8P zCQhwgg&PZ78Y|g$+QVtRDd~?8^1BG;Ch2qmz;hj99~mZyNh-0%%!(&b?8`N_%c&e` zu?1zozV>_lgV77hIvSnq$dNwnAoKV9T zo=b^vR_qA2Ir}`AL??2tN3=m_3a4NL-N%pQ;T01L4m-^lq;q2C3mWw8jpN4t4iAASShISMC%PTz%y>8x#XYYM6|weI(dwr0d6f8&QXR}10qB0 zWa~rV0b9(4%oV3V$yLmvFUYWv4l>Bx9SNplw(R?GRR*sH!slYA6e-5;(I@D{b3PXl4S(1GXABnV^z zG(o2ZdfQ*=Z!xrCL@p$kJT-da3$QnE87f2rB>&RkSkrr!)Dr=$SJ-r$idSu?Qr!_D zJWt)b)ZFbm9IW4qy0m0*3(1Q1!@ZM$!a~lwC0qp0L{%%GN@Olq%Oz(7?$pAqSb7JI z5e!+U_JgjC*euqurbQYSQ~nKIps2&8VOnN9M{*Hh|00WlN|v!&zhFB=NfqM9z#twP zLmdbZWRO#^g(Ilz$?dK_1me@DgbTl7Ml^nFcKwG^{+V7kr$+F!deVLrT|Kq2^anSO8|5II$k7ss`u zg5)@C!H|Pg=@IqRvvF+7zRSaYZLMMsfr!b;qGLCWasm7hDDrETJH<;RqtKkqotPdb z0$H7gcQqvx7w{R&{Z^RG3C0}2mF}l6t*YliPdBP1B`8G^_4o_0Ma3Zf6O;96-#wurwCS4D_@|&Lc zz^)5T*blA|5?$4^{{pm|m;>eZ=Z^XH*}yHdI^!yuGWywDd@#wdMp|ENK+^97cF*9M zSKQ{omR29`(8WK`Bv{HcVHl|Wa6g)>D(y+#lyWu=ot#oJuv^Lz+rNti;8!4YoxY#1 zW&h!sn%({^O2PK?yBi+f!nxE~<^Mkxikk;o&aRxq{U|izkr#_!dEH(GiT^G9uYL`{ zhtFl5l4o_AUC`w3S^o*bkDot1SP`RI$ta9dK0ysdy#E3=!(-iwQXOd4?t_Pyq!KYP zrEU`gNKU!gq-&{Y8GG@^qmVe|pQYB!S}COQSIgfQ_2ttv1}PHf9?X@{JYn!7S1n<; zRud$(cb|bts?2TvP$Ii9+zN|1`qM9{>)=d(&8dF)Lq>~2N~1YvO`PGWUz*@IVD95K zr34!~FOc4!biu*@Z!EljH~jw1{`-%?YWysLqB~eB!XC*($*_a=G(dc?1d2b3T!F-0 ze8tU$OW}{4ZxaN_(JU*7j{PDTL_6HJF3Dg=1nj!tQv5!Jb0z<#lPmqtn|i}fI>Bjt zt-}W575u#)g|0!0g6pB3`sU=(15YL~%{m)RhqGf08fN6H8pP?y4F%YIcA^k!QNk@U zEe%4%f5vw>OkgMJwi}dn?cnrt03s3t-6$&xEyT5jyJGo!fmI|ErJB8`Y`Khh3oDXmm4ijN|sCGXvj@}pCC zeW(NmUCtmm@)BH@=Vnl^2g}{4Fvo3xLqkQ= zA{CJ2_9gN+t90AbZw!C>?}st9Lhu?J8ax?7lbm@IbMnkRf|FX{bQz%r6H-r3!*8)k z2xG)b$L+u^2&J`^d#C=Ezo*)?=uhrrFt2#N9uLAfb3(A)DbExs*VNz#G8Xb3wny0a zSSb2$;(a9F&e#G@QC@@kAl&^qD=8YGO2W|sa`sAUVEAsdd{Ti9%^u8T3W&O2`6xnfS70F&y+ zkl&{5ZM1mkc@DK(Xd^-s$3D6kYqVa;fyPOHhUaMOXGRbjcIo{MvRC$lx>)+555Z8# zt^)hd=4+y)8-3LUeM7I1-0qeQBIN59pfz|Zv^r~VrdBYIA^*v-=<9lHd93wc0NdvN zyQ{22ej7L2h=4Di@~QGd@)NwhA8Ze83T3y?dMcEuBNf~Tnc=){^dO_8KuJ-P@5NoA z7g&;WliW2Pkl#U^+lU(mBlb(KXnZ8uD|Arum|1kaBX>jto3HXLdvhmZ_-)`?BA{;j$e&Z!UZj63z54Yf%gc}lYz zbH3>|A%-@DX6;$B*@c@5mICyruTR#e>`E~``x}zhC5||XRKLPgel6BrdHw}_R{7~b zkPf_&S#&C0jhn%te;W1%s#&WR?9q&qn=VYz;0!|=k@dM(8db}v$G@$0bjo(w{?Z@a zzFyG1t)`5Q@=IvZE25&@aFQ37XS_9~qS3(Hjj^j$TlLqb{#C$`)J@#=JsW|%HzRwz zUY4CU75#am?WpH2nH5L55doxBDtE8VVW7IC4PmAJn9qq(VNEODxTy^_R_6-`iKa^4 zSvURUNAJg{xXtV&JnK%V11> z4eTa1yblSchZXvS`0iSuA~fkE<|T^-ld0#ZdtYa2NF#J&IG!tjHd-Yo!%GYeW{5xU z8flf0sA^E%r*huf>(gqkQDK_3*TEH*I6wxJecI~t*vpeObqN{kl|1BoFJWI=IZgxW z{ex1LE<1Z@%^N%KPk<2+=j;R(@*&}XzJQ12TH2{dcKbUz)K{w0dznC$apvGTd-W`A zZY^)VmqB{<5i0Ssw=w#!Z6Yi!^8}~zA*i3B=lRF6%6?0ve+XV$4e309_LDJ5{co)- zdYO(~)H?^rRWbK1{{sFo?kB3jL0#@V)F2jCJ;*w%fyi_E4BxG~62nCdy8*3Qn`Kw| z$j$1{m>7O7ySq2xifQcGKX^ZLsEhUyu*VQ{w|-1VKjFO?Y%)n%gZUtJ{Ln{s_23C< z!cCew`ZGg`eO+)DXZ{iPMhzwoB|2G=e5jT)KFLx1$)4iKI+HlBe46-q4NC+;rt*`I9SWyX%p|fq+LMz zct+)Tu=RCW*Gi3NKypM_4u)%JHJhRQ5W30%h%y~o4$t)oUp2_a$U(DxMHfhOK0-P> z_;?5a^;3A-E=-1vl%M}g0PH{h*Z;*vx;NB!v$j8K+3;}^&NERjPh0$-Nif&nj+4mU z4+}=?nb821E$rZO{h*!b*%|r=>zhc!SGmtvcOvlr#9?QkC`Jw9lzE1vOj>k?RYN|X zrG{}Yvwtm;*+ogrRUHv0(o@Ea#S#6_kUci^06#)!l*zX035lSZPl+UEZV|rcLc_~d z*AxP-Ygw@oLXYzojz=rycZ_J3Q~vFz)>>9Tro|fydspG0ws(W8EmVhhlUh_okv!a| z6QS$xlcixIIIhV=kM@COMm>S_#ruu!z(%%Id;4!Cv03+=lYjd% z$|(A#AfeGLvtRf%q~H^N4kn^ne;$(kw0*5Du7DotU6H8@gUVr3Ym}L~v;o_c1bx^p z6D^6V-|96QS|HL2c>0rMbk%TUj|LBxU5z4^5&#iZ#hx4s#Z(t*`LH5@ojZ=NnX*3O{N$Qk9u6k*?)@%Yq&|sM zwm#FkN*2XhQ`rR{<%l_aLMJ?-bPS19AZ~CJe=Q*4XJ**z$@Vv(Dzoh1WScEV=8mg_j&VVI#w6J+CKh$*RlS#)|u4!hi^77gr7dJD0nGNEzZZyc>CKl30U5vqa4Gn-2wfi}m#yEnUyH@Bocxk|?u&ejxh50QTRKkI$_kB}ytR&M@+c%E> zUat-=Y0DuF#kHyf_GWj`jVy_1QOejJw~ioeee!^_>4VXKN2UI|>gxN|j-7};N0ykM z3O(LThtH^~E$+NGdbt#+>1swixhP*$rZpKa$C{iC1#Mf1rBtV+9C5Jg)t9P9-c_0# zS&sIb3Fsz*v)^3@t2t64lESCOI~+L0FI}E*!Td8#Pici zvUSY4M}D+=Xp@Xp?x-PE5uJ~T006IywJWZQr9rY-N`~v2`%I`o_=d+bSU5OX!$o}B zGq-kkK#=d1nSRpJ=jaZhFeUwEcWF_yYikFeC zxTez_Y-Z%?*)IB7l*8t=a`E+4$5gcV?mOKMs$W8ww>C6~$oA^dyhCyrxlRdgd&DXe zgB?WdW-q04jvQ>Lf#vSrDWD-&*$$zP(;OGas=j6QZ}~nMcc^`$VmO40RK$YuQ>qs&Q+`81?_`IkGj_{lnErIKZ+@dYZR^hLk)kKkk1?(8(;0`-_2O!yD5s{Bfb`EedXN+mOAD2z4s6ES5 zQM{vodI6Dce#G{;KAIk6>c(+2?b8?5rE`y0(x#na$@fAxv9NP`C)WrunU)$z{(+tK zn~;DeOx9i`;RwSWsa5W^Czsr6NHslTPN*B}vBcT|!=H2m?QX|TdGa+=@e==J=7p|E zTCC`Y_yQe`Chgwpl*B5WIb*YewJ_w&Hi~pq>K3tvWZ#MmFIEmxZ{iwa1?`R6aL*s! ze6o0&HFQP~`f&M^`Rl2?JQ4=2QMG+UkluaO5K>9jalMMC)WC?PbCiVHWF6<~ryy31q29t3=4^8h=|8Iq=mS*fYVC`*Ws!CO8<;vY- zuqGQpLof-1U%{<}>2gK@gI5?D&OAwodszu}uEFQhho+sk7Cc7IdXQC(P0rlHEI4ZM z!WzEHmJV5zvBvtsUc}@HrhcH+iyE5C0Iy%lN+}C6SChdv7XU$YhL{oDUva!E0P(Gv z!o2*;E7U=Zf!14gBm4fq<$!+dra(-g5R#Eyw6ZN$gw=0y6_^IJ&4)JbB$%vGH?E}q z!3i+epP%=n)WC()APn=Z?gHkXP{F?d&K&DLFL0N?g>J*pR1P7qdlSDY8g6(2WH*cd;A-K?? z`WGOrBqAPbm7S6N&5)jL=6GFS-VC89wN~t78IiWrxpM>4Z4=~75o5yYX8OhtPQjaj z5YF4R$k|$G+j4XFep#VuCU)7HcMwIe=A!|?L8;c}_YA?5dpdUZgi5#t;z>;*%I+aA z-9)^6hWrq5^-=J*Bj6lp4lI2pDs0lkRpzhrUGYp4xITWF= ze}Ww7EZA)<-Kv|7on=qT;lOEekdD)$=hY~$zgRsCYfGnMzj?F$7f?u)xrjQ#*_`>o z$btCV4tK*)`8!@12C&TD(vPyDtyG~@HC-s{2QUxI6jkV-Aq=8ZqtxbsX)}=~{g#QF z9yPCshYcKlHR%GgC~#F{oxc^U*Ex*;Q% zFW8l%$HW%$xJ_x*;^84pW}YhERX9&Qn4{3HnetfwNRXimr89HCtF4cUXh55YU`1XI zUayDZvy7K;QNGj=x^hyx{?3IMHQ&9M#bHvIx|5{!`zU2c-jGaVn@sfM{&_q9AD#H5 zW*EYzq<&YkwAQ`JT++)PzPuza6RJe$Z$8Ib%%V~v1_JvLsvP4g23hEO3s`NNjZ*Y{ zv@nExDT_$aK?Nx9NaIq{zlTD8$@w@?0+KpcKxqf`RrNV)^j#BzY)RUcvW4oe&)obs z6vG}2+L2Fo3Y{sAHR8~cd5i`c8J=qZ@<&$ER{Ju{WaiZi^7$iAchlyoT8|9f8q^$~ zm5`-cxl}jH6M3!}lnwJIWb#~eY?)5M$*2h8XreMRvLUpug#A|&7p;mGg3FzO&65^% zkTw=xE-c#)(qY|#6`n*n>9E9}H$I*PpA6VVk5RBUnzJU%_U|M1g0RZu7Ue?@OV;2t zxGZRi@{CCo2v-dsm=i-Z$QRHZ`2$!XZho`}8l7iTA~h3MYYsag*ONb?jOYjRV>H}v zNtv;#z0;>hf8nsh--CCrGuvQbE^~t3|qef9jwGcG?&*uePIvoj{n6I>}GwnP@i@SSqQj0)0gsJ%* zOjfClQ^1$kWf!3DGJVE0n{9ZqFA@0*K-f}VjHJx95T7i@{}K;> zivJU=GDy&EyO}*`^U*KeM@u%*l=0Y2nRQlJT&C|=!GD-NFBSk9gn^WyZ1G4}m{>Gh zzr?>dNDmw1Pofi!XX)g1C9b3*7esBI57n z-}jQ;FA@eG_J1(1UgXAX7tZ-DBktJ6!-e@Gj7_axFg~me?w$(qumwT^?x`T@<=n375ETyxh?n_zEQG>@eM9q3t^EUm<}uCu zKT`p^J?-~z{wEveq<_3c?773fg}?c~PrqNO1uecPM{O$)r#U$seV2Ld@m6X6!S#px z7T*2ZaiQANclW=>*u+rAiQ5(( zDtXUZ&e!v&xLBMP$7BH#7W-+>d<5J!qLkJ`Gc(rcjfI{;bpkp2!N&R>oyXE79=D=n zF>94dDAhvMV;LrZ6k@aNi$rK+27aZ>?`HHfP^MDD0n-j8i;xq;);7SbD)jE zDE%7oY+=vkw9~rX;K7p?aYiPfcPpZ@ukdM{&>%jGk@!&aD=&Y?H^ArY&q?|J>uLOs zk+M8-Kitd=+^K{5Mh#``Sa+&GMgjEPXdr?iDCBo+={nb4nUgV1?xNmK9wx*tL|jjj zjK4A(vh&U}lwaN?=_~0?Uw}7!W;gx&1;L-ny;MzZymUe~T?rhFgMev^30`&67gn`u zd2&h}TZxj%!P)?K)oZaT?O&EO_Uw~=6*vyaYhty~oIYMm$IYaNMolGUM1OZdK?HIB z+0;@Eob@Vm?(cmaaVe$^l2%kw@je%SPU?T<_3iln1^kblzpmQ9o2yWJg~QQ?>REvW z+LX3>=*xS@yV)|M9GL5O|4m8Oe?KDiU!zs7e>ydZc6Olo(}RmzlrlPp`}il$L0MWj zTW=GZoOFY`e+6M?MSbZL?+5Ny;UIq?U+N*N-=!m_44gcALDdQ78?jK{vBT{U)I`rS zkNB-g4g5i&5bKE%D$A=|Txo8Xhdfm7U|#XbD;0(V4b?FFrFd=Mc9 z4Ls5hld+oxjqTu&m!Ao>g$h=E=dGd>ahBq*Q%uUl@N$kZWldGF9FVXA*)4)s*|Bwk zx2&VyA7YGx*4Wd|{a@2*pZ@|Sd-!e&clHiU9zUw|1Pfq1V3BbR z{u2*^7+pAcWH}ZH`OL5;G2IFpHnk`fB=230Lch+l#7*XCHFiv#(FkntINvwnpiJ{r zyfz-}U^Gw(9ku5p5R9xK!Ro0#Mzkt+wU~Ic!i-SF!X{5j@2rRM%1rUZAxHd}McS0H z)?3z1g`y&}*&WG5?n+hQMknhFm&*HIs16oE5Y8pLlYtDsBW{wM))}!QqxlmdYG^Wwo!xMiLN{K z7?Is~_yB}+tq~WDaG5c9Cv2e%ljUIF?KV&zWq~z2u+tyA#$ZJlD1`sr@eKBy#gtE> zmW~dtB68?0G&3^p?Bfk`u7_1IDJGLlmt9WeG)R{q82pKj;Rcj=59{O4k4=@cDkdll zyEx37r$}iC3!rrLP~y19el8wE;(-2$~5N1OzgodA(_?93O&-%IV_ zYx8epn+MSJ+oduV!On75Ov1TrxzrK#If>mOHFz@eh|*bfW=ukM;} zd#l$Z2(_jG15u?$ts+!bDbE3y?C5J*yX78pI|OnS0h5b1m{;zvuhNF20N3dLqJXsD zUw~3vqr&HERhnd)U5}rjp3j9tuZUgN5ox;k-Q-ODFRwd1(lykE`q|(8g9L)NOmaj9 zJfmQhny-ckX6co?5KFzBblx#gzwJw@F2gf|0pI8JT&SCG~L^V$xcl2vg!X<4#}M zt8C4y4!;5WLQW~_jsf8|=5l^Sy;(rScT=X`*DY|jQ1%3Icw!ldAA>Z4nE|doS_mtaEra_I+Zin7p z)GO8o={AqB!-1(&Y$!RZ=|QgjAkQI z&j;r(Ik-CFMOj1&+8gV8P*P^(H=wltBw-*BJBs!l zA+pDMY>al6-m*=s&fP#5LcD*QM-U%p`D-2HODku zgIJ3mlJM@JCPg_hwMS1Ic{|-k$P`(nxj}K-y*wSlJ_`ver=AZK+Azz=lGlYFh zfB*QuFUWt!!_fX0kdwOBD)!t+?v;L34__Ds5I+SZ{snxTQJzlM>XpL;|FkDm0m{@e zX>gO&KX6g@i+lf09CYnMA*SP`-KWSI+Y&Q4WH!b{?j-Z48?t?<=WRv$3IC)zc}o_7 z-o31kDKKI(*-_AQ^cRqpaHFQHAJ$mgND5GW9uaLM9Oj-`Ava%`s=vZ{u={WUCNA~R zdeN~j5X#~#E-70kvou0;J7^oxjxIQ%`U1jSR^j=LAvS9hv|-ItnpVoJPIz{aqa(=O zR9&W@Lko%&;3{p*mGlt9o<`yO)CX;#0 zNgala0UlUQbreR-UqJGYHQ8{RIItM6S4&*@fj(s%|U;qyMHXa7+SBbB9^hy+oQ6I7%}$&PTJ4WtToT2(Rx2ci)1nuL z2MX3ol{$Yku&@L@?bDuiWl0_R6o6f{3gP-Q{|kF)eNhA)QVu>x?QbB)1UJ zpjCdSX;Uh21q7f9(f&X`?by;oW-AF;`;krDFT zg0!~lI`~ugIhdCV-rQuJU|Z|Bva@;&d9uMlc+Fb?qRRa{l$3{rPbeh#W}ig zQ#R`r1{kZiH}Ir;89rtDQ`1)ct4+`wK9bK{g(vZ0A@W3mcb>(vQ3@@JDX_ z2oEcw1O4-ax(Njm z$J^0wK+>I(i+K3oQXKh`IA^tDu|abp0?08L?#?arjb!`C02|nfS4iA`no#KwY&e0i!YwOqebsHaGbqFTal|dwRLyVW~ErM&)0Cv*UJ{ z9DZeC^=*q=qnT4Qvh4in1~kESv?e02UZmR}A&}{SFCd z3S^hQZZoLcNc|U(0Cx3T<}agp7PB7j+e#HMSa2g_za-P9z3;($2+hKb-v840#g&Tq z0FF(F!H;Q=TPo3{cjeco>RtD-q)sy4p;ij?iFA<6*pZSh@~L&QR57WsOcPCe z4iR$$4)UyyPK`-tHYJyGV~DQDHBgimW7T{+@v?V|@HAdqaP)I4lB0!}oh@4CVCvMl z@T+9j=kl=p5Z|nyfw5$`Ztl~U$eM5qC$*@>*dvJclub&_)I&@%0qy%!P<@GP(5k= z(8B)ih!baI<7#jiBI)}hv(S2KBxvQ7IbMNCr9Q12VEDRcA4}6uvZ6$M9-)yE-T172ww6y1!C}x{{a$9w-lrMsPYLBRftr!-F{by7G z@}Y%&LmI2e57TqoyKB2>Dmn6HHB+%B)^DI_o+5TveRws6CcK0b_2f|x8&l65IFY+z zs!!Z$RS}}LMw~NM*ZS4co^gAqpWt?`#zkfwEghhmNoa|=A+BwV0VY$?JO>y(G;p9EyKP?a zf_8Hat0i)>gvMcB4tK=Wr0J=epca~E*LiWl>^QP1nW#G`4SuDn1xkkHRt7*BGSBI9 z*a7G-CPZMxrYv{^=QrVy;$7#pw?o#}ModB{(T_28+yAN?DsW4R8fAgy*F>K7DyNq-2Mou72b1|?6}bfgyqs7_(g>%y*-(0DmKv?m8I>{KPA zH>>gE1oQ;~H4A9Ibd#7+`mBX&8>_-5oPI0Phb+I1LmO2?ezzdLWrM;R`SDIQZYL;m z;+aAu`f(AdCnxO|sgRx%*pw`A3r32KB#g9uxm=UY%k&2yTiOb(p};UZnPY!I$+q#D z;9!lA={KE#i_X#xH0aQYZWplEfto+_y14}*kZPj6OYJLrVNT14GOgVknjjRGo@A5?;z}T z7xin9HnkIy;o=Lr5|a}Z#g#bgvSyid>1?ruBq#8?)s`b%pv(tldwx4nE1du$Nv9gJ z`A&7&Dho91N5j7tlC2znvc&##Le8aM7>=!yH91ivBj++ zh~0X>mmj;)BtV6YIDn5*QSxv|7aw80jY?vI!A)LUmbx2e-MsPX_p6toaROezHKS)EFzTL#st}>Cyvvv$m@xtE5iWPA4YquI6FPiNY0%1}UMoU=o^P&v-LsTfLyUwWb z3YpPB@pb<~E-Y+>7H*|m-l+zZ=(>{~GOzyasCr8an2M1Hex6pTU+-;c)!q;;A@v%z z;MsGEge9UdTP}imfISy?T{iiWt;2EEES1@7kZbc?uktk>_liySVDR{_>1U<$7EYQg z&0GKAfbR}^nh)&%DlqxiE*vjgo=S$bjH7pZG!hUPmy)voPK$ZL4whZBJi9nT(`1B} zcHwa8e0q`;tEwsS4~e~HfD1Lfq9>d>xZk%Qxri7g27dNUn1E62kIW1gdwL@8tJNsB zm?1JKA{(6dE`8D(DGuA3wOsnJS+P0Rj5JzA#shu+(Z*an)+WuI(;ddiTFNlG?{JF! z81TOTn@G_%TGl`9(OHkoA?ct1^UgkbdVZ$Z8oQ;-%K z0GDk>ai3VI_tG|$$wlgQu0{mChjZL>#kT5gy!AnAn)akr&1!pisyu)#T;tp^uPOg3(ZzZQquK0M}h^Lj%g3uv6t*>q%x*O4T9_R4Jq zTwbxl1;|-R{sn%Tc3a+t7;FhZ<+`rBT}L$kj>84%sCJ5JqtYt`UB?tOwBc44UPzh+ zm|si!2}Oe!Mz}NZcmZi;1eyg|5^0@q&!MnKoL#2)xW}r5HSuzkju(St0*! zXtuydTE?AW{-{POX;v9Jrlw<5L{&y+|H{&uCGMj0P8TYBT`({S#@AZq4*i2aI97pr zFU3a)@J}Eo@^Htg(2Wy$Bvdt=0R>l2ihT2G2^HXI{Gqh9v??FGUiwc!-3jW4*@MB> zWrgH>9mzFk?T4qCmnQBr7*8Jp0S{z$%yfu4mNOSj+hEVQ~hR*TmH-QvFUGqn|xjs(` z2CSGWA$>M+Kd^1omdG5QqAQn-Pf^Un@-;MNg4_!Tfa`gAwjcb8m+ncdRwk5L_! zSyv3zceTjWZ;Bfls5cJ&t)A=1J8a8-DWM@fP1r;pB>S-2`}xZO)4pROkP-PlShB(blQsrQdtBWdAJM*UPXrVe{=`b6lL&}q%VbPJ&~4)B)lMI zsH%dg$M-E86rs65jva@^bFE+hcH?Ah0Z~%W!bKBUV|V!R&8#i=)5%a~xnDEEdwiIQ z3z96(k?Dyg=*M z>}TTURSh1caa+2>L_Bkb$bE@T4k+GV02%3f?^7eRkuD)v3p1PUVMnO9P6M=(ABDqDJdQ=u^8ZpL5(TJFZnn$c0AAMkQ9ZRvoKByMwpyA}Ph`e^Vu_pW1EVFioD`X@0kl(Bw)Ko3`%DdyYoInmn;Q z;&obi*2TF0Bu|%mDl!o|Fghihs>(RK9mQ<_7r-8P8T67BFTKkBixbY6{EnE)VU)f_ ziC#Z$k4rmY_C9}HTof=0WiJ$?Q$AONX-%S4=iY%0N&}FGno!Q5dIb}OS8zK6(w;h# zNJM^ z<$@{FC$v5v?KdmU)=(VHj$Z4KU{f1Il?FvU4Ve zOWXAp^bzF$#xG@+%{Lttyk%B&27R9Ze1=a?n^Cw zyN5pG$Ymu5RuO#nB{QheEwgKND8fN6DnlWmv$FG7$Rq^8QOR|Yoc0s_WVMpu?VfK- z6uA!WRXvvK{CvWadAJxqg*#t=3q#D|zDwwZh!?NynD4bt8+R z8ASxDMc;U&{lbfMR-MtY#>BU>rlDAWf)G@g#TQ>ZJ8*F`_kgn^{W@)Lq;xJ1AbAWw z6A-|E!YOIECDN|X^aay!WKy`Ct02AzJDPKA=Y(!vP!S|40v(bGt%wS@DP0oRZ#h&K zAEh8^fB%&82ZTQI`KY*#$qsbxTRK;}fb%u{U&ZK-#$UK?(X|3Cg{xRawri0}CrxV%X2eMtXr@S5yK5RdV91iug4 zMbtLZH76>YB|SR7vxH;e&_D{Obj(Ij#G*Z);V1NhQ?wqqgam|H8>y? zd_@`qSh-*I_TCLhsULfUEe+0bN_l{|cO(&t$Ts@LIGKeg?`H~__D*Wy8q^7;w89~u zJhDC8s+Z5CVd)D|5%m|F@^glUe&xz-a=g9~o*=B4VXmLQZrDDG`H2Sv$I#TPM{C3BTsRg1*CR)IUs!+j*UT+zoHVQ%&$`g7 z%8e@1q|QJr&F`58Yt|=Dk9SWi4kVbHXuKjvTI0diG+mCEADkVRpJmKYozvpY>JwK` z*e{76yKUmnq3NKSK(mi^2L%sG@MhDRoRNLt@{s<->-o0q&T zCO0rSJoArWJ0I6*cgK4cVAN)?t>)m&1F#`@Da*@0uIjQHl$6Dybp27(byhR{P`V6@ z>jHvFDjeQ4If#ULPJ+VM$O5-|;=K2nOibSUt&v&jN%CJWFGtLmXO#FXQj9{;pR)H`=t)e`x5$1SBOBB-R<=;S|m-It=Y$XVK`z*kC!uS5o+pJqET?D&$WI!EnguN;m1apidc?XPoQhE?9qOkyz4v=1?O~f$poTw807I8x0s-Jx!+tH_ zLtXnJzJycmtBnR@RUbb(N?aYQ$q@ZwQ7t6)H{G0pjNt?^| zs(Za7g@K|*4A+iPXQBIlZ054d-75P^DOnN^{);g2U-gN9t9vV=W4L;a zS-a+$osS0SO)8U0)oxSxX({ohRGLmE1M}7CB%66!vd8!4Hg$f{VG~+3*W`irWAHA{ zY+d@U%M^QPvC8k6xz`tiYT8nQdBAE786kG>A1A_78*FG))sc9Y6V)u+v7DxR`W&1F z>aoFaB{UXs_Nde_etsbG2}kxRP|;mxz784rRs=ocFMu9%TsK)^{zAqvp9EUGM}u9R z9yv*gLK|-H`*76?rvsy89hL&(2J`+10hg(Y)3WUw5eot*BNJc>B~TVDxIL=LYv5-(`1Ksjm*qAo(P=I9XMZ z=IPof^uz1}o7512t*1RF0rZ|d7UK+=I|J-|VgqO)-N;S+fn~e!)Bkxw9_jS`o;gC# z2Qo2x=(%sKxN0nwsNzY+N{Z6E|dGF(qgfyFm*KE!?0h2>&Zk5aZP5__3w{U zgUH3b&KzbOBC015ma&M|n*5)XB6g?`XwQPEAKfTYmDVmbH#%>lFcLR7x_Kf^-YjIOBX`F020||FvP-TY}eAR6uugHr%4)2_>h#a9xc67 zIj2hXYSpANn!=v{FS%ZNCBa4H8@P5{!q+ygr6O=~!;42<4)Wx z9rLYO;MPSkp}4oxb-U$9}aRFU1JPz5Iy z^A)h-HcIVx3MZbU=>R8tGJu6MJpay!7*vARXR!O2p$p;51oX5LOkkFV7P_m(k%mq7 zDEoc7R|*{yUa*0DyiC*u)MEnB5sml^Ew8)1JXjT9f~hZmM~bMub{AH3=KpVxhGr<@Qkro)pTkGo}?O@7qyhq0|U zEB@cij=NufxcQIaf6T-Gy+yG7sMsp!e^>J)hgZw6>UP>!?ib^1+u&h%z-Y}vXNG9{O^$et5Th=OB=GEc{bUtB z5qG(-zT^_Kn{HGziG-HgfLu0`-!S?oO@+k?T>-x(ZKM1Oh`7-#?;1DMnlHkX-w_u7%)4GAoU5!w z&QoKsiG+KehP81&lQ(D3&Pc62Hi{m&XxCK@UydyO%_J9Q(hqAc0GP0!#w4+Kn<0}c zA*PO&Bu;u^_6ulkDJGM@Iq|3g6n^-l_EL`!#onW8sUirhmXqm4W_>a1jd@RM8~8^& zZl5~XBz59T6oKal8s|Gkpnk;pCW3E%U+nrK+0!c2WLEx00xUk8`vZCFLvZ#oeuKAl7Lb=5n zuBrLI01B#drq%@4*f$$3LT5JCST%@`!&op$d*Q%Ji&s&Rsrg>lXa+=}I{#hxSsBez zXq{(=P#}|uLzXGUb*A~?25FH&-@cAhREvc)bPrQXhSrUQya8H-GQ@;h5EEj)-SgV4 zjI+xT7W?`Zcn$>-6?Xlhaa=BRQgdQoejg@2!!nL$)CBM^yA9o_tvlu=9`bpJN}8cKP%ON*!ZFI)YS(OPvHarX!-om?bHhOXCs zlEU^bjNxbX-1x2JFh91aDFjrRJRiABN7^w)(Jt8LfZ>^Ddjx-E#7)(w4FQgzzU^VN za*XnHMXbkGN3oi(gp{;4oC&W>4zuTXoI18*%ZTI<4>x}SeTQ%0eFd@8y}riX(^8Hj z9duJId0!~U>nobI>G@^(MI(qp12u391F=NevAQkRn0b{WL`XuN{OXlE`e6E|TJ9%W z@GOVempkSe%_lQ7hisz*-=Jzeq7bX0>AKVw4Si$UAa#gkb8QdLh}QgSbl))KwAZtm-JL(lYQfvDs@y7u7o`^82f zO0oUa()L##GAGDdKGc8p4_-tlprdgjUG(@VkNSN>w_`7f7Pc501jaRv7MC@WlS1Xu zpdcc7oV(|6o2Z8mm6FKn3Z5#*rU3E;2uMYSDk10hiAW$6FU4%ZWydJ{zPP4O>MAee zd_WNdMT}U4tF}z7?qc=h)=+$;%2QN~H1K7de4aPcfdQc2p{c_ax+N@FxDVvp(+MnR z1GYF7>lqiDEPo9m-a(;d*k==MJ<`y^9{TRr!L%;U<;+SYV$1Y5qyP%_-K0BZx`dw> zPQv+X5+#$Z6qF&Rx*F;6lGa3k>_rg%_!InG$tUk(`E>0+VUxnl(F+dl9zOkWPsV1x zkmEtX5{r`Xk4JEHt79}_VCFC?T=a@^kNEp@kg7 z^=223Gy<MRPW-( zN|GO&)qlJj&5QxWRqy{re?;Uq+L*!S?7;as0U#9VqaiTzmAq1du-DICF5JMgDxMZQ z++-C+WS)_zv%=B((OvYIY$wH}HODl|Zo0lD*Nd7OfJX;E7j{*69sje9U+oP-8jBb) zR!On)jX#rD^+NTjjj{xmNXCK?d152^UYiCiAy*?7-A0k41kuHe746hJ9r%D}me( z&3uXkiyM~zNF#hgB2Awm~!JYAyVrKX|FSBvq6V}HoH5^<0dbtQ`dNN4z@3G zI%q_eageZChL9*QuZbj4Oc};8KjQ4lvxV5&1ob^bdSZ!9wu^Bb7$N2xStag~N;2?U z-9}|sgzkt<6W}#b&8TE=*WSn7DN^N^e<1vNLRB968`OYeAT~?y%~ZAz=YjNh%H2=C z7Z>JnPpiS+vJdsWn?Lir&_PxnKkL#dti?Y*vX$0dmt5Sr8)%o0K;>FpHsH6-4A7tc zk6iPAiF)}@Da8MdC;ni#ciN6gja7S%p5s{-NYlgmYU$#P#|UtB6T>8Dnu%f>^Ej%q`|i z(h{P>JlZ(I-5gu~>I^&Py9hkdxm*XQr-UIxYX%2{8^rY^)F0UFC!byb z>0^9M4V`<2Sumtuh7Z0d4FPN{@=QOO#3v$IogiEU7;*R~vJ60xI0Req6MvXv6`w<8 z-DyO4&l%R_jYcZ(E#{k_?G9Zf0Mm$fU3!D}O#htw*!nw=8R3$5D`&k!O-EC^+=boa zLFfml@cFZBZsR}hQXmgce0mp49_u6kmCoKYteolN=$vH*D;gYj&6V9MRCBqG}#QbC(m+7HD zzX`l%f0?UPS$Q`Y{?m*9hETGwMBTi^B;zzn+Oy7$M#%`wY2_HlRpJgy+-$ zDox18_!<5k6UK7C`52d_)BL)Zl)h)Z#MT$xfyL@U!N~l`NRAITduWsls_~#6x(T${y zC@UgXB3={dN;>ZE75LcbCqlUuRtRfq1z4`b1MZZC`?%Sk42e85Z7I*WNUPg0D#Y@b zwlcJ)NPTxVl!;k@JSF$N!8GkFG~Z1!+Uge9q%Mb-N=)dD+jZ|XkfSr#*ONz~%U~?* zlTc#Vp}hjjm?10B^FPqH`76vhWyL2}Z%nO7I8u|B?SvM3>6#i>Q)?0upi7?P@s#+K zoaPA;sU-)BHI|wb_TYDOI}im;I@>o(`B>$myj5KaO*S0#Bj&Ax% zw6O79+x=X6@ZTQF!alkVy`Yur8Rl&jgE>m=;)qaUev!=#3}&pZBBl;lEvepCy03E& zD?>h)vsj_|SPm0+%k_GBfor+hmGspt+UuNiaOs@1u*SHDoxD91ubw7NXNU9dnfoXG zmQBZA{&1A8K_l99ujjA=12W&&3O$ezgqoAKN7m#9snYsSHM$7R0~ zO1Z9a<&0t&W=^^b>;-KTvn6t$SoW33zK(dy-$|4ZbbE~d8l*HL$mh2^%-xTk?Iz93 z&zaJ|c?;h~+t*k86PSl=Rz2gRD|s%08e(m~@?lhfAv(*>F}619RCK0u$U^R3iaCjv zzdA%M(Na;pFSM$iI+xyV`WdvadZ+#BTspt$$G#@{s7KwW#@$ZJ zU%v;Bc?8=^ko)XSXtT2=R!OML^0!CUbER8Xa0%!@s|Y0VA$_9KOju%lcy!+JqyueS z#llr8x3=F6OQJ`iW?hASB~Hj!)mXP_`hEAUycO0Oliv}!_MZ=bimOLwiuB)Q#SHm) z1Sab0RX5p>2@ENGBNJe>ddiv|F!DxSs`5u-wffPRz%&Ml&p@ _}po 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 literal 0 HcmV?d00001 diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 00000000..e9e57dc4 --- /dev/null +++ b/web/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/web/src/App.js b/web/src/App.js new file mode 100644 index 00000000..2d715767 --- /dev/null +++ b/web/src/App.js @@ -0,0 +1,297 @@ +import React, { lazy, Suspense } from 'react'; +import { Route, Routes, useLocation } from 'react-router-dom'; +import Loading from './components/common/Loading.js'; +import User from './pages/User'; +import { AuthRedirect, PrivateRoute } from './helpers'; +import RegisterForm from './components/auth/RegisterForm.js'; +import LoginForm from './components/auth/LoginForm.js'; +import NotFound from './pages/NotFound'; +import Setting from './pages/Setting'; +import EditUser from './pages/User/EditUser'; +import PasswordResetForm from './components/auth/PasswordResetForm.js'; +import PasswordResetConfirm from './components/auth/PasswordResetConfirm.js'; +import Channel from './pages/Channel'; +import Token from './pages/Token'; +import EditChannel from './pages/Channel/EditChannel'; +import Redemption from './pages/Redemption'; +import TopUp from './pages/TopUp'; +import Log from './pages/Log'; +import Chat from './pages/Chat'; +import Chat2Link from './pages/Chat2Link'; +import Midjourney from './pages/Midjourney'; +import Pricing from './pages/Pricing/index.js'; +import Task from './pages/Task/index.js'; +import Playground from './pages/Playground/index.js'; +import OAuth2Callback from './components/auth/OAuth2Callback.js'; +import PersonalSetting from './components/settings/PersonalSetting.js'; +import Setup from './pages/Setup/index.js'; +import SetupCheck from './components/layout/SetupCheck.js'; + +const Home = lazy(() => import('./pages/Home')); +const Detail = lazy(() => import('./pages/Detail')); +const About = lazy(() => import('./pages/About')); + +function App() { + const location = useLocation(); + + return ( + + + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + + + } + /> + } key={location.pathname}> + + + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + }> + + + } + /> + } key={location.pathname}> + + + } + /> + + } key={location.pathname}> + + + + } + /> + + } key={location.pathname}> + + + + } + /> + + } key={location.pathname}> + + + + } + /> + + + + } + /> + + } key={location.pathname}> + + + + } + /> + + } key={location.pathname}> + + + + } + /> + + } key={location.pathname}> + + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + } key={location.pathname}> + + + } + /> + {/* 方便使用chat2link直接跳转聊天... */} + + } key={location.pathname}> + + + + } + /> + } /> + + + ); +} + +export default App; diff --git a/web/src/components/auth/LoginForm.js b/web/src/components/auth/LoginForm.js new file mode 100644 index 00000000..ae7fc0fc --- /dev/null +++ b/web/src/components/auth/LoginForm.js @@ -0,0 +1,547 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { UserContext } from '../../context/User/index.js'; +import { + API, + getLogo, + showError, + showInfo, + showSuccess, + updateAPI, + getSystemName, + setUserData, + onGitHubOAuthClicked, + onOIDCClicked, + onLinuxDOOAuthClicked +} from '../../helpers/index.js'; +import Turnstile from 'react-turnstile'; +import { + Button, + Card, + Divider, + Form, + Icon, + Modal, +} from '@douyinfe/semi-ui'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import TelegramLoginButton from 'react-telegram-login'; + +import { IconGithubLogo, IconMail, IconLock } from '@douyinfe/semi-icons'; +import OIDCIcon from '../common/logo/OIDCIcon.js'; +import WeChatIcon from '../common/logo/WeChatIcon.js'; +import LinuxDoIcon from '../common/logo/LinuxDoIcon.js'; +import { useTranslation } from 'react-i18next'; + +const LoginForm = () => { + let navigate = useNavigate(); + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + username: '', + password: '', + wechat_verification_code: '', + }); + const { username, password } = inputs; + const [searchParams, setSearchParams] = useSearchParams(); + const [submitted, setSubmitted] = useState(false); + const [userState, userDispatch] = useContext(UserContext); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); + const [showEmailLogin, setShowEmailLogin] = useState(false); + const [wechatLoading, setWechatLoading] = useState(false); + const [githubLoading, setGithubLoading] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); + const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [emailLoginLoading, setEmailLoginLoading] = useState(false); + const [loginLoading, setLoginLoading] = useState(false); + const [resetPasswordLoading, setResetPasswordLoading] = useState(false); + const [otherLoginOptionsLoading, setOtherLoginOptionsLoading] = useState(false); + const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); + + const logo = getLogo(); + const systemName = getSystemName(); + + let affCode = new URLSearchParams(window.location.search).get('aff'); + if (affCode) { + localStorage.setItem('aff', affCode); + } + + const [status] = useState(() => { + const savedStatus = localStorage.getItem('status'); + return savedStatus ? JSON.parse(savedStatus) : {}; + }); + + useEffect(() => { + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + }, [status]); + + useEffect(() => { + if (searchParams.get('expired')) { + showError(t('未登录或登录已过期,请重新登录')); + } + }, []); + + const onWeChatLoginClicked = () => { + setWechatLoading(true); + setShowWeChatLoginModal(true); + setWechatLoading(false); + }; + + const onSubmitWeChatVerificationCode = async () => { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setWechatCodeSubmitLoading(true); + try { + const res = await API.get( + `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + setUserData(data); + updateAPI(); + navigate('/'); + showSuccess('登录成功!'); + setShowWeChatLoginModal(false); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setWechatCodeSubmitLoading(false); + } + }; + + function handleChange(name, value) { + setInputs((inputs) => ({ ...inputs, [name]: value })); + } + + async function handleSubmit(e) { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setSubmitted(true); + setLoginLoading(true); + try { + if (username && password) { + const res = await API.post( + `/api/user/login?turnstile=${turnstileToken}`, + { + username, + password, + }, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + setUserData(data); + updateAPI(); + showSuccess('登录成功!'); + if (username === 'root' && password === '123456') { + Modal.error({ + title: '您正在使用默认密码!', + content: '请立刻修改默认密码!', + centered: true, + }); + } + navigate('/console'); + } else { + showError(message); + } + } else { + showError('请输入用户名和密码!'); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setLoginLoading(false); + } + } + + // 添加Telegram登录处理函数 + const onTelegramLoginClicked = async (response) => { + const fields = [ + 'id', + 'first_name', + 'last_name', + 'username', + 'photo_url', + 'auth_date', + 'hash', + 'lang', + ]; + const params = {}; + fields.forEach((field) => { + if (response[field]) { + params[field] = response[field]; + } + }); + try { + const res = await API.get(`/api/oauth/telegram/login`, { params }); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + setUserData(data); + updateAPI(); + navigate('/'); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } + }; + + // 包装的GitHub登录点击处理 + const handleGitHubClick = () => { + setGithubLoading(true); + try { + onGitHubOAuthClicked(status.github_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setGithubLoading(false), 3000); + } + }; + + // 包装的OIDC登录点击处理 + const handleOIDCClick = () => { + setOidcLoading(true); + try { + onOIDCClicked( + status.oidc_authorization_endpoint, + status.oidc_client_id + ); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setOidcLoading(false), 3000); + } + }; + + // 包装的LinuxDO登录点击处理 + const handleLinuxDOClick = () => { + setLinuxdoLoading(true); + try { + onLinuxDOOAuthClicked(status.linuxdo_client_id); + } finally { + // 由于重定向,这里不会执行到,但为了完整性添加 + setTimeout(() => setLinuxdoLoading(false), 3000); + } + }; + + // 包装的邮箱登录选项点击处理 + const handleEmailLoginClick = () => { + setEmailLoginLoading(true); + setShowEmailLogin(true); + setEmailLoginLoading(false); + }; + + // 包装的重置密码点击处理 + const handleResetPasswordClick = () => { + setResetPasswordLoading(true); + navigate('/reset'); + setResetPasswordLoading(false); + }; + + // 包装的其他登录选项点击处理 + const handleOtherLoginOptionsClick = () => { + setOtherLoginOptionsLoading(true); + setShowEmailLogin(false); + setOtherLoginOptionsLoading(false); + }; + + const renderOAuthOptions = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('登 录')} +
+
+
+ {status.wechat_login && ( + + )} + + {status.github_oauth && ( + + )} + + {status.oidc_enabled && ( + + )} + + {status.linuxdo_oauth && ( + + )} + + {status.telegram_oauth && ( +
+ +
+ )} + + + {t('或')} + + + +
+ + {!status.self_use_mode_enabled && ( +
+ + {t('没有账户?')}{' '} + + {t('注册')} + + +
+ )} +
+
+
+
+ ); + }; + + const renderEmailLoginForm = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('登 录')} +
+
+
+ handleChange('username', value)} + prefix={} + /> + + handleChange('password', value)} + prefix={} + /> + +
+ + + +
+ + + {(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && ( + <> + + {t('或')} + + +
+ +
+ + )} + + {!status.self_use_mode_enabled && ( +
+ + {t('没有账户?')}{' '} + + {t('注册')} + + +
+ )} +
+
+
+
+ ); + }; + + // 微信登录模态框 + const renderWeChatLoginModal = () => { + return ( + setShowWeChatLoginModal(false)} + okText={t('登录')} + size="small" + centered={true} + okButtonProps={{ + loading: wechatCodeSubmitLoading, + }} + > +
+ 微信二维码 +
+ +
+

{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}

+
+ +
+ handleChange('wechat_verification_code', value)} + /> + +
+ ); + }; + + return ( +
+ {/* 背景模糊晕染球 */} +
+
+
+ {showEmailLogin || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) + ? renderEmailLoginForm() + : renderOAuthOptions()} + {renderWeChatLoginModal()} + + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+ ); +}; + +export default LoginForm; diff --git a/web/src/components/auth/OAuth2Callback.js b/web/src/components/auth/OAuth2Callback.js new file mode 100644 index 00000000..7d435574 --- /dev/null +++ b/web/src/components/auth/OAuth2Callback.js @@ -0,0 +1,70 @@ +import React, { useContext, useEffect } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { API, showError, showSuccess, updateAPI, setUserData } from '../../helpers'; +import { UserContext } from '../../context/User'; +import Loading from '../common/Loading'; + +const OAuth2Callback = (props) => { + const { t } = useTranslation(); + const [searchParams] = useSearchParams(); + const [, userDispatch] = useContext(UserContext); + const navigate = useNavigate(); + + // 最大重试次数 + const MAX_RETRIES = 3; + + const sendCode = async (code, state, retry = 0) => { + try { + const { data: resData } = await API.get( + `/api/oauth/${props.type}?code=${code}&state=${state}`, + ); + + const { success, message, data } = resData; + + if (!success) { + throw new Error(message || 'OAuth2 callback error'); + } + + if (message === 'bind') { + showSuccess(t('绑定成功!')); + navigate('/console/personal'); + } else { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + setUserData(data); + updateAPI(); + showSuccess(t('登录成功!')); + navigate('/console/token'); + } + } catch (error) { + if (retry < MAX_RETRIES) { + // 递增的退避等待 + await new Promise((resolve) => setTimeout(resolve, (retry + 1) * 2000)); + return sendCode(code, state, retry + 1); + } + + // 重试次数耗尽,提示错误并返回设置页面 + showError(error.message || t('授权失败')); + navigate('/console/personal'); + } + }; + + useEffect(() => { + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + // 参数缺失直接返回 + if (!code) { + showError(t('未获取到授权码')); + navigate('/console/personal'); + return; + } + + sendCode(code, state); + }, []); + + return ; +}; + +export default OAuth2Callback; diff --git a/web/src/components/auth/PasswordResetConfirm.js b/web/src/components/auth/PasswordResetConfirm.js new file mode 100644 index 00000000..5fbd1fc5 --- /dev/null +++ b/web/src/components/auth/PasswordResetConfirm.js @@ -0,0 +1,173 @@ +import React, { useEffect, useState } from 'react'; +import { API, copy, showError, showNotice, getLogo, getSystemName } from '../../helpers'; +import { useSearchParams, Link } from 'react-router-dom'; +import { Button, Card, Form, Typography, Banner } from '@douyinfe/semi-ui'; +import { IconMail, IconLock, IconCopy } from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; + +const { Text, Title } = Typography; + +const PasswordResetConfirm = () => { + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + email: '', + token: '', + }); + const { email, token } = inputs; + const isValidResetLink = email && token; + + const [loading, setLoading] = useState(false); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + const [newPassword, setNewPassword] = useState(''); + const [searchParams, setSearchParams] = useSearchParams(); + const [formApi, setFormApi] = useState(null); + + const logo = getLogo(); + const systemName = getSystemName(); + + useEffect(() => { + let token = searchParams.get('token'); + let email = searchParams.get('email'); + setInputs({ + token: token || '', + email: email || '', + }); + if (formApi) { + formApi.setValues({ + email: email || '', + newPassword: newPassword || '' + }); + } + }, [searchParams, newPassword, formApi]); + + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); + }, [disableButton, countdown]); + + async function handleSubmit(e) { + if (!email || !token) { + showError(t('无效的重置链接,请重新发起密码重置请求')); + return; + } + setDisableButton(true); + setLoading(true); + const res = await API.post(`/api/user/reset`, { + email, + token, + }); + const { success, message } = res.data; + if (success) { + let password = res.data.data; + setNewPassword(password); + await copy(password); + showNotice(`${t('密码已重置并已复制到剪贴板:')} ${password}`); + } else { + showError(message); + } + setLoading(false); + } + + return ( +
+ {/* 背景模糊晕染球 */} +
+
+
+
+
+
+ Logo + {systemName} +
+ + +
+ {t('密码重置确认')} +
+
+ {!isValidResetLink && ( + + )} +
setFormApi(api)} + initValues={{ email: email || '', newPassword: newPassword || '' }} + className="space-y-4" + > + } + placeholder={email ? '' : t('等待获取邮箱信息...')} + /> + + {newPassword && ( + } + suffix={ + + } + /> + )} + +
+ +
+ + +
+ {t('返回登录')} +
+
+
+
+
+
+
+ ); +}; + +export default PasswordResetConfirm; diff --git a/web/src/components/auth/PasswordResetForm.js b/web/src/components/auth/PasswordResetForm.js new file mode 100644 index 00000000..033989e0 --- /dev/null +++ b/web/src/components/auth/PasswordResetForm.js @@ -0,0 +1,149 @@ +import React, { useEffect, useState } from 'react'; +import { API, getLogo, showError, showInfo, showSuccess, getSystemName } from '../../helpers'; +import Turnstile from 'react-turnstile'; +import { Button, Card, Form, Typography } from '@douyinfe/semi-ui'; +import { IconMail } from '@douyinfe/semi-icons'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +const { Text, Title } = Typography; + +const PasswordResetForm = () => { + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + email: '', + }); + const { email } = inputs; + + const [loading, setLoading] = useState(false); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + + const logo = getLogo(); + const systemName = getSystemName(); + + useEffect(() => { + let status = localStorage.getItem('status'); + if (status) { + status = JSON.parse(status); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + } + }, []); + + useEffect(() => { + let countdownInterval = null; + if (disableButton && countdown > 0) { + countdownInterval = setInterval(() => { + setCountdown(countdown - 1); + }, 1000); + } else if (countdown === 0) { + setDisableButton(false); + setCountdown(30); + } + return () => clearInterval(countdownInterval); + }, [disableButton, countdown]); + + function handleChange(value) { + setInputs((inputs) => ({ ...inputs, email: value })); + } + + async function handleSubmit(e) { + if (!email) { + showError(t('请输入邮箱地址')); + return; + } + if (turnstileEnabled && turnstileToken === '') { + showInfo(t('请稍后几秒重试,Turnstile 正在检查用户环境!')); + return; + } + setDisableButton(true); + setLoading(true); + const res = await API.get( + `/api/reset_password?email=${email}&turnstile=${turnstileToken}`, + ); + const { success, message } = res.data; + if (success) { + showSuccess(t('重置邮件发送成功,请检查邮箱!')); + setInputs({ ...inputs, email: '' }); + } else { + showError(message); + } + setLoading(false); + } + + return ( +
+ {/* 背景模糊晕染球 */} +
+
+
+
+
+
+ Logo + {systemName} +
+ + +
+ {t('密码重置')} +
+
+
+ } + /> + +
+ +
+ + +
+ {t('想起来了?')} {t('登录')} +
+
+
+ + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+
+
+ ); +}; + +export default PasswordResetForm; diff --git a/web/src/components/auth/RegisterForm.js b/web/src/components/auth/RegisterForm.js new file mode 100644 index 00000000..9d213a60 --- /dev/null +++ b/web/src/components/auth/RegisterForm.js @@ -0,0 +1,564 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { + API, + getLogo, + showError, + showInfo, + showSuccess, + updateAPI, + getSystemName, + setUserData +} from '../../helpers/index.js'; +import Turnstile from 'react-turnstile'; +import { + Button, + Card, + Divider, + Form, + Icon, + Modal, +} from '@douyinfe/semi-ui'; +import Title from '@douyinfe/semi-ui/lib/es/typography/title'; +import Text from '@douyinfe/semi-ui/lib/es/typography/text'; +import { IconGithubLogo, IconMail, IconUser, IconLock, IconKey } from '@douyinfe/semi-icons'; +import { + onGitHubOAuthClicked, + onLinuxDOOAuthClicked, + onOIDCClicked, +} from '../../helpers/index.js'; +import OIDCIcon from '../common/logo/OIDCIcon.js'; +import LinuxDoIcon from '../common/logo/LinuxDoIcon.js'; +import WeChatIcon from '../common/logo/WeChatIcon.js'; +import TelegramLoginButton from 'react-telegram-login/src'; +import { UserContext } from '../../context/User/index.js'; +import { useTranslation } from 'react-i18next'; + +const RegisterForm = () => { + let navigate = useNavigate(); + const { t } = useTranslation(); + const [inputs, setInputs] = useState({ + username: '', + password: '', + password2: '', + email: '', + verification_code: '', + wechat_verification_code: '', + }); + const { username, password, password2 } = inputs; + const [userState, userDispatch] = useContext(UserContext); + const [turnstileEnabled, setTurnstileEnabled] = useState(false); + const [turnstileSiteKey, setTurnstileSiteKey] = useState(''); + const [turnstileToken, setTurnstileToken] = useState(''); + const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false); + const [showEmailRegister, setShowEmailRegister] = useState(false); + const [wechatLoading, setWechatLoading] = useState(false); + const [githubLoading, setGithubLoading] = useState(false); + const [oidcLoading, setOidcLoading] = useState(false); + const [linuxdoLoading, setLinuxdoLoading] = useState(false); + const [emailRegisterLoading, setEmailRegisterLoading] = useState(false); + const [registerLoading, setRegisterLoading] = useState(false); + const [verificationCodeLoading, setVerificationCodeLoading] = useState(false); + const [otherRegisterOptionsLoading, setOtherRegisterOptionsLoading] = useState(false); + const [wechatCodeSubmitLoading, setWechatCodeSubmitLoading] = useState(false); + + const logo = getLogo(); + const systemName = getSystemName(); + + let affCode = new URLSearchParams(window.location.search).get('aff'); + if (affCode) { + localStorage.setItem('aff', affCode); + } + + const [status] = useState(() => { + const savedStatus = localStorage.getItem('status'); + return savedStatus ? JSON.parse(savedStatus) : {}; + }); + + const [showEmailVerification, setShowEmailVerification] = useState(() => { + return status.email_verification ?? false; + }); + + useEffect(() => { + setShowEmailVerification(status.email_verification); + if (status.turnstile_check) { + setTurnstileEnabled(true); + setTurnstileSiteKey(status.turnstile_site_key); + } + }, [status]); + + const onWeChatLoginClicked = () => { + setWechatLoading(true); + setShowWeChatLoginModal(true); + setWechatLoading(false); + }; + + const onSubmitWeChatVerificationCode = async () => { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setWechatCodeSubmitLoading(true); + try { + const res = await API.get( + `/api/oauth/wechat?code=${inputs.wechat_verification_code}`, + ); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + setUserData(data); + updateAPI(); + navigate('/'); + showSuccess('登录成功!'); + setShowWeChatLoginModal(false); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } finally { + setWechatCodeSubmitLoading(false); + } + }; + + function handleChange(name, value) { + setInputs((inputs) => ({ ...inputs, [name]: value })); + } + + async function handleSubmit(e) { + if (password.length < 8) { + showInfo('密码长度不得小于 8 位!'); + return; + } + if (password !== password2) { + showInfo('两次输入的密码不一致'); + return; + } + if (username && password) { + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setRegisterLoading(true); + try { + if (!affCode) { + affCode = localStorage.getItem('aff'); + } + inputs.aff_code = affCode; + const res = await API.post( + `/api/user/register?turnstile=${turnstileToken}`, + inputs, + ); + const { success, message } = res.data; + if (success) { + navigate('/login'); + showSuccess('注册成功!'); + } else { + showError(message); + } + } catch (error) { + showError('注册失败,请重试'); + } finally { + setRegisterLoading(false); + } + } + } + + const sendVerificationCode = async () => { + if (inputs.email === '') return; + if (turnstileEnabled && turnstileToken === '') { + showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!'); + return; + } + setVerificationCodeLoading(true); + try { + const res = await API.get( + `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`, + ); + const { success, message } = res.data; + if (success) { + showSuccess('验证码发送成功,请检查你的邮箱!'); + } else { + showError(message); + } + } catch (error) { + showError('发送验证码失败,请重试'); + } finally { + setVerificationCodeLoading(false); + } + }; + + const handleGitHubClick = () => { + setGithubLoading(true); + try { + onGitHubOAuthClicked(status.github_client_id); + } finally { + setTimeout(() => setGithubLoading(false), 3000); + } + }; + + const handleOIDCClick = () => { + setOidcLoading(true); + try { + onOIDCClicked( + status.oidc_authorization_endpoint, + status.oidc_client_id + ); + } finally { + setTimeout(() => setOidcLoading(false), 3000); + } + }; + + const handleLinuxDOClick = () => { + setLinuxdoLoading(true); + try { + onLinuxDOOAuthClicked(status.linuxdo_client_id); + } finally { + setTimeout(() => setLinuxdoLoading(false), 3000); + } + }; + + const handleEmailRegisterClick = () => { + setEmailRegisterLoading(true); + setShowEmailRegister(true); + setEmailRegisterLoading(false); + }; + + const handleOtherRegisterOptionsClick = () => { + setOtherRegisterOptionsLoading(true); + setShowEmailRegister(false); + setOtherRegisterOptionsLoading(false); + }; + + const onTelegramLoginClicked = async (response) => { + const fields = [ + 'id', + 'first_name', + 'last_name', + 'username', + 'photo_url', + 'auth_date', + 'hash', + 'lang', + ]; + const params = {}; + fields.forEach((field) => { + if (response[field]) { + params[field] = response[field]; + } + }); + try { + const res = await API.get(`/api/oauth/telegram/login`, { params }); + const { success, message, data } = res.data; + if (success) { + userDispatch({ type: 'login', payload: data }); + localStorage.setItem('user', JSON.stringify(data)); + showSuccess('登录成功!'); + setUserData(data); + updateAPI(); + navigate('/'); + } else { + showError(message); + } + } catch (error) { + showError('登录失败,请重试'); + } + }; + + const renderOAuthOptions = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('注 册')} +
+
+
+ {status.wechat_login && ( + + )} + + {status.github_oauth && ( + + )} + + {status.oidc_enabled && ( + + )} + + {status.linuxdo_oauth && ( + + )} + + {status.telegram_oauth && ( +
+ +
+ )} + + + {t('或')} + + + +
+ +
+ {t('已有账户?')} {t('登录')} +
+
+
+
+
+ ); + }; + + const renderEmailRegisterForm = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ {t('注 册')} +
+
+
+ handleChange('username', value)} + prefix={} + /> + + handleChange('password', value)} + prefix={} + /> + + handleChange('password2', value)} + prefix={} + /> + + {showEmailVerification && ( + <> + handleChange('email', value)} + prefix={} + suffix={ + + } + /> + handleChange('verification_code', value)} + prefix={} + /> + + )} + +
+ +
+ + + {(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) && ( + <> + + {t('或')} + + +
+ +
+ + )} + +
+ {t('已有账户?')} {t('登录')} +
+
+
+
+
+ ); + }; + + const renderWeChatLoginModal = () => { + return ( + setShowWeChatLoginModal(false)} + okText={t('登录')} + size="small" + centered={true} + okButtonProps={{ + loading: wechatCodeSubmitLoading, + }} + > +
+ 微信二维码 +
+ +
+

{t('微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)')}

+
+ +
+ handleChange('wechat_verification_code', value)} + /> + +
+ ); + }; + + return ( +
+ {/* 背景模糊晕染球 */} +
+
+
+ {showEmailRegister || !(status.github_oauth || status.oidc_enabled || status.wechat_login || status.linuxdo_oauth || status.telegram_oauth) + ? renderEmailRegisterForm() + : renderOAuthOptions()} + {renderWeChatLoginModal()} + + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+ ); +}; + +export default RegisterForm; diff --git a/web/src/components/common/Loading.js b/web/src/components/common/Loading.js new file mode 100644 index 00000000..73822755 --- /dev/null +++ b/web/src/components/common/Loading.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { Spin } from '@douyinfe/semi-ui'; + +const Loading = ({ size = 'small' }) => { + + return ( +
+ +
+ ); +}; + +export default Loading; diff --git a/web/src/components/common/logo/LinuxDoIcon.js b/web/src/components/common/logo/LinuxDoIcon.js new file mode 100644 index 00000000..f6ee9b31 --- /dev/null +++ b/web/src/components/common/logo/LinuxDoIcon.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { Icon } from '@douyinfe/semi-ui'; + +const LinuxDoIcon = (props) => { + function CustomIcon() { + return ( + + + + + + + + ); + } + + return } />; +}; + +export default LinuxDoIcon; diff --git a/web/src/components/common/logo/OIDCIcon.js b/web/src/components/common/logo/OIDCIcon.js new file mode 100644 index 00000000..bd98c8fb --- /dev/null +++ b/web/src/components/common/logo/OIDCIcon.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { Icon } from '@douyinfe/semi-ui'; + +const OIDCIcon = (props) => { + function CustomIcon() { + return ( + + + + + ); + } + + return } />; +}; + +export default OIDCIcon; diff --git a/web/src/components/common/logo/WeChatIcon.js b/web/src/components/common/logo/WeChatIcon.js new file mode 100644 index 00000000..723c7ecb --- /dev/null +++ b/web/src/components/common/logo/WeChatIcon.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { Icon } from '@douyinfe/semi-ui'; + +const WeChatIcon = () => { + function CustomIcon() { + return ( + + + + + ); + } + + return ( +
+ } /> +
+ ); +}; + +export default WeChatIcon; diff --git a/web/src/components/common/markdown/MarkdownRenderer.js b/web/src/components/common/markdown/MarkdownRenderer.js new file mode 100644 index 00000000..a48d34d1 --- /dev/null +++ b/web/src/components/common/markdown/MarkdownRenderer.js @@ -0,0 +1,513 @@ +import ReactMarkdown from 'react-markdown'; +import 'katex/dist/katex.min.css'; +import 'highlight.js/styles/github.css'; +import './markdown.css'; +import RemarkMath from 'remark-math'; +import RemarkBreaks from 'remark-breaks'; +import RehypeKatex from 'rehype-katex'; +import RemarkGfm from 'remark-gfm'; +import RehypeHighlight from 'rehype-highlight'; +import { useRef, useState, useEffect, useMemo } from 'react'; +import mermaid from 'mermaid'; +import React from 'react'; +import { useDebouncedCallback } from 'use-debounce'; +import clsx from 'clsx'; +import { Button, Tooltip, Toast } from '@douyinfe/semi-ui'; +import { copy, rehypeSplitWordsIntoSpans } from '../../../helpers'; +import { IconCopy } from '@douyinfe/semi-icons'; +import { useTranslation } from 'react-i18next'; + +mermaid.initialize({ + startOnLoad: false, + theme: 'default', + securityLevel: 'loose', +}); + +export function Mermaid(props) { + const ref = useRef(null); + const [hasError, setHasError] = useState(false); + + useEffect(() => { + if (props.code && ref.current) { + mermaid + .run({ + nodes: [ref.current], + suppressErrors: true, + }) + .catch((e) => { + setHasError(true); + console.error('[Mermaid] ', e.message); + }); + } + }, [props.code]); + + function viewSvgInNewWindow() { + const svg = ref.current?.querySelector('svg'); + if (!svg) return; + const text = new XMLSerializer().serializeToString(svg); + const blob = new Blob([text], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + window.open(url, '_blank'); + } + + if (hasError) { + return null; + } + + return ( +
viewSvgInNewWindow()} + > + {props.code} +
+ ); +} + +export function PreCode(props) { + const ref = useRef(null); + const [mermaidCode, setMermaidCode] = useState(''); + const [htmlCode, setHtmlCode] = useState(''); + const { t } = useTranslation(); + + const renderArtifacts = useDebouncedCallback(() => { + if (!ref.current) return; + const mermaidDom = ref.current.querySelector('code.language-mermaid'); + if (mermaidDom) { + setMermaidCode(mermaidDom.innerText); + } + const htmlDom = ref.current.querySelector('code.language-html'); + const refText = ref.current.querySelector('code')?.innerText; + if (htmlDom) { + setHtmlCode(htmlDom.innerText); + } else if ( + refText?.startsWith(' { + if (ref.current) { + const codeElements = ref.current.querySelectorAll('code'); + const wrapLanguages = [ + '', + 'md', + 'markdown', + 'text', + 'txt', + 'plaintext', + 'tex', + 'latex', + ]; + codeElements.forEach((codeElement) => { + let languageClass = codeElement.className.match(/language-(\w+)/); + let name = languageClass ? languageClass[1] : ''; + if (wrapLanguages.includes(name)) { + codeElement.style.whiteSpace = 'pre-wrap'; + } + }); + setTimeout(renderArtifacts, 1); + } + }, []); + + return ( + <> +
+        
+ +
+ {props.children} +
+ {mermaidCode.length > 0 && ( + + )} + {htmlCode.length > 0 && ( +
+
+ HTML预览: +
+
+
+ )} + + ); +} + +function CustomCode(props) { + const ref = useRef(null); + const [collapsed, setCollapsed] = useState(true); + const [showToggle, setShowToggle] = useState(false); + const { t } = useTranslation(); + + useEffect(() => { + if (ref.current) { + const codeHeight = ref.current.scrollHeight; + setShowToggle(codeHeight > 400); + ref.current.scrollTop = ref.current.scrollHeight; + } + }, [props.children]); + + const toggleCollapsed = () => { + setCollapsed((collapsed) => !collapsed); + }; + + const renderShowMoreButton = () => { + if (showToggle && collapsed) { + return ( +
+ +
+ ); + } + return null; + }; + + return ( +
+ + {props.children} + + {renderShowMoreButton()} +
+ ); +} + +function escapeBrackets(text) { + const pattern = + /(```[\s\S]*?```|`.*?`)|\\\[([\s\S]*?[^\\])\\\]|\\\((.*?)\\\)/g; + return text.replace( + pattern, + (match, codeBlock, squareBracket, roundBracket) => { + if (codeBlock) { + return codeBlock; + } else if (squareBracket) { + return `$$${squareBracket}$$`; + } else if (roundBracket) { + return `$${roundBracket}$`; + } + return match; + }, + ); +} + +function tryWrapHtmlCode(text) { + // 尝试包装HTML代码 + if (text.includes('```')) { + return text; + } + return text + .replace( + /([`]*?)(\w*?)([\n\r]*?)()/g, + (match, quoteStart, lang, newLine, doctype) => { + return !quoteStart ? '\n```html\n' + doctype : match; + }, + ) + .replace( + /(<\/body>)([\r\n\s]*?)(<\/html>)([\n\r]*)([`]*)([\n\r]*?)/g, + (match, bodyEnd, space, htmlEnd, newLine, quoteEnd) => { + return !quoteEnd ? bodyEnd + space + htmlEnd + '\n```\n' : match; + }, + ); +} + +function _MarkdownContent(props) { + const { + content, + className, + animated = false, + previousContentLength = 0, + } = props; + + const escapedContent = useMemo(() => { + return tryWrapHtmlCode(escapeBrackets(content)); + }, [content]); + + // 判断是否为用户消息 + const isUserMessage = className && className.includes('user-message'); + + const rehypePluginsBase = useMemo(() => { + const base = [ + RehypeKatex, + [ + RehypeHighlight, + { + detect: false, + ignoreMissing: true, + }, + ], + ]; + if (animated) { + base.push([rehypeSplitWordsIntoSpans, { previousContentLength }]); + } + return base; + }, [animated, previousContentLength]); + + return ( +

, + a: (aProps) => { + const href = aProps.href || ''; + if (/\.(aac|mp3|opus|wav)$/.test(href)) { + return ( +

+ +
+ ); + } + if (/\.(3gp|3g2|webm|ogv|mpeg|mp4|avi)$/.test(href)) { + return ( + + ); + } + const isInternal = /^\/#/i.test(href); + const target = isInternal ? '_self' : aProps.target ?? '_blank'; + return ( + { + e.target.style.textDecoration = 'underline'; + }} + onMouseLeave={(e) => { + e.target.style.textDecoration = 'none'; + }} + /> + ); + }, + h1: (props) =>

, + h2: (props) =>

, + h3: (props) =>

, + h4: (props) =>

, + h5: (props) =>

, + h6: (props) =>
, + blockquote: (props) => ( +
+ ), + ul: (props) =>
    , + ol: (props) =>
      , + li: (props) =>
    1. , + table: (props) => ( +
      + + + ), + th: (props) => ( +
      + ), + td: (props) => ( + + ), + }} + > + {escapedContent} + + ); +} + +export const MarkdownContent = React.memo(_MarkdownContent); + +export function MarkdownRenderer(props) { + const { + content, + loading, + fontSize = 14, + fontFamily = 'inherit', + className, + style, + animated = false, + previousContentLength = 0, + ...otherProps + } = props; + + return ( +
      + {loading ? ( +
      +
      + 正在渲染... +
      + ) : ( + + )} +
      + ); +} + +export default MarkdownRenderer; \ No newline at end of file diff --git a/web/src/components/common/markdown/markdown.css b/web/src/components/common/markdown/markdown.css new file mode 100644 index 00000000..3b5c1067 --- /dev/null +++ b/web/src/components/common/markdown/markdown.css @@ -0,0 +1,444 @@ +/* 基础markdown样式 */ +.markdown-body { + font-family: inherit; + line-height: 1.6; + color: var(--semi-color-text-0); + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; +} + +/* 用户消息样式 - 白色字体适配蓝色背景 */ +.user-message { + color: white !important; +} + +.user-message .markdown-body { + color: white !important; +} + +.user-message h1, +.user-message h2, +.user-message h3, +.user-message h4, +.user-message h5, +.user-message h6 { + color: white !important; +} + +.user-message p { + color: white !important; +} + +.user-message span { + color: white !important; +} + +.user-message div { + color: white !important; +} + +.user-message li { + color: white !important; +} + +.user-message td, +.user-message th { + color: white !important; +} + +.user-message blockquote { + color: white !important; + border-left-color: rgba(255, 255, 255, 0.5) !important; + background-color: rgba(255, 255, 255, 0.1) !important; +} + +.user-message code:not(pre code) { + color: #000 !important; + background-color: rgba(255, 255, 255, 0.9) !important; +} + +.user-message a { + color: #87CEEB !important; + /* 浅蓝色链接 */ +} + +.user-message a:hover { + color: #B0E0E6 !important; + /* hover时更浅的蓝色 */ +} + +/* 表格在用户消息中的样式 */ +.user-message table { + border-color: rgba(255, 255, 255, 0.3) !important; +} + +.user-message th { + background-color: rgba(255, 255, 255, 0.2) !important; + border-color: rgba(255, 255, 255, 0.3) !important; +} + +.user-message td { + border-color: rgba(255, 255, 255, 0.3) !important; +} + +/* 加载动画 */ +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* 代码高亮主题 - 适配Semi Design */ +.hljs { + display: block; + overflow-x: auto; + padding: 0; + background: transparent; + color: var(--semi-color-text-0); +} + +.hljs-comment, +.hljs-quote { + color: var(--semi-color-text-2); + font-style: italic; +} + +.hljs-keyword, +.hljs-selector-tag, +.hljs-subst { + color: var(--semi-color-primary); + font-weight: bold; +} + +.hljs-number, +.hljs-literal, +.hljs-variable, +.hljs-template-variable, +.hljs-tag .hljs-attr { + color: var(--semi-color-warning); +} + +.hljs-string, +.hljs-doctag { + color: var(--semi-color-success); +} + +.hljs-title, +.hljs-section, +.hljs-selector-id { + color: var(--semi-color-primary); + font-weight: bold; +} + +.hljs-subst { + font-weight: normal; +} + +.hljs-type, +.hljs-class .hljs-title { + color: var(--semi-color-info); + font-weight: bold; +} + +.hljs-tag, +.hljs-name, +.hljs-attribute { + color: var(--semi-color-primary); + font-weight: normal; +} + +.hljs-regexp, +.hljs-link { + color: var(--semi-color-tertiary); +} + +.hljs-symbol, +.hljs-bullet { + color: var(--semi-color-warning); +} + +.hljs-built_in, +.hljs-builtin-name { + color: var(--semi-color-info); +} + +.hljs-meta { + color: var(--semi-color-text-2); +} + +.hljs-deletion { + background: var(--semi-color-danger-light-default); +} + +.hljs-addition { + background: var(--semi-color-success-light-default); +} + +.hljs-emphasis { + font-style: italic; +} + +.hljs-strong { + font-weight: bold; +} + +/* Mermaid容器样式 */ +.mermaid-container { + transition: all 0.2s ease; +} + +.mermaid-container:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); +} + +/* 代码块样式增强 */ +pre { + position: relative; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + transition: all 0.2s ease; +} + +pre:hover { + border-color: var(--semi-color-primary) !important; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +pre:hover .copy-code-button { + opacity: 1 !important; +} + +.copy-code-button { + opacity: 0; + transition: opacity 0.2s ease; + z-index: 10; + pointer-events: auto; +} + +.copy-code-button:hover { + opacity: 1 !important; +} + +.copy-code-button button { + pointer-events: auto !important; + cursor: pointer !important; +} + +/* 确保按钮可点击 */ +.copy-code-button .semi-button { + pointer-events: auto !important; + cursor: pointer !important; + transition: all 0.2s ease; +} + +.copy-code-button .semi-button:hover { + background-color: var(--semi-color-fill-1) !important; + border-color: var(--semi-color-primary) !important; + transform: scale(1.05); +} + +/* 表格响应式 */ +@media (max-width: 768px) { + .markdown-body table { + font-size: 12px; + } + + .markdown-body th, + .markdown-body td { + padding: 6px 8px; + } +} + +/* 数学公式样式 */ +.katex { + font-size: 1em; +} + +.katex-display { + margin: 1em 0; + text-align: center; +} + +/* 链接hover效果 */ +.markdown-body a { + transition: all 0.2s ease; +} + +/* 引用块样式增强 */ +.markdown-body blockquote { + position: relative; +} + +.markdown-body blockquote::before { + content: '"'; + position: absolute; + left: -8px; + top: -8px; + font-size: 24px; + color: var(--semi-color-primary); + opacity: 0.3; +} + +/* 列表样式增强 */ +.markdown-body ul li::marker { + color: var(--semi-color-primary); +} + +.markdown-body ol li::marker { + color: var(--semi-color-primary); + font-weight: bold; +} + +/* 分隔线样式 */ +.markdown-body hr { + border: none; + height: 1px; + background: linear-gradient(to right, transparent, var(--semi-color-border), transparent); + margin: 24px 0; +} + +/* 图片样式 */ +.markdown-body img { + max-width: 100%; + height: auto; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin: 12px 0; +} + +/* 内联代码样式 */ +.markdown-body code:not(pre code) { + background-color: var(--semi-color-fill-1); + padding: 2px 6px; + border-radius: 4px; + font-size: 0.9em; + color: var(--semi-color-primary); + border: 1px solid var(--semi-color-border); +} + +/* 标题锚点样式 */ +.markdown-body h1:hover, +.markdown-body h2:hover, +.markdown-body h3:hover, +.markdown-body h4:hover, +.markdown-body h5:hover, +.markdown-body h6:hover { + position: relative; +} + +/* 任务列表样式 */ +.markdown-body input[type="checkbox"] { + margin-right: 8px; + transform: scale(1.1); +} + +.markdown-body li.task-list-item { + list-style: none; + margin-left: -20px; +} + +/* 键盘按键样式 */ +.markdown-body kbd { + background-color: var(--semi-color-fill-0); + border: 1px solid var(--semi-color-border); + border-radius: 3px; + box-shadow: 0 1px 0 var(--semi-color-border); + color: var(--semi-color-text-0); + display: inline-block; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 0.85em; + font-weight: 700; + line-height: 1; + padding: 2px 4px; + white-space: nowrap; +} + +/* 详情折叠样式 */ +.markdown-body details { + border: 1px solid var(--semi-color-border); + border-radius: 6px; + padding: 12px; + margin: 12px 0; +} + +.markdown-body summary { + cursor: pointer; + font-weight: bold; + color: var(--semi-color-primary); + margin-bottom: 8px; +} + +.markdown-body summary:hover { + color: var(--semi-color-primary-hover); +} + +/* 脚注样式 */ +.markdown-body .footnote-ref { + color: var(--semi-color-primary); + text-decoration: none; + font-weight: bold; +} + +.markdown-body .footnote-ref:hover { + text-decoration: underline; +} + +/* 警告块样式 */ +.markdown-body .warning { + background-color: var(--semi-color-warning-light-default); + border-left: 4px solid var(--semi-color-warning); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .info { + background-color: var(--semi-color-info-light-default); + border-left: 4px solid var(--semi-color-info); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .success { + background-color: var(--semi-color-success-light-default); + border-left: 4px solid var(--semi-color-success); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +.markdown-body .danger { + background-color: var(--semi-color-danger-light-default); + border-left: 4px solid var(--semi-color-danger); + padding: 12px 16px; + margin: 12px 0; + border-radius: 0 6px 6px 0; +} + +@keyframes fade-in { + 0% { + opacity: 0; + transform: translateY(6px) scale(0.98); + filter: blur(3px); + } + 60% { + opacity: 0.85; + filter: blur(0.5px); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } +} + +.animate-fade-in { + animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; + will-change: opacity, transform; +} \ No newline at end of file diff --git a/web/src/components/layout/Footer.js b/web/src/components/layout/Footer.js new file mode 100644 index 00000000..d380e574 --- /dev/null +++ b/web/src/components/layout/Footer.js @@ -0,0 +1,112 @@ +import React, { useEffect, useState, useMemo, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Typography } from '@douyinfe/semi-ui'; +import { getFooterHTML, getLogo, getSystemName } from '../../helpers'; +import { StatusContext } from '../../context/Status'; + +const FooterBar = () => { + const { t } = useTranslation(); + const [footer, setFooter] = useState(getFooterHTML()); + const systemName = getSystemName(); + const logo = getLogo(); + const [statusState] = useContext(StatusContext); + const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; + + const loadFooter = () => { + let footer_html = localStorage.getItem('footer_html'); + if (footer_html) { + setFooter(footer_html); + } + }; + + const currentYear = new Date().getFullYear(); + + const customFooter = useMemo(() => ( + + ), [logo, systemName, t, currentYear, isDemoSiteMode]); + + useEffect(() => { + loadFooter(); + }, []); + + return ( +
      + {footer ? ( +
      + ) : ( + customFooter + )} +
      + ); +}; + +export default FooterBar; diff --git a/web/src/components/layout/HeaderBar.js b/web/src/components/layout/HeaderBar.js new file mode 100644 index 00000000..4d83d48b --- /dev/null +++ b/web/src/components/layout/HeaderBar.js @@ -0,0 +1,646 @@ +import React, { useContext, useEffect, useState, useRef } from 'react'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { UserContext } from '../../context/User/index.js'; +import { useSetTheme, useTheme } from '../../context/Theme/index.js'; +import { useTranslation } from 'react-i18next'; +import { API, getLogo, getSystemName, showSuccess, stringToColor } from '../../helpers/index.js'; +import fireworks from 'react-fireworks'; +import { CN, GB } from 'country-flag-icons/react/3x2'; +import NoticeModal from './NoticeModal.js'; + +import { + IconClose, + IconMenu, + IconLanguage, + IconChevronDown, + IconSun, + IconMoon, + IconExit, + IconUserSetting, + IconCreditCard, + IconKey, + IconBell, +} from '@douyinfe/semi-icons'; +import { + Avatar, + Button, + Dropdown, + Tag, + Typography, + Skeleton, + Badge, +} from '@douyinfe/semi-ui'; +import { StatusContext } from '../../context/Status/index.js'; +import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js'; + +const HeaderBar = ({ onMobileMenuToggle, drawerOpen }) => { + const { t, i18n } = useTranslation(); + const [userState, userDispatch] = useContext(UserContext); + const [statusState, statusDispatch] = useContext(StatusContext); + const isMobile = useIsMobile(); + const [collapsed, toggleCollapsed] = useSidebarCollapsed(); + const [isLoading, setIsLoading] = useState(true); + let navigate = useNavigate(); + const [currentLang, setCurrentLang] = useState(i18n.language); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const location = useLocation(); + const [noticeVisible, setNoticeVisible] = useState(false); + const [unreadCount, setUnreadCount] = useState(0); + const loadingStartRef = useRef(Date.now()); + + const systemName = getSystemName(); + const logo = getLogo(); + const currentDate = new Date(); + const isNewYear = currentDate.getMonth() === 0 && currentDate.getDate() === 1; + + const isSelfUseMode = statusState?.status?.self_use_mode_enabled || false; + const docsLink = statusState?.status?.docs_link || ''; + const isDemoSiteMode = statusState?.status?.demo_site_enabled || false; + + const isConsoleRoute = location.pathname.startsWith('/console'); + + const theme = useTheme(); + const setTheme = useSetTheme(); + + const announcements = statusState?.status?.announcements || []; + + const getAnnouncementKey = (a) => `${a?.publishDate || ''}-${(a?.content || '').slice(0, 30)}`; + + const calculateUnreadCount = () => { + if (!announcements.length) return 0; + let readKeys = []; + try { + readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || []; + } catch (_) { + readKeys = []; + } + const readSet = new Set(readKeys); + return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).length; + }; + + const getUnreadKeys = () => { + if (!announcements.length) return []; + let readKeys = []; + try { + readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || []; + } catch (_) { + readKeys = []; + } + const readSet = new Set(readKeys); + return announcements.filter((a) => !readSet.has(getAnnouncementKey(a))).map(getAnnouncementKey); + }; + + useEffect(() => { + setUnreadCount(calculateUnreadCount()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [announcements]); + + const mainNavLinks = [ + { + text: t('首页'), + itemKey: 'home', + to: '/', + }, + { + text: t('控制台'), + itemKey: 'console', + to: '/console', + }, + { + text: t('定价'), + itemKey: 'pricing', + to: '/pricing', + }, + ...(docsLink + ? [ + { + text: t('文档'), + itemKey: 'docs', + isExternal: true, + externalLink: docsLink, + }, + ] + : []), + { + text: t('关于'), + itemKey: 'about', + to: '/about', + }, + ]; + + async function logout() { + await API.get('/api/user/logout'); + showSuccess(t('注销成功!')); + userDispatch({ type: 'logout' }); + localStorage.removeItem('user'); + navigate('/login'); + setMobileMenuOpen(false); + } + + const handleNewYearClick = () => { + fireworks.init('root', {}); + fireworks.start(); + setTimeout(() => { + fireworks.stop(); + }, 3000); + }; + + const handleNoticeOpen = () => { + setNoticeVisible(true); + }; + + const handleNoticeClose = () => { + setNoticeVisible(false); + if (announcements.length) { + let readKeys = []; + try { + readKeys = JSON.parse(localStorage.getItem('notice_read_keys')) || []; + } catch (_) { + readKeys = []; + } + const mergedKeys = Array.from(new Set([...readKeys, ...announcements.map(getAnnouncementKey)])); + localStorage.setItem('notice_read_keys', JSON.stringify(mergedKeys)); + } + setUnreadCount(0); + }; + + useEffect(() => { + if (theme === 'dark') { + document.body.setAttribute('theme-mode', 'dark'); + document.documentElement.classList.add('dark'); + } else { + document.body.removeAttribute('theme-mode'); + document.documentElement.classList.remove('dark'); + } + + const iframe = document.querySelector('iframe'); + if (iframe) { + iframe.contentWindow.postMessage({ themeMode: theme }, '*'); + } + + }, [theme, isNewYear]); + + useEffect(() => { + const handleLanguageChanged = (lng) => { + setCurrentLang(lng); + const iframe = document.querySelector('iframe'); + if (iframe) { + iframe.contentWindow.postMessage({ lang: lng }, '*'); + } + }; + + i18n.on('languageChanged', handleLanguageChanged); + return () => { + i18n.off('languageChanged', handleLanguageChanged); + }; + }, [i18n]); + + useEffect(() => { + if (statusState?.status !== undefined) { + const elapsed = Date.now() - loadingStartRef.current; + const remaining = Math.max(0, 500 - elapsed); + const timer = setTimeout(() => { + setIsLoading(false); + }, remaining); + return () => clearTimeout(timer); + } + }, [statusState?.status]); + + const handleLanguageChange = (lang) => { + i18n.changeLanguage(lang); + setMobileMenuOpen(false); + }; + + const handleNavLinkClick = (itemKey) => { + if (itemKey === 'home') { + // styleDispatch(styleActions.setSider(false)); // This line is removed + } + setMobileMenuOpen(false); + }; + + const renderNavLinks = (isMobileView = false, isLoading = false) => { + if (isLoading) { + const skeletonLinkClasses = isMobileView + ? 'flex items-center gap-1 p-3 w-full rounded-md' + : 'flex items-center gap-1 p-2 rounded-md'; + return Array(4) + .fill(null) + .map((_, index) => ( +
      + + } + /> +
      + )); + } + + return mainNavLinks.map((link) => { + const commonLinkClasses = isMobileView + ? 'flex items-center gap-1 p-3 w-full text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors font-semibold' + : 'flex items-center gap-1 p-2 text-sm text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors rounded-md font-semibold'; + + const linkContent = ( + {link.text} + ); + + if (link.isExternal) { + return ( + handleNavLinkClick(link.itemKey)} + > + {linkContent} + + ); + } + + let targetPath = link.to; + if (link.itemKey === 'console' && !userState.user) { + targetPath = '/login'; + } + + return ( + handleNavLinkClick(link.itemKey)} + > + {linkContent} + + ); + }); + }; + + const renderUserArea = () => { + if (isLoading) { + return ( +
      + } + /> +
      + + } + /> +
      +
      + ); + } + + if (userState.user) { + return ( + + { + navigate('/console/personal'); + setMobileMenuOpen(false); + }} + className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white" + > +
      + + {t('个人设置')} +
      +
      + { + navigate('/console/token'); + setMobileMenuOpen(false); + }} + className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white" + > +
      + + {t('API令牌')} +
      +
      + { + navigate('/console/topup'); + setMobileMenuOpen(false); + }} + className="!px-3 !py-1.5 !text-sm !text-semi-color-text-0 hover:!bg-semi-color-fill-1 dark:!text-gray-200 dark:hover:!bg-blue-500 dark:hover:!text-white" + > +
      + + {t('钱包')} +
      +
      + +
      + + {t('退出')} +
      +
      + + } + > + +
      + ); + } else { + const showRegisterButton = !isSelfUseMode; + + const commonSizingAndLayoutClass = "flex items-center justify-center !py-[10px] !px-1.5"; + + const loginButtonSpecificStyling = "!bg-semi-color-fill-0 dark:!bg-semi-color-fill-1 hover:!bg-semi-color-fill-1 dark:hover:!bg-gray-700 transition-colors"; + let loginButtonClasses = `${commonSizingAndLayoutClass} ${loginButtonSpecificStyling}`; + + let registerButtonClasses = `${commonSizingAndLayoutClass}`; + + const loginButtonTextSpanClass = "!text-xs !text-semi-color-text-1 dark:!text-gray-300 !p-1.5"; + const registerButtonTextSpanClass = "!text-xs !text-white !p-1.5"; + + if (showRegisterButton) { + if (isMobile) { + loginButtonClasses += " !rounded-full"; + } else { + loginButtonClasses += " !rounded-l-full !rounded-r-none"; + } + registerButtonClasses += " !rounded-r-full !rounded-l-none"; + } else { + loginButtonClasses += " !rounded-full"; + } + + return ( +
      + handleNavLinkClick('login')} className="flex"> + + + {showRegisterButton && ( +
      + handleNavLinkClick('register')} className="flex -ml-px"> + + +
      + )} +
      + ); + } + }; + + return ( +
      + 0 ? 'system' : 'inApp'} + unreadKeys={getUnreadKeys()} + /> +
      +
      +
      +
      +
      + handleNavLinkClick('home')} className="flex items-center gap-2 group ml-2"> + + } + > + logo + +
      +
      + + } + > + + {systemName} + + + {(isSelfUseMode || isDemoSiteMode) && !isLoading && ( + + {isSelfUseMode ? t('自用模式') : t('演示站点')} + + )} +
      +
      + + {(isSelfUseMode || isDemoSiteMode) && !isLoading && ( +
      + + {isSelfUseMode ? t('自用模式') : t('演示站点')} + +
      + )} + + +
      + +
      + {isNewYear && ( + + + Happy New Year!!! 🎉 + + + } + > +
      +
      +
      + +
      +
      + +
      +
      +
      + ); +}; + +export default HeaderBar; diff --git a/web/src/components/layout/NoticeModal.js b/web/src/components/layout/NoticeModal.js new file mode 100644 index 00000000..2a79540c --- /dev/null +++ b/web/src/components/layout/NoticeModal.js @@ -0,0 +1,184 @@ +import React, { useEffect, useState, useContext, useMemo } from 'react'; +import { Button, Modal, Empty, Tabs, TabPane, Timeline } from '@douyinfe/semi-ui'; +import { useTranslation } from 'react-i18next'; +import { API, showError, getRelativeTime } from '../../helpers'; +import { marked } from 'marked'; +import { IllustrationNoContent, IllustrationNoContentDark } from '@douyinfe/semi-illustrations'; +import { StatusContext } from '../../context/Status/index.js'; +import { Bell, Megaphone } from 'lucide-react'; + +const NoticeModal = ({ visible, onClose, isMobile, defaultTab = 'inApp', unreadKeys = [] }) => { + const { t } = useTranslation(); + const [noticeContent, setNoticeContent] = useState(''); + const [loading, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState(defaultTab); + + const [statusState] = useContext(StatusContext); + + const announcements = statusState?.status?.announcements || []; + + const unreadSet = useMemo(() => new Set(unreadKeys), [unreadKeys]); + + const getKeyForItem = (item) => `${item?.publishDate || ''}-${(item?.content || '').slice(0, 30)}`; + + const processedAnnouncements = useMemo(() => { + return (announcements || []).slice(0, 20).map(item => ({ + key: getKeyForItem(item), + type: item.type || 'default', + time: getRelativeTime(item.publishDate), + content: item.content, + extra: item.extra, + isUnread: unreadSet.has(getKeyForItem(item)) + })); + }, [announcements, unreadSet]); + + const handleCloseTodayNotice = () => { + const today = new Date().toDateString(); + localStorage.setItem('notice_close_date', today); + onClose(); + }; + + const displayNotice = async () => { + setLoading(true); + try { + const res = await API.get('/api/notice'); + const { success, message, data } = res.data; + if (success) { + if (data !== '') { + const htmlNotice = marked.parse(data); + setNoticeContent(htmlNotice); + } else { + setNoticeContent(''); + } + } else { + showError(message); + } + } catch (error) { + showError(error.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (visible) { + displayNotice(); + } + }, [visible]); + + useEffect(() => { + if (visible) { + setActiveTab(defaultTab); + } + }, [defaultTab, visible]); + + const renderMarkdownNotice = () => { + if (loading) { + return
      ; + } + + if (!noticeContent) { + return ( +
      + } + darkModeImage={} + description={t('暂无公告')} + /> +
      + ); + } + + return ( +
      + ); + }; + + const renderAnnouncementTimeline = () => { + if (processedAnnouncements.length === 0) { + return ( +
      + } + darkModeImage={} + description={t('暂无系统公告')} + /> +
      + ); + } + + return ( +
      + + {processedAnnouncements.map((item, idx) => { + const htmlContent = marked.parse(item.content || ''); + const htmlExtra = item.extra ? marked.parse(item.extra) : ''; + return ( + +
      +
      + {item.extra && ( +
      + )} +
      + + ); + })} + +
      + ); + }; + + const renderBody = () => { + if (activeTab === 'inApp') { + return renderMarkdownNotice(); + } + return renderAnnouncementTimeline(); + }; + + return ( + + {t('系统公告')} + + {t('通知')}} itemKey='inApp' /> + {t('系统公告')}} itemKey='system' /> + +
      + } + visible={visible} + onCancel={onClose} + footer={( +
      + + +
      + )} + size={isMobile ? 'full-width' : 'large'} + > + {renderBody()} + + ); +}; + +export default NoticeModal; \ No newline at end of file diff --git a/web/src/components/layout/PageLayout.js b/web/src/components/layout/PageLayout.js new file mode 100644 index 00000000..7ef42eb7 --- /dev/null +++ b/web/src/components/layout/PageLayout.js @@ -0,0 +1,165 @@ +import HeaderBar from './HeaderBar.js'; +import { Layout } from '@douyinfe/semi-ui'; +import SiderBar from './SiderBar.js'; +import App from '../../App.js'; +import FooterBar from './Footer.js'; +import { ToastContainer } from 'react-toastify'; +import React, { useContext, useEffect, useState } from 'react'; +import { useIsMobile } from '../../hooks/useIsMobile.js'; +import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js'; +import { useTranslation } from 'react-i18next'; +import { API, getLogo, getSystemName, showError, setStatusData } from '../../helpers/index.js'; +import { UserContext } from '../../context/User/index.js'; +import { StatusContext } from '../../context/Status/index.js'; +import { useLocation } from 'react-router-dom'; +const { Sider, Content, Header } = Layout; + +const PageLayout = () => { + const [, userDispatch] = useContext(UserContext); + const [, statusDispatch] = useContext(StatusContext); + const isMobile = useIsMobile(); + const [collapsed, , setCollapsed] = useSidebarCollapsed(); + const [drawerOpen, setDrawerOpen] = useState(false); + const { i18n } = useTranslation(); + const location = useLocation(); + + const shouldHideFooter = location.pathname === '/console/playground' || location.pathname.startsWith('/console/chat'); + + const shouldInnerPadding = location.pathname.includes('/console') && + !location.pathname.startsWith('/console/chat') && + location.pathname !== '/console/playground'; + + const isConsoleRoute = location.pathname.startsWith('/console'); + const showSider = isConsoleRoute && (!isMobile || drawerOpen); + + useEffect(() => { + if (isMobile && drawerOpen && collapsed) { + setCollapsed(false); + } + }, [isMobile, drawerOpen, collapsed, setCollapsed]); + + const loadUser = () => { + let user = localStorage.getItem('user'); + if (user) { + let data = JSON.parse(user); + userDispatch({ type: 'login', payload: data }); + } + }; + + const loadStatus = async () => { + try { + const res = await API.get('/api/status'); + const { success, data } = res.data; + if (success) { + statusDispatch({ type: 'set', payload: data }); + setStatusData(data); + } else { + showError('Unable to connect to server'); + } + } catch (error) { + showError('Failed to load status'); + } + }; + + useEffect(() => { + loadUser(); + loadStatus().catch(console.error); + let systemName = getSystemName(); + if (systemName) { + document.title = systemName; + } + let logo = getLogo(); + if (logo) { + let linkElement = document.querySelector("link[rel~='icon']"); + if (linkElement) { + linkElement.href = logo; + } + } + const savedLang = localStorage.getItem('i18nextLng'); + if (savedLang) { + i18n.changeLanguage(savedLang); + } + }, [i18n]); + + return ( + +
      + setDrawerOpen(prev => !prev)} drawerOpen={drawerOpen} /> +
      + + {showSider && ( + + { if (isMobile) setDrawerOpen(false); }} /> + + )} + + + + + {!shouldHideFooter && ( + + + + )} + + + +
      + ); +}; + +export default PageLayout; diff --git a/web/src/components/layout/SetupCheck.js b/web/src/components/layout/SetupCheck.js new file mode 100644 index 00000000..3fbd9012 --- /dev/null +++ b/web/src/components/layout/SetupCheck.js @@ -0,0 +1,18 @@ +import React, { useContext, useEffect } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { StatusContext } from '../../context/Status'; + +const SetupCheck = ({ children }) => { + const [statusState] = useContext(StatusContext); + const location = useLocation(); + + useEffect(() => { + if (statusState?.status?.setup === false && location.pathname !== '/setup') { + window.location.href = '/setup'; + } + }, [statusState?.status?.setup, location.pathname]); + + return children; +}; + +export default SetupCheck; \ No newline at end of file diff --git a/web/src/components/layout/SiderBar.js b/web/src/components/layout/SiderBar.js new file mode 100644 index 00000000..b18dad6c --- /dev/null +++ b/web/src/components/layout/SiderBar.js @@ -0,0 +1,434 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { getLucideIcon, sidebarIconColors } from '../../helpers/render.js'; +import { ChevronLeft } from 'lucide-react'; +import { useSidebarCollapsed } from '../../hooks/useSidebarCollapsed.js'; +import { + isAdmin, + isRoot, + showError +} from '../../helpers/index.js'; + +import { + Nav, + Divider, + Button, +} from '@douyinfe/semi-ui'; + +const routerMap = { + home: '/', + channel: '/console/channel', + token: '/console/token', + redemption: '/console/redemption', + topup: '/console/topup', + user: '/console/user', + log: '/console/log', + midjourney: '/console/midjourney', + setting: '/console/setting', + about: '/about', + detail: '/console', + pricing: '/pricing', + task: '/console/task', + playground: '/console/playground', + personal: '/console/personal', +}; + +const SiderBar = ({ onNavigate = () => { } }) => { + const { t } = useTranslation(); + const [collapsed, toggleCollapsed] = useSidebarCollapsed(); + + const [selectedKeys, setSelectedKeys] = useState(['home']); + const [chatItems, setChatItems] = useState([]); + const [openedKeys, setOpenedKeys] = useState([]); + const location = useLocation(); + const [routerMapState, setRouterMapState] = useState(routerMap); + + const workspaceItems = useMemo( + () => [ + { + text: t('数据看板'), + itemKey: 'detail', + to: '/detail', + className: + localStorage.getItem('enable_data_export') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('API令牌'), + itemKey: 'token', + to: '/token', + }, + { + text: t('使用日志'), + itemKey: 'log', + to: '/log', + }, + { + text: t('绘图日志'), + itemKey: 'midjourney', + to: '/midjourney', + className: + localStorage.getItem('enable_drawing') === 'true' + ? '' + : 'tableHiddle', + }, + { + text: t('任务日志'), + itemKey: 'task', + to: '/task', + className: + localStorage.getItem('enable_task') === 'true' ? '' : 'tableHiddle', + }, + ], + [ + localStorage.getItem('enable_data_export'), + localStorage.getItem('enable_drawing'), + localStorage.getItem('enable_task'), + t, + ], + ); + + const financeItems = useMemo( + () => [ + { + text: t('钱包'), + itemKey: 'topup', + to: '/topup', + }, + { + text: t('个人设置'), + itemKey: 'personal', + to: '/personal', + }, + ], + [t], + ); + + const adminItems = useMemo( + () => [ + { + text: t('渠道'), + itemKey: 'channel', + to: '/channel', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('兑换码'), + itemKey: 'redemption', + to: '/redemption', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('用户管理'), + itemKey: 'user', + to: '/user', + className: isAdmin() ? '' : 'tableHiddle', + }, + { + text: t('系统设置'), + itemKey: 'setting', + to: '/setting', + className: isRoot() ? '' : 'tableHiddle', + }, + ], + [isAdmin(), isRoot(), t], + ); + + const chatMenuItems = useMemo( + () => [ + { + text: t('操练场'), + itemKey: 'playground', + to: '/playground', + }, + { + text: t('聊天'), + itemKey: 'chat', + items: chatItems, + }, + ], + [chatItems, t], + ); + + // 更新路由映射,添加聊天路由 + const updateRouterMapWithChats = (chats) => { + const newRouterMap = { ...routerMap }; + + if (Array.isArray(chats) && chats.length > 0) { + for (let i = 0; i < chats.length; i++) { + newRouterMap['chat' + i] = '/console/chat/' + i; + } + } + + setRouterMapState(newRouterMap); + return newRouterMap; + }; + + // 加载聊天项 + useEffect(() => { + let chats = localStorage.getItem('chats'); + if (chats) { + try { + chats = JSON.parse(chats); + if (Array.isArray(chats)) { + let chatItems = []; + for (let i = 0; i < chats.length; i++) { + let chat = {}; + for (let key in chats[i]) { + chat.text = key; + chat.itemKey = 'chat' + i; + chat.to = '/console/chat/' + i; + } + chatItems.push(chat); + } + setChatItems(chatItems); + updateRouterMapWithChats(chats); + } + } catch (e) { + console.error(e); + showError('聊天数据解析失败'); + } + } + }, []); + + // 根据当前路径设置选中的菜单项 + useEffect(() => { + const currentPath = location.pathname; + let matchingKey = Object.keys(routerMapState).find( + (key) => routerMapState[key] === currentPath, + ); + + // 处理聊天路由 + if (!matchingKey && currentPath.startsWith('/console/chat/')) { + const chatIndex = currentPath.split('/').pop(); + if (!isNaN(chatIndex)) { + matchingKey = 'chat' + chatIndex; + } else { + matchingKey = 'chat'; + } + } + + // 如果找到匹配的键,更新选中的键 + if (matchingKey) { + setSelectedKeys([matchingKey]); + } + }, [location.pathname, routerMapState]); + + // 监控折叠状态变化以更新 body class + useEffect(() => { + if (collapsed) { + document.body.classList.add('sidebar-collapsed'); + } else { + document.body.classList.remove('sidebar-collapsed'); + } + }, [collapsed]); + + // 获取菜单项对应的颜色 + const getItemColor = (itemKey) => { + switch (itemKey) { + case 'detail': return sidebarIconColors.dashboard; + case 'playground': return sidebarIconColors.terminal; + case 'chat': return sidebarIconColors.message; + case 'token': return sidebarIconColors.key; + case 'log': return sidebarIconColors.chart; + case 'midjourney': return sidebarIconColors.image; + case 'task': return sidebarIconColors.check; + case 'topup': return sidebarIconColors.credit; + case 'channel': return sidebarIconColors.layers; + case 'redemption': return sidebarIconColors.gift; + case 'user': + case 'personal': return sidebarIconColors.user; + case 'setting': return sidebarIconColors.settings; + default: + // 处理聊天项 + if (itemKey && itemKey.startsWith('chat')) return sidebarIconColors.message; + return 'currentColor'; + } + }; + + // 渲染自定义菜单项 + const renderNavItem = (item) => { + // 跳过隐藏的项目 + if (item.className === 'tableHiddle') return null; + + const isSelected = selectedKeys.includes(item.itemKey); + const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit'; + + return ( + + + {item.text} + +
      + } + icon={ +
      + {getLucideIcon(item.itemKey, isSelected)} +
      + } + className={item.className} + /> + ); + }; + + // 渲染子菜单项 + const renderSubItem = (item) => { + if (item.items && item.items.length > 0) { + const isSelected = selectedKeys.includes(item.itemKey); + const textColor = isSelected ? getItemColor(item.itemKey) : 'inherit'; + + return ( + + + {item.text} + +
      + } + icon={ +
      + {getLucideIcon(item.itemKey, isSelected)} +
      + } + > + {item.items.map((subItem) => { + const isSubSelected = selectedKeys.includes(subItem.itemKey); + const subTextColor = isSubSelected ? getItemColor(subItem.itemKey) : 'inherit'; + + return ( + + {subItem.text} + + } + /> + ); + })} + + ); + } else { + return renderNavItem(item); + } + }; + + return ( +
      + + + {/* 底部折叠按钮 */} +
      + +
      +
      + ); +}; + +export default SiderBar; diff --git a/web/src/components/playground/ChatArea.js b/web/src/components/playground/ChatArea.js new file mode 100644 index 00000000..81e2df90 --- /dev/null +++ b/web/src/components/playground/ChatArea.js @@ -0,0 +1,113 @@ +import React from 'react'; +import { + Card, + Chat, + Typography, + Button, +} from '@douyinfe/semi-ui'; +import { + MessageSquare, + Eye, + EyeOff, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import CustomInputRender from './CustomInputRender'; + +const ChatArea = ({ + chatRef, + message, + inputs, + styleState, + showDebugPanel, + roleInfo, + onMessageSend, + onMessageCopy, + onMessageReset, + onMessageDelete, + onStopGenerator, + onClearMessages, + onToggleDebugPanel, + renderCustomChatContent, + renderChatBoxAction, +}) => { + const { t } = useTranslation(); + + const renderInputArea = React.useCallback((props) => { + return ; + }, []); + + return ( + + {/* 聊天头部 */} + {styleState.isMobile ? ( +
      + ) : ( +
      +
      +
      +
      + +
      +
      + + {t('AI 对话')} + + + {inputs.model || t('选择模型开始对话')} + +
      +
      +
      + +
      +
      +
      + )} + + {/* 聊天内容区域 */} +
      + null, + }} + renderInputArea={renderInputArea} + roleConfig={roleInfo} + style={{ + height: '100%', + maxWidth: '100%', + overflow: 'hidden' + }} + chats={message} + onMessageSend={onMessageSend} + onMessageCopy={onMessageCopy} + onMessageReset={onMessageReset} + onMessageDelete={onMessageDelete} + showClearContext + showStopGenerate + onStopGenerator={onStopGenerator} + onClear={onClearMessages} + className="h-full" + placeholder={t('请输入您的问题...')} + /> +
      +
      + ); +}; + +export default ChatArea; \ No newline at end of file diff --git a/web/src/components/playground/CodeViewer.js b/web/src/components/playground/CodeViewer.js new file mode 100644 index 00000000..1ce723ce --- /dev/null +++ b/web/src/components/playground/CodeViewer.js @@ -0,0 +1,313 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { Button, Tooltip, Toast } from '@douyinfe/semi-ui'; +import { Copy, ChevronDown, ChevronUp } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { copy } from '../../helpers'; + +const PERFORMANCE_CONFIG = { + MAX_DISPLAY_LENGTH: 50000, // 最大显示字符数 + PREVIEW_LENGTH: 5000, // 预览长度 + VERY_LARGE_MULTIPLIER: 2, // 超大内容倍数 +}; + +const codeThemeStyles = { + container: { + backgroundColor: '#1e1e1e', + color: '#d4d4d4', + fontFamily: 'Consolas, "Courier New", Monaco, "SF Mono", monospace', + fontSize: '13px', + lineHeight: '1.4', + borderRadius: '8px', + border: '1px solid #3c3c3c', + position: 'relative', + overflow: 'hidden', + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)', + }, + content: { + height: '100%', + overflowY: 'auto', + overflowX: 'auto', + padding: '16px', + margin: 0, + whiteSpace: 'pre', + wordBreak: 'normal', + background: '#1e1e1e', + }, + actionButton: { + position: 'absolute', + zIndex: 10, + backgroundColor: 'rgba(45, 45, 45, 0.9)', + border: '1px solid rgba(255, 255, 255, 0.1)', + color: '#d4d4d4', + borderRadius: '6px', + transition: 'all 0.2s ease', + }, + actionButtonHover: { + backgroundColor: 'rgba(60, 60, 60, 0.95)', + borderColor: 'rgba(255, 255, 255, 0.2)', + transform: 'scale(1.05)', + }, + noContent: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + height: '100%', + color: '#666', + fontSize: '14px', + fontStyle: 'italic', + backgroundColor: 'var(--semi-color-fill-0)', + borderRadius: '8px', + }, + performanceWarning: { + padding: '8px 12px', + backgroundColor: 'rgba(255, 193, 7, 0.1)', + border: '1px solid rgba(255, 193, 7, 0.3)', + borderRadius: '6px', + color: '#ffc107', + fontSize: '12px', + marginBottom: '8px', + display: 'flex', + alignItems: 'center', + gap: '8px', + }, +}; + +const highlightJson = (str) => { + return str.replace( + /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, + (match) => { + let color = '#b5cea8'; + if (/^"/.test(match)) { + color = /:$/.test(match) ? '#9cdcfe' : '#ce9178'; + } else if (/true|false|null/.test(match)) { + color = '#569cd6'; + } + return `${match}`; + } + ); +}; + +const isJsonLike = (content, language) => { + if (language === 'json') return true; + const trimmed = content.trim(); + return (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')); +}; + +const formatContent = (content) => { + if (!content) return ''; + + if (typeof content === 'object') { + try { + return JSON.stringify(content, null, 2); + } catch (e) { + return String(content); + } + } + + if (typeof content === 'string') { + try { + const parsed = JSON.parse(content); + return JSON.stringify(parsed, null, 2); + } catch (e) { + return content; + } + } + + return String(content); +}; + +const CodeViewer = ({ content, title, language = 'json' }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const [isHoveringCopy, setIsHoveringCopy] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + + const formattedContent = useMemo(() => formatContent(content), [content]); + + const contentMetrics = useMemo(() => { + const length = formattedContent.length; + const isLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH; + const isVeryLarge = length > PERFORMANCE_CONFIG.MAX_DISPLAY_LENGTH * PERFORMANCE_CONFIG.VERY_LARGE_MULTIPLIER; + return { length, isLarge, isVeryLarge }; + }, [formattedContent.length]); + + const displayContent = useMemo(() => { + if (!contentMetrics.isLarge || isExpanded) { + return formattedContent; + } + return formattedContent.substring(0, PERFORMANCE_CONFIG.PREVIEW_LENGTH) + + '\n\n// ... 内容被截断以提升性能 ...'; + }, [formattedContent, contentMetrics.isLarge, isExpanded]); + + const highlightedContent = useMemo(() => { + if (contentMetrics.isVeryLarge && !isExpanded) { + return displayContent; + } + + if (isJsonLike(displayContent, language)) { + return highlightJson(displayContent); + } + + return displayContent; + }, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]); + + const handleCopy = useCallback(async () => { + try { + const textToCopy = typeof content === 'object' && content !== null + ? JSON.stringify(content, null, 2) + : content; + + const success = await copy(textToCopy); + setCopied(true); + Toast.success(t('已复制到剪贴板')); + setTimeout(() => setCopied(false), 2000); + + if (!success) { + throw new Error('Copy operation failed'); + } + } catch (err) { + Toast.error(t('复制失败')); + console.error('Copy failed:', err); + } + }, [content, t]); + + const handleToggleExpand = useCallback(() => { + if (contentMetrics.isVeryLarge && !isExpanded) { + setIsProcessing(true); + setTimeout(() => { + setIsExpanded(true); + setIsProcessing(false); + }, 100); + } else { + setIsExpanded(!isExpanded); + } + }, [isExpanded, contentMetrics.isVeryLarge]); + + if (!content) { + const placeholderText = { + preview: t('正在构造请求体预览...'), + request: t('暂无请求数据'), + response: t('暂无响应数据') + }[title] || t('暂无数据'); + + return ( +
      + {placeholderText} +
      + ); + } + + const warningTop = contentMetrics.isLarge ? '52px' : '12px'; + const contentPadding = contentMetrics.isLarge ? '52px' : '16px'; + + return ( +
      + {/* 性能警告 */} + {contentMetrics.isLarge && ( +
      + + + {contentMetrics.isVeryLarge + ? t('内容较大,已启用性能优化模式') + : t('内容较大,部分功能可能受限')} + +
      + )} + + {/* 复制按钮 */} +
      setIsHoveringCopy(true)} + onMouseLeave={() => setIsHoveringCopy(false)} + > + +
      + + {/* 代码内容 */} +
      + {isProcessing ? ( +
      +
      + {t('正在处理大内容...')} +
      + ) : ( +
      + )} +
      + + {/* 展开/收起按钮 */} + {contentMetrics.isLarge && !isProcessing && ( +
      + + + +
      + )} +
      + ); +}; + +export default CodeViewer; \ No newline at end of file diff --git a/web/src/components/playground/ConfigManager.js b/web/src/components/playground/ConfigManager.js new file mode 100644 index 00000000..ddff8785 --- /dev/null +++ b/web/src/components/playground/ConfigManager.js @@ -0,0 +1,260 @@ +import React, { useRef } from 'react'; +import { + Button, + Typography, + Toast, + Modal, + Dropdown, +} from '@douyinfe/semi-ui'; +import { + Download, + Upload, + RotateCcw, + Settings2, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { exportConfig, importConfig, clearConfig, hasStoredConfig, getConfigTimestamp } from './configStorage'; + +const ConfigManager = ({ + currentConfig, + onConfigImport, + onConfigReset, + styleState, + messages, +}) => { + const { t } = useTranslation(); + const fileInputRef = useRef(null); + + const handleExport = () => { + try { + // 在导出前先保存当前配置,确保导出的是最新内容 + const configWithTimestamp = { + ...currentConfig, + timestamp: new Date().toISOString(), + }; + localStorage.setItem('playground_config', JSON.stringify(configWithTimestamp)); + + exportConfig(currentConfig, messages); + Toast.success({ + content: t('配置已导出到下载文件夹'), + duration: 3, + }); + } catch (error) { + Toast.error({ + content: t('导出配置失败: ') + error.message, + duration: 3, + }); + } + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (event) => { + const file = event.target.files[0]; + if (!file) return; + + try { + const importedConfig = await importConfig(file); + + Modal.confirm({ + title: t('确认导入配置'), + content: t('导入的配置将覆盖当前设置,是否继续?'), + okText: t('确定导入'), + cancelText: t('取消'), + onOk: () => { + onConfigImport(importedConfig); + Toast.success({ + content: t('配置导入成功'), + duration: 3, + }); + }, + }); + } catch (error) { + Toast.error({ + content: t('导入配置失败: ') + error.message, + duration: 3, + }); + } finally { + // 重置文件输入,允许重复选择同一文件 + event.target.value = ''; + } + }; + + const handleReset = () => { + Modal.confirm({ + title: t('重置配置'), + content: t('将清除所有保存的配置并恢复默认设置,此操作不可撤销。是否继续?'), + okText: t('确定重置'), + cancelText: t('取消'), + okButtonProps: { + type: 'danger', + }, + onOk: () => { + // 询问是否同时重置消息 + Modal.confirm({ + title: t('重置选项'), + content: t('是否同时重置对话消息?选择"是"将清空所有对话记录并恢复默认示例;选择"否"将保留当前对话记录。'), + okText: t('同时重置消息'), + cancelText: t('仅重置配置'), + okButtonProps: { + type: 'danger', + }, + onOk: () => { + clearConfig(); + onConfigReset({ resetMessages: true }); + Toast.success({ + content: t('配置和消息已全部重置'), + duration: 3, + }); + }, + onCancel: () => { + clearConfig(); + onConfigReset({ resetMessages: false }); + Toast.success({ + content: t('配置已重置,对话消息已保留'), + duration: 3, + }); + }, + }); + }, + }); + }; + + const getConfigStatus = () => { + if (hasStoredConfig()) { + const timestamp = getConfigTimestamp(); + if (timestamp) { + const date = new Date(timestamp); + return t('上次保存: ') + date.toLocaleString(); + } + return t('已有保存的配置'); + } + return t('暂无保存的配置'); + }; + + const dropdownItems = [ + { + node: 'item', + name: 'export', + onClick: handleExport, + children: ( +
      + + {t('导出配置')} +
      + ), + }, + { + node: 'item', + name: 'import', + onClick: handleImportClick, + children: ( +
      + + {t('导入配置')} +
      + ), + }, + { + node: 'divider', + }, + { + node: 'item', + name: 'reset', + onClick: handleReset, + children: ( +
      + + {t('重置配置')} +
      + ), + }, + ]; + + if (styleState.isMobile) { + // 移动端显示简化的下拉菜单 + return ( + <> + +
      + + {/* 导出和导入按钮 */} +
      + + + +
      + + +
      + ); +}; + +export default ConfigManager; \ No newline at end of file diff --git a/web/src/components/playground/CustomInputRender.js b/web/src/components/playground/CustomInputRender.js new file mode 100644 index 00000000..ff62c104 --- /dev/null +++ b/web/src/components/playground/CustomInputRender.js @@ -0,0 +1,58 @@ +import React from 'react'; + +const CustomInputRender = (props) => { + const { detailProps } = props; + const { clearContextNode, uploadNode, inputNode, sendNode, onClick } = detailProps; + + // 清空按钮 + const styledClearNode = clearContextNode + ? React.cloneElement(clearContextNode, { + className: `!rounded-full !bg-gray-100 hover:!bg-red-500 hover:!text-white flex-shrink-0 transition-all ${clearContextNode.props.className || ''}`, + style: { + ...clearContextNode.props.style, + width: '32px', + height: '32px', + minWidth: '32px', + padding: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + } + }) + : null; + + // 发送按钮 + const styledSendNode = React.cloneElement(sendNode, { + className: `!rounded-full !bg-purple-500 hover:!bg-purple-600 flex-shrink-0 transition-all ${sendNode.props.className || ''}`, + style: { + ...sendNode.props.style, + width: '32px', + height: '32px', + minWidth: '32px', + padding: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + } + }); + + return ( +
      +
      + {/* 清空对话按钮 - 左边 */} + {styledClearNode} +
      + {inputNode} +
      + {/* 发送按钮 - 右边 */} + {styledSendNode} +
      +
      + ); +}; + +export default CustomInputRender; \ No newline at end of file diff --git a/web/src/components/playground/CustomRequestEditor.js b/web/src/components/playground/CustomRequestEditor.js new file mode 100644 index 00000000..9b11b4f4 --- /dev/null +++ b/web/src/components/playground/CustomRequestEditor.js @@ -0,0 +1,190 @@ +import React, { useState, useEffect } from 'react'; +import { + TextArea, + Typography, + Button, + Switch, + Banner, +} from '@douyinfe/semi-ui'; +import { + Code, + Edit, + Check, + X, + AlertTriangle, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +const CustomRequestEditor = ({ + customRequestMode, + customRequestBody, + onCustomRequestModeChange, + onCustomRequestBodyChange, + defaultPayload, +}) => { + const { t } = useTranslation(); + const [isValid, setIsValid] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [localValue, setLocalValue] = useState(customRequestBody || ''); + + // 当切换到自定义模式时,用默认payload初始化 + useEffect(() => { + if (customRequestMode && (!customRequestBody || customRequestBody.trim() === '')) { + const defaultJson = defaultPayload ? JSON.stringify(defaultPayload, null, 2) : ''; + setLocalValue(defaultJson); + onCustomRequestBodyChange(defaultJson); + } + }, [customRequestMode, defaultPayload, customRequestBody, onCustomRequestBodyChange]); + + // 同步外部传入的customRequestBody到本地状态 + useEffect(() => { + if (customRequestBody !== localValue) { + setLocalValue(customRequestBody || ''); + validateJson(customRequestBody || ''); + } + }, [customRequestBody]); + + // 验证JSON格式 + const validateJson = (value) => { + if (!value.trim()) { + setIsValid(true); + setErrorMessage(''); + return true; + } + + try { + JSON.parse(value); + setIsValid(true); + setErrorMessage(''); + return true; + } catch (error) { + setIsValid(false); + setErrorMessage(`JSON格式错误: ${error.message}`); + return false; + } + }; + + const handleValueChange = (value) => { + setLocalValue(value); + validateJson(value); + // 始终保存用户输入,让预览逻辑处理JSON解析错误 + onCustomRequestBodyChange(value); + }; + + const handleModeToggle = (enabled) => { + onCustomRequestModeChange(enabled); + if (enabled && defaultPayload) { + const defaultJson = JSON.stringify(defaultPayload, null, 2); + setLocalValue(defaultJson); + onCustomRequestBodyChange(defaultJson); + } + }; + + const formatJson = () => { + try { + const parsed = JSON.parse(localValue); + const formatted = JSON.stringify(parsed, null, 2); + setLocalValue(formatted); + onCustomRequestBodyChange(formatted); + setIsValid(true); + setErrorMessage(''); + } catch (error) { + // 如果格式化失败,保持原样 + } + }; + + return ( +
      + {/* 自定义模式开关 */} +
      +
      + + + 自定义请求体模式 + +
      + +
      + + {customRequestMode && ( + <> + {/* 提示信息 */} + } + className="!rounded-lg" + closable={false} + /> + + {/* JSON编辑器 */} +
      +
      + + 请求体 JSON + +
      + {isValid ? ( +
      + + + 格式正确 + +
      + ) : ( +
      + + + 格式错误 + +
      + )} + +
      +
      + +