diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..c6dfb9274e99ce46d2ba5947ff4ec64c237317cd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.github +.git +*.md +.vscode +.gitignore +Makefile +docs +.eslintcache \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..4332abc07e287e2a71cff95118e0eb274c89f130 --- /dev/null +++ b/.env.example @@ -0,0 +1,73 @@ +# 端口号 +# 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=300 + +# Gemini 识别图片 最大图片数量 +# GEMINI_VISION_MAX_IMAGE_NUM=16 + +# 会话密钥 +# SESSION_SECRET=random_string + +# 其他配置 +# 生成默认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/.gitattributes b/.gitattributes index 6683a6fcb99f4eeb42da831013f9ad503725e5f5..0e3756c96fc10993ec2cfe7be4ff612f836dfc86 100644 --- a/.gitattributes +++ b/.gitattributes @@ -35,3 +35,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text new-api/web/public/azure_model_name.png filter=lfs diff=lfs merge=lfs -text new-api/web/public/ratio.png filter=lfs diff=lfs merge=lfs -text +web/public/azure_model_name.png filter=lfs diff=lfs merge=lfs -text +web/public/ratio.png filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..7fa0dcad62e2a51a1b897979f026a1c5612de4f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.idea +.vscode +upload +*.exe +*.db +build +*.db-journal +logs +web/dist +.env +one-api +.DS_Store +tiktoken_cache +.eslintcache \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..c1d0ba7947a6b343b6222e33c98b11f2bd2d1123 --- /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 0000000000000000000000000000000000000000..43854ba4bfc206d66e5f3c61e698b94a1d7e4805 --- /dev/null +++ b/LICENSE @@ -0,0 +1,103 @@ +# **New API 许可协议 (Licensing)** + +本项目采用**基于使用场景的双重许可 (Usage-Based Dual Licensing)** 模式。 + +**核心原则:** + +- **默认许可:** 本项目默认在 **GNU Affero 通用公共许可证 v3.0 (AGPLv3)** 下提供。任何用户在遵守 AGPLv3 条款和下述附加限制的前提下,均可免费使用。 +- **商业许可:** 在特定商业场景下,或当您希望获得 AGPLv3 之外的权利时,**必须**获取**商业许可证 (Commercial License)**。 + +--- + +## **1. 开源许可证 (Open Source License): AGPLv3 - 适用于基础使用** + +- 在遵守 **AGPLv3** 条款的前提下,您可以自由地使用、修改和分发 New API。AGPLv3 的完整文本可以访问 [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html) 获取。 +- **核心义务:** AGPLv3 的一个关键要求是,如果您修改了 New API 并通过网络提供服务 (SaaS),或者分发了修改后的版本,您必须以 AGPLv3 许可证向所有用户提供相应的**完整源代码**。 +- **附加限制 (重要):** 在仅使用 AGPLv3 开源许可证的情况下,您**必须**完整保留项目代码中原有的品牌标识、LOGO 及版权声明信息。**禁止以任何形式修改、移除或遮盖**这些信息。如需移除,必须获取商业许可证。 +- 使用前请务必仔细阅读并理解 AGPLv3 的所有条款及上述附加限制。 + +## **2. 商业许可证 (Commercial License) - 适用于高级场景及闭源需求** + +在以下任一情况下,您**必须**联系我们获取并签署一份商业许可证,才能合法使用 New API: + +- **场景一:移除品牌和版权信息** + 您希望在您的产品或服务中移除 New API 的 LOGO、UI界面中的版权声明或其他品牌标识。 + +- **场景二:规避 AGPLv3 开源义务** + 您基于 New API 进行了修改,并希望: + - 通过网络提供服务(SaaS),但**不希望**向您的服务用户公开您修改后的源代码。 + - 分发一个集成了 New API 的软件产品,但**不希望**以 AGPLv3 许可证发布您的产品或公开源代码。 + +- **场景三:企业政策与集成需求** + - 您所在公司的政策、客户合同或项目要求不允许使用 AGPLv3 许可的软件。 + - 您需要进行 OEM 集成,将 New API 作为您闭源商业产品的一部分进行再分发。 + +- **场景四:需要商业支持与保障** + 您需要 AGPLv3 未提供的商业保障,如官方技术支持等。 + +**获取商业许可:** +请通过电子邮件 **support@quantumnous.com** 联系 New API 团队洽谈商业授权事宜。 + +## **3. 贡献 (Contributions)** + +- 我们欢迎社区对 New API 的贡献。所有向本项目提交的贡献(例如通过 Pull Request)都将被视为在 **AGPLv3** 许可证下提供。 +- 通过向本项目提交贡献,即表示您同意您的代码以 AGPLv3 许可证授权给本项目及所有后续使用者(无论这些使用者最终遵循 AGPLv3 还是商业许可)。 +- 您也理解并同意,您的贡献可能会被包含在根据商业许可证分发的 New API 版本中。 + +## **4. 其他条款 (Other Terms)** + +- 关于商业许可证的具体条款、条件和价格,以双方签署的正式商业许可协议为准。 +- 项目维护者保留根据需要更新本许可政策的权利。相关更新将通过项目官方渠道(如代码仓库、官方网站)进行通知。 + +--- + +# **New API Licensing** + +This project uses a **Usage-Based Dual Licensing** model. + +**Core Principles:** + +- **Default License:** This project is available by default under the **GNU Affero General Public License v3.0 (AGPLv3)**. Any user may use it free of charge, provided they comply with both the AGPLv3 terms and the additional restrictions listed below. +- **Commercial License:** For specific commercial scenarios, or if you require rights beyond those granted by AGPLv3, you **must** obtain a **Commercial License**. + +--- + +## **1. Open Source License: AGPLv3 – For Basic Usage** + +- Under the terms of the **AGPLv3**, you are free to use, modify, and distribute New API. The complete AGPLv3 license text can be viewed at [https://www.gnu.org/licenses/agpl-3.0.html](https://www.gnu.org/licenses/agpl-3.0.html). +- **Core Obligation:** A key AGPLv3 requirement is that if you modify New API and provide it as a network service (SaaS), or distribute a modified version, you must make the **complete corresponding source code** available to all users under the AGPLv3 license. +- **Additional Restriction (Important):** When using only the AGPLv3 open-source license, you **must** retain all original branding, logos, and copyright statements within the project’s code. **You are strictly prohibited from modifying, removing, or concealing** any such information. If you wish to remove this, you must obtain a Commercial License. +- Please read and ensure that you fully understand all AGPLv3 terms and the above additional restriction before use. + +## **2. Commercial License – For Advanced Scenarios & Closed Source Needs** + +You **must** contact us to obtain and sign a Commercial License in any of the following scenarios in order to legally use New API: + +- **Scenario 1: Removal of Branding and Copyright** + You wish to remove the New API logo, copyright statement, or other branding elements from your product or service. + +- **Scenario 2: Avoidance of AGPLv3 Open Source Obligations** + You have modified New API and wish to: + - Offer it as a network service (SaaS) **without** disclosing your modifications' source code to your users. + - Distribute a software product integrated with New API **without** releasing your product under AGPLv3 or open-sourcing the code. + +- **Scenario 3: Enterprise Policy & Integration Needs** + - Your organization’s policies, client contracts, or project requirements prohibit the use of AGPLv3-licensed software. + - You require OEM integration and need to redistribute New API as part of your closed-source commercial product. + +- **Scenario 4: Commercial Support and Assurances** + You require commercial assurances not provided by AGPLv3, such as official technical support. + +**Obtaining a Commercial License:** +Please contact the New API team via email at **support@quantumnous.com** to discuss commercial licensing. + +## **3. Contributions** + +- We welcome community contributions to New API. All contributions (e.g., via Pull Request) are deemed to be provided under the **AGPLv3** license. +- By submitting a contribution, you agree that your code is licensed to this project and all downstream users under the AGPLv3 license (regardless of whether those users ultimately operate under AGPLv3 or a Commercial License). +- You also acknowledge and agree that your contribution may be included in New API releases distributed under a Commercial License. + +## **4. Other Terms** + +- The specific terms, conditions, and pricing of the Commercial License are governed by the formal commercial license agreement executed by both parties. +- Project maintainers reserve the right to update this licensing policy as needed. Updates will be communicated via official project channels (e.g., repository, official website). diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000000000000000000000000000000000000..bc19966b688ecd1571a5a4f3b9b38b92efee51fa --- /dev/null +++ b/README.en.md @@ -0,0 +1,216 @@ +

+ 中文 | English | Français +

+
+ +![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/README.fr.md b/README.fr.md new file mode 100644 index 0000000000000000000000000000000000000000..9b800003c4a216c1700fc2acb9216039be881462 --- /dev/null +++ b/README.fr.md @@ -0,0 +1,216 @@ +

+ 中文 | English | Français +

+
+ +![new-api](/web/public/logo.png) + +# New API + +🍥 Passerelle de modèles étendus de nouvelle génération et système de gestion d'actifs d'IA + +Calcium-Ion%2Fnew-api | Trendshift + +

+ + licence + + + version + + + docker + + + docker + + + GoReportCard + +

+
+ +## 📝 Description du projet + +> [!NOTE] +> Il s'agit d'un projet open-source développé sur la base de [One API](https://github.com/songquanpeng/one-api) + +> [!IMPORTANT] +> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique. +> - Les utilisateurs doivent se conformer aux [Conditions d'utilisation](https://openai.com/policies/terms-of-use) d'OpenAI et aux **lois et réglementations applicables**, et ne doivent pas l'utiliser à des fins illégales. +> - Conformément aux [《Mesures provisoires pour la gestion des services d'intelligence artificielle générative》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), veuillez ne fournir aucun service d'IA générative non enregistré au public en Chine. + +

🤝 Partenaires de confiance

+

 

+

Sans ordre particulier

+

+ Cherry Studio + Université de Pékin + UCloud + Alibaba Cloud + IO.NET +

+

 

+ +## 📚 Documentation + +Pour une documentation détaillée, veuillez consulter notre Wiki officiel : [https://docs.newapi.pro/](https://docs.newapi.pro/) + +Vous pouvez également accéder au DeepWiki généré par l'IA : +[![Demander à DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + +## ✨ Fonctionnalités clés + +New API offre un large éventail de fonctionnalités, veuillez vous référer à [Présentation des fonctionnalités](https://docs.newapi.pro/wiki/features-introduction) pour plus de détails : + +1. 🎨 Nouvelle interface utilisateur +2. 🌍 Prise en charge multilingue +3. 💰 Fonctionnalité de recharge en ligne (YiPay) +4. 🔍 Prise en charge de la recherche de quotas d'utilisation avec des clés (fonctionne avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) +5. 🔄 Compatible avec la base de données originale de One API +6. 💵 Prise en charge de la tarification des modèles de paiement à l'utilisation +7. ⚖️ Prise en charge de la sélection aléatoire pondérée des canaux +8. 📈 Tableau de bord des données (console) +9. 🔒 Regroupement de jetons et restrictions de modèles +10. 🤖 Prise en charge de plus de méthodes de connexion par autorisation (LinuxDO, Telegram, OIDC) +11. 🔄 Prise en charge des modèles Rerank (Cohere et Jina), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank) +12. ⚡ Prise en charge de l'API OpenAI Realtime (y compris les canaux Azure), [Documentation de l'API](https://docs.newapi.pro/api/openai-realtime) +13. ⚡ Prise en charge du format Claude Messages, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat) +14. Prise en charge de l'accès à l'interface de discussion via la route /chat2link +15. 🧠 Prise en charge de la définition de l'effort de raisonnement via les suffixes de nom de modèle : + 1. Modèles de la série o d'OpenAI + - Ajouter le suffixe `-high` pour un effort de raisonnement élevé (par exemple : `o3-mini-high`) + - Ajouter le suffixe `-medium` pour un effort de raisonnement moyen (par exemple : `o3-mini-medium`) + - Ajouter le suffixe `-low` pour un effort de raisonnement faible (par exemple : `o3-mini-low`) + 2. Modèles de pensée de Claude + - Ajouter le suffixe `-thinking` pour activer le mode de pensée (par exemple : `claude-3-7-sonnet-20250219-thinking`) +16. 🔄 Fonctionnalité de la pensée au contenu +17. 🔄 Limitation du débit du modèle pour les utilisateurs +18. 💰 Prise en charge de la facturation du cache, qui permet de facturer à un ratio défini lorsque le cache est atteint : + 1. Définir l'option `Ratio de cache d'invite` dans `Paramètres système->Paramètres de fonctionnement` + 2. Définir le `Ratio de cache d'invite` dans le canal, plage de 0 à 1, par exemple, le définir sur 0,5 signifie facturer à 50 % lorsque le cache est atteint + 3. Canaux pris en charge : + - [x] OpenAI + - [x] Azure + - [x] DeepSeek + - [x] Claude + +## Prise en charge des modèles + +Cette version prend en charge plusieurs modèles, veuillez vous référer à [Documentation de l'API-Interface de relais](https://docs.newapi.pro/api) pour plus de détails : + +1. Modèles tiers **gpts** (gpt-4-gizmo-*) +2. Canal tiers [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy), [Documentation de l'API](https://docs.newapi.pro/api/midjourney-proxy-image) +3. Canal tiers [Suno API](https://github.com/Suno-API/Suno-API), [Documentation de l'API](https://docs.newapi.pro/api/suno-music) +4. Canaux personnalisés, prenant en charge la saisie complète de l'adresse d'appel +5. Modèles Rerank ([Cohere](https://cohere.ai/) et [Jina](https://jina.ai/)), [Documentation de l'API](https://docs.newapi.pro/api/jinaai-rerank) +6. Format de messages Claude, [Documentation de l'API](https://docs.newapi.pro/api/anthropic-chat) +7. Dify, ne prend actuellement en charge que chatflow + +## Configuration des variables d'environnement + +Pour des instructions de configuration détaillées, veuillez vous référer à [Guide d'installation-Configuration des variables d'environnement](https://docs.newapi.pro/installation/environment-variables) : + +- `GENERATE_DEFAULT_TOKEN` : S'il faut générer des jetons initiaux pour les utilisateurs nouvellement enregistrés, la valeur par défaut est `false` +- `STREAMING_TIMEOUT` : Délai d'expiration de la réponse en streaming, la valeur par défaut est de 300 secondes +- `DIFY_DEBUG` : S'il faut afficher les informations sur le flux de travail et les nœuds pour les canaux Dify, la valeur par défaut est `true` +- `FORCE_STREAM_OPTION` : S'il faut remplacer le paramètre client stream_options, la valeur par défaut est `true` +- `GET_MEDIA_TOKEN` : S'il faut compter les jetons d'image, la valeur par défaut est `true` +- `GET_MEDIA_TOKEN_NOT_STREAM` : S'il faut compter les jetons d'image dans les cas sans streaming, la valeur par défaut est `true` +- `UPDATE_TASK` : S'il faut mettre à jour les tâches asynchrones (Midjourney, Suno), la valeur par défaut est `true` +- `COHERE_SAFETY_SETTING` : Paramètres de sécurité du modèle Cohere, les options sont `NONE`, `CONTEXTUAL`, `STRICT`, la valeur par défaut est `NONE` +- `GEMINI_VISION_MAX_IMAGE_NUM` : Nombre maximum d'images pour les modèles Gemini, la valeur par défaut est `16` +- `MAX_FILE_DOWNLOAD_MB` : Taille maximale de téléchargement de fichier en Mo, la valeur par défaut est `20` +- `CRYPTO_SECRET` : Clé de chiffrement utilisée pour chiffrer le contenu de la base de données +- `AZURE_DEFAULT_API_VERSION` : Version de l'API par défaut du canal Azure, la valeur par défaut est `2025-04-01-preview` +- `NOTIFICATION_LIMIT_DURATION_MINUTE` : Durée de la limite de notification, la valeur par défaut est de `10` minutes +- `NOTIFY_LIMIT_COUNT` : Nombre maximal de notifications utilisateur dans la durée spécifiée, la valeur par défaut est `2` +- `ERROR_LOG_ENABLED=true` : S'il faut enregistrer et afficher les journaux d'erreurs, la valeur par défaut est `false` + +## Déploiement + +Pour des guides de déploiement détaillés, veuillez vous référer à [Guide d'installation-Méthodes de déploiement](https://docs.newapi.pro/installation) : + +> [!TIP] +> Dernière image Docker : `calciumion/new-api:latest` + +### Considérations sur le déploiement multi-machines +- La variable d'environnement `SESSION_SECRET` doit être définie, sinon l'état de connexion sera incohérent sur plusieurs machines +- Si vous partagez Redis, `CRYPTO_SECRET` doit être défini, sinon le contenu de Redis ne pourra pas être consulté sur plusieurs machines + +### Exigences de déploiement +- Base de données locale (par défaut) : SQLite (le déploiement Docker doit monter le répertoire `/data`) +- Base de données distante : MySQL version >= 5.7.8, PgSQL version >= 9.6 + +### Méthodes de déploiement + +#### Utilisation de la fonctionnalité Docker du panneau BaoTa +Installez le panneau BaoTa (version **9.2.0** ou supérieure), recherchez **New-API** dans le magasin d'applications et installez-le. +[Tutoriel avec des images](./docs/BT.md) + +#### Utilisation de Docker Compose (recommandé) +```shell +# Télécharger le projet +git clone https://github.com/Calcium-Ion/new-api.git +cd new-api +# Modifier docker-compose.yml si nécessaire +# Démarrer +docker-compose up -d +``` + +#### Utilisation directe de l'image Docker +```shell +# Utilisation de 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 + +# Utilisation de 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 +``` + +## Nouvelle tentative de canal et cache +La fonctionnalité de nouvelle tentative de canal a été implémentée, vous pouvez définir le nombre de tentatives dans `Paramètres->Paramètres de fonctionnement->Paramètres généraux`. Il est **recommandé d'activer la mise en cache**. + +### Méthode de configuration du cache +1. `REDIS_CONN_STRING` : Définir Redis comme cache +2. `MEMORY_CACHE_ENABLED` : Activer le cache mémoire (pas besoin de le définir manuellement si Redis est défini) + +## Documentation de l'API + +Pour une documentation détaillée de l'API, veuillez vous référer à [Documentation de l'API](https://docs.newapi.pro/api) : + +- [API de discussion](https://docs.newapi.pro/api/openai-chat) +- [API d'image](https://docs.newapi.pro/api/openai-image) +- [API de rerank](https://docs.newapi.pro/api/jinaai-rerank) +- [API en temps réel](https://docs.newapi.pro/api/openai-realtime) +- [API de discussion Claude (messages)](https://docs.newapi.pro/api/anthropic-chat) + +## Projets connexes +- [One API](https://github.com/songquanpeng/one-api) : Projet original +- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy) : Prise en charge de l'interface Midjourney +- [chatnio](https://github.com/Deeptrain-Community/chatnio) : Solution B/C unique d'IA de nouvelle génération +- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) : Interroger le quota d'utilisation avec une clé + +Autres projets basés sur New API : +- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) : Version optimisée hautes performances de New API +- [VoAPI](https://github.com/VoAPI/VoAPI) : Version embellie du frontend basée sur New API + +## Aide et support + +Si vous avez des questions, veuillez vous référer à [Aide et support](https://docs.newapi.pro/support) : +- [Interaction avec la communauté](https://docs.newapi.pro/support/community-interaction) +- [Commentaires sur les problèmes](https://docs.newapi.pro/support/feedback-issues) +- [FAQ](https://docs.newapi.pro/support/faq) + +## 🌟 Historique des étoiles + +[![Graphique de l'historique des étoiles](https://api.star-history.com/svg?repos=Calcium-Ion/new-api&type=Date)](https://star-history.com/#Calcium-Ion/new-api&Date) \ No newline at end of file diff --git a/README.md b/README.md index 26ce4e378bae7ba9094f4c119d1c59f99692f6aa..8198d5da32ebd36a72fc41c8d771cab11bd6f32c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,219 @@ ---- -title: Api -emoji: 🚀 -colorFrom: indigo -colorTo: blue -sdk: docker -pinned: false -license: apache-2.0 ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +

+ 中文 | English | Français +

+
+ +![new-api](/web/public/logo.png) + +# New API + +🍥新一代大模型网关与AI资产管理系统 + +Calcium-Ion%2Fnew-api | Trendshift + +

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

+
+ +## 📝 项目说明 + +> [!NOTE] +> 本项目为开源项目,在[One API](https://github.com/songquanpeng/one-api)的基础上进行二次开发 + +> [!IMPORTANT] +> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持。 +> - 使用者必须在遵循 OpenAI 的[使用条款](https://openai.com/policies/terms-of-use)以及**法律法规**的情况下使用,不得用于非法用途。 +> - 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。 + +

🤝 我们信任的合作伙伴

+

 

+

排名不分先后

+

+ Cherry Studio + 北京大学 + UCloud 优刻得 + 阿里云 + IO.NET +

+

 

+ +## 📚 文档 + +详细文档请访问我们的官方Wiki:[https://docs.newapi.pro/](https://docs.newapi.pro/) + +也可访问AI生成的DeepWiki: +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) + +## ✨ 主要特性 + +New API提供了丰富的功能,详细特性请参考[特性说明](https://docs.newapi.pro/wiki/features-introduction): + +1. 🎨 全新的UI界面 +2. 🌍 多语言支持 +3. 💰 支持在线充值功能(易支付) +4. 🔍 支持用key查询使用额度(配合[neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)) +5. 🔄 兼容原版One API的数据库 +6. 💵 支持模型按次数收费 +7. ⚖️ 支持渠道加权随机 +8. 📈 数据看板(控制台) +9. 🔒 令牌分组、模型限制 +10. 🤖 支持更多授权登陆方式(LinuxDO,Telegram、OIDC) +11. 🔄 支持Rerank模型(Cohere和Jina),[接口文档](https://docs.newapi.pro/api/jinaai-rerank) +12. ⚡ 支持OpenAI Realtime API(包括Azure渠道),[接口文档](https://docs.newapi.pro/api/openai-realtime) +13. ⚡ 支持Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat) +14. 支持使用路由/chat2link进入聊天界面 +15. 🧠 支持通过模型名称后缀设置 reasoning effort: + 1. OpenAI o系列模型 + - 添加后缀 `-high` 设置为 high reasoning effort (例如: `o3-mini-high`) + - 添加后缀 `-medium` 设置为 medium reasoning effort (例如: `o3-mini-medium`) + - 添加后缀 `-low` 设置为 low reasoning effort (例如: `o3-mini-low`) + 2. Claude 思考模型 + - 添加后缀 `-thinking` 启用思考模式 (例如: `claude-3-7-sonnet-20250219-thinking`) +16. 🔄 思考转内容功能 +17. 🔄 针对用户的模型限流功能 +18. 🔄 请求格式转换功能,支持以下三种格式转换: + 1. OpenAI Chat Completions => Claude Messages + 2. Clade Messages => OpenAI Chat Completions (可用于Claude Code调用第三方模型) + 3. OpenAI Chat Completions => Gemini Chat +19. 💰 缓存计费支持,开启后可以在缓存命中时按照设定的比例计费: + 1. 在 `系统设置-运营设置` 中设置 `提示缓存倍率` 选项 + 2. 在渠道中设置 `提示缓存倍率`,范围 0-1,例如设置为 0.5 表示缓存命中时按照 50% 计费 + 3. 支持的渠道: + - [x] OpenAI + - [x] Azure + - [x] DeepSeek + - [x] Claude + +## 模型支持 + +此版本支持多种模型,详情请参考[接口文档-中继接口](https://docs.newapi.pro/api): + +1. 第三方模型 **gpts** (gpt-4-gizmo-*) +2. 第三方渠道[Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy)接口,[接口文档](https://docs.newapi.pro/api/midjourney-proxy-image) +3. 第三方渠道[Suno API](https://github.com/Suno-API/Suno-API)接口,[接口文档](https://docs.newapi.pro/api/suno-music) +4. 自定义渠道,支持填入完整调用地址 +5. Rerank模型([Cohere](https://cohere.ai/)和[Jina](https://jina.ai/)),[接口文档](https://docs.newapi.pro/api/jinaai-rerank) +6. Claude Messages 格式,[接口文档](https://docs.newapi.pro/api/anthropic-chat) +7. Dify,当前仅支持chatflow + +## 环境变量配置 + +详细配置说明请参考[安装指南-环境变量配置](https://docs.newapi.pro/installation/environment-variables): + +- `GENERATE_DEFAULT_TOKEN`:是否为新注册用户生成初始令牌,默认为 `false` +- `STREAMING_TIMEOUT`:流式回复超时时间,默认300秒 +- `DIFY_DEBUG`:Dify渠道是否输出工作流和节点信息,默认 `true` +- `FORCE_STREAM_OPTION`:是否覆盖客户端stream_options参数,默认 `true` +- `GET_MEDIA_TOKEN`:是否统计图片token,默认 `true` +- `GET_MEDIA_TOKEN_NOT_STREAM`:非流情况下是否统计图片token,默认 `true` +- `UPDATE_TASK`:是否更新异步任务(Midjourney、Suno),默认 `true` +- `COHERE_SAFETY_SETTING`:Cohere模型安全设置,可选值为 `NONE`, `CONTEXTUAL`, `STRICT`,默认 `NONE` +- `GEMINI_VISION_MAX_IMAGE_NUM`:Gemini模型最大图片数量,默认 `16` +- `MAX_FILE_DOWNLOAD_MB`: 最大文件下载大小,单位MB,默认 `20` +- `CRYPTO_SECRET`:加密密钥,用于加密数据库内容 +- `AZURE_DEFAULT_API_VERSION`:Azure渠道默认API版本,默认 `2025-04-01-preview` +- `NOTIFICATION_LIMIT_DURATION_MINUTE`:通知限制持续时间,默认 `10`分钟 +- `NOTIFY_LIMIT_COUNT`:用户通知在指定持续时间内的最大数量,默认 `2` +- `ERROR_LOG_ENABLED=true`: 是否记录并显示错误日志,默认`false` + +## 部署 + +详细部署指南请参考[安装指南-部署方式](https://docs.newapi.pro/installation): + +> [!TIP] +> 最新版Docker镜像:`calciumion/new-api:latest` + +### 多机部署注意事项 +- 必须设置环境变量 `SESSION_SECRET`,否则会导致多机部署时登录状态不一致 +- 如果公用Redis,必须设置 `CRYPTO_SECRET`,否则会导致多机部署时Redis内容无法获取 + +### 部署要求 +- 本地数据库(默认):SQLite(Docker部署必须挂载`/data`目录) +- 远程数据库:MySQL版本 >= 5.7.8,PgSQL版本 >= 9.6 + +### 部署方式 + +#### 使用宝塔面板Docker功能部署 +安装宝塔面板(**9.2.0版本**及以上),在应用商店中找到**New-API**安装即可。 +[图文教程](./docs/BT.md) + +#### 使用Docker Compose部署(推荐) +```shell +# 下载项目 +git clone https://github.com/Calcium-Ion/new-api.git +cd new-api +# 按需编辑docker-compose.yml +# 启动 +docker-compose up -d +``` + +#### 直接使用Docker镜像 +```shell +# 使用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 + +# 使用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 +``` + +## 渠道重试与缓存 +渠道重试功能已经实现,可以在`设置->运营设置->通用设置`设置重试次数,**建议开启缓存**功能。 + +### 缓存设置方法 +1. `REDIS_CONN_STRING`:设置Redis作为缓存 +2. `MEMORY_CACHE_ENABLED`:启用内存缓存(设置了Redis则无需手动设置) + +## 接口文档 + +详细接口文档请参考[接口文档](https://docs.newapi.pro/api): + +- [聊天接口(Chat)](https://docs.newapi.pro/api/openai-chat) +- [图像接口(Image)](https://docs.newapi.pro/api/openai-image) +- [重排序接口(Rerank)](https://docs.newapi.pro/api/jinaai-rerank) +- [实时对话接口(Realtime)](https://docs.newapi.pro/api/openai-realtime) +- [Claude聊天接口(messages)](https://docs.newapi.pro/api/anthropic-chat) + +## 相关项目 +- [One API](https://github.com/songquanpeng/one-api):原版项目 +- [Midjourney-Proxy](https://github.com/novicezk/midjourney-proxy):Midjourney接口支持 +- [chatnio](https://github.com/Deeptrain-Community/chatnio):下一代AI一站式B/C端解决方案 +- [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool):用key查询使用额度 + +其他基于New API的项目: +- [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon):New API高性能优化版 + +## 帮助支持 + +如有问题,请参考[帮助支持](https://docs.newapi.pro/support): +- [社区交流](https://docs.newapi.pro/support/community-interaction) +- [反馈问题](https://docs.newapi.pro/support/feedback-issues) +- [常见问题](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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/bin/migration_v0.2-v0.3.sql b/bin/migration_v0.2-v0.3.sql new file mode 100644 index 0000000000000000000000000000000000000000..5b18e72b4b878733c6af53a37683c8f898719564 --- /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 0000000000000000000000000000000000000000..3c9893410c9fffcd381373539e589dbfc32f1158 --- /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 0000000000000000000000000000000000000000..af5d23bce8db372f00bae4c450c260fed9506ae8 --- /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 0000000000000000000000000000000000000000..89ea5d76c578eb80676f4e29069e0c5dea4d7466 --- /dev/null +++ b/common/api_type.go @@ -0,0 +1,77 @@ +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 + case constant.ChannelTypeMoonshot: + apiType = constant.APITypeMoonshot + case constant.ChannelTypeSubmodel: + apiType = constant.APITypeSubmodel + } + 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 0000000000000000000000000000000000000000..7d45cb891b02309809b0775f440ea3c130145de7 --- /dev/null +++ b/common/constants.go @@ -0,0 +1,202 @@ +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 LinuxDOMinimumTrustLevel = 0 + +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/copy.go b/common/copy.go new file mode 100644 index 0000000000000000000000000000000000000000..a7bfa760dfcd4cd37942059bbc577524c02f0d10 --- /dev/null +++ b/common/copy.go @@ -0,0 +1,19 @@ +package common + +import ( + "fmt" + + "github.com/jinzhu/copier" +) + +func DeepCopy[T any](src *T) (*T, error) { + if src == nil { + return nil, fmt.Errorf("copy source cannot be nil") + } + var dst T + err := copier.CopyWithOption(&dst, src, copier.Option{DeepCopy: true, IgnoreEmpty: true}) + if err != nil { + return nil, err + } + return &dst, nil +} diff --git a/common/crypto.go b/common/crypto.go new file mode 100644 index 0000000000000000000000000000000000000000..a4f3028a598e7e135f3afc685a58d6a370b0210b --- /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 0000000000000000000000000000000000000000..976e29969f3c735ea4988f4338145f467cf9b109 --- /dev/null +++ b/common/custom-event.go @@ -0,0 +1,87 @@ +// 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" + "sync" +) + +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{} + + Mutex sync.Mutex +} + +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) { + r.Mutex.Lock() + defer r.Mutex.Unlock() + 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 0000000000000000000000000000000000000000..2fad5a2ca03bc4bb69eb05a98983ad6bfdde50fd --- /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=30000" \ No newline at end of file diff --git a/common/email-outlook-auth.go b/common/email-outlook-auth.go new file mode 100644 index 0000000000000000000000000000000000000000..070da21105a791f7ef470c50dc689039908218fa --- /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 0000000000000000000000000000000000000000..1aab000c422bdc16e85641e48e306a5e4319279c --- /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 0000000000000000000000000000000000000000..bf2247e1dcc6b28176659c0aea0d3a88c74c6039 --- /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_defaults.go b/common/endpoint_defaults.go new file mode 100644 index 0000000000000000000000000000000000000000..8915d23a129f53bdf0295eb1cbbd5fcd63f1b54d --- /dev/null +++ b/common/endpoint_defaults.go @@ -0,0 +1,33 @@ +package common + +import "one-api/constant" + +// EndpointInfo 描述单个端点的默认请求信息 +// path: 上游路径 +// method: HTTP 请求方式,例如 POST/GET +// 目前均为 POST,后续可扩展 +// +// json 标签用于直接序列化到 API 输出 +// 例如:{"path":"/v1/chat/completions","method":"POST"} + +type EndpointInfo struct { + Path string `json:"path"` + Method string `json:"method"` +} + +// defaultEndpointInfoMap 保存内置端点的默认 Path 与 Method +var defaultEndpointInfoMap = map[constant.EndpointType]EndpointInfo{ + constant.EndpointTypeOpenAI: {Path: "/v1/chat/completions", Method: "POST"}, + constant.EndpointTypeOpenAIResponse: {Path: "/v1/responses", Method: "POST"}, + constant.EndpointTypeAnthropic: {Path: "/v1/messages", Method: "POST"}, + constant.EndpointTypeGemini: {Path: "/v1beta/models/{model}:generateContent", Method: "POST"}, + constant.EndpointTypeJinaRerank: {Path: "/rerank", Method: "POST"}, + constant.EndpointTypeImageGeneration: {Path: "/v1/images/generations", Method: "POST"}, + constant.EndpointTypeEmbeddings: {Path: "/v1/embeddings", Method: "POST"}, +} + +// GetDefaultEndpointInfo 返回指定端点类型的默认信息以及是否存在 +func GetDefaultEndpointInfo(et constant.EndpointType) (EndpointInfo, bool) { + info, ok := defaultEndpointInfoMap[et] + return info, ok +} diff --git a/common/endpoint_type.go b/common/endpoint_type.go new file mode 100644 index 0000000000000000000000000000000000000000..d473ac7b569e22f1e48c3cd8306778b7aa3063d8 --- /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 0000000000000000000000000000000000000000..a22344568f2368668d88affc39c287117c5fa264 --- /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 0000000000000000000000000000000000000000..8b74c51f3153649c856e26df5796955a791b134d --- /dev/null +++ b/common/gin.go @@ -0,0 +1,115 @@ +package common + +import ( + "bytes" + "io" + "net/http" + "one-api/constant" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +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 + } + //if DebugEnabled { + // println("UnmarshalBodyReusable request body:", string(requestBody)) + //} + 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 0000000000000000000000000000000000000000..65b5537cf5d5e3edfdb925b87c41396e8ce37fcf --- /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 0000000000000000000000000000000000000000..9eac80ba5e06a7daecdcfa0f9be9c3f2100cddbb --- /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 0000000000000000000000000000000000000000..f7c82748384da8b1a5eb8ae6289f3fbeda3491ed --- /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/init.go b/common/init.go new file mode 100644 index 0000000000000000000000000000000000000000..53782d56b11c582d9095ea6aa1c663c1788ed461 --- /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", 300) + 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/ip.go b/common/ip.go new file mode 100644 index 0000000000000000000000000000000000000000..7b61a012e5bc2489b382c477251e237ddd5759ca --- /dev/null +++ b/common/ip.go @@ -0,0 +1,22 @@ +package common + +import "net" + +func IsPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + private := []net.IPNet{ + {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, + {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, + {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, + } + + for _, privateNet := range private { + if privateNet.Contains(ip) { + return true + } + } + return false +} diff --git a/common/json.go b/common/json.go new file mode 100644 index 0000000000000000000000000000000000000000..531e737d65e2d0d9496c508ec2c2e8a8e0999bf2 --- /dev/null +++ b/common/json.go @@ -0,0 +1,44 @@ +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) +} + +func GetJsonType(data json.RawMessage) string { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return "unknown" + } + firstChar := bytes.TrimSpace(data)[0] + switch firstChar { + case '{': + return "object" + case '[': + return "array" + case '"': + return "string" + case 't', 'f': + return "boolean" + case 'n': + return "null" + default: + return "number" + } +} diff --git a/common/limiter/limiter.go b/common/limiter/limiter.go new file mode 100644 index 0000000000000000000000000000000000000000..e966cc898b59f885f82498f3fb39e3387f3c2145 --- /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 0000000000000000000000000000000000000000..c67fac240f040838b3119c3a4f43b82fa11ae724 --- /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/model.go b/common/model.go new file mode 100644 index 0000000000000000000000000000000000000000..181e2c3a0ca3a61fa564996220e629f20f423be5 --- /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 0000000000000000000000000000000000000000..58bf2ab12d0aa1afa8fdbc48c55b9aca2fef6875 --- /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("p")); 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 0000000000000000000000000000000000000000..b18fd024473526dfd960c2b134e862ac84a152eb --- /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/quota.go b/common/quota.go new file mode 100644 index 0000000000000000000000000000000000000000..5961d3c4939db7db05b388086d74c753b23ed568 --- /dev/null +++ b/common/quota.go @@ -0,0 +1,5 @@ +package common + +func GetTrustQuota() int { + return int(10 * QuotaPerUnit) +} diff --git a/common/rate-limit.go b/common/rate-limit.go new file mode 100644 index 0000000000000000000000000000000000000000..be08b6fbfeb7e90ce6daa6ced7412e26e0d72a38 --- /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 0000000000000000000000000000000000000000..90a9ce3c4a253f3c11e006b59da1e50644052556 --- /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/ssrf_protection.go b/common/ssrf_protection.go new file mode 100644 index 0000000000000000000000000000000000000000..9eaadc2d77a81b2cd5d4b2da49555fe487c0eace --- /dev/null +++ b/common/ssrf_protection.go @@ -0,0 +1,327 @@ +package common + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +// SSRFProtection SSRF防护配置 +type SSRFProtection struct { + AllowPrivateIp bool + DomainFilterMode bool // true: 白名单, false: 黑名单 + DomainList []string // domain format, e.g. example.com, *.example.com + IpFilterMode bool // true: 白名单, false: 黑名单 + IpList []string // CIDR or single IP + AllowedPorts []int // 允许的端口范围 + ApplyIPFilterForDomain bool // 对域名启用IP过滤 +} + +// DefaultSSRFProtection 默认SSRF防护配置 +var DefaultSSRFProtection = &SSRFProtection{ + AllowPrivateIp: false, + DomainFilterMode: true, + DomainList: []string{}, + IpFilterMode: true, + IpList: []string{}, + AllowedPorts: []int{}, +} + +// isPrivateIP 检查IP是否为私有地址 +func isPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { + return true + } + + // 检查私有网段 + private := []net.IPNet{ + {IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8 + {IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12 + {IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16 + {IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8 + {IP: net.IPv4(169, 254, 0, 0), Mask: net.CIDRMask(16, 32)}, // 169.254.0.0/16 (链路本地) + {IP: net.IPv4(224, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 224.0.0.0/4 (组播) + {IP: net.IPv4(240, 0, 0, 0), Mask: net.CIDRMask(4, 32)}, // 240.0.0.0/4 (保留) + } + + for _, privateNet := range private { + if privateNet.Contains(ip) { + return true + } + } + + // 检查IPv6私有地址 + if ip.To4() == nil { + // IPv6 loopback + if ip.Equal(net.IPv6loopback) { + return true + } + // IPv6 link-local + if strings.HasPrefix(ip.String(), "fe80:") { + return true + } + // IPv6 unique local + if strings.HasPrefix(ip.String(), "fc") || strings.HasPrefix(ip.String(), "fd") { + return true + } + } + + return false +} + +// parsePortRanges 解析端口范围配置 +// 支持格式: "80", "443", "8000-9000" +func parsePortRanges(portConfigs []string) ([]int, error) { + var ports []int + + for _, config := range portConfigs { + config = strings.TrimSpace(config) + if config == "" { + continue + } + + if strings.Contains(config, "-") { + // 处理端口范围 "8000-9000" + parts := strings.Split(config, "-") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid port range format: %s", config) + } + + startPort, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil { + return nil, fmt.Errorf("invalid start port in range %s: %v", config, err) + } + + endPort, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil { + return nil, fmt.Errorf("invalid end port in range %s: %v", config, err) + } + + if startPort > endPort { + return nil, fmt.Errorf("invalid port range %s: start port cannot be greater than end port", config) + } + + if startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 { + return nil, fmt.Errorf("port range %s contains invalid port numbers (must be 1-65535)", config) + } + + // 添加范围内的所有端口 + for port := startPort; port <= endPort; port++ { + ports = append(ports, port) + } + } else { + // 处理单个端口 "80" + port, err := strconv.Atoi(config) + if err != nil { + return nil, fmt.Errorf("invalid port number: %s", config) + } + + if port < 1 || port > 65535 { + return nil, fmt.Errorf("invalid port number %d (must be 1-65535)", port) + } + + ports = append(ports, port) + } + } + + return ports, nil +} + +// isAllowedPort 检查端口是否被允许 +func (p *SSRFProtection) isAllowedPort(port int) bool { + if len(p.AllowedPorts) == 0 { + return true // 如果没有配置端口限制,则允许所有端口 + } + + for _, allowedPort := range p.AllowedPorts { + if port == allowedPort { + return true + } + } + return false +} + +// isDomainWhitelisted 检查域名是否在白名单中 +func isDomainListed(domain string, list []string) bool { + if len(list) == 0 { + return false + } + + domain = strings.ToLower(domain) + for _, item := range list { + item = strings.ToLower(strings.TrimSpace(item)) + if item == "" { + continue + } + // 精确匹配 + if domain == item { + return true + } + // 通配符匹配 (*.example.com) + if strings.HasPrefix(item, "*.") { + suffix := strings.TrimPrefix(item, "*.") + if strings.HasSuffix(domain, "."+suffix) || domain == suffix { + return true + } + } + } + return false +} + +func (p *SSRFProtection) isDomainAllowed(domain string) bool { + listed := isDomainListed(domain, p.DomainList) + if p.DomainFilterMode { // 白名单 + return listed + } + // 黑名单 + return !listed +} + +// isIPWhitelisted 检查IP是否在白名单中 + +func isIPListed(ip net.IP, list []string) bool { + if len(list) == 0 { + return false + } + + for _, whitelistCIDR := range list { + _, network, err := net.ParseCIDR(whitelistCIDR) + if err != nil { + // 尝试作为单个IP处理 + if whitelistIP := net.ParseIP(whitelistCIDR); whitelistIP != nil { + if ip.Equal(whitelistIP) { + return true + } + } + continue + } + + if network.Contains(ip) { + return true + } + } + return false +} + +// IsIPAccessAllowed 检查IP是否允许访问 +func (p *SSRFProtection) IsIPAccessAllowed(ip net.IP) bool { + // 私有IP限制 + if isPrivateIP(ip) && !p.AllowPrivateIp { + return false + } + + listed := isIPListed(ip, p.IpList) + if p.IpFilterMode { // 白名单 + return listed + } + // 黑名单 + return !listed +} + +// ValidateURL 验证URL是否安全 +func (p *SSRFProtection) ValidateURL(urlStr string) error { + // 解析URL + u, err := url.Parse(urlStr) + if err != nil { + return fmt.Errorf("invalid URL format: %v", err) + } + + // 只允许HTTP/HTTPS协议 + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("unsupported protocol: %s (only http/https allowed)", u.Scheme) + } + + // 解析主机和端口 + host, portStr, err := net.SplitHostPort(u.Host) + if err != nil { + // 没有端口,使用默认端口 + host = u.Hostname() + if u.Scheme == "https" { + portStr = "443" + } else { + portStr = "80" + } + } + + // 验证端口 + port, err := strconv.Atoi(portStr) + if err != nil { + return fmt.Errorf("invalid port: %s", portStr) + } + + if !p.isAllowedPort(port) { + return fmt.Errorf("port %d is not allowed", port) + } + + // 如果 host 是 IP,则跳过域名检查 + if ip := net.ParseIP(host); ip != nil { + if !p.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) { + return fmt.Errorf("private IP address not allowed: %s", ip.String()) + } + if p.IpFilterMode { + return fmt.Errorf("ip not in whitelist: %s", ip.String()) + } + return fmt.Errorf("ip in blacklist: %s", ip.String()) + } + return nil + } + + // 先进行域名过滤 + if !p.isDomainAllowed(host) { + if p.DomainFilterMode { + return fmt.Errorf("domain not in whitelist: %s", host) + } + return fmt.Errorf("domain in blacklist: %s", host) + } + + // 若未启用对域名应用IP过滤,则到此通过 + if !p.ApplyIPFilterForDomain { + return nil + } + + // 解析域名对应IP并检查 + ips, err := net.LookupIP(host) + if err != nil { + return fmt.Errorf("DNS resolution failed for %s: %v", host, err) + } + for _, ip := range ips { + if !p.IsIPAccessAllowed(ip) { + if isPrivateIP(ip) && !p.AllowPrivateIp { + return fmt.Errorf("private IP address not allowed: %s resolves to %s", host, ip.String()) + } + if p.IpFilterMode { + return fmt.Errorf("ip not in whitelist: %s resolves to %s", host, ip.String()) + } + return fmt.Errorf("ip in blacklist: %s resolves to %s", host, ip.String()) + } + } + return nil +} + +// ValidateURLWithFetchSetting 使用FetchSetting配置验证URL +func ValidateURLWithFetchSetting(urlStr string, enableSSRFProtection, allowPrivateIp bool, domainFilterMode bool, ipFilterMode bool, domainList, ipList, allowedPorts []string, applyIPFilterForDomain bool) error { + // 如果SSRF防护被禁用,直接返回成功 + if !enableSSRFProtection { + return nil + } + + // 解析端口范围配置 + allowedPortInts, err := parsePortRanges(allowedPorts) + if err != nil { + return fmt.Errorf("request reject - invalid port configuration: %v", err) + } + + protection := &SSRFProtection{ + AllowPrivateIp: allowPrivateIp, + DomainFilterMode: domainFilterMode, + DomainList: domainList, + IpFilterMode: ipFilterMode, + IpList: ipList, + AllowedPorts: allowedPortInts, + ApplyIPFilterForDomain: applyIPFilterForDomain, + } + return protection.ValidateURL(urlStr) +} diff --git a/common/str.go b/common/str.go new file mode 100644 index 0000000000000000000000000000000000000000..1a016a51208f90551bc398accf4598fa4f19b9dd --- /dev/null +++ b/common/str.go @@ -0,0 +1,237 @@ +package common + +import ( + "encoding/base64" + "encoding/json" + "math/rand" + "net/url" + "regexp" + "strconv" + "strings" + "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) +} + +// MaskEmail masks a user email to prevent PII leakage in logs +// Returns "***masked***" if email is empty, otherwise shows only the domain part +func MaskEmail(email string) string { + if email == "" { + return "***masked***" + } + + // Find the @ symbol + atIndex := strings.Index(email, "@") + if atIndex == -1 { + // No @ symbol found, return masked + return "***masked***" + } + + // Return only the domain part with @ symbol + return "***@" + email[atIndex+1:] +} + +// maskHostTail returns the tail parts of a domain/host that should be preserved. +// It keeps 2 parts for likely country-code TLDs (e.g., co.uk, com.cn), otherwise keeps only the TLD. +func maskHostTail(parts []string) []string { + if len(parts) < 2 { + return parts + } + lastPart := parts[len(parts)-1] + secondLastPart := parts[len(parts)-2] + if len(lastPart) == 2 && len(secondLastPart) <= 3 { + // Likely country code TLD like co.uk, com.cn + return []string{secondLastPart, lastPart} + } + return []string{lastPart} +} + +// maskHostForURL collapses subdomains and keeps only masked prefix + preserved tail. +// Example: api.openai.com -> ***.com, sub.domain.co.uk -> ***.co.uk +func maskHostForURL(host string) string { + parts := strings.Split(host, ".") + if len(parts) < 2 { + return "***" + } + tail := maskHostTail(parts) + return "***." + strings.Join(tail, ".") +} + +// maskHostForPlainDomain masks a plain domain and reflects subdomain depth with multiple ***. +// Example: openai.com -> ***.com, api.openai.com -> ***.***.com, sub.domain.co.uk -> ***.***.co.uk +func maskHostForPlainDomain(domain string) string { + parts := strings.Split(domain, ".") + if len(parts) < 2 { + return domain + } + tail := maskHostTail(parts) + numStars := len(parts) - len(tail) + if numStars < 1 { + numStars = 1 + } + stars := strings.TrimSuffix(strings.Repeat("***.", numStars), ".") + return stars + "." + strings.Join(tail, ".") +} + +// MaskSensitiveInfo masks sensitive information like URLs, IPs, and domain names in a string +// Example: +// http://example.com -> http://***.com +// https://api.test.org/v1/users/123?key=secret -> https://***.org/***/***/?key=*** +// https://sub.domain.co.uk/path/to/resource -> https://***.co.uk/***/*** +// 192.168.1.1 -> ***.***.***.*** +// openai.com -> ***.com +// www.openai.com -> ***.***.com +// api.openai.com -> ***.***.com +func MaskSensitiveInfo(str string) string { + // Mask URLs + urlPattern := regexp.MustCompile(`(http|https)://[^\s/$.?#].[^\s]*`) + str = urlPattern.ReplaceAllStringFunc(str, func(urlStr string) string { + u, err := url.Parse(urlStr) + if err != nil { + return urlStr + } + + host := u.Host + if host == "" { + return urlStr + } + + // Mask host with unified logic + maskedHost := maskHostForURL(host) + + result := u.Scheme + "://" + maskedHost + + // Mask path + if u.Path != "" && u.Path != "/" { + pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") + maskedPathParts := make([]string, len(pathParts)) + for i := range pathParts { + if pathParts[i] != "" { + maskedPathParts[i] = "***" + } + } + if len(maskedPathParts) > 0 { + result += "/" + strings.Join(maskedPathParts, "/") + } + } else if u.Path == "/" { + result += "/" + } + + // Mask query parameters + if u.RawQuery != "" { + values, err := url.ParseQuery(u.RawQuery) + if err != nil { + // If can't parse query, just mask the whole query string + result += "?***" + } else { + maskedParams := make([]string, 0, len(values)) + for key := range values { + maskedParams = append(maskedParams, key+"=***") + } + if len(maskedParams) > 0 { + result += "?" + strings.Join(maskedParams, "&") + } + } + } + + return result + }) + + // Mask domain names without protocol (like openai.com, www.openai.com) + domainPattern := regexp.MustCompile(`\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b`) + str = domainPattern.ReplaceAllStringFunc(str, func(domain string) string { + return maskHostForPlainDomain(domain) + }) + + // Mask IP addresses + ipPattern := regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) + str = ipPattern.ReplaceAllString(str, "***.***.***.***") + + return str +} diff --git a/common/sys_log.go b/common/sys_log.go new file mode 100644 index 0000000000000000000000000000000000000000..95b40ea60987ed805739487b810f228f0ca920d7 --- /dev/null +++ b/common/sys_log.go @@ -0,0 +1,55 @@ +package common + +import ( + "fmt" + "os" + "time" + + "github.com/gin-gonic/gin" +) + +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 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 LogStartupSuccess(startTime time.Time, port string) { + + duration := time.Since(startTime) + durationMs := duration.Milliseconds() + + // Get network IPs + networkIps := GetNetworkIps() + + // Print blank line for spacing + fmt.Fprintf(gin.DefaultWriter, "\n") + + // Print the main success message + fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs) + fmt.Fprintf(gin.DefaultWriter, "\n") + + // Skip fancy startup message in container environments + if !IsRunningInContainer() { + // Print local URL + fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port) + } + + // Print network URLs + for _, ip := range networkIps { + fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port) + } + + // Print blank line for spacing + fmt.Fprintf(gin.DefaultWriter, "\n") +} diff --git a/common/topup-ratio.go b/common/topup-ratio.go new file mode 100644 index 0000000000000000000000000000000000000000..9c0e9c85a9e116c10fe7fbbda0eb2c4db1df7c3c --- /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/totp.go b/common/totp.go new file mode 100644 index 0000000000000000000000000000000000000000..7502626041f03c969c0a0ed5b5802045868e7e34 --- /dev/null +++ b/common/totp.go @@ -0,0 +1,150 @@ +package common + +import ( + "crypto/rand" + "fmt" + "os" + "strconv" + "strings" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +const ( + // 备用码配置 + BackupCodeLength = 8 // 备用码长度 + BackupCodeCount = 4 // 生成备用码数量 + + // 限制配置 + MaxFailAttempts = 5 // 最大失败尝试次数 + LockoutDuration = 300 // 锁定时间(秒) +) + +// GenerateTOTPSecret 生成TOTP密钥和配置 +func GenerateTOTPSecret(accountName string) (*otp.Key, error) { + issuer := Get2FAIssuer() + return totp.Generate(totp.GenerateOpts{ + Issuer: issuer, + AccountName: accountName, + Period: 30, + Digits: otp.DigitsSix, + Algorithm: otp.AlgorithmSHA1, + }) +} + +// ValidateTOTPCode 验证TOTP验证码 +func ValidateTOTPCode(secret, code string) bool { + // 清理验证码格式 + cleanCode := strings.ReplaceAll(code, " ", "") + if len(cleanCode) != 6 { + return false + } + + // 验证验证码 + return totp.Validate(cleanCode, secret) +} + +// GenerateBackupCodes 生成备用恢复码 +func GenerateBackupCodes() ([]string, error) { + codes := make([]string, BackupCodeCount) + + for i := 0; i < BackupCodeCount; i++ { + code, err := generateRandomBackupCode() + if err != nil { + return nil, err + } + codes[i] = code + } + + return codes, nil +} + +// generateRandomBackupCode 生成单个备用码 +func generateRandomBackupCode() (string, error) { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + code := make([]byte, BackupCodeLength) + + for i := range code { + randomBytes := make([]byte, 1) + _, err := rand.Read(randomBytes) + if err != nil { + return "", err + } + code[i] = charset[int(randomBytes[0])%len(charset)] + } + + // 格式化为 XXXX-XXXX 格式 + return fmt.Sprintf("%s-%s", string(code[:4]), string(code[4:])), nil +} + +// ValidateBackupCode 验证备用码格式 +func ValidateBackupCode(code string) bool { + // 移除所有分隔符并转为大写 + cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", "")) + if len(cleanCode) != BackupCodeLength { + return false + } + + // 检查字符是否合法 + for _, char := range cleanCode { + if !((char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9')) { + return false + } + } + + return true +} + +// NormalizeBackupCode 标准化备用码格式 +func NormalizeBackupCode(code string) string { + cleanCode := strings.ToUpper(strings.ReplaceAll(code, "-", "")) + if len(cleanCode) == BackupCodeLength { + return fmt.Sprintf("%s-%s", cleanCode[:4], cleanCode[4:]) + } + return code +} + +// HashBackupCode 对备用码进行哈希 +func HashBackupCode(code string) (string, error) { + normalizedCode := NormalizeBackupCode(code) + return Password2Hash(normalizedCode) +} + +// Get2FAIssuer 获取2FA发行者名称 +func Get2FAIssuer() string { + return SystemName +} + +// getEnvOrDefault 获取环境变量或默认值 +func getEnvOrDefault(key, defaultValue string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultValue +} + +// ValidateNumericCode 验证数字验证码格式 +func ValidateNumericCode(code string) (string, error) { + // 移除空格 + code = strings.ReplaceAll(code, " ", "") + + if len(code) != 6 { + return "", fmt.Errorf("验证码必须是6位数字") + } + + // 检查是否为纯数字 + if _, err := strconv.Atoi(code); err != nil { + return "", fmt.Errorf("验证码只能包含数字") + } + + return code, nil +} + +// GenerateQRCodeData 生成二维码数据 +func GenerateQRCodeData(secret, username string) string { + issuer := Get2FAIssuer() + accountName := fmt.Sprintf("%s (%s)", username, issuer) + return fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&digits=6&period=30", + issuer, accountName, secret, issuer) +} diff --git a/common/utils.go b/common/utils.go new file mode 100644 index 0000000000000000000000000000000000000000..08a90bd4bea129ad63079d098bd992968d01c23e --- /dev/null +++ b/common/utils.go @@ -0,0 +1,384 @@ +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 +} + +func GetNetworkIps() []string { + var networkIps []string + ips, err := net.InterfaceAddrs() + if err != nil { + log.Println(err) + return networkIps + } + + for _, a := range ips { + if ipNet, ok := a.(*net.IPNet); ok && !ipNet.IP.IsLoopback() { + if ipNet.IP.To4() != nil { + ip := ipNet.IP.String() + // Include common private network ranges + if strings.HasPrefix(ip, "10.") || + strings.HasPrefix(ip, "172.") || + strings.HasPrefix(ip, "192.168.") { + networkIps = append(networkIps, ip) + } + } + } + } + return networkIps +} + +// IsRunningInContainer detects if the application is running inside a container +func IsRunningInContainer() bool { + // Method 1: Check for .dockerenv file (Docker containers) + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + + // Method 2: Check cgroup for container indicators + if data, err := os.ReadFile("/proc/1/cgroup"); err == nil { + content := string(data) + if strings.Contains(content, "docker") || + strings.Contains(content, "containerd") || + strings.Contains(content, "kubepods") || + strings.Contains(content, "/lxc/") { + return true + } + } + + // Method 3: Check environment variables commonly set by container runtimes + containerEnvVars := []string{ + "KUBERNETES_SERVICE_HOST", + "DOCKER_CONTAINER", + "container", + } + + for _, envVar := range containerEnvVars { + if os.Getenv(envVar) != "" { + return true + } + } + + // Method 4: Check if init process is not the traditional init + if data, err := os.ReadFile("/proc/1/comm"); err == nil { + comm := strings.TrimSpace(string(data)) + // In containers, process 1 is often not "init" or "systemd" + if comm != "init" && comm != "systemd" { + // Additional check: if it's a common container entrypoint + if strings.Contains(comm, "docker") || + strings.Contains(comm, "containerd") || + strings.Contains(comm, "runc") { + return true + } + } + } + + return false +} + +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)) + case bool: + if inter.(bool) { + return "true" + } else { + return "false" + } + case nil: + return "" + } + return fmt.Sprintf("%v", inter) +} + +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 0000000000000000000000000000000000000000..4e1888508bb8d63556bac2fff48e58203012d361 --- /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 0000000000000000000000000000000000000000..2c1059aa16ca6cda95e386b79a60a3edbc90c48f --- /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 0000000000000000000000000000000000000000..963cb439ca007a4637f9210245c8fbbcc1f447c4 --- /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 0000000000000000000000000000000000000000..4f517fcf351e90eb300833fa8ae750fbd03bf502 --- /dev/null +++ b/constant/api_type.go @@ -0,0 +1,37 @@ +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 + APITypeMoonshot + APITypeSubmodel + 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 0000000000000000000000000000000000000000..20dc7083d27b124ea3d26d6d0ceae2e06119a883 --- /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 0000000000000000000000000000000000000000..5f274406ed37f3e31c4cf613c187e6fefd308d25 --- /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 0000000000000000000000000000000000000000..68ad8960d4677ce0b2ccf5253af2ac80f47fd42e --- /dev/null +++ b/constant/channel.go @@ -0,0 +1,114 @@ +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 + ChannelTypeVidu = 52 + ChannelTypeSubmodel = 53 + 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 + "https://api.vidu.cn", //52 + "https://llm.submodel.ai", //53 +} diff --git a/constant/context_key.go b/constant/context_key.go new file mode 100644 index 0000000000000000000000000000000000000000..7d766c850a9b4e7bf665310708aeab653e6fec5a --- /dev/null +++ b/constant/context_key.go @@ -0,0 +1,50 @@ +package constant + +type ContextKey string + +const ( + ContextKeyTokenCountMeta ContextKey = "token_count_meta" + ContextKeyPromptTokens ContextKey = "prompt_tokens" + + 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" + 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" + ContextKeyChannelOtherSetting ContextKey = "channel_other_setting" + ContextKeyChannelParamOverride ContextKey = "param_override" + ContextKeyChannelHeaderOverride ContextKey = "header_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" + + ContextKeySystemPromptOverride ContextKey = "system_prompt_override" +) diff --git a/constant/endpoint_type.go b/constant/endpoint_type.go new file mode 100644 index 0000000000000000000000000000000000000000..67c0ebb4e115887ea3dac25f151e675806cf2c6b --- /dev/null +++ b/constant/endpoint_type.go @@ -0,0 +1,17 @@ +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" + EndpointTypeEmbeddings EndpointType = "embeddings" + //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 0000000000000000000000000000000000000000..20c44b2168b683cb13e291c3c6cf7a3d03d25509 --- /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 0000000000000000000000000000000000000000..72047538ec6e72e5097555463d2fa587f6f18728 --- /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 0000000000000000000000000000000000000000..891499ae356d2e123dd8a3420357d5d71e0e698b --- /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 0000000000000000000000000000000000000000..8419698c16a6ebc3d37b6db02d18c2ae555d70ff --- /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 0000000000000000000000000000000000000000..5de6e789805bf1c807a91edc0cd4ed2169516a87 --- /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 0000000000000000000000000000000000000000..c0fc45376847ed56be4a60af0deff1d0e1394f1b --- /dev/null +++ b/constant/task.go @@ -0,0 +1,23 @@ +package constant + +type TaskPlatform string + +const ( + TaskPlatformSuno TaskPlatform = "suno" + TaskPlatformMidjourney = "mj" +) + +const ( + SunoActionMusic = "MUSIC" + SunoActionLyrics = "LYRICS" + + TaskActionGenerate = "generate" + TaskActionTextGenerate = "textGenerate" + TaskActionFirstTailGenerate = "firstTailGenerate" + TaskActionReferenceGenerate = "referenceGenerate" +) + +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 0000000000000000000000000000000000000000..24c667cdb3b4dd0c4e4cb0a658177a20610fe4ea --- /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 0000000000000000000000000000000000000000..c4a6ef1253c219e9d3cba34a7ceac161f00a7813 --- /dev/null +++ b/controller/channel-billing.go @@ -0,0 +1,496 @@ +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/operation_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)) + } + client, err := service.NewProxyHttpClient(channel.GetSetting().Proxy) + if err != nil { + return nil, err + } + res, err := client.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(operation_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 0000000000000000000000000000000000000000..65261c9d85a87cebdae2d69eb82aafb41a3453ee --- /dev/null +++ b/controller/channel-test.go @@ -0,0 +1,655 @@ +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/setting/operation_setting" + "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, endpointType 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, + } + } + if channel.Type == constant.ChannelTypeVidu { + return testResult{ + localErr: errors.New("vidu channel test is not supported"), + newAPIError: nil, + } + } + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + requestPath := "/v1/chat/completions" + + // 如果指定了端点类型,使用指定的端点类型 + if endpointType != "" { + if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(endpointType)); ok { + requestPath = endpointInfo.Path + } + } else { + // 如果没有指定端点类型,使用原有的自动检测逻辑 + // 先判断是否为 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" // 修改请求路径 + } + + // VolcEngine 图像生成模型 + if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { + requestPath = "/v1/images/generations" + } + } + + 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, + } + } + + // Determine relay format based on endpoint type or request path + var relayFormat types.RelayFormat + if endpointType != "" { + // 根据指定的端点类型设置 relayFormat + switch constant.EndpointType(endpointType) { + case constant.EndpointTypeOpenAI: + relayFormat = types.RelayFormatOpenAI + case constant.EndpointTypeOpenAIResponse: + relayFormat = types.RelayFormatOpenAIResponses + case constant.EndpointTypeAnthropic: + relayFormat = types.RelayFormatClaude + case constant.EndpointTypeGemini: + relayFormat = types.RelayFormatGemini + case constant.EndpointTypeJinaRerank: + relayFormat = types.RelayFormatRerank + case constant.EndpointTypeImageGeneration: + relayFormat = types.RelayFormatOpenAIImage + case constant.EndpointTypeEmbeddings: + relayFormat = types.RelayFormatEmbedding + default: + relayFormat = types.RelayFormatOpenAI + } + } else { + // 根据请求路径自动检测 + relayFormat = types.RelayFormatOpenAI + if c.Request.URL.Path == "/v1/embeddings" { + relayFormat = types.RelayFormatEmbedding + } + if c.Request.URL.Path == "/v1/images/generations" { + relayFormat = types.RelayFormatOpenAIImage + } + if c.Request.URL.Path == "/v1/messages" { + relayFormat = types.RelayFormatClaude + } + if strings.Contains(c.Request.URL.Path, "/v1beta/models") { + relayFormat = types.RelayFormatGemini + } + if c.Request.URL.Path == "/v1/rerank" || c.Request.URL.Path == "/rerank" { + relayFormat = types.RelayFormatRerank + } + if c.Request.URL.Path == "/v1/responses" { + relayFormat = types.RelayFormatOpenAIResponses + } + } + + request := buildTestRequest(testModel, endpointType) + + info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil) + + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeGenRelayInfoFailed), + } + } + + info.InitChannelMeta(c) + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError), + } + } + + testModel = info.UpstreamModelName + // 更新请求中的模型名称 + request.SetModelName(testModel) + + 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), + } + } + + //// 创建一个用于日志的 info 副本,移除 ApiKey + //logInfo := info + //logInfo.ApiKey = "" + common.SysLog(fmt.Sprintf("testing channel %d with model %s , info %+v ", channel.Id, testModel, info.ToString())) + + priceData, err := helper.ModelPriceHelper(c, info, 0, request.GetTokenCountMeta()) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewError(err, types.ErrorCodeModelPriceError), + } + } + + adaptor.Init(info) + + var convertedRequest any + // 根据 RelayMode 选择正确的转换函数 + switch info.RelayMode { + case relayconstant.RelayModeEmbeddings: + // Embedding 请求 - request 已经是正确的类型 + if embeddingReq, ok := request.(*dto.EmbeddingRequest); ok { + convertedRequest, err = adaptor.ConvertEmbeddingRequest(c, info, *embeddingReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid embedding request type"), + newAPIError: types.NewError(errors.New("invalid embedding request type"), types.ErrorCodeConvertRequestFailed), + } + } + case relayconstant.RelayModeImagesGenerations: + // 图像生成请求 - request 已经是正确的类型 + if imageReq, ok := request.(*dto.ImageRequest); ok { + convertedRequest, err = adaptor.ConvertImageRequest(c, info, *imageReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid image request type"), + newAPIError: types.NewError(errors.New("invalid image request type"), types.ErrorCodeConvertRequestFailed), + } + } + case relayconstant.RelayModeRerank: + // Rerank 请求 - request 已经是正确的类型 + if rerankReq, ok := request.(*dto.RerankRequest); ok { + convertedRequest, err = adaptor.ConvertRerankRequest(c, info.RelayMode, *rerankReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid rerank request type"), + newAPIError: types.NewError(errors.New("invalid rerank request type"), types.ErrorCodeConvertRequestFailed), + } + } + case relayconstant.RelayModeResponses: + // Response 请求 - request 已经是正确的类型 + if responseReq, ok := request.(*dto.OpenAIResponsesRequest); ok { + convertedRequest, err = adaptor.ConvertOpenAIResponsesRequest(c, info, *responseReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid response request type"), + newAPIError: types.NewError(errors.New("invalid response request type"), types.ErrorCodeConvertRequestFailed), + } + } + default: + // Chat/Completion 等其他请求类型 + if generalReq, ok := request.(*dto.GeneralOpenAIRequest); ok { + convertedRequest, err = adaptor.ConvertOpenAIRequest(c, info, generalReq) + } else { + return testResult{ + context: c, + localErr: errors.New("invalid general request type"), + newAPIError: types.NewError(errors.New("invalid general request type"), types.ErrorCodeConvertRequestFailed), + } + } + } + + 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.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError), + } + } + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + if httpResp.StatusCode != http.StatusOK { + err := service.RelayErrorHandler(c.Request.Context(), httpResp, true) + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError), + } + } + } + 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.NewOpenAIError(errors.New("usage is nil"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError), + } + } + usage := usageA.(*dto.Usage) + result := w.Result() + respBody, err := io.ReadAll(result.Body) + if err != nil { + return testResult{ + context: c, + localErr: err, + newAPIError: types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), + } + } + 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: info.IsStream, + 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, endpointType string) dto.Request { + // 根据端点类型构建不同的测试请求 + if endpointType != "" { + switch constant.EndpointType(endpointType) { + case constant.EndpointTypeEmbeddings: + // 返回 EmbeddingRequest + return &dto.EmbeddingRequest{ + Model: model, + Input: []any{"hello world"}, + } + case constant.EndpointTypeImageGeneration: + // 返回 ImageRequest + return &dto.ImageRequest{ + Model: model, + Prompt: "a cute cat", + N: 1, + Size: "1024x1024", + } + case constant.EndpointTypeJinaRerank: + // 返回 RerankRequest + return &dto.RerankRequest{ + Model: model, + Query: "What is Deep Learning?", + Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."}, + TopN: 2, + } + case constant.EndpointTypeOpenAIResponse: + // 返回 OpenAIResponsesRequest + return &dto.OpenAIResponsesRequest{ + Model: model, + Input: json.RawMessage("\"hi\""), + } + case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI: + // 返回 GeneralOpenAIRequest + maxTokens := uint(10) + if constant.EndpointType(endpointType) == constant.EndpointTypeGemini { + maxTokens = 3000 + } + return &dto.GeneralOpenAIRequest{ + Model: model, + Stream: false, + Messages: []dto.Message{ + { + Role: "user", + Content: "hi", + }, + }, + MaxTokens: maxTokens, + } + } + } + + // 自动检测逻辑(保持原有行为) + // 先判断是否为 Embedding 模型 + if strings.Contains(strings.ToLower(model), "embedding") || + strings.HasPrefix(model, "m3e") || + strings.Contains(model, "bge-") { + // 返回 EmbeddingRequest + return &dto.EmbeddingRequest{ + Model: model, + Input: []any{"hello world"}, + } + } + + // Chat/Completion 请求 - 返回 GeneralOpenAIRequest + testRequest := &dto.GeneralOpenAIRequest{ + Model: model, + Stream: false, + Messages: []dto.Message{ + { + Role: "user", + Content: "hi", + }, + }, + } + + 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 + } + + 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 { + channel, err = model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, err) + return + } + } + //defer func() { + // if channel.ChannelInfo.IsMultiKey { + // go func() { _ = channel.SaveChannelInfo() }() + // } + //}() + testModel := c.Query("model") + endpointType := c.Query("endpoint_type") + tik := time.Now() + result := testChannel(channel, testModel, endpointType) + 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, + }) +} + +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 := fmt.Errorf("响应时间 %.2fs 超过阈值 %.2fs", float64(milliseconds)/1000.0, float64(disableThreshold)/1000.0) + newAPIError = types.NewOpenAIError(err, types.ErrorCodeChannelResponseTimeExceeded, http.StatusRequestTimeout) + shouldBanChannel = true + } + } + + // disable channel + if isChannelEnabled && shouldBanChannel && channel.GetAutoBan() { + 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": "", + }) +} + +var autoTestChannelsOnce sync.Once + +func AutomaticallyTestChannels() { + autoTestChannelsOnce.Do(func() { + for { + if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled { + time.Sleep(10 * time.Minute) + continue + } + frequency := operation_setting.GetMonitorSetting().AutoTestChannelMinutes + common.SysLog(fmt.Sprintf("automatically test channels with interval %d minutes", frequency)) + for { + time.Sleep(time.Duration(frequency) * time.Minute) + common.SysLog("automatically testing all channels") + _ = testAllChannels(false) + common.SysLog("automatically channel test finished") + if !operation_setting.GetMonitorSetting().AutoTestChannelEnabled { + break + } + } + } + }) +} diff --git a/controller/channel.go b/controller/channel.go new file mode 100644 index 0000000000000000000000000000000000000000..d0a13c4874d415e02a4216417b7012a7d31e9b39 --- /dev/null +++ b/controller/channel.go @@ -0,0 +1,1567 @@ +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/model" + "one-api/service" + "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 clearChannelInfo(channel *model.Channel) { + if channel.ChannelInfo.IsMultiKey { + channel.ChannelInfo.MultiKeyDisabledReason = nil + channel.ChannelInfo.MultiKeyDisabledTime = nil + } +} + +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 + } + } + + for _, datum := range channelData { + clearChannelInfo(datum) + } + + 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() + } + + var url string + switch channel.Type { + case constant.ChannelTypeGemini: + // curl https://example.com/v1beta/models?key=$GEMINI_API_KEY + url = fmt.Sprintf("%s/v1beta/openai/models", baseURL) // Remove key in url since we need to use AuthHeader + case constant.ChannelTypeAli: + url = fmt.Sprintf("%s/compatible-mode/v1/models", baseURL) + case constant.ChannelTypeZhipu_v4: + url = fmt.Sprintf("%s/api/paas/v4/models", baseURL) + default: + url = fmt.Sprintf("%s/v1/models", baseURL) + } + + // 获取响应体 - 根据渠道类型决定是否添加 AuthHeader + var body []byte + key := strings.Split(channel.Key, "\n")[0] + if channel.Type == constant.ChannelTypeGemini { + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(key)) // Use AuthHeader since Gemini now forces it + } else { + body, err = GetResponseBody("GET", url, channel, GetAuthHeader(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] + + for _, datum := range pagedData { + clearChannelInfo(datum) + } + + 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 + } + if channel != nil { + clearChannelInfo(channel) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": channel, + }) + return +} + +// GetChannelKey 获取渠道密钥(需要通过安全验证中间件) +// 此函数依赖 SecureVerificationRequired 中间件,确保用户已通过安全验证 +func GetChannelKey(c *gin.Context) { + userId := c.GetInt("id") + channelId, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, fmt.Errorf("渠道ID格式错误: %v", err)) + return + } + + // 获取渠道信息(包含密钥) + channel, err := model.GetChannelById(channelId, true) + if err != nil { + common.ApiError(c, fmt.Errorf("获取渠道信息失败: %v", err)) + return + } + + if channel == nil { + common.ApiError(c, fmt.Errorf("渠道不存在")) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("查看渠道密钥信息 (渠道ID: %d)", channelId)) + + // 返回渠道密钥 + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "获取成功", + "data": map[string]interface{}{ + "key": channel.Key, + }, + }) +} + +// validateTwoFactorAuth 统一的2FA验证函数 +func validateTwoFactorAuth(twoFA *model.TwoFA, code string) bool { + // 尝试验证TOTP + if cleanCode, err := common.ValidateNumericCode(code); err == nil { + if isValid, _ := twoFA.ValidateTOTPAndUpdateUsage(cleanCode); isValid { + return true + } + } + + // 尝试验证备用码 + if isValid, err := twoFA.ValidateBackupCodeAndUpdateUsage(code); err == nil && isValid { + return true + } + + return false +} + +// 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"` + BatchAddSetKeyPrefix2Name bool `json:"batch_add_set_key_prefix_2_name"` + 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 && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { + 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 && addChannelRequest.Channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { + // 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 + if addChannelRequest.BatchAddSetKeyPrefix2Name && len(keys) > 1 { + keyPrefix := localChannel.Key + if len(localChannel.Key) > 8 { + keyPrefix = localChannel.Key[:8] + } + localChannel.Name = fmt.Sprintf("%s %s", localChannel.Name, keyPrefix) + } + channels = append(channels, *localChannel) + } + err = model.BatchInsertChannels(channels) + if err != nil { + common.ApiError(c, err) + return + } + service.ResetProxyClientCache() + 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"` + KeyMode *string `json:"key_mode"` // 多key模式下密钥覆盖或者追加 +} + +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, true) + 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) + } + + // 处理多key模式下的密钥追加/覆盖逻辑 + if channel.KeyMode != nil && channel.ChannelInfo.IsMultiKey { + switch *channel.KeyMode { + case "append": + // 追加模式:将新密钥添加到现有密钥列表 + if originChannel.Key != "" { + var newKeys []string + var existingKeys []string + + // 解析现有密钥 + if strings.HasPrefix(strings.TrimSpace(originChannel.Key), "[") { + // JSON数组格式 + var arr []json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(originChannel.Key)), &arr); err == nil { + existingKeys = make([]string, len(arr)) + for i, v := range arr { + existingKeys[i] = string(v) + } + } + } else { + // 换行分隔格式 + existingKeys = strings.Split(strings.Trim(originChannel.Key, "\n"), "\n") + } + + // 处理 Vertex AI 的特殊情况 + if channel.Type == constant.ChannelTypeVertexAi && channel.GetOtherSettings().VertexKeyType != dto.VertexKeyTypeAPIKey { + // 尝试解析新密钥为JSON数组 + if strings.HasPrefix(strings.TrimSpace(channel.Key), "[") { + array, err := getVertexArrayKeys(channel.Key) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "追加密钥解析失败: " + err.Error(), + }) + return + } + newKeys = array + } else { + // 单个JSON密钥 + newKeys = []string{channel.Key} + } + // 合并密钥 + allKeys := append(existingKeys, newKeys...) + channel.Key = strings.Join(allKeys, "\n") + } else { + // 普通渠道的处理 + inputKeys := strings.Split(channel.Key, "\n") + for _, key := range inputKeys { + key = strings.TrimSpace(key) + if key != "" { + newKeys = append(newKeys, key) + } + } + // 合并密钥 + allKeys := append(existingKeys, newKeys...) + channel.Key = strings.Join(allKeys, "\n") + } + } + case "replace": + // 覆盖模式:直接使用新密钥(默认行为,不需要特殊处理) + } + } + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + model.InitChannelCache() + service.ResetProxyClientCache() + channel.Key = "" + clearChannelInfo(&channel.Channel) + 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}}) +} + +// MultiKeyManageRequest represents the request for multi-key management operations +type MultiKeyManageRequest struct { + ChannelId int `json:"channel_id"` + Action string `json:"action"` // "disable_key", "enable_key", "delete_key", "delete_disabled_keys", "get_key_status" + KeyIndex *int `json:"key_index,omitempty"` // for disable_key, enable_key, and delete_key actions + Page int `json:"page,omitempty"` // for get_key_status pagination + PageSize int `json:"page_size,omitempty"` // for get_key_status pagination + Status *int `json:"status,omitempty"` // for get_key_status filtering: 1=enabled, 2=manual_disabled, 3=auto_disabled, nil=all +} + +// MultiKeyStatusResponse represents the response for key status query +type MultiKeyStatusResponse struct { + Keys []KeyStatus `json:"keys"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` + // Statistics + EnabledCount int `json:"enabled_count"` + ManualDisabledCount int `json:"manual_disabled_count"` + AutoDisabledCount int `json:"auto_disabled_count"` +} + +type KeyStatus struct { + Index int `json:"index"` + Status int `json:"status"` // 1: enabled, 2: disabled + DisabledTime int64 `json:"disabled_time,omitempty"` + Reason string `json:"reason,omitempty"` + KeyPreview string `json:"key_preview"` // first 10 chars of key for identification +} + +// ManageMultiKeys handles multi-key management operations +func ManageMultiKeys(c *gin.Context) { + request := MultiKeyManageRequest{} + err := c.ShouldBindJSON(&request) + if err != nil { + common.ApiError(c, err) + return + } + + channel, err := model.GetChannelById(request.ChannelId, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "渠道不存在", + }) + return + } + + if !channel.ChannelInfo.IsMultiKey { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该渠道不是多密钥模式", + }) + return + } + + lock := model.GetChannelPollingLock(channel.Id) + lock.Lock() + defer lock.Unlock() + + switch request.Action { + case "get_key_status": + keys := channel.GetKeys() + + // Default pagination parameters + page := request.Page + pageSize := request.PageSize + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 50 // Default page size + } + + // Statistics for all keys (unchanged by filtering) + var enabledCount, manualDisabledCount, autoDisabledCount int + + // Build all key status data first + var allKeyStatusList []KeyStatus + for i, key := range keys { + status := 1 // default enabled + var disabledTime int64 + var reason string + + if channel.ChannelInfo.MultiKeyStatusList != nil { + if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { + status = s + } + } + + // Count for statistics (all keys) + switch status { + case 1: + enabledCount++ + case 2: + manualDisabledCount++ + case 3: + autoDisabledCount++ + } + + if status != 1 { + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + disabledTime = channel.ChannelInfo.MultiKeyDisabledTime[i] + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + reason = channel.ChannelInfo.MultiKeyDisabledReason[i] + } + } + + // Create key preview (first 10 chars) + keyPreview := key + if len(key) > 10 { + keyPreview = key[:10] + "..." + } + + allKeyStatusList = append(allKeyStatusList, KeyStatus{ + Index: i, + Status: status, + DisabledTime: disabledTime, + Reason: reason, + KeyPreview: keyPreview, + }) + } + + // Apply status filter if specified + var filteredKeyStatusList []KeyStatus + if request.Status != nil { + for _, keyStatus := range allKeyStatusList { + if keyStatus.Status == *request.Status { + filteredKeyStatusList = append(filteredKeyStatusList, keyStatus) + } + } + } else { + filteredKeyStatusList = allKeyStatusList + } + + // Calculate pagination based on filtered results + filteredTotal := len(filteredKeyStatusList) + totalPages := (filteredTotal + pageSize - 1) / pageSize + if totalPages == 0 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + + // Calculate range for current page + start := (page - 1) * pageSize + end := start + pageSize + if end > filteredTotal { + end = filteredTotal + } + + // Get the page data + var pageKeyStatusList []KeyStatus + if start < filteredTotal { + pageKeyStatusList = filteredKeyStatusList[start:end] + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": MultiKeyStatusResponse{ + Keys: pageKeyStatusList, + Total: filteredTotal, // Total of filtered results + Page: page, + PageSize: pageSize, + TotalPages: totalPages, + EnabledCount: enabledCount, // Overall statistics + ManualDisabledCount: manualDisabledCount, // Overall statistics + AutoDisabledCount: autoDisabledCount, // Overall statistics + }, + }) + return + + case "disable_key": + if request.KeyIndex == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "未指定要禁用的密钥索引", + }) + return + } + + keyIndex := *request.KeyIndex + if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "密钥索引超出范围", + }) + return + } + + if channel.ChannelInfo.MultiKeyStatusList == nil { + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + if channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + + channel.ChannelInfo.MultiKeyStatusList[keyIndex] = 2 // disabled + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "密钥已禁用", + }) + return + + case "enable_key": + if request.KeyIndex == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "未指定要启用的密钥索引", + }) + return + } + + keyIndex := *request.KeyIndex + if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "密钥索引超出范围", + }) + return + } + + // 从状态列表中删除该密钥的记录,使其回到默认启用状态 + if channel.ChannelInfo.MultiKeyStatusList != nil { + delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex) + } + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + delete(channel.ChannelInfo.MultiKeyDisabledTime, keyIndex) + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + delete(channel.ChannelInfo.MultiKeyDisabledReason, keyIndex) + } + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "密钥已启用", + }) + return + + case "enable_all_keys": + // 清空所有禁用状态,使所有密钥回到默认启用状态 + var enabledCount int + if channel.ChannelInfo.MultiKeyStatusList != nil { + enabledCount = len(channel.ChannelInfo.MultiKeyStatusList) + } + + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("已启用 %d 个密钥", enabledCount), + }) + return + + case "disable_all_keys": + // 禁用所有启用的密钥 + if channel.ChannelInfo.MultiKeyStatusList == nil { + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + if channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + + var disabledCount int + for i := 0; i < channel.ChannelInfo.MultiKeySize; i++ { + status := 1 // default enabled + if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { + status = s + } + + // 只禁用当前启用的密钥 + if status == 1 { + channel.ChannelInfo.MultiKeyStatusList[i] = 2 // disabled + disabledCount++ + } + } + + if disabledCount == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "没有可禁用的密钥", + }) + return + } + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("已禁用 %d 个密钥", disabledCount), + }) + return + + case "delete_key": + if request.KeyIndex == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "未指定要删除的密钥索引", + }) + return + } + + keyIndex := *request.KeyIndex + if keyIndex < 0 || keyIndex >= channel.ChannelInfo.MultiKeySize { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "密钥索引超出范围", + }) + return + } + + keys := channel.GetKeys() + var remainingKeys []string + var newStatusList = make(map[int]int) + var newDisabledTime = make(map[int]int64) + var newDisabledReason = make(map[int]string) + + newIndex := 0 + for i, key := range keys { + // 跳过要删除的密钥 + if i == keyIndex { + continue + } + + remainingKeys = append(remainingKeys, key) + + // 保留其他密钥的状态信息,重新索引 + if channel.ChannelInfo.MultiKeyStatusList != nil { + if status, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists && status != 1 { + newStatusList[newIndex] = status + } + } + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists { + newDisabledTime[newIndex] = t + } + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists { + newDisabledReason[newIndex] = r + } + } + newIndex++ + } + + if len(remainingKeys) == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不能删除最后一个密钥", + }) + return + } + + // Update channel with remaining keys + channel.Key = strings.Join(remainingKeys, "\n") + channel.ChannelInfo.MultiKeySize = len(remainingKeys) + channel.ChannelInfo.MultiKeyStatusList = newStatusList + channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime + channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "密钥已删除", + }) + return + + case "delete_disabled_keys": + keys := channel.GetKeys() + var remainingKeys []string + var deletedCount int + var newStatusList = make(map[int]int) + var newDisabledTime = make(map[int]int64) + var newDisabledReason = make(map[int]string) + + newIndex := 0 + for i, key := range keys { + status := 1 // default enabled + if channel.ChannelInfo.MultiKeyStatusList != nil { + if s, exists := channel.ChannelInfo.MultiKeyStatusList[i]; exists { + status = s + } + } + + // 只删除自动禁用(status == 3)的密钥,保留启用(status == 1)和手动禁用(status == 2)的密钥 + if status == 3 { + deletedCount++ + } else { + remainingKeys = append(remainingKeys, key) + // 保留非自动禁用密钥的状态信息,重新索引 + if status != 1 { + newStatusList[newIndex] = status + if channel.ChannelInfo.MultiKeyDisabledTime != nil { + if t, exists := channel.ChannelInfo.MultiKeyDisabledTime[i]; exists { + newDisabledTime[newIndex] = t + } + } + if channel.ChannelInfo.MultiKeyDisabledReason != nil { + if r, exists := channel.ChannelInfo.MultiKeyDisabledReason[i]; exists { + newDisabledReason[newIndex] = r + } + } + } + newIndex++ + } + } + + if deletedCount == 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "没有需要删除的自动禁用密钥", + }) + return + } + + // Update channel with remaining keys + channel.Key = strings.Join(remainingKeys, "\n") + channel.ChannelInfo.MultiKeySize = len(remainingKeys) + channel.ChannelInfo.MultiKeyStatusList = newStatusList + channel.ChannelInfo.MultiKeyDisabledTime = newDisabledTime + channel.ChannelInfo.MultiKeyDisabledReason = newDisabledReason + + err = channel.Update() + if err != nil { + common.ApiError(c, err) + return + } + + model.InitChannelCache() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": fmt.Sprintf("已删除 %d 个自动禁用的密钥", deletedCount), + "data": deletedCount, + }) + return + + default: + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "不支持的操作", + }) + return + } +} diff --git a/controller/console_migrate.go b/controller/console_migrate.go new file mode 100644 index 0000000000000000000000000000000000000000..d79181c08b470671ddd6adbbaa9424efb3273d6d --- /dev/null +++ b/controller/console_migrate.go @@ -0,0 +1,104 @@ +// 用于迁移检测的旧键,该文件下个版本会删除 + +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"}) +} diff --git a/controller/github.go b/controller/github.go new file mode 100644 index 0000000000000000000000000000000000000000..f18095e96d7818a5e221c7f988b327c76c64dcc0 --- /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 0000000000000000000000000000000000000000..7ee7a637ef0d1844d41ab84b49f08df61513d5a7 --- /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 0000000000000000000000000000000000000000..9d66047e8ee164315ecc1bd8dda5a8919ac41f94 --- /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 0000000000000000000000000000000000000000..d3f9667950add4e8ad01274bd451ae62108a664f --- /dev/null +++ b/controller/linuxdo.go @@ -0,0 +1,267 @@ +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 { + if linuxdoUser.TrustLevel >= common.LinuxDOMinimumTrustLevel { + 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": "Linux DO 信任等级未达到管理员设置的最低信任等级", + }) + 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 0000000000000000000000000000000000000000..d8529d2d715374bdf93bebe01653d97a5bd1ac11 --- /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 0000000000000000000000000000000000000000..ecb570cf1d43c183d6a810446a36fa0487f9bdb9 --- /dev/null +++ b/controller/midjourney.go @@ -0,0 +1,295 @@ +package controller + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/logger" + "one-api/model" + "one-api/service" + "one-api/setting" + "one-api/setting/system_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 + } + + logger.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 { + logger.LogError(ctx, fmt.Sprintf("Fix null mj_id task error: %v", err)) + } else { + logger.LogInfo(ctx, fmt.Sprintf("Fix null mj_id task success: %v", nullTaskIds)) + } + } + if len(taskChannelM) == 0 { + continue + } + + for channelId, taskIds := range taskChannelM { + logger.LogInfo(ctx, fmt.Sprintf("渠道 #%d 未完成的任务有: %d", channelId, len(taskIds))) + if len(taskIds) == 0 { + continue + } + midjourneyChannel, err := model.CacheGetChannel(channelId) + if err != nil { + logger.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 { + logger.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 { + logger.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 { + logger.LogError(ctx, fmt.Sprintf("Get Task Do req error: %v", err)) + continue + } + if resp.StatusCode != http.StatusOK { + logger.LogError(ctx, fmt.Sprintf("Get Task status code: %d", resp.StatusCode)) + continue + } + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("Get Task parse body error: %v", err)) + continue + } + var responseItems []dto.MidjourneyDto + err = json.Unmarshal(responseBody, &responseItems) + if err != nil { + logger.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) + } + // 映射 VideoUrl + task.VideoUrl = responseItem.VideoUrl + + // 映射 VideoUrls - 将数组序列化为 JSON 字符串 + if responseItem.VideoUrls != nil && len(responseItem.VideoUrls) > 0 { + videoUrlsStr, err := json.Marshal(responseItem.VideoUrls) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("序列化 VideoUrls 失败: %v", err)) + task.VideoUrls = "[]" // 失败时设置为空数组 + } else { + task.VideoUrls = string(videoUrlsStr) + } + } else { + task.VideoUrls = "" // 空值时清空字段 + } + + shouldReturnQuota := false + if (task.Progress != "100%" && responseItem.FailReason != "") || (task.Progress == "100%" && task.Status == "FAILURE") { + logger.LogInfo(ctx, task.MjId+" 构建失败,"+task.FailReason) + task.Progress = "100%" + if task.Quota != 0 { + shouldReturnQuota = true + } + } + err = task.Update() + if err != nil { + logger.LogError(ctx, "UpdateMidjourneyTask task error: "+err.Error()) + } else { + if shouldReturnQuota { + err = model.IncreaseUserQuota(task.UserId, task.Quota, false) + if err != nil { + logger.LogError(ctx, "fail to increase user quota: "+err.Error()) + } + logContent := fmt.Sprintf("构图失败 %s,补偿 %s", task.MjId, logger.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 + } + // 检查 VideoUrl 是否需要更新 + if oldTask.VideoUrl != newTask.VideoUrl { + return true + } + // 检查 VideoUrls 是否需要更新 + if newTask.VideoUrls != nil && len(newTask.VideoUrls) > 0 { + newVideoUrlsStr, _ := json.Marshal(newTask.VideoUrls) + if oldTask.VideoUrls != string(newVideoUrlsStr) { + return true + } + } else if oldTask.VideoUrls != "" { + // 如果新数据没有 VideoUrls 但旧数据有,需要更新(清空) + 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 = system_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 = system_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 0000000000000000000000000000000000000000..fb1e1b6b83eb4b2c1df51d7112732284add13093 --- /dev/null +++ b/controller/misc.go @@ -0,0 +1,314 @@ +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() + common.OptionMapRWMutex.RLock() + defer common.OptionMapRWMutex.RUnlock() + + passkeySetting := system_setting.GetPasskeySettings() + + 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, + "linuxdo_minimum_trust_level": common.LinuxDOMinimumTrustLevel, + "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": system_setting.ServerAddress, + "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, + "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, + + "usd_exchange_rate": operation_setting.USDExchangeRate, + "price": operation_setting.Price, + "stripe_unit_price": setting.StripeUnitPrice, + + // 面板启用开关 + "api_info_enabled": cs.ApiInfoEnabled, + "uptime_kuma_enabled": cs.UptimeKumaEnabled, + "announcements_enabled": cs.AnnouncementsEnabled, + "faq_enabled": cs.FAQEnabled, + + // 模块管理配置 + "HeaderNavModules": common.OptionMap["HeaderNavModules"], + "SidebarModulesAdmin": common.OptionMap["SidebarModulesAdmin"], + + "oidc_enabled": system_setting.GetOIDCSettings().Enabled, + "oidc_client_id": system_setting.GetOIDCSettings().ClientId, + "oidc_authorization_endpoint": system_setting.GetOIDCSettings().AuthorizationEndpoint, + "passkey_login": passkeySetting.Enabled, + "passkey_display_name": passkeySetting.RPDisplayName, + "passkey_rp_id": passkeySetting.RPID, + "passkey_origins": passkeySetting.Origins, + "passkey_allow_insecure": passkeySetting.AllowInsecureOrigin, + "passkey_user_verification": passkeySetting.UserVerification, + "passkey_attachment": passkeySetting.AttachmentPreference, + "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", system_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/missing_models.go b/controller/missing_models.go new file mode 100644 index 0000000000000000000000000000000000000000..c18afba25e7934d9779fd057faf72411963b6092 --- /dev/null +++ b/controller/missing_models.go @@ -0,0 +1,27 @@ +package controller + +import ( + "net/http" + "one-api/model" + + "github.com/gin-gonic/gin" +) + +// GetMissingModels returns the list of model names that are referenced by channels +// but do not have corresponding records in the models meta table. +// This helps administrators quickly discover models that need configuration. +func GetMissingModels(c *gin.Context) { + missing, err := model.GetMissingModels() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": missing, + }) +} diff --git a/controller/model.go b/controller/model.go new file mode 100644 index 0000000000000000000000000000000000000000..07a77fac76ff6525669d2ca886f8666407dfbafe --- /dev/null +++ b/controller/model.go @@ -0,0 +1,261 @@ +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" + "time" +) + +// 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{ChannelMeta: &relaycommon.ChannelMeta{ + 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, modelType int) { + 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), + }) + } + } + } + switch modelType { + case constant.ChannelTypeAnthropic: + useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels)) + for i, model := range userOpenAiModels { + useranthropicModels[i] = dto.AnthropicModel{ + ID: model.Id, + CreatedAt: time.Unix(int64(model.Created), 0).UTC().Format(time.RFC3339), + DisplayName: model.Id, + Type: "model", + } + } + c.JSON(200, gin.H{ + "data": useranthropicModels, + "first_id": useranthropicModels[0].ID, + "has_more": false, + "last_id": useranthropicModels[len(useranthropicModels)-1].ID, + }) + case constant.ChannelTypeGemini: + userGeminiModels := make([]dto.GeminiModel, len(userOpenAiModels)) + for i, model := range userOpenAiModels { + userGeminiModels[i] = dto.GeminiModel{ + Name: model.Id, + DisplayName: model.Id, + } + } + c.JSON(200, gin.H{ + "models": userGeminiModels, + "nextPageToken": nil, + }) + default: + c.JSON(200, gin.H{ + "success": true, + "data": userOpenAiModels, + "object": "list", + }) + } +} + +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, modelType int) { + modelId := c.Param("model") + if aiModel, ok := openAIModelsMap[modelId]; ok { + switch modelType { + case constant.ChannelTypeAnthropic: + c.JSON(200, dto.AnthropicModel{ + ID: aiModel.Id, + CreatedAt: time.Unix(int64(aiModel.Created), 0).UTC().Format(time.RFC3339), + DisplayName: aiModel.Id, + Type: "model", + }) + default: + 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/model_meta.go b/controller/model_meta.go new file mode 100644 index 0000000000000000000000000000000000000000..bf879d432306eaf1e67f1d74732d7cb2d4a0f5a8 --- /dev/null +++ b/controller/model_meta.go @@ -0,0 +1,330 @@ +package controller + +import ( + "encoding/json" + "sort" + "strconv" + "strings" + + "one-api/common" + "one-api/constant" + "one-api/model" + + "github.com/gin-gonic/gin" +) + +// GetAllModelsMeta 获取模型列表(分页) +func GetAllModelsMeta(c *gin.Context) { + + pageInfo := common.GetPageQuery(c) + modelsMeta, err := model.GetAllModels(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + // 批量填充附加字段,提升列表接口性能 + enrichModels(modelsMeta) + var total int64 + model.DB.Model(&model.Model{}).Count(&total) + + // 统计供应商计数(全部数据,不受分页影响) + vendorCounts, _ := model.GetVendorModelCounts() + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(modelsMeta) + common.ApiSuccess(c, gin.H{ + "items": modelsMeta, + "total": total, + "page": pageInfo.GetPage(), + "page_size": pageInfo.GetPageSize(), + "vendor_counts": vendorCounts, + }) +} + +// SearchModelsMeta 搜索模型列表 +func SearchModelsMeta(c *gin.Context) { + + keyword := c.Query("keyword") + vendor := c.Query("vendor") + pageInfo := common.GetPageQuery(c) + + modelsMeta, total, err := model.SearchModels(keyword, vendor, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + // 批量填充附加字段,提升列表接口性能 + enrichModels(modelsMeta) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(modelsMeta) + common.ApiSuccess(c, pageInfo) +} + +// GetModelMeta 根据 ID 获取单条模型信息 +func GetModelMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + var m model.Model + if err := model.DB.First(&m, id).Error; err != nil { + common.ApiError(c, err) + return + } + enrichModels([]*model.Model{&m}) + common.ApiSuccess(c, &m) +} + +// CreateModelMeta 新建模型 +func CreateModelMeta(c *gin.Context) { + var m model.Model + if err := c.ShouldBindJSON(&m); err != nil { + common.ApiError(c, err) + return + } + if m.ModelName == "" { + common.ApiErrorMsg(c, "模型名称不能为空") + return + } + // 名称冲突检查 + if dup, err := model.IsModelNameDuplicated(0, m.ModelName); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "模型名称已存在") + return + } + + if err := m.Insert(); err != nil { + common.ApiError(c, err) + return + } + model.RefreshPricing() + common.ApiSuccess(c, &m) +} + +// UpdateModelMeta 更新模型 +func UpdateModelMeta(c *gin.Context) { + statusOnly := c.Query("status_only") == "true" + + var m model.Model + if err := c.ShouldBindJSON(&m); err != nil { + common.ApiError(c, err) + return + } + if m.Id == 0 { + common.ApiErrorMsg(c, "缺少模型 ID") + return + } + + if statusOnly { + // 只更新状态,防止误清空其他字段 + if err := model.DB.Model(&model.Model{}).Where("id = ?", m.Id).Update("status", m.Status).Error; err != nil { + common.ApiError(c, err) + return + } + } else { + // 名称冲突检查 + if dup, err := model.IsModelNameDuplicated(m.Id, m.ModelName); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "模型名称已存在") + return + } + + if err := m.Update(); err != nil { + common.ApiError(c, err) + return + } + } + model.RefreshPricing() + common.ApiSuccess(c, &m) +} + +// DeleteModelMeta 删除模型 +func DeleteModelMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DB.Delete(&model.Model{}, id).Error; err != nil { + common.ApiError(c, err) + return + } + model.RefreshPricing() + common.ApiSuccess(c, nil) +} + +// enrichModels 批量填充附加信息:端点、渠道、分组、计费类型,避免 N+1 查询 +func enrichModels(models []*model.Model) { + if len(models) == 0 { + return + } + + // 1) 拆分精确与规则匹配 + exactNames := make([]string, 0) + exactIdx := make(map[string][]int) // modelName -> indices in models + ruleIndices := make([]int, 0) + for i, m := range models { + if m == nil { + continue + } + if m.NameRule == model.NameRuleExact { + exactNames = append(exactNames, m.ModelName) + exactIdx[m.ModelName] = append(exactIdx[m.ModelName], i) + } else { + ruleIndices = append(ruleIndices, i) + } + } + + // 2) 批量查询精确模型的绑定渠道 + channelsByModel, _ := model.GetBoundChannelsByModelsMap(exactNames) + + // 3) 精确模型:端点从缓存、渠道批量映射、分组/计费类型从缓存 + for name, indices := range exactIdx { + chs := channelsByModel[name] + for _, idx := range indices { + mm := models[idx] + if mm.Endpoints == "" { + eps := model.GetModelSupportEndpointTypes(mm.ModelName) + if b, err := json.Marshal(eps); err == nil { + mm.Endpoints = string(b) + } + } + mm.BoundChannels = chs + mm.EnableGroups = model.GetModelEnableGroups(mm.ModelName) + mm.QuotaTypes = model.GetModelQuotaTypes(mm.ModelName) + } + } + + if len(ruleIndices) == 0 { + return + } + + // 4) 一次性读取定价缓存,内存匹配所有规则模型 + pricings := model.GetPricing() + + // 为全部规则模型收集匹配名集合、端点并集、分组并集、配额集合 + matchedNamesByIdx := make(map[int][]string) + endpointSetByIdx := make(map[int]map[constant.EndpointType]struct{}) + groupSetByIdx := make(map[int]map[string]struct{}) + quotaSetByIdx := make(map[int]map[int]struct{}) + + for _, p := range pricings { + for _, idx := range ruleIndices { + mm := models[idx] + var matched bool + switch mm.NameRule { + case model.NameRulePrefix: + matched = strings.HasPrefix(p.ModelName, mm.ModelName) + case model.NameRuleSuffix: + matched = strings.HasSuffix(p.ModelName, mm.ModelName) + case model.NameRuleContains: + matched = strings.Contains(p.ModelName, mm.ModelName) + } + if !matched { + continue + } + matchedNamesByIdx[idx] = append(matchedNamesByIdx[idx], p.ModelName) + + es := endpointSetByIdx[idx] + if es == nil { + es = make(map[constant.EndpointType]struct{}) + endpointSetByIdx[idx] = es + } + for _, et := range p.SupportedEndpointTypes { + es[et] = struct{}{} + } + + gs := groupSetByIdx[idx] + if gs == nil { + gs = make(map[string]struct{}) + groupSetByIdx[idx] = gs + } + for _, g := range p.EnableGroup { + gs[g] = struct{}{} + } + + qs := quotaSetByIdx[idx] + if qs == nil { + qs = make(map[int]struct{}) + quotaSetByIdx[idx] = qs + } + qs[p.QuotaType] = struct{}{} + } + } + + // 5) 汇总所有匹配到的模型名称,批量查询一次渠道 + allMatchedSet := make(map[string]struct{}) + for _, names := range matchedNamesByIdx { + for _, n := range names { + allMatchedSet[n] = struct{}{} + } + } + allMatched := make([]string, 0, len(allMatchedSet)) + for n := range allMatchedSet { + allMatched = append(allMatched, n) + } + matchedChannelsByModel, _ := model.GetBoundChannelsByModelsMap(allMatched) + + // 6) 回填每个规则模型的并集信息 + for _, idx := range ruleIndices { + mm := models[idx] + + // 端点并集 -> 序列化 + if es, ok := endpointSetByIdx[idx]; ok && mm.Endpoints == "" { + eps := make([]constant.EndpointType, 0, len(es)) + for et := range es { + eps = append(eps, et) + } + if b, err := json.Marshal(eps); err == nil { + mm.Endpoints = string(b) + } + } + + // 分组并集 + if gs, ok := groupSetByIdx[idx]; ok { + groups := make([]string, 0, len(gs)) + for g := range gs { + groups = append(groups, g) + } + mm.EnableGroups = groups + } + + // 配额类型集合(保持去重并排序) + if qs, ok := quotaSetByIdx[idx]; ok { + arr := make([]int, 0, len(qs)) + for k := range qs { + arr = append(arr, k) + } + sort.Ints(arr) + mm.QuotaTypes = arr + } + + // 渠道并集 + names := matchedNamesByIdx[idx] + channelSet := make(map[string]model.BoundChannel) + for _, n := range names { + for _, ch := range matchedChannelsByModel[n] { + key := ch.Name + "_" + strconv.Itoa(ch.Type) + channelSet[key] = ch + } + } + if len(channelSet) > 0 { + chs := make([]model.BoundChannel, 0, len(channelSet)) + for _, ch := range channelSet { + chs = append(chs, ch) + } + mm.BoundChannels = chs + } + + // 匹配信息 + mm.MatchedModels = names + mm.MatchedCount = len(names) + } +} diff --git a/controller/model_sync.go b/controller/model_sync.go new file mode 100644 index 0000000000000000000000000000000000000000..d539e9c17c3e5024e2c1ee2de6dd2cf44e304900 --- /dev/null +++ b/controller/model_sync.go @@ -0,0 +1,604 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net" + "net/http" + "strings" + "sync" + "time" + + "one-api/common" + "one-api/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// 上游地址 +const ( + upstreamModelsURL = "https://basellm.github.io/llm-metadata/api/newapi/models.json" + upstreamVendorsURL = "https://basellm.github.io/llm-metadata/api/newapi/vendors.json" +) + +func normalizeLocale(locale string) (string, bool) { + l := strings.ToLower(strings.TrimSpace(locale)) + switch l { + case "en", "zh", "ja": + return l, true + default: + return "", false + } +} + +func getUpstreamBase() string { + return common.GetEnvOrDefaultString("SYNC_UPSTREAM_BASE", "https://basellm.github.io/llm-metadata") +} + +func getUpstreamURLs(locale string) (modelsURL, vendorsURL string) { + base := strings.TrimRight(getUpstreamBase(), "/") + if l, ok := normalizeLocale(locale); ok && l != "" { + return fmt.Sprintf("%s/api/i18n/%s/newapi/models.json", base, l), + fmt.Sprintf("%s/api/i18n/%s/newapi/vendors.json", base, l) + } + return fmt.Sprintf("%s/api/newapi/models.json", base), fmt.Sprintf("%s/api/newapi/vendors.json", base) +} + +type upstreamEnvelope[T any] struct { + Success bool `json:"success"` + Message string `json:"message"` + Data []T `json:"data"` +} + +type upstreamModel struct { + Description string `json:"description"` + Endpoints json.RawMessage `json:"endpoints"` + Icon string `json:"icon"` + ModelName string `json:"model_name"` + NameRule int `json:"name_rule"` + Status int `json:"status"` + Tags string `json:"tags"` + VendorName string `json:"vendor_name"` +} + +type upstreamVendor struct { + Description string `json:"description"` + Icon string `json:"icon"` + Name string `json:"name"` + Status int `json:"status"` +} + +var ( + etagCache = make(map[string]string) + bodyCache = make(map[string][]byte) + cacheMutex sync.RWMutex +) + +type overwriteField struct { + ModelName string `json:"model_name"` + Fields []string `json:"fields"` +} + +type syncRequest struct { + Overwrite []overwriteField `json:"overwrite"` + Locale string `json:"locale"` +} + +func newHTTPClient() *http.Client { + timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 10) + dialer := &net.Dialer{Timeout: time.Duration(timeoutSec) * time.Second} + transport := &http.Transport{ + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: time.Duration(timeoutSec) * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ResponseHeaderTimeout: time.Duration(timeoutSec) * time.Second, + } + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + if strings.HasSuffix(host, "github.io") { + if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil { + return conn, nil + } + return dialer.DialContext(ctx, "tcp6", addr) + } + return dialer.DialContext(ctx, network, addr) + } + return &http.Client{Transport: transport} +} + +var httpClient = newHTTPClient() + +func fetchJSON[T any](ctx context.Context, url string, out *upstreamEnvelope[T]) error { + var lastErr error + attempts := common.GetEnvOrDefault("SYNC_HTTP_RETRY", 3) + if attempts < 1 { + attempts = 1 + } + baseDelay := 200 * time.Millisecond + maxMB := common.GetEnvOrDefault("SYNC_HTTP_MAX_MB", 10) + maxBytes := int64(maxMB) << 20 + for attempt := 0; attempt < attempts; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + // ETag conditional request + cacheMutex.RLock() + if et := etagCache[url]; et != "" { + req.Header.Set("If-None-Match", et) + } + cacheMutex.RUnlock() + + resp, err := httpClient.Do(req) + if err != nil { + lastErr = err + // backoff with jitter + sleep := baseDelay * time.Duration(1< id + vendorIDCache := make(map[string]int) + + for _, name := range missing { + up, ok := modelByName[name] + if !ok { + skipped = append(skipped, name) + continue + } + + // 若本地已存在且设置为不同步,则跳过(极端情况:缺失列表与本地状态不同步时) + var existing model.Model + if err := model.DB.Where("model_name = ?", name).First(&existing).Error; err == nil { + if existing.SyncOfficial == 0 { + skipped = append(skipped, name) + continue + } + } + + // 确保 vendor 存在 + vendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) + + // 创建模型 + mi := &model.Model{ + ModelName: name, + Description: up.Description, + Icon: up.Icon, + Tags: up.Tags, + VendorID: vendorID, + Status: chooseStatus(up.Status, 1), + NameRule: up.NameRule, + } + if err := mi.Insert(); err == nil { + createdModels++ + createdList = append(createdList, name) + } else { + skipped = append(skipped, name) + } + } + + // 4) 处理可选覆盖(更新本地已有模型的差异字段) + if len(req.Overwrite) > 0 { + // vendorIDCache 已用于创建阶段,可复用 + for _, ow := range req.Overwrite { + up, ok := modelByName[ow.ModelName] + if !ok { + continue + } + var local model.Model + if err := model.DB.Where("model_name = ?", ow.ModelName).First(&local).Error; err != nil { + continue + } + + // 跳过被禁用官方同步的模型 + if local.SyncOfficial == 0 { + continue + } + + // 映射 vendor + newVendorID := ensureVendorID(up.VendorName, vendorByName, vendorIDCache, &createdVendors) + + // 应用字段覆盖(事务) + _ = model.DB.Transaction(func(tx *gorm.DB) error { + needUpdate := false + if containsField(ow.Fields, "description") { + local.Description = up.Description + needUpdate = true + } + if containsField(ow.Fields, "icon") { + local.Icon = up.Icon + needUpdate = true + } + if containsField(ow.Fields, "tags") { + local.Tags = up.Tags + needUpdate = true + } + if containsField(ow.Fields, "vendor") { + local.VendorID = newVendorID + needUpdate = true + } + if containsField(ow.Fields, "name_rule") { + local.NameRule = up.NameRule + needUpdate = true + } + if containsField(ow.Fields, "status") { + local.Status = chooseStatus(up.Status, local.Status) + needUpdate = true + } + if !needUpdate { + return nil + } + if err := tx.Save(&local).Error; err != nil { + return err + } + updatedModels++ + updatedList = append(updatedList, ow.ModelName) + return nil + }) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "created_models": createdModels, + "created_vendors": createdVendors, + "updated_models": updatedModels, + "skipped_models": skipped, + "created_list": createdList, + "updated_list": updatedList, + "source": gin.H{ + "locale": req.Locale, + "models_url": modelsURL, + "vendors_url": vendorsURL, + }, + }, + }) +} + +func containsField(fields []string, key string) bool { + key = strings.ToLower(strings.TrimSpace(key)) + for _, f := range fields { + if strings.ToLower(strings.TrimSpace(f)) == key { + return true + } + } + return false +} + +func coalesce(a, b string) string { + if strings.TrimSpace(a) != "" { + return a + } + return b +} + +func chooseStatus(primary, fallback int) int { + if primary == 0 && fallback != 0 { + return fallback + } + if primary != 0 { + return primary + } + return 1 +} + +// SyncUpstreamPreview 预览上游与本地的差异(仅用于弹窗选择) +func SyncUpstreamPreview(c *gin.Context) { + // 1) 拉取上游数据 + timeoutSec := common.GetEnvOrDefault("SYNC_HTTP_TIMEOUT_SECONDS", 15) + ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSec)*time.Second) + defer cancel() + + locale := c.Query("locale") + modelsURL, vendorsURL := getUpstreamURLs(locale) + + var vendorsEnv upstreamEnvelope[upstreamVendor] + var modelsEnv upstreamEnvelope[upstreamModel] + var fetchErr error + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _ = fetchJSON(ctx, vendorsURL, &vendorsEnv) + }() + go func() { + defer wg.Done() + if err := fetchJSON(ctx, modelsURL, &modelsEnv); err != nil { + fetchErr = err + } + }() + wg.Wait() + if fetchErr != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取上游模型失败: " + fetchErr.Error(), "locale": locale, "source_urls": gin.H{"models_url": modelsURL, "vendors_url": vendorsURL}}) + return + } + + vendorByName := make(map[string]upstreamVendor) + for _, v := range vendorsEnv.Data { + if v.Name != "" { + vendorByName[v.Name] = v + } + } + modelByName := make(map[string]upstreamModel) + upstreamNames := make([]string, 0, len(modelsEnv.Data)) + for _, m := range modelsEnv.Data { + if m.ModelName != "" { + modelByName[m.ModelName] = m + upstreamNames = append(upstreamNames, m.ModelName) + } + } + + // 2) 本地已有模型 + var locals []model.Model + if len(upstreamNames) > 0 { + _ = model.DB.Where("model_name IN ? AND sync_official <> 0", upstreamNames).Find(&locals).Error + } + + // 本地 vendor 名称映射 + vendorIdSet := make(map[int]struct{}) + for _, m := range locals { + if m.VendorID != 0 { + vendorIdSet[m.VendorID] = struct{}{} + } + } + vendorIDs := make([]int, 0, len(vendorIdSet)) + for id := range vendorIdSet { + vendorIDs = append(vendorIDs, id) + } + idToVendorName := make(map[int]string) + if len(vendorIDs) > 0 { + var dbVendors []model.Vendor + _ = model.DB.Where("id IN ?", vendorIDs).Find(&dbVendors).Error + for _, v := range dbVendors { + idToVendorName[v.Id] = v.Name + } + } + + // 3) 缺失且上游存在的模型 + missingList, _ := model.GetMissingModels() + var missing []string + for _, name := range missingList { + if _, ok := modelByName[name]; ok { + missing = append(missing, name) + } + } + + // 4) 计算冲突字段 + type conflictField struct { + Field string `json:"field"` + Local interface{} `json:"local"` + Upstream interface{} `json:"upstream"` + } + type conflictItem struct { + ModelName string `json:"model_name"` + Fields []conflictField `json:"fields"` + } + + var conflicts []conflictItem + for _, local := range locals { + up, ok := modelByName[local.ModelName] + if !ok { + continue + } + fields := make([]conflictField, 0, 6) + if strings.TrimSpace(local.Description) != strings.TrimSpace(up.Description) { + fields = append(fields, conflictField{Field: "description", Local: local.Description, Upstream: up.Description}) + } + if strings.TrimSpace(local.Icon) != strings.TrimSpace(up.Icon) { + fields = append(fields, conflictField{Field: "icon", Local: local.Icon, Upstream: up.Icon}) + } + if strings.TrimSpace(local.Tags) != strings.TrimSpace(up.Tags) { + fields = append(fields, conflictField{Field: "tags", Local: local.Tags, Upstream: up.Tags}) + } + // vendor 对比使用名称 + localVendor := idToVendorName[local.VendorID] + if strings.TrimSpace(localVendor) != strings.TrimSpace(up.VendorName) { + fields = append(fields, conflictField{Field: "vendor", Local: localVendor, Upstream: up.VendorName}) + } + if local.NameRule != up.NameRule { + fields = append(fields, conflictField{Field: "name_rule", Local: local.NameRule, Upstream: up.NameRule}) + } + if local.Status != chooseStatus(up.Status, local.Status) { + fields = append(fields, conflictField{Field: "status", Local: local.Status, Upstream: up.Status}) + } + if len(fields) > 0 { + conflicts = append(conflicts, conflictItem{ModelName: local.ModelName, Fields: fields}) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "missing": missing, + "conflicts": conflicts, + "source": gin.H{ + "locale": locale, + "models_url": modelsURL, + "vendors_url": vendorsURL, + }, + }, + }) +} diff --git a/controller/oidc.go b/controller/oidc.go new file mode 100644 index 0000000000000000000000000000000000000000..1684da7eca758bbe4251fc6747369c87ba3ddc09 --- /dev/null +++ b/controller/oidc.go @@ -0,0 +1,227 @@ +package controller + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "one-api/common" + "one-api/model" + "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", system_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.SysLog("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.SysLog("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.SysLog("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 0000000000000000000000000000000000000000..345a4626c977ff8daa12c6668dfebef10beb1bdb --- /dev/null +++ b/controller/option.go @@ -0,0 +1,214 @@ +package controller + +import ( + "encoding/json" + "fmt" + "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 +} + +type OptionUpdateRequest struct { + Key string `json:"key"` + Value any `json:"value"` +} + +func UpdateOption(c *gin.Context) { + var option OptionUpdateRequest + err := json.NewDecoder(c.Request.Body).Decode(&option) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + switch option.Value.(type) { + case bool: + option.Value = common.Interface2String(option.Value.(bool)) + case float64: + option.Value = common.Interface2String(option.Value.(float64)) + case int: + option.Value = common.Interface2String(option.Value.(int)) + default: + option.Value = fmt.Sprintf("%v", option.Value) + } + 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.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + case "ImageRatio": + err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "图片倍率设置失败: " + err.Error(), + }) + return + } + case "AudioRatio": + err = ratio_setting.UpdateAudioRatioByJSONString(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "音频倍率设置失败: " + err.Error(), + }) + return + } + case "AudioCompletionRatio": + err = ratio_setting.UpdateAudioCompletionRatioByJSONString(option.Value.(string)) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "音频补全倍率设置失败: " + err.Error(), + }) + return + } + case "ModelRequestRateLimitGroup": + err = setting.CheckModelRequestRateLimitGroup(option.Value.(string)) + 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.(string), "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.(string), "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.(string), "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.(string), "UptimeKumaGroups") + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + err = model.UpdateOption(option.Key, option.Value.(string)) + if err != nil { + common.ApiError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} diff --git a/controller/passkey.go b/controller/passkey.go new file mode 100644 index 0000000000000000000000000000000000000000..54d07a39a58d8009dee6d7d36e8fe8a61c919dd3 --- /dev/null +++ b/controller/passkey.go @@ -0,0 +1,497 @@ +package controller + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "time" + + "one-api/common" + "one-api/model" + passkeysvc "one-api/service/passkey" + "one-api/setting/system_setting" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + webauthnlib "github.com/go-webauthn/webauthn/webauthn" +) + +func PasskeyRegisterBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { + common.ApiError(c, err) + return + } + if errors.Is(err, model.ErrPasskeyNotFound) { + credential = nil + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + var options []webauthnlib.RegistrationOption + if credential != nil { + descriptor := credential.ToWebAuthnCredential().Descriptor() + options = append(options, webauthnlib.WithExclusions([]protocol.CredentialDescriptor{descriptor})) + } + + creation, sessionData, err := wa.BeginRegistration(waUser, options...) + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.RegistrationSessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": creation, + }, + }) +} + +func PasskeyRegisterFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + credentialRecord, err := model.GetPasskeyByUserID(user.Id) + if err != nil && !errors.Is(err, model.ErrPasskeyNotFound) { + common.ApiError(c, err) + return + } + if errors.Is(err, model.ErrPasskeyNotFound) { + credentialRecord = nil + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.RegistrationSessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credentialRecord) + credential, err := wa.FinishRegistration(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + passkeyCredential := model.NewPasskeyCredentialFromWebAuthn(user.Id, credential) + if passkeyCredential == nil { + common.ApiErrorMsg(c, "无法创建 Passkey 凭证") + return + } + + if err := model.UpsertPasskeyCredential(passkeyCredential); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 注册成功", + }) +} + +func PasskeyDelete(c *gin.Context) { + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + if err := model.DeletePasskeyByUserID(user.Id); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 已解绑", + }) +} + +func PasskeyStatus(c *gin.Context) { + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if errors.Is(err, model.ErrPasskeyNotFound) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "enabled": false, + }, + }) + return + } + if err != nil { + common.ApiError(c, err) + return + } + + data := gin.H{ + "enabled": true, + "last_used_at": credential.LastUsedAt, + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) +} + +func PasskeyLoginBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + assertion, sessionData, err := wa.BeginDiscoverableLogin() + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.LoginSessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": assertion, + }, + }) +} + +func PasskeyLoginFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.LoginSessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + handler := func(rawID, userHandle []byte) (webauthnlib.User, error) { + // 首先通过凭证ID查找用户 + credential, err := model.GetPasskeyByCredentialID(rawID) + if err != nil { + return nil, fmt.Errorf("未找到 Passkey 凭证: %w", err) + } + + // 通过凭证获取用户 + user := &model.User{Id: credential.UserID} + if err := user.FillUserById(); err != nil { + return nil, fmt.Errorf("用户信息获取失败: %w", err) + } + + if user.Status != common.UserStatusEnabled { + return nil, errors.New("该用户已被禁用") + } + + if len(userHandle) > 0 { + userID, parseErr := strconv.Atoi(string(userHandle)) + if parseErr != nil { + // 记录异常但继续验证,因为某些客户端可能使用非数字格式 + common.SysLog(fmt.Sprintf("PasskeyLogin: userHandle parse error for credential, length: %d", len(userHandle))) + } else if userID != user.Id { + return nil, errors.New("用户句柄与凭证不匹配") + } + } + + return passkeysvc.NewWebAuthnUser(user, credential), nil + } + + waUser, credential, err := wa.FinishPasskeyLogin(handler, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + userWrapper, ok := waUser.(*passkeysvc.WebAuthnUser) + if !ok { + common.ApiErrorMsg(c, "Passkey 登录状态异常") + return + } + + modelUser := userWrapper.ModelUser() + if modelUser == nil { + common.ApiErrorMsg(c, "Passkey 登录状态异常") + return + } + + if modelUser.Status != common.UserStatusEnabled { + common.ApiErrorMsg(c, "该用户已被禁用") + return + } + + // 更新凭证信息 + updatedCredential := model.NewPasskeyCredentialFromWebAuthn(modelUser.Id, credential) + if updatedCredential == nil { + common.ApiErrorMsg(c, "Passkey 凭证更新失败") + return + } + now := time.Now() + updatedCredential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(updatedCredential); err != nil { + common.ApiError(c, err) + return + } + + setupLogin(modelUser, c) + return +} + +func AdminResetPasskey(c *gin.Context) { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiErrorMsg(c, "无效的用户 ID") + return + } + + user := &model.User{Id: id} + if err := user.FillUserById(); err != nil { + common.ApiError(c, err) + return + } + + if _, err := model.GetPasskeyByUserID(user.Id); err != nil { + if errors.Is(err, model.ErrPasskeyNotFound) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + common.ApiError(c, err) + return + } + + if err := model.DeletePasskeyByUserID(user.Id); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 已重置", + }) +} + +func PasskeyVerifyBegin(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + assertion, sessionData, err := wa.BeginLogin(waUser) + if err != nil { + common.ApiError(c, err) + return + } + + if err := passkeysvc.SaveSessionData(c, passkeysvc.VerifySessionKey, sessionData); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "options": assertion, + }, + }) +} + +func PasskeyVerifyFinish(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + user, err := getSessionUser(c) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + credential, err := model.GetPasskeyByUserID(user.Id) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + _, err = wa.FinishLogin(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + // 更新凭证的最后使用时间 + now := time.Now() + credential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(credential); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 验证成功", + }) +} + +func getSessionUser(c *gin.Context) (*model.User, error) { + session := sessions.Default(c) + idRaw := session.Get("id") + if idRaw == nil { + return nil, errors.New("未登录") + } + id, ok := idRaw.(int) + if !ok { + return nil, errors.New("无效的会话信息") + } + user := &model.User{Id: id} + if err := user.FillUserById(); err != nil { + return nil, err + } + if user.Status != common.UserStatusEnabled { + return nil, errors.New("该用户已被禁用") + } + return user, nil +} diff --git a/controller/playground.go b/controller/playground.go new file mode 100644 index 0000000000000000000000000000000000000000..8f509b89ec5e1c9b48b6b430a643b9fb7f86110e --- /dev/null +++ b/controller/playground.go @@ -0,0 +1,60 @@ +package controller + +import ( + "errors" + "fmt" + "one-api/common" + "one-api/constant" + "one-api/middleware" + "one-api/model" + "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, types.ErrOptionWithSkipRetry()) + return + } + + group := c.GetString("group") + modelName := c.GetString("original_model") + + 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, types.ErrOptionWithSkipRetry()) + 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, modelName, 0) + if newAPIError != nil { + return + } + //middleware.SetupContextForSelectedChannel(c, channel, playgroundRequest.Model) + common.SetContextKey(c, constant.ContextKeyRequestStartTime, time.Now()) + + Relay(c, types.RelayFormatOpenAI) +} diff --git a/controller/prefill_group.go b/controller/prefill_group.go new file mode 100644 index 0000000000000000000000000000000000000000..296e19fb35af880e0c307fa5537a0ed1e99bd287 --- /dev/null +++ b/controller/prefill_group.go @@ -0,0 +1,90 @@ +package controller + +import ( + "strconv" + + "one-api/common" + "one-api/model" + + "github.com/gin-gonic/gin" +) + +// GetPrefillGroups 获取预填组列表,可通过 ?type=xxx 过滤 +func GetPrefillGroups(c *gin.Context) { + groupType := c.Query("type") + groups, err := model.GetAllPrefillGroups(groupType) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, groups) +} + +// CreatePrefillGroup 创建新的预填组 +func CreatePrefillGroup(c *gin.Context) { + var g model.PrefillGroup + if err := c.ShouldBindJSON(&g); err != nil { + common.ApiError(c, err) + return + } + if g.Name == "" || g.Type == "" { + common.ApiErrorMsg(c, "组名称和类型不能为空") + return + } + // 创建前检查名称 + if dup, err := model.IsPrefillGroupNameDuplicated(0, g.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "组名称已存在") + return + } + + if err := g.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &g) +} + +// UpdatePrefillGroup 更新预填组 +func UpdatePrefillGroup(c *gin.Context) { + var g model.PrefillGroup + if err := c.ShouldBindJSON(&g); err != nil { + common.ApiError(c, err) + return + } + if g.Id == 0 { + common.ApiErrorMsg(c, "缺少组 ID") + return + } + // 名称冲突检查 + if dup, err := model.IsPrefillGroupNameDuplicated(g.Id, g.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "组名称已存在") + return + } + + if err := g.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &g) +} + +// DeletePrefillGroup 删除预填组 +func DeletePrefillGroup(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DeletePrefillGroupByID(id); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} diff --git a/controller/pricing.go b/controller/pricing.go new file mode 100644 index 0000000000000000000000000000000000000000..e8d80416ed59571445e486fa9864105abfc40b33 --- /dev/null +++ b/controller/pricing.go @@ -0,0 +1,74 @@ +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, + "vendors": model.GetVendors(), + "group_ratio": groupRatio, + "usable_group": usableGroup, + "supported_endpoint": model.GetSupportedEndpointMap(), + "auto_groups": setting.AutoGroups, + }) +} + +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 0000000000000000000000000000000000000000..b72cb638257923670670b24b110c46997957d165 --- /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(), + }) +} diff --git a/controller/ratio_sync.go b/controller/ratio_sync.go new file mode 100644 index 0000000000000000000000000000000000000000..2f7786666a5cb1652b9c78c000bee7c2eca66843 --- /dev/null +++ b/controller/ratio_sync.go @@ -0,0 +1,539 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "one-api/logger" + "strings" + "sync" + "time" + + "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 + maxRatioConfigBytes = 10 << 20 // 10MB + floatEpsilon = 1e-9 +) + +func nearlyEqual(a, b float64) bool { + if a > b { + return a-b < floatEpsilon + } + return b-a < floatEpsilon +} + +func valuesEqual(a, b interface{}) bool { + af, aok := a.(float64) + bf, bok := b.(float64) + if aok && bok { + return nearlyEqual(af, bf) + } + return a == b +} + +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 { + logger.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) + + dialer := &net.Dialer{Timeout: 10 * time.Second} + transport := &http.Transport{MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, ResponseHeaderTimeout: 10 * time.Second} + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + // 对 github.io 优先尝试 IPv4,失败则回退 IPv6 + if strings.HasSuffix(host, "github.io") { + if conn, err := dialer.DialContext(ctx, "tcp4", addr); err == nil { + return conn, nil + } + return dialer.DialContext(ctx, "tcp6", addr) + } + return dialer.DialContext(ctx, network, addr) + } + client := &http.Client{Transport: transport} + + for _, chn := range upstreams { + wg.Add(1) + go func(chItem dto.UpstreamDTO) { + defer wg.Done() + + sem <- struct{}{} + defer func() { <-sem }() + + endpoint := chItem.Endpoint + var fullURL string + if strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") { + fullURL = endpoint + } else { + 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 { + logger.LogWarn(c.Request.Context(), "build request failed: "+err.Error()) + ch <- upstreamResult{Name: uniqueName, Err: err.Error()} + return + } + + // 简单重试:最多 3 次,指数退避 + var resp *http.Response + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + resp, lastErr = client.Do(httpReq) + if lastErr == nil { + break + } + time.Sleep(time.Duration(200*(1< 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(limited).Decode(&body); err != nil { + logger.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 + } + + // 若 Data 为空,将继续按 type1 尝试解析(与多数静态 ratio_config 兼容) + + // 尝试按 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 { + logger.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 && !valuesEqual(localValue, val) { + hasDifference = true + } else if valuesEqual(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, + }) + } + } + + syncableChannels = append(syncableChannels, dto.SyncableChannel{ + ID: -100, + Name: "官方倍率预设", + BaseURL: "https://basellm.github.io", + Status: 1, + }) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": syncableChannels, + }) +} diff --git a/controller/redemption.go b/controller/redemption.go new file mode 100644 index 0000000000000000000000000000000000000000..081a934ac2b63c37dc7e9dc0f6308ef42aa09ea8 --- /dev/null +++ b/controller/redemption.go @@ -0,0 +1,194 @@ +package controller + +import ( + "errors" + "net/http" + "one-api/common" + "one-api/model" + "strconv" + "unicode/utf8" + + "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 utf8.RuneCountInString(redemption.Name) == 0 || utf8.RuneCountInString(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 0000000000000000000000000000000000000000..918a0c24b512286dca96b4f39af925d297b4807f --- /dev/null +++ b/controller/relay.go @@ -0,0 +1,476 @@ +package controller + +import ( + "bytes" + "fmt" + "io" + "log" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/logger" + "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/setting" + "one-api/types" + "strings" + + "github.com/bytedance/gopkg/util/gopool" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { + var err *types.NewAPIError + switch info.RelayMode { + case relayconstant.RelayModeImagesGenerations, relayconstant.RelayModeImagesEdits: + err = relay.ImageHelper(c, info) + case relayconstant.RelayModeAudioSpeech: + fallthrough + case relayconstant.RelayModeAudioTranslation: + fallthrough + case relayconstant.RelayModeAudioTranscription: + err = relay.AudioHelper(c, info) + case relayconstant.RelayModeRerank: + err = relay.RerankHelper(c, info) + case relayconstant.RelayModeEmbeddings: + err = relay.EmbeddingHelper(c, info) + case relayconstant.RelayModeResponses: + err = relay.ResponsesHelper(c, info) + default: + err = relay.TextHelper(c, info) + } + return err +} + +func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { + var err *types.NewAPIError + if strings.Contains(c.Request.URL.Path, "embed") { + err = relay.GeminiEmbeddingHandler(c, info) + } else { + err = relay.GeminiHelper(c, info) + } + return err +} + +func Relay(c *gin.Context, relayFormat types.RelayFormat) { + + requestId := c.GetString(common.RequestIdKey) + group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) + + var ( + newAPIError *types.NewAPIError + ws *websocket.Conn + ) + + if relayFormat == types.RelayFormatOpenAIRealtime { + var err error + ws, err = upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + helper.WssError(c, ws, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()).ToOpenAIError()) + return + } + defer ws.Close() + } + + defer func() { + if newAPIError != nil { + newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId)) + switch relayFormat { + case types.RelayFormatOpenAIRealtime: + helper.WssError(c, ws, newAPIError.ToOpenAIError()) + case types.RelayFormatClaude: + c.JSON(newAPIError.StatusCode, gin.H{ + "type": "error", + "error": newAPIError.ToClaudeError(), + }) + default: + c.JSON(newAPIError.StatusCode, gin.H{ + "error": newAPIError.ToOpenAIError(), + }) + } + } + }() + + request, err := helper.GetAndValidateRequest(c, relayFormat) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest) + return + } + + relayInfo, err := relaycommon.GenRelayInfo(c, relayFormat, request, ws) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeGenRelayInfoFailed) + return + } + + meta := request.GetTokenCountMeta() + + if setting.ShouldCheckPromptSensitive() { + contains, words := service.CheckSensitiveText(meta.CombineText) + if contains { + logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", "))) + newAPIError = types.NewError(err, types.ErrorCodeSensitiveWordsDetected) + return + } + } + + tokens, err := service.CountRequestToken(c, meta, relayInfo) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeCountTokenFailed) + return + } + + relayInfo.SetPromptTokens(tokens) + + priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta) + if err != nil { + newAPIError = types.NewError(err, types.ErrorCodeModelPriceError) + return + } + + // common.SetContextKey(c, constant.ContextKeyTokenCountMeta, meta) + + newAPIError = service.PreConsumeQuota(c, priceData.ShouldPreConsumedQuota, relayInfo) + if newAPIError != nil { + return + } + + defer func() { + // Only return quota if downstream failed and quota was actually pre-consumed + if newAPIError != nil && relayInfo.FinalPreConsumedQuota != 0 { + service.ReturnPreConsumedQuota(c, relayInfo) + } + }() + + for i := 0; i <= common.RetryTimes; i++ { + channel, err := getChannel(c, group, originalModel, i) + if err != nil { + logger.LogError(c, err.Error()) + newAPIError = err + break + } + + addUsedChannel(c, channel.Id) + requestBody, _ := common.GetRequestBody(c) + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + + switch relayFormat { + case types.RelayFormatOpenAIRealtime: + newAPIError = relay.WssHelper(c, relayInfo) + case types.RelayFormatClaude: + newAPIError = relay.ClaudeHelper(c, relayInfo) + case types.RelayFormatGemini: + newAPIError = geminiRelayHandler(c, relayInfo) + default: + newAPIError = relayHandler(c, relayInfo) + } + + if newAPIError == nil { + return + } + + 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)), "->"), "[]")) + logger.LogInfo(c, retryLogStr) + } +} + +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 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 { + return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + } + if channel == nil { + return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(数据库一致性已被破坏,retry)", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + } + 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.IsSkipRetryError(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 { + 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) { + logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error())) + // 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况 + // do not use context to get channel info, there may be inconsistent channel info when processing asynchronously + if service.ShouldDisableChannel(channelError.ChannelId, err) && channelError.AutoBan { + gopool.Go(func() { + service.DisableChannel(channelError, err.Error()) + }) + } + + if constant.ErrorLogEnabled && types.IsRecordErrorLog(err) { + // 保存错误日志到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.GetErrorType() + 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") + adminInfo := make(map[string]interface{}) + adminInfo["use_channel"] = c.GetStringSlice("use_channel") + isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey) + if isMultiKey { + adminInfo["is_multi_key"] = true + adminInfo["multi_key_index"] = common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex) + } + other["admin_info"] = adminInfo + model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveError(), tokenId, 0, false, userGroup, other) + } + +} + +func RelayMidjourney(c *gin.Context) { + relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatMjProxy, nil, nil) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "description": fmt.Sprintf("failed to generate relay info: %s", err.Error()), + "type": "upstream_error", + "code": 4, + }) + return + } + + var mjErr *dto.MidjourneyResponse + switch relayInfo.RelayMode { + case relayconstant.RelayModeMidjourneyNotify: + mjErr = relay.RelayMidjourneyNotify(c) + case relayconstant.RelayModeMidjourneyTaskFetch, relayconstant.RelayModeMidjourneyTaskFetchByCondition: + mjErr = relay.RelayMidjourneyTask(c, relayInfo.RelayMode) + case relayconstant.RelayModeMidjourneyTaskImageSeed: + mjErr = relay.RelayMidjourneyTaskImageSeed(c) + case relayconstant.RelayModeSwapFace: + mjErr = relay.RelaySwapFace(c, relayInfo) + default: + mjErr = relay.RelayMidjourneySubmit(c, relayInfo) + } + //err = relayMidjourneySubmit(c, relayMode) + log.Println(mjErr) + if mjErr != nil { + statusCode := http.StatusBadRequest + if mjErr.Code == 30 { + mjErr.Result = "当前分组负载已饱和,请稍后再试,或升级账户以提升服务质量。" + statusCode = http.StatusTooManyRequests + } + c.JSON(statusCode, gin.H{ + "description": fmt.Sprintf("%s %s", mjErr.Description, mjErr.Result), + "type": "upstream_error", + "code": mjErr.Code, + }) + channelId := c.GetInt("channel_id") + logger.LogError(c, fmt.Sprintf("relay error (channel #%d, status code %d): %s", channelId, statusCode, fmt.Sprintf("%s %s", mjErr.Description, mjErr.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") + group := c.GetString("group") + originalModel := c.GetString("original_model") + c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)}) + relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil) + if err != nil { + return + } + taskErr := taskRelayHandler(c, relayInfo) + 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 { + logger.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) + logger.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, relayInfo) + } + useChannel := c.GetStringSlice("use_channel") + if len(useChannel) > 1 { + retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]")) + logger.LogInfo(c, retryLogStr) + } + if taskErr != nil { + if taskErr.StatusCode == http.StatusTooManyRequests { + taskErr.Message = "当前分组上游负载已饱和,请稍后再试" + } + c.JSON(taskErr.StatusCode, taskErr) + } +} + +func taskRelayHandler(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dto.TaskError { + var err *dto.TaskError + switch relayInfo.RelayMode { + case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID: + err = relay.RelayTaskFetch(c, relayInfo.RelayMode) + default: + err = relay.RelayTaskSubmit(c, relayInfo) + } + 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/secure_verification.go b/controller/secure_verification.go new file mode 100644 index 0000000000000000000000000000000000000000..8fefe7723dc132f0150b7498f0c2a377e549b40d --- /dev/null +++ b/controller/secure_verification.go @@ -0,0 +1,313 @@ +package controller + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/model" + passkeysvc "one-api/service/passkey" + "one-api/setting/system_setting" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +const ( + // SecureVerificationSessionKey 安全验证的 session key + SecureVerificationSessionKey = "secure_verified_at" + // SecureVerificationTimeout 验证有效期(秒) + SecureVerificationTimeout = 300 // 5分钟 +) + +type UniversalVerifyRequest struct { + Method string `json:"method"` // "2fa" 或 "passkey" + Code string `json:"code,omitempty"` +} + +type VerificationStatusResponse struct { + Verified bool `json:"verified"` + ExpiresAt int64 `json:"expires_at,omitempty"` +} + +// UniversalVerify 通用验证接口 +// 支持 2FA 和 Passkey 验证,验证成功后在 session 中记录时间戳 +func UniversalVerify(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + var req UniversalVerifyRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, fmt.Errorf("参数错误: %v", err)) + return + } + + // 获取用户信息 + user := &model.User{Id: userId} + if err := user.FillUserById(); err != nil { + common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err)) + return + } + + if user.Status != common.UserStatusEnabled { + common.ApiError(c, fmt.Errorf("该用户已被禁用")) + return + } + + // 检查用户的验证方式 + twoFA, _ := model.GetTwoFAByUserId(userId) + has2FA := twoFA != nil && twoFA.IsEnabled + + passkey, passkeyErr := model.GetPasskeyByUserID(userId) + hasPasskey := passkeyErr == nil && passkey != nil + + if !has2FA && !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用2FA或Passkey")) + return + } + + // 根据验证方式进行验证 + var verified bool + var verifyMethod string + + switch req.Method { + case "2fa": + if !has2FA { + common.ApiError(c, fmt.Errorf("用户未启用2FA")) + return + } + if req.Code == "" { + common.ApiError(c, fmt.Errorf("验证码不能为空")) + return + } + verified = validateTwoFactorAuth(twoFA, req.Code) + verifyMethod = "2FA" + + case "passkey": + if !hasPasskey { + common.ApiError(c, fmt.Errorf("用户未启用Passkey")) + return + } + // Passkey 验证需要先调用 PasskeyVerifyBegin 和 PasskeyVerifyFinish + // 这里只是验证 Passkey 验证流程是否已经完成 + // 实际上,前端应该先调用这两个接口,然后再调用本接口 + verified = true // Passkey 验证逻辑已在 PasskeyVerifyFinish 中完成 + verifyMethod = "Passkey" + + default: + common.ApiError(c, fmt.Errorf("不支持的验证方式: %s", req.Method)) + return + } + + if !verified { + common.ApiError(c, fmt.Errorf("验证失败,请检查验证码")) + return + } + + // 验证成功,在 session 中记录时间戳 + session := sessions.Default(c) + now := time.Now().Unix() + session.Set(SecureVerificationSessionKey, now) + if err := session.Save(); err != nil { + common.ApiError(c, fmt.Errorf("保存验证状态失败: %v", err)) + return + } + + // 记录日志 + model.RecordLog(userId, model.LogTypeSystem, fmt.Sprintf("通用安全验证成功 (验证方式: %s)", verifyMethod)) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "验证成功", + "data": gin.H{ + "verified": true, + "expires_at": now + SecureVerificationTimeout, + }, + }) +} + +// GetVerificationStatus 获取验证状态 +func GetVerificationStatus(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期 + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: false, + }, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": VerificationStatusResponse{ + Verified: true, + ExpiresAt: verifiedAt + SecureVerificationTimeout, + }, + }) +} + +// CheckSecureVerification 检查是否已通过安全验证 +// 返回 true 表示验证有效,false 表示需要重新验证 +func CheckSecureVerification(c *gin.Context) bool { + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + return false + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + return false + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期,清除 session + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + return false + } + + return true +} + +// PasskeyVerifyAndSetSession Passkey 验证完成后设置 session +// 这是一个辅助函数,供 PasskeyVerifyFinish 调用 +func PasskeyVerifyAndSetSession(c *gin.Context) { + session := sessions.Default(c) + now := time.Now().Unix() + session.Set(SecureVerificationSessionKey, now) + _ = session.Save() +} + +// PasskeyVerifyForSecure 用于安全验证的 Passkey 验证流程 +// 整合了 begin 和 finish 流程 +func PasskeyVerifyForSecure(c *gin.Context) { + if !system_setting.GetPasskeySettings().Enabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "管理员未启用 Passkey 登录", + }) + return + } + + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + return + } + + user := &model.User{Id: userId} + if err := user.FillUserById(); err != nil { + common.ApiError(c, fmt.Errorf("获取用户信息失败: %v", err)) + return + } + + if user.Status != common.UserStatusEnabled { + common.ApiError(c, fmt.Errorf("该用户已被禁用")) + return + } + + credential, err := model.GetPasskeyByUserID(userId) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "该用户尚未绑定 Passkey", + }) + return + } + + wa, err := passkeysvc.BuildWebAuthn(c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + waUser := passkeysvc.NewWebAuthnUser(user, credential) + sessionData, err := passkeysvc.PopSessionData(c, passkeysvc.VerifySessionKey) + if err != nil { + common.ApiError(c, err) + return + } + + _, err = wa.FinishLogin(waUser, *sessionData, c.Request) + if err != nil { + common.ApiError(c, err) + return + } + + // 更新凭证的最后使用时间 + now := time.Now() + credential.LastUsedAt = &now + if err := model.UpsertPasskeyCredential(credential); err != nil { + common.ApiError(c, err) + return + } + + // 验证成功,设置 session + PasskeyVerifyAndSetSession(c) + + // 记录日志 + model.RecordLog(userId, model.LogTypeSystem, "Passkey 安全验证成功") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Passkey 验证成功", + "data": gin.H{ + "verified": true, + "expires_at": time.Now().Unix() + SecureVerificationTimeout, + }, + }) +} diff --git a/controller/setup.go b/controller/setup.go new file mode 100644 index 0000000000000000000000000000000000000000..f9c43c270debd7a859ca574bc13636fec4691489 --- /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(200, 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(200, 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(200, gin.H{ + "success": false, + "message": "用户名长度不能超过12个字符", + }) + return + } + // Validate password + if req.Password != req.ConfirmPassword { + c.JSON(200, gin.H{ + "success": false, + "message": "两次输入的密码不一致", + }) + return + } + + if len(req.Password) < 8 { + c.JSON(200, gin.H{ + "success": false, + "message": "密码长度至少为8个字符", + }) + return + } + + // Create root user + hashedPassword, err := common.Password2Hash(req.Password) + if err != nil { + c.JSON(200, 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(200, 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(200, gin.H{ + "success": false, + "message": "保存自用模式设置失败: " + err.Error(), + }) + return + } + + err = model.UpdateOption("DemoSiteEnabled", boolToString(req.DemoSiteEnabled)) + if err != nil { + c.JSON(200, 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(200, 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" +} \ No newline at end of file diff --git a/controller/swag_video.go b/controller/swag_video.go new file mode 100644 index 0000000000000000000000000000000000000000..383ddaad0ef23dead5f3f6e13d924fdb104ee2ee --- /dev/null +++ b/controller/swag_video.go @@ -0,0 +1,136 @@ +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"` +} + +// KlingImage2videoTaskId godoc +// @Summary 可灵任务查询--图生视频 +// @Description Query the status and result of a Kling video generation task by task ID +// @Tags Origin +// @Accept json +// @Produce json +// @Param task_id path string true "Task ID" +// @Router /kling/v1/videos/image2video/{task_id} [get] +func KlingImage2videoTaskId(c *gin.Context) {} + +// KlingText2videoTaskId godoc +// @Summary 可灵任务查询--文生视频 +// @Description Query the status and result of a Kling text-to-video generation task by task ID +// @Tags Origin +// @Accept json +// @Produce json +// @Param task_id path string true "Task ID" +// @Router /kling/v1/videos/text2video/{task_id} [get] +func KlingText2videoTaskId(c *gin.Context) {} diff --git a/controller/task.go b/controller/task.go new file mode 100644 index 0000000000000000000000000000000000000000..3ce397860bda0ada889a1a8ea764715351dbde2d --- /dev/null +++ b/controller/task.go @@ -0,0 +1,274 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/logger" + "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 { + logger.LogError(ctx, fmt.Sprintf("Fix null task_id task error: %v", err)) + } else { + logger.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) + default: + if err := UpdateVideoTaskAll(context.Background(), platform, taskChannelM, taskM); err != nil { + common.SysLog(fmt.Sprintf("UpdateVideoTaskAll fail: %s", err)) + } + } +} + +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 { + logger.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 { + logger.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.SysLog(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.SysLog(fmt.Sprintf("Get Task Do req error: %v", err)) + return err + } + if resp.StatusCode != http.StatusOK { + logger.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.SysLog(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 { + logger.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 { + logger.LogInfo(ctx, task.TaskID+" 构建失败,"+task.FailReason) + task.Progress = "100%" + //err = model.CacheUpdateUserQuota(task.UserId) ? + if err != nil { + logger.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 { + logger.LogError(ctx, "fail to increase user quota: "+err.Error()) + } + logContent := fmt.Sprintf("异步任务执行失败 %s,补偿 %s", task.TaskID, logger.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.SysLog("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 0000000000000000000000000000000000000000..f75bae7dc1a9c7990e5081ad6076330e91578079 --- /dev/null +++ b/controller/task_video.go @@ -0,0 +1,184 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "io" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/logger" + "one-api/model" + "one-api/relay" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "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 { + logger.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 { + logger.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.SysLog(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 { + logger.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 { + logger.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 := &relaycommon.TaskInfo{} + // try parse as New API response format + var responseItems dto.TaskResponse[model.Task] + if err = json.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() { + t := responseItems.Data + taskResult.TaskID = t.TaskID + taskResult.Status = string(t.Status) + taskResult.Url = t.FailReason + taskResult.Progress = t.Progress + taskResult.Reason = t.FailReason + } else if taskResult, err = adaptor.ParseTaskResult(responseBody); err != nil { + return fmt.Errorf("parseTaskResult failed for task %s: %w", taskId, err) + } else { + task.Data = redactVideoResponseBody(responseBody) + } + + 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 + } + if !(len(taskResult.Url) > 5 && taskResult.Url[:5] == "data:") { + 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 + logger.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 { + logger.LogError(ctx, "Failed to increase user quota: "+err.Error()) + } + logContent := fmt.Sprintf("Video async task failed %s, refund %s", task.TaskID, logger.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 + } + if err := task.Update(); err != nil { + common.SysLog("UpdateVideoTask task error: " + err.Error()) + } + + return nil +} + +func redactVideoResponseBody(body []byte) []byte { + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + return body + } + resp, _ := m["response"].(map[string]any) + if resp != nil { + delete(resp, "bytesBase64Encoded") + if v, ok := resp["video"].(string); ok { + resp["video"] = truncateBase64(v) + } + if vs, ok := resp["videos"].([]any); ok { + for i := range vs { + if vm, ok := vs[i].(map[string]any); ok { + delete(vm, "bytesBase64Encoded") + } + } + } + } + b, err := json.Marshal(m) + if err != nil { + return body + } + return b +} + +func truncateBase64(s string) string { + const maxKeep = 256 + if len(s) <= maxKeep { + return s + } + return s[:maxKeep] + "..." +} diff --git a/controller/telegram.go b/controller/telegram.go new file mode 100644 index 0000000000000000000000000000000000000000..e4286608438517cbf11ff5ae5f9a5c8a42662c62 --- /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, "/console/personal") +} + +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 0000000000000000000000000000000000000000..37796b655c53c32cb105a586ff0c581e8de2e03a --- /dev/null +++ b/controller/token.go @@ -0,0 +1,288 @@ +package controller + +import ( + "net/http" + "one-api/common" + "one-api/model" + "strconv" + "strings" + + "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 GetTokenUsage(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "No Authorization header", + }) + return + } + + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "Invalid Bearer token", + }) + return + } + tokenKey := parts[1] + + token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + expiredAt := token.ExpiredTime + if expiredAt == -1 { + expiredAt = 0 + } + + c.JSON(http.StatusOK, gin.H{ + "code": true, + "message": "ok", + "data": gin.H{ + "object": "token_usage", + "name": token.Name, + "total_granted": token.RemainQuota + token.UsedQuota, + "total_used": token.UsedQuota, + "total_available": token.RemainQuota, + "unlimited_quota": token.UnlimitedQuota, + "model_limits": token.GetModelLimitsMap(), + "model_limits_enabled": token.ModelLimitsEnabled, + "expires_at": expiredAt, + }, + }) +} + +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.SysLog("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 0000000000000000000000000000000000000000..dd073f5429857c6dc0837a52768d0e5e5ba5a680 --- /dev/null +++ b/controller/topup.go @@ -0,0 +1,314 @@ +package controller + +import ( + "fmt" + "log" + "net/url" + "one-api/common" + "one-api/logger" + "one-api/model" + "one-api/service" + "one-api/setting" + "one-api/setting/operation_setting" + "one-api/setting/system_setting" + "strconv" + "sync" + "time" + + "github.com/Calcium-Ion/go-epay/epay" + "github.com/gin-gonic/gin" + "github.com/samber/lo" + "github.com/shopspring/decimal" +) + +func GetTopUpInfo(c *gin.Context) { + // 获取支付方式 + payMethods := operation_setting.PayMethods + + // 如果启用了 Stripe 支付,添加到支付方法列表 + if setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "" { + // 检查是否已经包含 Stripe + hasStripe := false + for _, method := range payMethods { + if method["type"] == "stripe" { + hasStripe = true + break + } + } + + if !hasStripe { + stripeMethod := map[string]string{ + "name": "Stripe", + "type": "stripe", + "color": "rgba(var(--semi-purple-5), 1)", + "min_topup": strconv.Itoa(setting.StripeMinTopUp), + } + payMethods = append(payMethods, stripeMethod) + } + } + + data := gin.H{ + "enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "", + "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", + "pay_methods": payMethods, + "min_topup": operation_setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, + "amount_options": operation_setting.GetPaymentSetting().AmountOptions, + "discount": operation_setting.GetPaymentSetting().AmountDiscount, + } + common.ApiSuccess(c, data) +} + +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 operation_setting.PayAddress == "" || operation_setting.EpayId == "" || operation_setting.EpayKey == "" { + return nil + } + withUrl, err := epay.NewClient(&epay.Config{ + PartnerID: operation_setting.EpayId, + Key: operation_setting.EpayKey, + }, operation_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(operation_setting.Price) + // apply optional preset discount by the original request amount (if configured), default 1.0 + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(amount)]; ok { + if ds > 0 { + discount = ds + } + } + dDiscount := decimal.NewFromFloat(discount) + + payMoney := dAmount.Mul(dPrice).Mul(dTopupGroupRatio).Mul(dDiscount) + + return payMoney.InexactFloat64() +} + +func getMinTopup() int64 { + minTopup := operation_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 !operation_setting.ContainsPayMethod(req.PaymentMethod) { + c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"}) + return + } + + callBackAddress := service.GetCallbackAddress() + returnUrl, _ := url.Parse(system_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", logger.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 0000000000000000000000000000000000000000..db6cc29942462f404d959891ba943f4708b3343f --- /dev/null +++ b/controller/topup_stripe.go @@ -0,0 +1,286 @@ +package controller + +import ( + "fmt" + "io" + "log" + "net/http" + "one-api/common" + "one-api/model" + "one-api/setting" + "one-api/setting/operation_setting" + "one-api/setting/system_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(system_setting.ServerAddress + "/console/log"), + CancelURL: stripe.String(system_setting.ServerAddress + "/topup"), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(setting.StripePriceId), + Quantity: stripe.Int64(amount), + }, + }, + Mode: stripe.String(string(stripe.CheckoutSessionModePayment)), + AllowPromotionCodes: stripe.Bool(setting.StripePromotionCodesEnabled), + } + + 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 { + originalAmount := amount + 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 + } + // apply optional preset discount by the original request amount (if configured), default 1.0 + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { + if ds > 0 { + discount = ds + } + } + payMoney := amount * setting.StripeUnitPrice * topupGroupRatio * discount + return payMoney +} + +func getStripeMinTopup() int64 { + minTopup := setting.StripeMinTopUp + if !common.DisplayInCurrencyEnabled { + minTopup = minTopup * int(common.QuotaPerUnit) + } + return int64(minTopup) +} diff --git a/controller/twofa.go b/controller/twofa.go new file mode 100644 index 0000000000000000000000000000000000000000..bb6f2d42fd4dacbeb5eaec7c02969fff63f09aca --- /dev/null +++ b/controller/twofa.go @@ -0,0 +1,553 @@ +package controller + +import ( + "errors" + "fmt" + "net/http" + "one-api/common" + "one-api/model" + "strconv" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +// Setup2FARequest 设置2FA请求结构 +type Setup2FARequest struct { + Code string `json:"code" binding:"required"` +} + +// Verify2FARequest 验证2FA请求结构 +type Verify2FARequest struct { + Code string `json:"code" binding:"required"` +} + +// Setup2FAResponse 设置2FA响应结构 +type Setup2FAResponse struct { + Secret string `json:"secret"` + QRCodeData string `json:"qr_code_data"` + BackupCodes []string `json:"backup_codes"` +} + +// Setup2FA 初始化2FA设置 +func Setup2FA(c *gin.Context) { + userId := c.GetInt("id") + + // 检查用户是否已经启用2FA + existing, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if existing != nil && existing.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户已启用2FA,请先禁用后重新设置", + }) + return + } + + // 如果存在已禁用的2FA记录,先删除它 + if existing != nil && !existing.IsEnabled { + if err := existing.Delete(); err != nil { + common.ApiError(c, err) + return + } + existing = nil // 重置为nil,后续将创建新记录 + } + + // 获取用户信息 + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + // 生成TOTP密钥 + key, err := common.GenerateTOTPSecret(user.Username) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成2FA密钥失败", + }) + common.SysLog("生成TOTP密钥失败: " + err.Error()) + return + } + + // 生成备用码 + backupCodes, err := common.GenerateBackupCodes() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成备用码失败", + }) + common.SysLog("生成备用码失败: " + err.Error()) + return + } + + // 生成二维码数据 + qrCodeData := common.GenerateQRCodeData(key.Secret(), user.Username) + + // 创建或更新2FA记录(暂未启用) + twoFA := &model.TwoFA{ + UserId: userId, + Secret: key.Secret(), + IsEnabled: false, + } + + if existing != nil { + // 更新现有记录 + twoFA.Id = existing.Id + err = twoFA.Update() + } else { + // 创建新记录 + err = twoFA.Create() + } + + if err != nil { + common.ApiError(c, err) + return + } + + // 创建备用码记录 + if err := model.CreateBackupCodes(userId, backupCodes); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "保存备用码失败", + }) + common.SysLog("保存备用码失败: " + err.Error()) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "开始设置两步验证") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "2FA设置初始化成功,请使用认证器扫描二维码并输入验证码完成设置", + "data": Setup2FAResponse{ + Secret: key.Secret(), + QRCodeData: qrCodeData, + BackupCodes: backupCodes, + }, + }) +} + +// Enable2FA 启用2FA +func Enable2FA(c *gin.Context) { + var req Setup2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + userId := c.GetInt("id") + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "请先完成2FA初始化设置", + }) + return + } + if twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "2FA已经启用", + }) + return + } + + // 验证TOTP验证码 + cleanCode, err := common.ValidateNumericCode(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + if !common.ValidateTOTPCode(twoFA.Secret, cleanCode) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 启用2FA + if err := twoFA.Enable(); err != nil { + common.ApiError(c, err) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "成功启用两步验证") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "两步验证启用成功", + }) +} + +// Disable2FA 禁用2FA +func Disable2FA(c *gin.Context) { + var req Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + userId := c.GetInt("id") + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil || !twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + + // 验证TOTP验证码或备用码 + cleanCode, err := common.ValidateNumericCode(req.Code) + isValidTOTP := false + isValidBackup := false + + if err == nil { + // 尝试验证TOTP + isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode) + } + + if !isValidTOTP { + // 尝试验证备用码 + isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + + if !isValidTOTP && !isValidBackup { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 禁用2FA + if err := model.DisableTwoFA(userId); err != nil { + common.ApiError(c, err) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "禁用两步验证") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "两步验证已禁用", + }) +} + +// Get2FAStatus 获取用户2FA状态 +func Get2FAStatus(c *gin.Context) { + userId := c.GetInt("id") + + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + + status := map[string]interface{}{ + "enabled": false, + "locked": false, + } + + if twoFA != nil { + status["enabled"] = twoFA.IsEnabled + status["locked"] = twoFA.IsLocked() + if twoFA.IsEnabled { + // 获取剩余备用码数量 + backupCount, err := model.GetUnusedBackupCodeCount(userId) + if err != nil { + common.SysLog("获取备用码数量失败: " + err.Error()) + } else { + status["backup_codes_remaining"] = backupCount + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": status, + }) +} + +// RegenerateBackupCodes 重新生成备用码 +func RegenerateBackupCodes(c *gin.Context) { + var req Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + userId := c.GetInt("id") + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(userId) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil || !twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + + // 验证TOTP验证码 + cleanCode, err := common.ValidateNumericCode(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + valid, err := twoFA.ValidateTOTPAndUpdateUsage(cleanCode) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + if !valid { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 生成新的备用码 + backupCodes, err := common.GenerateBackupCodes() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "生成备用码失败", + }) + common.SysLog("生成备用码失败: " + err.Error()) + return + } + + // 保存新的备用码 + if err := model.CreateBackupCodes(userId, backupCodes); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "保存备用码失败", + }) + common.SysLog("保存备用码失败: " + err.Error()) + return + } + + // 记录操作日志 + model.RecordLog(userId, model.LogTypeSystem, "重新生成两步验证备用码") + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "备用码重新生成成功", + "data": map[string]interface{}{ + "backup_codes": backupCodes, + }, + }) +} + +// Verify2FALogin 登录时验证2FA +func Verify2FALogin(c *gin.Context) { + var req Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "参数错误", + }) + return + } + + // 从会话中获取pending用户信息 + session := sessions.Default(c) + pendingUserId := session.Get("pending_user_id") + if pendingUserId == nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "会话已过期,请重新登录", + }) + return + } + userId, ok := pendingUserId.(int) + if !ok { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "会话数据无效,请重新登录", + }) + return + } + // 获取用户信息 + user, err := model.GetUserById(userId, false) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户不存在", + }) + return + } + + // 获取2FA记录 + twoFA, err := model.GetTwoFAByUserId(user.Id) + if err != nil { + common.ApiError(c, err) + return + } + if twoFA == nil || !twoFA.IsEnabled { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + + // 验证TOTP验证码或备用码 + cleanCode, err := common.ValidateNumericCode(req.Code) + isValidTOTP := false + isValidBackup := false + + if err == nil { + // 尝试验证TOTP + isValidTOTP, _ = twoFA.ValidateTOTPAndUpdateUsage(cleanCode) + } + + if !isValidTOTP { + // 尝试验证备用码 + isValidBackup, err = twoFA.ValidateBackupCodeAndUpdateUsage(req.Code) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + } + + if !isValidTOTP && !isValidBackup { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "验证码或备用码错误,请重试", + }) + return + } + + // 2FA验证成功,清理pending会话信息并完成登录 + session.Delete("pending_username") + session.Delete("pending_user_id") + session.Save() + + setupLogin(user, c) +} + +// Admin2FAStats 管理员获取2FA统计信息 +func Admin2FAStats(c *gin.Context) { + stats, err := model.GetTwoFAStats() + if err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": stats, + }) +} + +// AdminDisable2FA 管理员强制禁用用户2FA +func AdminDisable2FA(c *gin.Context) { + userIdStr := c.Param("id") + userId, err := strconv.Atoi(userIdStr) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户ID格式错误", + }) + return + } + + // 检查目标用户权限 + targetUser, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + myRole := c.GetInt("role") + if myRole <= targetUser.Role && myRole != common.RoleRootUser { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无权操作同级或更高级用户的2FA设置", + }) + return + } + + // 禁用2FA + if err := model.DisableTwoFA(userId); err != nil { + if errors.Is(err, model.ErrTwoFANotEnabled) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "用户未启用2FA", + }) + return + } + common.ApiError(c, err) + return + } + + // 记录操作日志 + adminId := c.GetInt("id") + model.RecordLog(userId, model.LogTypeManage, + fmt.Sprintf("管理员(ID:%d)强制禁用了用户的两步验证", adminId)) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "用户2FA已被强制禁用", + }) +} diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go new file mode 100644 index 0000000000000000000000000000000000000000..a8cd9037bf83fc1808724a9f4db135714173647b --- /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}) +} diff --git a/controller/usedata.go b/controller/usedata.go new file mode 100644 index 0000000000000000000000000000000000000000..5489c8dfdb8720bd02c6efcb5f67b80adcc8bf1a --- /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 0000000000000000000000000000000000000000..5c9100a0f98d9bfd0cee7f7502ead0a28c93992a --- /dev/null +++ b/controller/user.go @@ -0,0 +1,1242 @@ +package controller + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "one-api/common" + "one-api/dto" + "one-api/logger" + "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 + } + + // 检查是否启用2FA + if model.IsTwoFAEnabled(user.Id) { + // 设置pending session,等待2FA验证 + session := sessions.Default(c) + session.Set("pending_username", user.Username) + session.Set("pending_user_id", user.Id) + err := session.Save() + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "message": "无法保存会话信息,请重试", + "success": false, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "请输入两步验证码", + "success": true, + "data": map[string]interface{}{ + "require_2fa": true, + }, + }) + 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.SysLog(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, + Role: common.RoleCommonUser, // 明确设置角色为普通用户 + } + 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.SysLog("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.SysLog("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") + userRole := c.GetInt("role") + 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 = "" + + // 计算用户权限信息 + permissions := calculateUserPermissions(userRole) + + // 获取用户设置并提取sidebar_modules + userSetting := user.GetSetting() + + // 构建响应数据,包含用户信息和权限 + responseData := map[string]interface{}{ + "id": user.Id, + "username": user.Username, + "display_name": user.DisplayName, + "role": user.Role, + "status": user.Status, + "email": user.Email, + "github_id": user.GitHubId, + "oidc_id": user.OidcId, + "wechat_id": user.WeChatId, + "telegram_id": user.TelegramId, + "group": user.Group, + "quota": user.Quota, + "used_quota": user.UsedQuota, + "request_count": user.RequestCount, + "aff_code": user.AffCode, + "aff_count": user.AffCount, + "aff_quota": user.AffQuota, + "aff_history_quota": user.AffHistoryQuota, + "inviter_id": user.InviterId, + "linux_do_id": user.LinuxDOId, + "setting": user.Setting, + "stripe_customer": user.StripeCustomer, + "sidebar_modules": userSetting.SidebarModules, // 正确提取sidebar_modules字段 + "permissions": permissions, // 新增权限字段 + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": responseData, + }) + return +} + +// 计算用户权限的辅助函数 +func calculateUserPermissions(userRole int) map[string]interface{} { + permissions := map[string]interface{}{} + + // 根据用户角色计算权限 + if userRole == common.RoleRootUser { + // 超级管理员不需要边栏设置功能 + permissions["sidebar_settings"] = false + permissions["sidebar_modules"] = map[string]interface{}{} + } else if userRole == common.RoleAdminUser { + // 管理员可以设置边栏,但不包含系统设置功能 + permissions["sidebar_settings"] = true + permissions["sidebar_modules"] = map[string]interface{}{ + "admin": map[string]interface{}{ + "setting": false, // 管理员不能访问系统设置 + }, + } + } else { + // 普通用户只能设置个人功能,不包含管理员区域 + permissions["sidebar_settings"] = true + permissions["sidebar_modules"] = map[string]interface{}{ + "admin": false, // 普通用户不能访问管理员区域 + } + } + + return permissions +} + +// 根据用户角色生成默认的边栏配置 +func generateDefaultSidebarConfig(userRole int) string { + defaultConfig := map[string]interface{}{} + + // 聊天区域 - 所有用户都可以访问 + defaultConfig["chat"] = map[string]interface{}{ + "enabled": true, + "playground": true, + "chat": true, + } + + // 控制台区域 - 所有用户都可以访问 + defaultConfig["console"] = map[string]interface{}{ + "enabled": true, + "detail": true, + "token": true, + "log": true, + "midjourney": true, + "task": true, + } + + // 个人中心区域 - 所有用户都可以访问 + defaultConfig["personal"] = map[string]interface{}{ + "enabled": true, + "topup": true, + "personal": true, + } + + // 管理员区域 - 根据角色决定 + if userRole == common.RoleAdminUser { + // 管理员可以访问管理员区域,但不能访问系统设置 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": false, // 管理员不能访问系统设置 + } + } else if userRole == common.RoleRootUser { + // 超级管理员可以访问所有功能 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": true, + } + } + // 普通用户不包含admin区域 + + // 转换为JSON字符串 + configBytes, err := json.Marshal(defaultConfig) + if err != nil { + common.SysLog("生成默认边栏配置失败: " + err.Error()) + return "" + } + + return string(configBytes) +} + +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", logger.LogQuota(originUser.Quota), logger.LogQuota(updatedUser.Quota))) + } + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + }) + return +} + +func UpdateSelf(c *gin.Context) { + var requestData map[string]interface{} + err := json.NewDecoder(c.Request.Body).Decode(&requestData) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + + // 检查是否是sidebar_modules更新请求 + if sidebarModules, exists := requestData["sidebar_modules"]; exists { + userId := c.GetInt("id") + user, err := model.GetUserById(userId, false) + if err != nil { + common.ApiError(c, err) + return + } + + // 获取当前用户设置 + currentSetting := user.GetSetting() + + // 更新sidebar_modules字段 + if sidebarModulesStr, ok := sidebarModules.(string); ok { + currentSetting.SidebarModules = sidebarModulesStr + } + + // 保存更新后的设置 + user.SetSetting(currentSetting) + 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": "设置更新成功", + }) + return + } + + // 原有的用户信息更新逻辑 + var user model.User + requestDataBytes, err := json.Marshal(requestData) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的参数", + }) + return + } + err = json.Unmarshal(requestDataBytes, &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, + Role: user.Role, // 保持管理员设置的角色 + } + 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 topUpLocks sync.Map +var topUpCreateLock sync.Mutex + +type topUpTryLock struct { + ch chan struct{} +} + +func newTopUpTryLock() *topUpTryLock { + return &topUpTryLock{ch: make(chan struct{}, 1)} +} + +func (l *topUpTryLock) TryLock() bool { + select { + case l.ch <- struct{}{}: + return true + default: + return false + } +} + +func (l *topUpTryLock) Unlock() { + select { + case <-l.ch: + default: + } +} + +func getTopUpLock(userID int) *topUpTryLock { + if v, ok := topUpLocks.Load(userID); ok { + return v.(*topUpTryLock) + } + topUpCreateLock.Lock() + defer topUpCreateLock.Unlock() + if v, ok := topUpLocks.Load(userID); ok { + return v.(*topUpTryLock) + } + l := newTopUpTryLock() + topUpLocks.Store(userID, l) + return l +} + +func TopUp(c *gin.Context) { + id := c.GetInt("id") + lock := getTopUpLock(id) + if !lock.TryLock() { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "充值处理中,请稍后重试", + }) + return + } + defer lock.Unlock() + req := topUpRequest{} + err := c.ShouldBindJSON(&req) + if err != nil { + common.ApiError(c, err) + return + } + 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, + }) +} + +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"` + BarkUrl string `json:"bark_url,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 && req.QuotaWarningType != dto.NotifyTypeBark { + 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 + } + } + + // 如果是Bark类型,验证Bark URL + if req.QuotaWarningType == dto.NotifyTypeBark { + if req.BarkUrl == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Bark推送URL不能为空", + }) + return + } + // 验证URL格式 + if _, err := url.ParseRequestURI(req.BarkUrl); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的Bark推送URL", + }) + return + } + // 检查是否是HTTP或HTTPS + if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "Bark推送URL必须以http://或https://开头", + }) + 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 + } + + // 如果是Bark类型,添加Bark URL到设置中 + if req.QuotaWarningType == dto.NotifyTypeBark { + settings.BarkUrl = req.BarkUrl + } + + // 更新用户设置 + 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/vendor_meta.go b/controller/vendor_meta.go new file mode 100644 index 0000000000000000000000000000000000000000..f79c7c75241df93c0c7f06dbe757aec7312a06bf --- /dev/null +++ b/controller/vendor_meta.go @@ -0,0 +1,124 @@ +package controller + +import ( + "strconv" + + "one-api/common" + "one-api/model" + + "github.com/gin-gonic/gin" +) + +// GetAllVendors 获取供应商列表(分页) +func GetAllVendors(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + vendors, err := model.GetAllVendors(pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + var total int64 + model.DB.Model(&model.Vendor{}).Count(&total) + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(vendors) + common.ApiSuccess(c, pageInfo) +} + +// SearchVendors 搜索供应商 +func SearchVendors(c *gin.Context) { + keyword := c.Query("keyword") + pageInfo := common.GetPageQuery(c) + vendors, total, err := model.SearchVendors(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(vendors) + common.ApiSuccess(c, pageInfo) +} + +// GetVendorMeta 根据 ID 获取供应商 +func GetVendorMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + v, err := model.GetVendorByID(id) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, v) +} + +// CreateVendorMeta 新建供应商 +func CreateVendorMeta(c *gin.Context) { + var v model.Vendor + if err := c.ShouldBindJSON(&v); err != nil { + common.ApiError(c, err) + return + } + if v.Name == "" { + common.ApiErrorMsg(c, "供应商名称不能为空") + return + } + // 创建前先检查名称 + if dup, err := model.IsVendorNameDuplicated(0, v.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "供应商名称已存在") + return + } + + if err := v.Insert(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &v) +} + +// UpdateVendorMeta 更新供应商 +func UpdateVendorMeta(c *gin.Context) { + var v model.Vendor + if err := c.ShouldBindJSON(&v); err != nil { + common.ApiError(c, err) + return + } + if v.Id == 0 { + common.ApiErrorMsg(c, "缺少供应商 ID") + return + } + // 名称冲突检查 + if dup, err := model.IsVendorNameDuplicated(v.Id, v.Name); err != nil { + common.ApiError(c, err) + return + } else if dup { + common.ApiErrorMsg(c, "供应商名称已存在") + return + } + + if err := v.Update(); err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, &v) +} + +// DeleteVendorMeta 删除供应商 +func DeleteVendorMeta(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiError(c, err) + return + } + if err := model.DB.Delete(&model.Vendor{}, id).Error; err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, nil) +} diff --git a/controller/wechat.go b/controller/wechat.go new file mode 100644 index 0000000000000000000000000000000000000000..1f325cf437730b8e0e39eb3f2245df8538306094 --- /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 0000000000000000000000000000000000000000..62603cf061a3d15430457cf5c92e838602017a8a --- /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=300 # 流模式无响应超时时间,单位秒,默认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 0000000000000000000000000000000000000000..220d427b79ae65cfe3bc60f6c2fb988814ec7294 --- /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 0000000000000000000000000000000000000000..d8d6277d591632e2bd76464c2e353f8ccad65af6 --- /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 0000000000000000000000000000000000000000..2c9b999aa53c937f46a06f680404fff1e3e1677a --- /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 Binary files /dev/null and b/docs/images/aliyun.png differ diff --git a/docs/images/cherry-studio.png b/docs/images/cherry-studio.png new file mode 100644 index 0000000000000000000000000000000000000000..a58a77137ffee47008b2dbf6990ba1c31b523dc9 Binary files /dev/null and b/docs/images/cherry-studio.png differ diff --git a/docs/images/io-net.png b/docs/images/io-net.png new file mode 100644 index 0000000000000000000000000000000000000000..fb47534d3d60553ab7a0b2331ead32c652e30a2f Binary files /dev/null and b/docs/images/io-net.png differ diff --git a/docs/images/pku.png b/docs/images/pku.png new file mode 100644 index 0000000000000000000000000000000000000000..a058c3ce2338608f24c8051925850d89d71dc926 Binary files /dev/null and b/docs/images/pku.png differ diff --git a/docs/images/ucloud.png b/docs/images/ucloud.png new file mode 100644 index 0000000000000000000000000000000000000000..16cca7642bdca8096c2045904c2b55efe5e9ef7c Binary files /dev/null and b/docs/images/ucloud.png differ diff --git a/docs/installation/BT.md b/docs/installation/BT.md new file mode 100644 index 0000000000000000000000000000000000000000..e57cdab792db4d347622bbe9d915e8caf1fc7a42 --- /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 0000000000000000000000000000000000000000..3ccce63dee7f74228d2bef446b7bd3755a594712 --- /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 0000000000000000000000000000000000000000..db16a92d9733fbf7e7736d1969800e9a0a7c4071 --- /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 0000000000000000000000000000000000000000..3d9720a21d0fe413c799cc38e1f9ce824110dc87 --- /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 0000000000000000000000000000000000000000..347b1284b1c400efc4f1702a33ae2fe99170025c --- /dev/null +++ b/dto/audio.go @@ -0,0 +1,58 @@ +package dto + +import ( + "one-api/types" + + "github.com/gin-gonic/gin" +) + +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"` +} + +func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta { + meta := &types.TokenCountMeta{ + CombineText: r.Input, + TokenType: types.TokenTypeTextNumber, + } + return meta +} + +func (r *AudioRequest) IsStream(c *gin.Context) bool { + return false +} + +func (r *AudioRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +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 0000000000000000000000000000000000000000..b1c1ba20a1bbf37e838fdcac0974d6f20cb6728c --- /dev/null +++ b/dto/channel_settings.go @@ -0,0 +1,30 @@ +package dto + +type ChannelSettings struct { + ForceFormat bool `json:"force_format,omitempty"` + ThinkingToContent bool `json:"thinking_to_content,omitempty"` + Proxy string `json:"proxy"` + PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"` + SystemPrompt string `json:"system_prompt,omitempty"` + SystemPromptOverride bool `json:"system_prompt_override,omitempty"` +} + +type VertexKeyType string + +const ( + VertexKeyTypeJSON VertexKeyType = "json" + VertexKeyTypeAPIKey VertexKeyType = "api_key" +) + +type ChannelOtherSettings struct { + AzureResponsesVersion string `json:"azure_responses_version,omitempty"` + VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" + OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` +} + +func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { + if s == nil || s.OpenRouterEnterprise == nil { + return false + } + return *s.OpenRouterEnterprise +} diff --git a/dto/claude.go b/dto/claude.go new file mode 100644 index 0000000000000000000000000000000000000000..0855cbb67265c3b09fb68da3841adc7f9d11dc92 --- /dev/null +++ b/dto/claude.go @@ -0,0 +1,514 @@ +package dto + +import ( + "encoding/json" + "fmt" + "one-api/common" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +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, _ := common.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"` + ContextManagement json.RawMessage `json:"context_management,omitempty"` + ToolChoice any `json:"tool_choice,omitempty"` + Thinking *Thinking `json:"thinking,omitempty"` +} + +func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { + var tokenCountMeta = types.TokenCountMeta{ + TokenType: types.TokenTypeTokenizer, + MaxTokens: int(c.MaxTokens), + } + + var texts = make([]string, 0) + var fileMeta = make([]*types.FileMeta, 0) + + // system + if c.System != nil { + if c.IsStringSystem() { + sys := c.GetStringSystem() + if sys != "" { + texts = append(texts, sys) + } + } else { + systemMedia := c.ParseSystem() + for _, media := range systemMedia { + switch media.Type { + case "text": + texts = append(texts, media.GetText()) + case "image": + if media.Source != nil { + data := media.Source.Url + if data == "" { + data = common.Interface2String(media.Source.Data) + } + if data != "" { + fileMeta = append(fileMeta, &types.FileMeta{FileType: types.FileTypeImage, OriginData: data}) + } + } + } + } + } + } + + // messages + for _, message := range c.Messages { + tokenCountMeta.MessagesCount++ + texts = append(texts, message.Role) + if message.IsStringContent() { + content := message.GetStringContent() + if content != "" { + texts = append(texts, content) + } + continue + } + + content, _ := message.ParseContent() + for _, media := range content { + switch media.Type { + case "text": + texts = append(texts, media.GetText()) + case "image": + if media.Source != nil { + data := media.Source.Url + if data == "" { + data = common.Interface2String(media.Source.Data) + } + if data != "" { + fileMeta = append(fileMeta, &types.FileMeta{FileType: types.FileTypeImage, OriginData: data}) + } + } + case "tool_use": + if media.Name != "" { + texts = append(texts, media.Name) + } + if media.Input != nil { + b, _ := common.Marshal(media.Input) + texts = append(texts, string(b)) + } + case "tool_result": + if media.Content != nil { + b, _ := common.Marshal(media.Content) + texts = append(texts, string(b)) + } + } + } + } + + // tools + if c.Tools != nil { + tools := c.GetTools() + normalTools, webSearchTools := ProcessTools(tools) + if normalTools != nil { + for _, t := range normalTools { + tokenCountMeta.ToolsCount++ + if t.Name != "" { + texts = append(texts, t.Name) + } + if t.Description != "" { + texts = append(texts, t.Description) + } + if t.InputSchema != nil { + b, _ := common.Marshal(t.InputSchema) + texts = append(texts, string(b)) + } + } + } + if webSearchTools != nil { + for _, t := range webSearchTools { + tokenCountMeta.ToolsCount++ + if t.Name != "" { + texts = append(texts, t.Name) + } + if t.UserLocation != nil { + b, _ := common.Marshal(t.UserLocation) + texts = append(texts, string(b)) + } + } + } + } + + tokenCountMeta.CombineText = strings.Join(texts, "\n") + tokenCountMeta.Files = fileMeta + return &tokenCountMeta +} + +func (c *ClaudeRequest) IsStream(ctx *gin.Context) bool { + return c.Stream +} + +func (c *ClaudeRequest) SetModelName(modelName string) { + if modelName != "" { + c.Model = modelName + } +} + +func (c *ClaudeRequest) SearchToolNameByToolCallId(toolCallId string) string { + for _, message := range c.Messages { + content, _ := message.ParseContent() + for _, mediaMessage := range content { + if mediaMessage.Id == toolCallId { + return mediaMessage.Name + } + } + } + return "" +} + +// 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 ClaudeErrorWithStatusCode struct { + Error types.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 any `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 +} + +// GetClaudeError 从动态错误类型中提取ClaudeError结构 +func (c *ClaudeResponse) GetClaudeError() *types.ClaudeError { + if c.Error == nil { + return nil + } + + switch err := c.Error.(type) { + case types.ClaudeError: + return &err + case *types.ClaudeError: + return err + case map[string]interface{}: + // 处理从JSON解析来的map结构 + claudeErr := &types.ClaudeError{} + if errType, ok := err["type"].(string); ok { + claudeErr.Type = errType + } + if errMsg, ok := err["message"].(string); ok { + claudeErr.Message = errMsg + } + return claudeErr + case string: + // 处理简单字符串错误 + return &types.ClaudeError{ + Type: "upstream_error", + Message: err, + } + default: + // 未知类型,尝试转换为字符串 + return &types.ClaudeError{ + Type: "unknown_upstream_error", + Message: fmt.Sprintf("unknown_error: %v", err), + } + } +} + +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,omitempty"` +} + +type ClaudeServerToolUse struct { + WebSearchRequests int `json:"web_search_requests"` +} diff --git a/dto/embedding.go b/dto/embedding.go new file mode 100644 index 0000000000000000000000000000000000000000..2681cd17c6cec40f5f19f509d8c31f6acd491881 --- /dev/null +++ b/dto/embedding.go @@ -0,0 +1,87 @@ +package dto + +import ( + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +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) GetTokenCountMeta() *types.TokenCountMeta { + var texts = make([]string, 0) + + inputs := r.ParseInput() + for _, input := range inputs { + texts = append(texts, input) + } + + return &types.TokenCountMeta{ + CombineText: strings.Join(texts, "\n"), + } +} + +func (r *EmbeddingRequest) IsStream(c *gin.Context) bool { + return false +} + +func (r *EmbeddingRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +func (r *EmbeddingRequest) ParseInput() []string { + if r.Input == nil { + return make([]string, 0) + } + 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 0000000000000000000000000000000000000000..9c09e4e846b434adabce55d0223bffc47517c474 --- /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/gemini.go b/dto/gemini.go new file mode 100644 index 0000000000000000000000000000000000000000..077443e055afe3d6bfaace1b050e7afed0f85f0e --- /dev/null +++ b/dto/gemini.go @@ -0,0 +1,416 @@ +package dto + +import ( + "encoding/json" + "github.com/gin-gonic/gin" + "one-api/common" + "one-api/logger" + "one-api/types" + "strings" +) + +type GeminiChatRequest struct { + Contents []GeminiChatContent `json:"contents"` + SafetySettings []GeminiChatSafetySettings `json:"safetySettings,omitempty"` + GenerationConfig GeminiChatGenerationConfig `json:"generationConfig,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` + ToolConfig *ToolConfig `json:"toolConfig,omitempty"` + SystemInstructions *GeminiChatContent `json:"systemInstruction,omitempty"` + CachedContent string `json:"cachedContent,omitempty"` +} + +type ToolConfig struct { + FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"` + RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"` +} + +type FunctionCallingConfig struct { + Mode FunctionCallingConfigMode `json:"mode,omitempty"` + AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"` +} +type FunctionCallingConfigMode string + +type RetrievalConfig struct { + LatLng *LatLng `json:"latLng,omitempty"` + LanguageCode string `json:"languageCode,omitempty"` +} + +type LatLng struct { + Latitude *float64 `json:"latitude,omitempty"` + Longitude *float64 `json:"longitude,omitempty"` +} + +func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta { + var files []*types.FileMeta = make([]*types.FileMeta, 0) + + var maxTokens int + + if r.GenerationConfig.MaxOutputTokens > 0 { + maxTokens = int(r.GenerationConfig.MaxOutputTokens) + } + + var inputTexts []string + for _, content := range r.Contents { + for _, part := range content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + if part.InlineData != nil && part.InlineData.Data != "" { + if strings.HasPrefix(part.InlineData.MimeType, "image/") { + files = append(files, &types.FileMeta{ + FileType: types.FileTypeImage, + OriginData: part.InlineData.Data, + }) + } else if strings.HasPrefix(part.InlineData.MimeType, "audio/") { + files = append(files, &types.FileMeta{ + FileType: types.FileTypeAudio, + OriginData: part.InlineData.Data, + }) + } else if strings.HasPrefix(part.InlineData.MimeType, "video/") { + files = append(files, &types.FileMeta{ + FileType: types.FileTypeVideo, + OriginData: part.InlineData.Data, + }) + } else { + files = append(files, &types.FileMeta{ + FileType: types.FileTypeFile, + OriginData: part.InlineData.Data, + }) + } + } + } + } + + inputText := strings.Join(inputTexts, "\n") + return &types.TokenCountMeta{ + CombineText: inputText, + Files: files, + MaxTokens: maxTokens, + } +} + +func (r *GeminiChatRequest) IsStream(c *gin.Context) bool { + if c.Query("alt") == "sse" { + return true + } + return false +} + +func (r *GeminiChatRequest) SetModelName(modelName string) { + // GeminiChatRequest does not have a model field, so this method does nothing. +} + +func (r *GeminiChatRequest) GetTools() []GeminiChatTool { + var tools []GeminiChatTool + if strings.HasSuffix(string(r.Tools), "[") { + // is array + if err := common.Unmarshal(r.Tools, &tools); err != nil { + logger.LogError(nil, "error_unmarshalling_tools: "+err.Error()) + return nil + } + } else if strings.HasPrefix(string(r.Tools), "{") { + // is object + singleTool := GeminiChatTool{} + if err := common.Unmarshal(r.Tools, &singleTool); err != nil { + logger.LogError(nil, "error_unmarshalling_single_tool: "+err.Error()) + return nil + } + tools = []GeminiChatTool{singleTool} + } + return tools +} + +func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) { + if len(tools) == 0 { + r.Tools = json.RawMessage("[]") + return + } + + // Marshal the tools to JSON + data, err := common.Marshal(tools) + if err != nil { + logger.LogError(nil, "error_marshalling_tools: "+err.Error()) + return + } + r.Tools = data +} + +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 := common.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 GeminiFunctionResponse 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 *GeminiFunctionResponse `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 := common.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"` + URLContext any `json:"urlContext,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"` + ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"` + PresencePenalty *float32 `json:"presencePenalty,omitempty"` + FrequencyPenalty *float32 `json:"frequencyPenalty,omitempty"` + ResponseLogprobs bool `json:"responseLogprobs,omitempty"` + Logprobs *int32 `json:"logprobs,omitempty"` + MediaResolution MediaResolution `json:"mediaResolution,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 MediaResolution string + +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 { + Model string `json:"model,omitempty"` + Content GeminiChatContent `json:"content"` + TaskType string `json:"taskType,omitempty"` + Title string `json:"title,omitempty"` + OutputDimensionality int `json:"outputDimensionality,omitempty"` +} + +func (r *GeminiEmbeddingRequest) IsStream(c *gin.Context) bool { + // Gemini embedding requests are not streamed + return false +} + +func (r *GeminiEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta { + var inputTexts []string + for _, part := range r.Content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + } + inputText := strings.Join(inputTexts, "\n") + return &types.TokenCountMeta{ + CombineText: inputText, + } +} + +func (r *GeminiEmbeddingRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +type GeminiBatchEmbeddingRequest struct { + Requests []*GeminiEmbeddingRequest `json:"requests"` +} + +func (r *GeminiBatchEmbeddingRequest) IsStream(c *gin.Context) bool { + // Gemini batch embedding requests are not streamed + return false +} + +func (r *GeminiBatchEmbeddingRequest) GetTokenCountMeta() *types.TokenCountMeta { + var inputTexts []string + for _, request := range r.Requests { + meta := request.GetTokenCountMeta() + if meta != nil && meta.CombineText != "" { + inputTexts = append(inputTexts, meta.CombineText) + } + } + inputText := strings.Join(inputTexts, "\n") + return &types.TokenCountMeta{ + CombineText: inputText, + } +} + +func (r *GeminiBatchEmbeddingRequest) SetModelName(modelName string) { + if modelName != "" { + for _, req := range r.Requests { + req.SetModelName(modelName) + } + } +} + +type GeminiEmbeddingResponse struct { + Embedding ContentEmbedding `json:"embedding"` +} + +type GeminiBatchEmbeddingResponse struct { + Embeddings []*ContentEmbedding `json:"embeddings"` +} + +type ContentEmbedding struct { + Values []float64 `json:"values"` +} diff --git a/dto/midjourney.go b/dto/midjourney.go new file mode 100644 index 0000000000000000000000000000000000000000..0057aaa495a02fdf64d6282ba658a66bb0000b1d --- /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 0000000000000000000000000000000000000000..1c0e9b837d5f102cbae49eee0150f5ca1ea009f4 --- /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_image.go b/dto/openai_image.go new file mode 100644 index 0000000000000000000000000000000000000000..66f6dca7c17fbe548045e2123ef6aa1908df8295 --- /dev/null +++ b/dto/openai_image.go @@ -0,0 +1,172 @@ +package dto + +import ( + "encoding/json" + "one-api/common" + "one-api/types" + "reflect" + "strings" + + "github.com/gin-gonic/gin" +) + +type ImageRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt" binding:"required"` + N uint `json:"n,omitempty"` + Size string `json:"size,omitempty"` + Quality string `json:"quality,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` + Style json.RawMessage `json:"style,omitempty"` + User json.RawMessage `json:"user,omitempty"` + ExtraFields json.RawMessage `json:"extra_fields,omitempty"` + Background json.RawMessage `json:"background,omitempty"` + Moderation json.RawMessage `json:"moderation,omitempty"` + OutputFormat json.RawMessage `json:"output_format,omitempty"` + OutputCompression json.RawMessage `json:"output_compression,omitempty"` + PartialImages json.RawMessage `json:"partial_images,omitempty"` + // Stream bool `json:"stream,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + // 用匿名参数接收额外参数 + Extra map[string]json.RawMessage `json:"-"` +} + +func (i *ImageRequest) UnmarshalJSON(data []byte) error { + // 先解析成 map[string]interface{} + var rawMap map[string]json.RawMessage + if err := common.Unmarshal(data, &rawMap); err != nil { + return err + } + + // 用 struct tag 获取所有已定义字段名 + knownFields := GetJSONFieldNames(reflect.TypeOf(*i)) + + // 再正常解析已定义字段 + type Alias ImageRequest + var known Alias + if err := common.Unmarshal(data, &known); err != nil { + return err + } + *i = ImageRequest(known) + + // 提取多余字段 + i.Extra = make(map[string]json.RawMessage) + for k, v := range rawMap { + if _, ok := knownFields[k]; !ok { + i.Extra[k] = v + } + } + return nil +} + +// 序列化时需要重新把字段平铺 +func (r ImageRequest) MarshalJSON() ([]byte, error) { + // 将已定义字段转为 map + type Alias ImageRequest + alias := Alias(r) + base, err := common.Marshal(alias) + if err != nil { + return nil, err + } + + var baseMap map[string]json.RawMessage + if err := common.Unmarshal(base, &baseMap); err != nil { + return nil, err + } + + // 合并 ExtraFields + for k, v := range r.Extra { + if _, exists := baseMap[k]; !exists { + baseMap[k] = v + } + } + + return json.Marshal(baseMap) +} + +func GetJSONFieldNames(t reflect.Type) map[string]struct{} { + fields := make(map[string]struct{}) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + + // 跳过匿名字段(例如 ExtraFields) + if field.Anonymous { + continue + } + + tag := field.Tag.Get("json") + if tag == "-" || tag == "" { + continue + } + + // 取逗号前字段名(排除 omitempty 等) + name := tag + if commaIdx := indexComma(tag); commaIdx != -1 { + name = tag[:commaIdx] + } + fields[name] = struct{}{} + } + return fields +} + +func indexComma(s string) int { + for i := 0; i < len(s); i++ { + if s[i] == ',' { + return i + } + } + return -1 +} + +func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta { + var sizeRatio = 1.0 + var qualityRatio = 1.0 + + if strings.HasPrefix(i.Model, "dall-e") { + // Size + if i.Size == "256x256" { + sizeRatio = 0.4 + } else if i.Size == "512x512" { + sizeRatio = 0.45 + } else if i.Size == "1024x1024" { + sizeRatio = 1 + } else if i.Size == "1024x1792" || i.Size == "1792x1024" { + sizeRatio = 2 + } + + if i.Model == "dall-e-3" && i.Quality == "hd" { + qualityRatio = 2.0 + if i.Size == "1024x1792" || i.Size == "1792x1024" { + qualityRatio = 1.5 + } + } + } + + // not support token count for dalle + return &types.TokenCountMeta{ + CombineText: i.Prompt, + MaxTokens: 1584, + ImagePriceRatio: sizeRatio * qualityRatio * float64(i.N), + } +} + +func (i *ImageRequest) IsStream(c *gin.Context) bool { + return false +} + +func (i *ImageRequest) SetModelName(modelName string) { + if modelName != "" { + i.Model = modelName + } +} + +type ImageResponse struct { + Data []ImageData `json:"data"` + Created int64 `json:"created"` + Extra any `json:"extra,omitempty"` +} +type ImageData struct { + Url string `json:"url"` + B64Json string `json:"b64_json"` + RevisedPrompt string `json:"revised_prompt"` +} diff --git a/dto/openai_request.go b/dto/openai_request.go new file mode 100644 index 0000000000000000000000000000000000000000..5ab77e7beb41821ce9c412681cd3569ddb337e11 --- /dev/null +++ b/dto/openai_request.go @@ -0,0 +1,959 @@ +package dto + +import ( + "encoding/json" + "fmt" + "one-api/common" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +type ResponseFormat struct { + Type string `json:"type,omitempty"` + JsonSchema json.RawMessage `json:"json_schema,omitempty"` +} + +type FormatJsonSchema struct { + Description string `json:"description,omitempty"` + Name string `json:"name"` + Schema any `json:"schema,omitempty"` + Strict json.RawMessage `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"` + Verbosity json.RawMessage `json:"verbosity,omitempty"` // gpt-5 + 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"` + // gemini + ExtraBody json.RawMessage `json:"extra_body,omitempty"` + //xai + SearchParameters json.RawMessage `json:"search_parameters,omitempty"` + // claude + 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"` + EnableThinking any `json:"enable_thinking,omitempty"` + // ollama Params + Think json.RawMessage `json:"think,omitempty"` + // baidu v2 + WebSearch json.RawMessage `json:"web_search,omitempty"` + // doubao,zhipu_v4 + THINKING json.RawMessage `json:"thinking,omitempty"` +} + +func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta { + var tokenCountMeta types.TokenCountMeta + var texts = make([]string, 0) + var fileMeta = make([]*types.FileMeta, 0) + + if r.Prompt != nil { + switch v := r.Prompt.(type) { + case string: + texts = append(texts, v) + case []any: + for _, item := range v { + if str, ok := item.(string); ok { + texts = append(texts, str) + } + } + default: + texts = append(texts, fmt.Sprintf("%v", r.Prompt)) + } + } + + if r.Input != nil { + inputs := r.ParseInput() + texts = append(texts, inputs...) + } + + if r.MaxCompletionTokens > r.MaxTokens { + tokenCountMeta.MaxTokens = int(r.MaxCompletionTokens) + } else { + tokenCountMeta.MaxTokens = int(r.MaxTokens) + } + + for _, message := range r.Messages { + tokenCountMeta.MessagesCount++ + texts = append(texts, message.Role) + if message.Content != nil { + if message.Name != nil { + tokenCountMeta.NameCount++ + texts = append(texts, *message.Name) + } + arrayContent := message.ParseContent() + for _, m := range arrayContent { + if m.Type == ContentTypeImageURL { + imageUrl := m.GetImageMedia() + if imageUrl != nil { + if imageUrl.Url != "" { + meta := &types.FileMeta{ + FileType: types.FileTypeImage, + } + meta.OriginData = imageUrl.Url + meta.Detail = imageUrl.Detail + fileMeta = append(fileMeta, meta) + } + } + } else if m.Type == ContentTypeInputAudio { + inputAudio := m.GetInputAudio() + if inputAudio != nil { + meta := &types.FileMeta{ + FileType: types.FileTypeAudio, + } + meta.OriginData = inputAudio.Data + fileMeta = append(fileMeta, meta) + } + } else if m.Type == ContentTypeFile { + file := m.GetFile() + if file != nil { + meta := &types.FileMeta{ + FileType: types.FileTypeFile, + } + meta.OriginData = file.FileData + fileMeta = append(fileMeta, meta) + } + } else if m.Type == ContentTypeVideoUrl { + videoUrl := m.GetVideoUrl() + if videoUrl != nil && videoUrl.Url != "" { + meta := &types.FileMeta{ + FileType: types.FileTypeVideo, + } + meta.OriginData = videoUrl.Url + fileMeta = append(fileMeta, meta) + } + } else { + texts = append(texts, m.Text) + } + } + } + } + + if r.Tools != nil { + openaiTools := r.Tools + for _, tool := range openaiTools { + tokenCountMeta.ToolsCount++ + texts = append(texts, tool.Function.Name) + if tool.Function.Description != "" { + texts = append(texts, tool.Function.Description) + } + if tool.Function.Parameters != nil { + texts = append(texts, fmt.Sprintf("%v", tool.Function.Parameters)) + } + } + //toolTokens := CountTokenInput(countStr, request.Model) + //tkm += 8 + //tkm += toolTokens + } + tokenCountMeta.CombineText = strings.Join(texts, "\n") + tokenCountMeta.Files = fileMeta + return &tokenCountMeta +} + +func (r *GeneralOpenAIRequest) IsStream(c *gin.Context) bool { + return r.Stream +} + +func (r *GeneralOpenAIRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +func (r *GeneralOpenAIRequest) ToMap() map[string]any { + result := make(map[string]any) + data, _ := common.Marshal(r) + _ = common.Unmarshal(data, &result) + return result +} + +func (r *GeneralOpenAIRequest) GetSystemRoleName() string { + if strings.HasPrefix(r.Model, "o") { + if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") { + return "developer" + } + } else if strings.HasPrefix(r.Model, "gpt-5") { + return "developer" + } + return "system" +} + +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() uint { + if r.MaxCompletionTokens != 0 { + return r.MaxCompletionTokens + } + return 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 +} + +func (m *MediaContent) GetVideoUrl() *MessageVideoUrl { + if m.VideoUrl != nil { + if _, ok := m.VideoUrl.(*MessageVideoUrl); ok { + return m.VideoUrl.(*MessageVideoUrl) + } + if itemMap, ok := m.VideoUrl.(map[string]any); ok { + out := &MessageVideoUrl{ + Url: common.Interface2String(itemMap["url"]), + } + 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" // 阿里百炼视频识别 + //ContentTypeAudioUrl = "audio_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 json.RawMessage `json:"parallel_tool_calls,omitempty"` + PreviousResponseID string `json:"previous_response_id,omitempty"` + Reasoning *Reasoning `json:"reasoning,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + Store json.RawMessage `json:"store,omitempty"` + PromptCacheKey json.RawMessage `json:"prompt_cache_key,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 json.RawMessage `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"` +} + +func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta { + var fileMeta = make([]*types.FileMeta, 0) + var texts = make([]string, 0) + + if r.Input != nil { + inputs := r.ParseInput() + for _, input := range inputs { + if input.Type == "input_image" { + if input.ImageUrl != "" { + fileMeta = append(fileMeta, &types.FileMeta{ + FileType: types.FileTypeImage, + OriginData: input.ImageUrl, + Detail: input.Detail, + }) + } + } else if input.Type == "input_file" { + if input.FileUrl != "" { + fileMeta = append(fileMeta, &types.FileMeta{ + FileType: types.FileTypeFile, + OriginData: input.FileUrl, + }) + } + } else { + texts = append(texts, input.Text) + } + } + } + + if len(r.Instructions) > 0 { + texts = append(texts, string(r.Instructions)) + } + + if len(r.Metadata) > 0 { + texts = append(texts, string(r.Metadata)) + } + + if len(r.Text) > 0 { + texts = append(texts, string(r.Text)) + } + + if len(r.ToolChoice) > 0 { + texts = append(texts, string(r.ToolChoice)) + } + + if len(r.Prompt) > 0 { + texts = append(texts, string(r.Prompt)) + } + + if len(r.Tools) > 0 { + texts = append(texts, string(r.Tools)) + } + + return &types.TokenCountMeta{ + CombineText: strings.Join(texts, "\n"), + Files: fileMeta, + MaxTokens: int(r.MaxOutputTokens), + } +} + +func (r *OpenAIResponsesRequest) IsStream(c *gin.Context) bool { + return r.Stream +} + +func (r *OpenAIResponsesRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +func (r *OpenAIResponsesRequest) GetToolsMap() []map[string]any { + var toolsMap []map[string]any + if len(r.Tools) > 0 { + _ = common.Unmarshal(r.Tools, &toolsMap) + } + return toolsMap +} + +type Reasoning struct { + Effort string `json:"effort,omitempty"` + Summary string `json:"summary,omitempty"` +} + +type MediaInput struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + FileUrl string `json:"file_url,omitempty"` + ImageUrl string `json:"image_url,omitempty"` + Detail string `json:"detail,omitempty"` // 仅 input_image 有效 +} + +// ParseInput parses the Responses API `input` field into a normalized slice of MediaInput. +// Reference implementation mirrors Message.ParseContent: +// - input can be a string, treated as an input_text item +// - input can be an array of objects with a `type` field +// supported types: input_text, input_image, input_file +func (r *OpenAIResponsesRequest) ParseInput() []MediaInput { + if r.Input == nil { + return nil + } + + var inputs []MediaInput + + // Try string first + // if str, ok := common.GetJsonType(r.Input); ok { + // inputs = append(inputs, MediaInput{Type: "input_text", Text: str}) + // return inputs + // } + if common.GetJsonType(r.Input) == "string" { + var str string + _ = common.Unmarshal(r.Input, &str) + inputs = append(inputs, MediaInput{Type: "input_text", Text: str}) + return inputs + } + + // Try array of parts + if common.GetJsonType(r.Input) == "array" { + var array []any + _ = common.Unmarshal(r.Input, &array) + for _, itemAny := range array { + // Already parsed MediaInput + if media, ok := itemAny.(MediaInput); ok { + inputs = append(inputs, media) + continue + } + // Generic map + item, ok := itemAny.(map[string]any) + if !ok { + continue + } + typeVal, ok := item["type"].(string) + if !ok { + continue + } + switch typeVal { + case "input_text": + text, _ := item["text"].(string) + inputs = append(inputs, MediaInput{Type: "input_text", Text: text}) + case "input_image": + // image_url may be string or object with url field + var imageUrl string + switch v := item["image_url"].(type) { + case string: + imageUrl = v + case map[string]any: + if url, ok := v["url"].(string); ok { + imageUrl = url + } + } + inputs = append(inputs, MediaInput{Type: "input_image", ImageUrl: imageUrl}) + case "input_file": + // file_url may be string or object with url field + var fileUrl string + switch v := item["file_url"].(type) { + case string: + fileUrl = v + case map[string]any: + if url, ok := v["url"].(string); ok { + fileUrl = url + } + } + inputs = append(inputs, MediaInput{Type: "input_file", FileUrl: fileUrl}) + } + } + } + + return inputs +} diff --git a/dto/openai_response.go b/dto/openai_response.go new file mode 100644 index 0000000000000000000000000000000000000000..c152b3d7f45e8dc628403c403840886381082d69 --- /dev/null +++ b/dto/openai_response.go @@ -0,0 +1,398 @@ +package dto + +import ( + "encoding/json" + "fmt" + "one-api/types" +) + +const ( + ResponsesOutputTypeImageGenerationCall = "image_generation_call" +) + +type SimpleResponse struct { + Usage `json:"usage"` + Error any `json:"error"` +} + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (s *SimpleResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(s.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 any `json:"error,omitempty"` + Usage `json:"usage"` +} + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (o *OpenAITextResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(o.Error) +} + +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) IsFinished() bool { + if len(c.Choices) == 0 { + return false + } + return c.Choices[0].FinishReason != nil && *c.Choices[0].FinishReason != "" +} + +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) ClearToolCalls() { + if !c.IsToolCall() { + return + } + for choiceIdx := range c.Choices { + for callIdx := range c.Choices[choiceIdx].Delta.ToolCalls { + c.Choices[choiceIdx].Delta.ToolCalls[callIdx].ID = "" + c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Type = nil + c.Choices[choiceIdx].Delta.ToolCalls[callIdx].Function.Name = "" + } + } +} + +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 any `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"` +} + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func (o *OpenAIResponsesResponse) GetOpenAIError() *types.OpenAIError { + return GetOpenAIError(o.Error) +} + +func (o *OpenAIResponsesResponse) HasImageGenerationCall() bool { + if len(o.Output) == 0 { + return false + } + for _, output := range o.Output { + if output.Type == ResponsesOutputTypeImageGenerationCall { + return true + } + } + return false +} + +func (o *OpenAIResponsesResponse) GetQuality() string { + if len(o.Output) == 0 { + return "" + } + for _, output := range o.Output { + if output.Type == ResponsesOutputTypeImageGenerationCall { + return output.Quality + } + } + return "" +} + +func (o *OpenAIResponsesResponse) GetSize() string { + if len(o.Output) == 0 { + return "" + } + for _, output := range o.Output { + if output.Type == ResponsesOutputTypeImageGenerationCall { + return output.Size + } + } + return "" +} + +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"` + Quality string `json:"quality"` + Size string `json:"size"` +} + +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"` +} + +// GetOpenAIError 从动态错误类型中提取OpenAIError结构 +func GetOpenAIError(errorField any) *types.OpenAIError { + if errorField == nil { + return nil + } + + switch err := errorField.(type) { + case types.OpenAIError: + return &err + case *types.OpenAIError: + return err + case map[string]interface{}: + // 处理从JSON解析来的map结构 + openaiErr := &types.OpenAIError{} + if errType, ok := err["type"].(string); ok { + openaiErr.Type = errType + } + if errMsg, ok := err["message"].(string); ok { + openaiErr.Message = errMsg + } + if errParam, ok := err["param"].(string); ok { + openaiErr.Param = errParam + } + if errCode, ok := err["code"]; ok { + openaiErr.Code = errCode + } + return openaiErr + case string: + // 处理简单字符串错误 + return &types.OpenAIError{ + Type: "error", + Message: err, + } + default: + // 未知类型,尝试转换为字符串 + return &types.OpenAIError{ + Type: "unknown_error", + Message: fmt.Sprintf("%v", err), + } + } +} diff --git a/dto/playground.go b/dto/playground.go new file mode 100644 index 0000000000000000000000000000000000000000..75f4fc6ff1a79abca7ffaea8a20d0324afa579ce --- /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 0000000000000000000000000000000000000000..37e60c8b505ec0ce53e1d856893c04afd63e352c --- /dev/null +++ b/dto/pricing.go @@ -0,0 +1,35 @@ +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"` +} + +type AnthropicModel struct { + ID string `json:"id"` + CreatedAt string `json:"created_at"` + DisplayName string `json:"display_name"` + Type string `json:"type"` +} + +type GeminiModel struct { + Name interface{} `json:"name"` + BaseModelId interface{} `json:"baseModelId"` + Version interface{} `json:"version"` + DisplayName interface{} `json:"displayName"` + Description interface{} `json:"description"` + InputTokenLimit interface{} `json:"inputTokenLimit"` + OutputTokenLimit interface{} `json:"outputTokenLimit"` + SupportedGenerationMethods []interface{} `json:"supportedGenerationMethods"` + Thinking interface{} `json:"thinking"` + Temperature interface{} `json:"temperature"` + MaxTemperature interface{} `json:"maxTemperature"` + TopP interface{} `json:"topP"` + TopK interface{} `json:"topK"` +} diff --git a/dto/ratio_sync.go b/dto/ratio_sync.go new file mode 100644 index 0000000000000000000000000000000000000000..c475069ccf47baf8cc09a2d443111ff508f68f5d --- /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"` +} diff --git a/dto/realtime.go b/dto/realtime.go new file mode 100644 index 0000000000000000000000000000000000000000..cfc435c1839b1e3898f55ba822b587b181ccaedd --- /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/request_common.go b/dto/request_common.go new file mode 100644 index 0000000000000000000000000000000000000000..39f92a6bfe0b95833340730d840673bc191923ca --- /dev/null +++ b/dto/request_common.go @@ -0,0 +1,25 @@ +package dto + +import ( + "github.com/gin-gonic/gin" + "one-api/types" +) + +type Request interface { + GetTokenCountMeta() *types.TokenCountMeta + IsStream(c *gin.Context) bool + SetModelName(modelName string) +} + +type BaseRequest struct { +} + +func (b *BaseRequest) GetTokenCountMeta() *types.TokenCountMeta { + return &types.TokenCountMeta{ + TokenType: types.TokenTypeTokenizer, + } +} +func (b *BaseRequest) IsStream(c *gin.Context) bool { + return false +} +func (b *BaseRequest) SetModelName(modelName string) {} diff --git a/dto/rerank.go b/dto/rerank.go new file mode 100644 index 0000000000000000000000000000000000000000..fe11579d5083bf71ecb61d7724701a753ab6533a --- /dev/null +++ b/dto/rerank.go @@ -0,0 +1,66 @@ +package dto + +import ( + "fmt" + "github.com/gin-gonic/gin" + "one-api/types" + "strings" +) + +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) IsStream(c *gin.Context) bool { + return false +} + +func (r *RerankRequest) GetTokenCountMeta() *types.TokenCountMeta { + var texts = make([]string, 0) + + for _, document := range r.Documents { + texts = append(texts, fmt.Sprintf("%v", document)) + } + + if r.Query != "" { + texts = append(texts, r.Query) + } + + return &types.TokenCountMeta{ + CombineText: strings.Join(texts, "\n"), + } +} + +func (r *RerankRequest) SetModelName(modelName string) { + if modelName != "" { + r.Model = modelName + } +} + +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 0000000000000000000000000000000000000000..8b2956d0133ac9f3e03d743a338a8a0f248310b4 --- /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 0000000000000000000000000000000000000000..54e1b9ebc8ffac7f30afdbd100d5ba79d22e1f58 --- /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 0000000000000000000000000000000000000000..3300f4b3e50086c5ba0d3aa105a6e087d256094c --- /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 0000000000000000000000000000000000000000..b4a1eee9c875319bf83a720b5cbf9998a0c91e77 --- /dev/null +++ b/dto/user_settings.go @@ -0,0 +1,19 @@ +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 通知邮箱地址 + BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL + AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型 + RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP + SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 +} + +var ( + NotifyTypeEmail = "email" // Email 邮件 + NotifyTypeWebhook = "webhook" // Webhook + NotifyTypeBark = "bark" // Bark 推送 +) diff --git a/dto/video.go b/dto/video.go new file mode 100644 index 0000000000000000000000000000000000000000..367feec5d742b939cd22b12f1af10abe12ce6e8a --- /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 0000000000000000000000000000000000000000..a3bdd2fb44d2ff7aace5c82fd18f3a561672e8f4 --- /dev/null +++ b/go.mod @@ -0,0 +1,113 @@ +module one-api + +// +heroku goVersion go1.18 +go 1.24.0 + +toolchain go1.24.6 + +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.37.2 + github.com/aws/aws-sdk-go-v2/credentials v1.17.11 + github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 + github.com/aws/smithy-go v1.22.5 + 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/go-webauthn/webauthn v0.14.0 + 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/jinzhu/copier v0.4.0 + github.com/joho/godotenv v1.5.1 + github.com/pkg/errors v0.9.1 + github.com/pquerna/otp v1.5.0 + 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/tidwall/gjson v1.18.0 + github.com/tidwall/sjson v1.2.5 + github.com/tiktoken-go/tokenizer v0.6.2 + golang.org/x/crypto v0.42.0 + golang.org/x/image v0.23.0 + golang.org/x/net v0.43.0 + golang.org/x/sync v0.17.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.7.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 // indirect + github.com/boombuler/barcode v1.1.0 // 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/fxamacker/cbor/v2 v2.9.0 // 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/go-webauthn/x v0.1.25 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-tpm v0.9.5 // 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/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // 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/x448/float16 v0.8.4 // 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.36.0 // indirect + golang.org/x/text v0.29.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 0000000000000000000000000000000000000000..9780e5c773d0379a5c8327c89209e9274e2f13ff --- /dev/null +++ b/go.sum @@ -0,0 +1,324 @@ +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.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo= +github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg= +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.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 h1:JzidOz4Hcn2RbP5fvIS1iAP+DcRv5VJtgixbEYDsI5g= +github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0/go.mod h1:9A4/PJYlWjvjEzzoOLGQjkLt4bYK9fRWi7uz1GSsAcA= +github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= +github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +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/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0= +github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k= +github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88= +github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY= +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-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +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/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= +github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +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/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= +github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= +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/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= +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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +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/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +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= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +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.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= +golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= +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.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +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.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +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/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000000000000000000000000000000000000..42860b60514b02870abdd76ce75dbf5a25433381 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,118 @@ +package logger + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "one-api/common" + "os" + "path/filepath" + "sync" + "time" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" +) + +const ( + loggerINFO = "INFO" + loggerWarn = "WARN" + loggerError = "ERR" + loggerDebug = "DEBUG" +) + +const maxLogCount = 1000000 + +var logCount int +var setupLogLock sync.Mutex +var setupLogWorking bool + +func SetupLogger() { + defer func() { + setupLogWorking = false + }() + if *common.LogDir != "" { + ok := setupLogLock.TryLock() + if !ok { + log.Println("setup log is already working") + return + } + defer func() { + setupLogLock.Unlock() + }() + logPath := filepath.Join(*common.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 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 LogDebug(ctx context.Context, msg string) { + if common.DebugEnabled { + logHelper(ctx, loggerDebug, msg) + } +} + +func logHelper(ctx context.Context, level string, msg string) { + writer := gin.DefaultErrorWriter + if level == loggerINFO { + writer = gin.DefaultWriter + } + id := ctx.Value(common.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 LogQuota(quota int) string { + if common.DisplayInCurrencyEnabled { + return fmt.Sprintf("$%.6f 额度", float64(quota)/common.QuotaPerUnit) + } else { + return fmt.Sprintf("%d 点额度", quota) + } +} + +func FormatQuota(quota int) string { + if common.DisplayInCurrencyEnabled { + return fmt.Sprintf("$%.6f", float64(quota)/common.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/main.go b/main.go new file mode 100644 index 0000000000000000000000000000000000000000..8404f1ac9da2213580ad40ae1331217d9a95ec27 --- /dev/null +++ b/main.go @@ -0,0 +1,232 @@ +package main + +import ( + "bytes" + "embed" + "fmt" + "log" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/controller" + "one-api/logger" + "one-api/middleware" + "one-api/model" + "one-api/router" + "one-api/service" + "one-api/setting/ratio_setting" + "os" + "strconv" + "strings" + "time" + + "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() { + startTime := time.Now() + + 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.SysLog(fmt.Sprintf("sync frequency: %d seconds", common.SyncFrequency)) + + // Add panic recovery and retry for InitChannelCache + func() { + defer func() { + if r := recover(); r != nil { + common.SysLog(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) + } + + go controller.AutomaticallyTestChannels() + + 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.SysLog(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)) + + analyticsInjectBuilder := &strings.Builder{} + if os.Getenv("UMAMI_WEBSITE_ID") != "" { + umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID") + umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL") + if umamiScriptURL == "" { + umamiScriptURL = "https://analytics.umami.is/script.js" + } + analyticsInjectBuilder.WriteString("") + } + analyticsInject := analyticsInjectBuilder.String() + indexPage = bytes.ReplaceAll(indexPage, []byte("\n"), []byte(analyticsInject)) + + router.SetRouter(server, buildFS, indexPage) + var port = os.Getenv("PORT") + if port == "" { + port = strconv.Itoa(*common.Port) + } + + // Log startup success message + common.LogStartupSuccess(startTime, 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 { + if common.DebugEnabled { + common.SysLog("No .env file found, using default environment variables. If needed, please create a .env file and set the relevant variables.") + } + } + + // 加载环境变量 + common.InitEnv() + + logger.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 0000000000000000000000000000000000000000..91555ebbc673a8feab44c360145e4f857df029c9 --- /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 0000000000000000000000000000000000000000..58404323e28e819bc8578c8e877add1a4fe642ef --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,319 @@ +package middleware + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/model" + "one-api/setting" + "one-api/setting/ratio_setting" + "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("user_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") { + anthropicKey := c.Request.Header.Get("x-api-key") + if anthropicKey != "" { + c.Request.Header.Set("Authorization", "Bearer "+anthropicKey) + } + } + // gemini api 从query中获取key + if strings.HasPrefix(c.Request.URL.Path, "/v1beta/models") || + strings.HasPrefix(c.Request.URL.Path, "/v1beta/openai/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 + } + + allowIpsMap := token.GetIpLimitsMap() + if len(allowIpsMap) != 0 { + clientIp := c.ClientIP() + if _, ok := allowIpsMap[clientIp]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中") + 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) + + userGroup := userCache.Group + tokenGroup := token.Group + 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) + + 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("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 0000000000000000000000000000000000000000..8899b12369b2c9210d83e89f74ef37000c4b0afe --- /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 0000000000000000000000000000000000000000..81f79a4bdbaa3f3754f541ecd545f096c9b99896 --- /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/disable-cache.go b/middleware/disable-cache.go new file mode 100644 index 0000000000000000000000000000000000000000..6e2113f1026606d5e9965376fc277d9b022e2497 --- /dev/null +++ b/middleware/disable-cache.go @@ -0,0 +1,12 @@ +package middleware + +import "github.com/gin-gonic/gin" + +func DisableCache() gin.HandlerFunc { + return func(c *gin.Context) { + c.Header("Cache-Control", "no-store, no-cache, must-revalidate, private, max-age=0") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") + c.Next() + } +} diff --git a/middleware/distributor.go b/middleware/distributor.go new file mode 100644 index 0000000000000000000000000000000000000000..f6a88ee28cc9443c778c4598cc40143987fdcfac --- /dev/null +++ b/middleware/distributor.go @@ -0,0 +1,327 @@ +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) { + 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 + } + 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) + if !ok { + // token model limit is empty, all models are not allowed + abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问任何模型") + return + } + var tokenModelLimit map[string]bool + tokenModelLimit, ok = s.(map[string]bool) + if !ok { + tokenModelLimit = map[string]bool{} + } + matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-* + if _, ok := tokenModelLimit[matchName]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, "该令牌无权访问模型 "+modelRequest.Model) + return + } + } + + if shouldSelectChannel { + if modelRequest.Model == "" { + abortWithOpenAiMessage(c, http.StatusBadRequest, "未指定模型名称,模型名称不能为空") + return + } + var selectGroup string + userGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + // check path is /pg/chat/completions + if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { + playgroundRequest := &dto.PlayGroundRequest{} + err = common.UnmarshalBodyReusable(c, playgroundRequest) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "无效的请求, "+err.Error()) + return + } + if playgroundRequest.Group != "" { + if !setting.GroupInUserUsableGroups(playgroundRequest.Group) && playgroundRequest.Group != userGroup { + abortWithOpenAiMessage(c, http.StatusForbidden, "无权访问该分组") + return + } + userGroup = playgroundRequest.Group + } + } + 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 的可用渠道失败(数据库一致性已被破坏,distributor): %s", showGroup, modelRequest.Model, err.Error()) + // 如果错误,但是渠道不为空,说明是数据库一致性问题 + //if channel != nil { + // common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) + // message = "数据库一致性已被破坏,请联系管理员" + //} + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, string(types.ErrorCodeModelNotFound)) + return + } + if channel == nil { + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, fmt.Sprintf("分组 %s 下模型 %s 无可用渠道(distributor)", userGroup, modelRequest.Model), string(types.ErrorCodeModelNotFound)) + 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") { + relayMode := relayconstant.RelayModeUnknown + if c.Request.Method == http.MethodPost { + err = common.UnmarshalBodyReusable(c, &modelRequest) + relayMode = relayconstant.RelayModeVideoSubmit + } else if c.Request.Method == http.MethodGet { + relayMode = relayconstant.RelayModeVideoFetchByID + shouldSelectChannel = false + } + if _, ok := c.Get("relay_mode"); !ok { + 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.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + 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.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + modelRequest.Model = c.PostForm("model") + } + } + 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, types.ErrOptionWithSkipRetry()) + } + 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.ContextKeyChannelOtherSetting, channel.GetOtherSettings()) + common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride()) + common.SetContextKey(c, constant.ContextKeyChannelHeaderOverride, channel.GetHeaderOverride()) + 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) + } else { + // 必须设置为 false,否则在重试到单个 key 的时候会导致日志显示错误 + common.SetContextKey(c, constant.ContextKeyChannelIsMultiKey, false) + } + // c.Request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) + common.SetContextKey(c, constant.ContextKeyChannelKey, key) + common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, channel.GetBaseURL()) + + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, false) + + // 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/email-verification-rate-limit.go b/middleware/email-verification-rate-limit.go new file mode 100644 index 0000000000000000000000000000000000000000..a3ef619813be3ff927ba04e9d689062614b046e2 --- /dev/null +++ b/middleware/email-verification-rate-limit.go @@ -0,0 +1,80 @@ +package middleware + +import ( + "context" + "fmt" + "net/http" + "one-api/common" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + EmailVerificationRateLimitMark = "EV" + EmailVerificationMaxRequests = 2 // 30秒内最多2次 + EmailVerificationDuration = 30 // 30秒时间窗口 +) + +func redisEmailVerificationRateLimiter(c *gin.Context) { + ctx := context.Background() + rdb := common.RDB + key := "emailVerification:" + EmailVerificationRateLimitMark + ":" + c.ClientIP() + + count, err := rdb.Incr(ctx, key).Result() + if err != nil { + // fallback + memoryEmailVerificationRateLimiter(c) + return + } + + // 第一次设置键时设置过期时间 + if count == 1 { + _ = rdb.Expire(ctx, key, time.Duration(EmailVerificationDuration)*time.Second).Err() + } + + // 检查是否超出限制 + if count <= int64(EmailVerificationMaxRequests) { + c.Next() + return + } + + // 获取剩余等待时间 + ttl, err := rdb.TTL(ctx, key).Result() + waitSeconds := int64(EmailVerificationDuration) + if err == nil && ttl > 0 { + waitSeconds = int64(ttl.Seconds()) + } + + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "message": fmt.Sprintf("发送过于频繁,请等待 %d 秒后再试", waitSeconds), + }) + c.Abort() +} + +func memoryEmailVerificationRateLimiter(c *gin.Context) { + key := EmailVerificationRateLimitMark + ":" + c.ClientIP() + + if !inMemoryRateLimiter.Request(key, EmailVerificationMaxRequests, EmailVerificationDuration) { + c.JSON(http.StatusTooManyRequests, gin.H{ + "success": false, + "message": "发送过于频繁,请稍后再试", + }) + c.Abort() + return + } + + c.Next() +} + +func EmailVerificationRateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + if common.RedisEnabled { + redisEmailVerificationRateLimiter(c) + } else { + inMemoryRateLimiter.Init(common.RateLimitKeyExpirationDuration) + memoryEmailVerificationRateLimiter(c) + } + } +} diff --git a/middleware/gzip.go b/middleware/gzip.go new file mode 100644 index 0000000000000000000000000000000000000000..bf333dbe266d89548d860c420726b722f4e7cef2 --- /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/jimeng_adapter.go b/middleware/jimeng_adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..aafccc42dc1c793edf69954762680090d0a19052 --- /dev/null +++ b/middleware/jimeng_adapter.go @@ -0,0 +1,66 @@ +package middleware + +import ( + "bytes" + "encoding/json" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/common" + "one-api/constant" + relayconstant "one-api/relay/constant" +) + +func JimengRequestConvert() func(c *gin.Context) { + return func(c *gin.Context) { + action := c.Query("Action") + if action == "" { + abortWithOpenAiMessage(c, http.StatusBadRequest, "Action query parameter is required") + return + } + + // Handle Jimeng official API request + var originalReq map[string]interface{} + if err := common.UnmarshalBodyReusable(c, &originalReq); err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, "Invalid request body") + return + } + model, _ := originalReq["req_key"].(string) + prompt, _ := originalReq["prompt"].(string) + + unifiedReq := map[string]interface{}{ + "model": model, + "prompt": prompt, + "metadata": originalReq, + } + + jsonData, err := json.Marshal(unifiedReq) + if err != nil { + abortWithOpenAiMessage(c, http.StatusInternalServerError, "Failed to marshal request body") + return + } + + // Update request body + c.Request.Body = io.NopCloser(bytes.NewBuffer(jsonData)) + c.Set(common.KeyRequestBody, jsonData) + + if image, ok := originalReq["image"]; !ok || image == "" { + c.Set("action", constant.TaskActionTextGenerate) + } + + c.Request.URL.Path = "/v1/video/generations" + + if action == "CVSync2AsyncGetResult" { + taskId, ok := originalReq["task_id"].(string) + if !ok || taskId == "" { + abortWithOpenAiMessage(c, http.StatusBadRequest, "task_id is required for CVSync2AsyncGetResult") + return + } + c.Request.URL.Path = "/v1/video/generations/" + taskId + c.Request.Method = http.MethodGet + c.Set("task_id", taskId) + c.Set("relay_mode", relayconstant.RelayModeVideoFetchByID) + } + c.Next() + } +} diff --git a/middleware/kling_adapter.go b/middleware/kling_adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..638b15d6ff6d9c7b019a4ef280b038e7ee4a164b --- /dev/null +++ b/middleware/kling_adapter.go @@ -0,0 +1,51 @@ +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 + } + + // Support both model_name and model fields + model, _ := originalReq["model_name"].(string) + if model == "" { + model, _ = originalReq["model"].(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 0000000000000000000000000000000000000000..244286e6040558195f6524984ff466c617588cf8 --- /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 0000000000000000000000000000000000000000..2ee77c560a4c197eaa8b80b9e6137e7638d2fd6f --- /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 0000000000000000000000000000000000000000..b00d1080e7b6a49a5c1b25e64e7941ad4427eff7 --- /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 0000000000000000000000000000000000000000..56777babf8537069f2f45a09af29145cad9aa96f --- /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.SysLog(fmt.Sprintf("panic detected: %v", err)) + common.SysLog(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 0000000000000000000000000000000000000000..cc75bb60508cdd6a78caeea3f3ed9cae37a5e1b7 --- /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/secure_verification.go b/middleware/secure_verification.go new file mode 100644 index 0000000000000000000000000000000000000000..ae9c2b9ceab94437b810b24d1c312a5f7ea9d45b --- /dev/null +++ b/middleware/secure_verification.go @@ -0,0 +1,131 @@ +package middleware + +import ( + "net/http" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" +) + +const ( + // SecureVerificationSessionKey 安全验证的 session key(与 controller 保持一致) + SecureVerificationSessionKey = "secure_verified_at" + // SecureVerificationTimeout 验证有效期(秒) + SecureVerificationTimeout = 300 // 5分钟 +) + +// SecureVerificationRequired 安全验证中间件 +// 检查用户是否在有效时间内通过了安全验证 +// 如果未验证或验证已过期,返回 401 错误 +func SecureVerificationRequired() gin.HandlerFunc { + return func(c *gin.Context) { + // 检查用户是否已登录 + userId := c.GetInt("id") + if userId == 0 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "未登录", + }) + c.Abort() + return + } + + // 检查 session 中的验证时间戳 + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "需要安全验证", + "code": "VERIFICATION_REQUIRED", + }) + c.Abort() + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + // session 数据格式错误 + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "验证状态异常,请重新验证", + "code": "VERIFICATION_INVALID", + }) + c.Abort() + return + } + + // 检查验证是否过期 + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + // 验证已过期,清除 session + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": "验证已过期,请重新验证", + "code": "VERIFICATION_EXPIRED", + }) + c.Abort() + return + } + + // 验证有效,继续处理请求 + c.Next() + } +} + +// OptionalSecureVerification 可选的安全验证中间件 +// 如果用户已验证,则在 context 中设置标记,但不阻止请求继续 +// 用于某些需要区分是否已验证的场景 +func OptionalSecureVerification() gin.HandlerFunc { + return func(c *gin.Context) { + userId := c.GetInt("id") + if userId == 0 { + c.Set("secure_verified", false) + c.Next() + return + } + + session := sessions.Default(c) + verifiedAtRaw := session.Get(SecureVerificationSessionKey) + + if verifiedAtRaw == nil { + c.Set("secure_verified", false) + c.Next() + return + } + + verifiedAt, ok := verifiedAtRaw.(int64) + if !ok { + c.Set("secure_verified", false) + c.Next() + return + } + + elapsed := time.Now().Unix() - verifiedAt + if elapsed >= SecureVerificationTimeout { + session.Delete(SecureVerificationSessionKey) + _ = session.Save() + c.Set("secure_verified", false) + c.Next() + return + } + + c.Set("secure_verified", true) + c.Set("secure_verified_at", verifiedAt) + c.Next() + } +} + +// ClearSecureVerification 清除安全验证状态 +// 用于用户登出或需要强制重新验证的场景 +func ClearSecureVerification(c *gin.Context) { + session := sessions.Default(c) + session.Delete(SecureVerificationSessionKey) + _ = session.Save() +} diff --git a/middleware/stats.go b/middleware/stats.go new file mode 100644 index 0000000000000000000000000000000000000000..fa1d61efb1cfcecac3d66b1063d52be1a0794942 --- /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), + } +} diff --git a/middleware/turnstile-check.go b/middleware/turnstile-check.go new file mode 100644 index 0000000000000000000000000000000000000000..1fcd7ad22bd35746fffc4ff9a6e7f79821679a19 --- /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.SysLog(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.SysLog(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 0000000000000000000000000000000000000000..7cf682ce20b99e4e7e6db9c083d1d0cfb4304a71 --- /dev/null +++ b/middleware/utils.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "fmt" + "github.com/gin-gonic/gin" + "one-api/common" + "one-api/logger" +) + +func abortWithOpenAiMessage(c *gin.Context, statusCode int, message string, code ...string) { + codeStr := "" + if len(code) > 0 { + codeStr = code[0] + } + userId := c.GetInt("id") + c.JSON(statusCode, gin.H{ + "error": gin.H{ + "message": common.MessageWithRequestId(message, c.GetString(common.RequestIdKey)), + "type": "new_api_error", + "code": codeStr, + }, + }) + c.Abort() + logger.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() + logger.LogError(c.Request.Context(), description) +} diff --git a/model/ability.go b/model/ability.go new file mode 100644 index 0000000000000000000000000000000000000000..ec2f7ee4f6fa94f6e733979fc982527034e08827 --- /dev/null +++ b/model/ability.go @@ -0,0 +1,340 @@ +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, nil + } + err = DB.First(&channel, "id = ?", channel.Id).Error + return &channel, err +} + +func (channel *Channel) AddAbilities(tx *gorm.DB) 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 + } + // choose DB or provided tx + useDB := DB + if tx != nil { + useDB = tx + } + for _, chunk := range lo.Chunk(abilities, 50) { + err := useDB.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() + + // truncate abilities table + if common.UsingSQLite { + err := DB.Exec("DELETE FROM abilities").Error + if err != nil { + common.SysLog(fmt.Sprintf("Delete abilities failed: %s", err.Error())) + return 0, 0, err + } + } else { + err := DB.Exec("TRUNCATE TABLE abilities").Error + if err != nil { + common.SysLog(fmt.Sprintf("Truncate abilities failed: %s", err.Error())) + return 0, 0, err + } + } + 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.SysLog(fmt.Sprintf("Delete abilities failed: %s", err.Error())) + failCount += len(chunk) + continue + } + // Then add new abilities + for _, channel := range chunk { + err = channel.AddAbilities(nil) + if err != nil { + common.SysLog(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 0000000000000000000000000000000000000000..f82542ffe47749e1bdbb5816fecdd275348a4d93 --- /dev/null +++ b/model/channel.go @@ -0,0 +1,992 @@ +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" + + "github.com/samber/lo" + "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"` + HeaderOverride *string `json:"header_override" gorm:"type:text"` + Remark string `json:"remark,omitempty" gorm:"type:varchar(255)" validate:"max=255"` + // add after v0.8.5 + ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` + + OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings + + // cache info + Keys []string `json:"-" gorm:"-"` +} + +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 + MultiKeyDisabledReason map[int]string `json:"multi_key_disabled_reason,omitempty"` // key禁用原因列表,key index -> reason + MultiKeyDisabledTime map[int]int64 `json:"multi_key_disabled_time,omitempty"` // key禁用时间列表,key index -> time + 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{} + } + if len(channel.Keys) > 0 { + return channel.Keys + } + 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 := common.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) + } + + lock := GetChannelPollingLock(channel.Id) + lock.Lock() + defer lock.Unlock() + + 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 + + channelInfo, err := CacheGetChannelInfo(channel.Id) + if err != nil { + return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + } + //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 := common.Unmarshal([]byte(channel.OtherInfo), &otherInfo) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal other info: channel_id=%d, tag=%s, name=%s, error=%v", channel.Id, channel.GetTag(), channel.Name, err)) + } + } + return otherInfo +} + +func (channel *Channel) SetOtherInfo(otherInfo map[string]interface{}) { + otherInfoBytes, err := json.Marshal(otherInfo) + if err != nil { + common.SysLog(fmt.Sprintf("failed to marshal other info: channel_id=%d, tag=%s, name=%s, error=%v", channel.Id, channel.GetTag(), channel.Name, err)) + 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 (channel *Channel) SaveWithoutKey() error { + return DB.Omit("key").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 { + if len(channels) == 0 { + return nil + } + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + for _, chunk := range lo.Chunk(channels, 50) { + if err := tx.Create(&chunk).Error; err != nil { + tx.Rollback() + return err + } + for _, channel_ := range chunk { + if err := channel_.AddAbilities(tx); err != nil { + tx.Rollback() + return err + } + } + } + return tx.Commit().Error +} + +func BatchDeleteChannels(ids []int) error { + if len(ids) == 0 { + return nil + } + // 使用事务 分批删除channel表和abilities表 + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + for _, chunk := range lo.Chunk(ids, 200) { + if err := tx.Where("id in (?)", chunk).Delete(&Channel{}).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Where("channel_id in (?)", chunk).Delete(&Ability{}).Error; err != nil { + tx.Rollback() + return err + } + } + return tx.Commit().Error +} + +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 "" + } + url := *channel.BaseURL + if url == "" { + url = constant.ChannelBaseURLs[channel.Type] + } + return url +} + +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(nil) + 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 := common.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.SysLog(fmt.Sprintf("failed to update response time: channel_id=%d, error=%v", channel.Id, err)) + } +} + +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.SysLog(fmt.Sprintf("failed to update balance: channel_id=%d, error=%v", channel.Id, err)) + } +} + +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, reason string) { + 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 channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason + channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp() + } + 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 { + // Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey + pollingLock := GetChannelPollingLock(channelId) + pollingLock.Lock() + // 如果是多Key模式,更新缓存中的状态 + handlerMultiKeyUpdate(channelCache, usingKey, status, reason) + pollingLock.Unlock() + //CacheUpdateChannel(channelCache) + //return true + } else { + // 如果缓存渠道存在,且状态已是目标状态,直接返回 + if channelCache.Status == status { + return false + } + CacheUpdateChannelStatus(channelId, status) + } + } + + shouldUpdateAbilities := false + defer func() { + if shouldUpdateAbilities { + err := UpdateAbilityStatus(channelId, status == common.ChannelStatusEnabled) + if err != nil { + common.SysLog(fmt.Sprintf("failed to update ability status: channel_id=%d, error=%v", channelId, err)) + } + } + }() + channel, err := GetChannelById(channelId, true) + if err != nil { + return false + } else { + if channel.Status == status { + return false + } + + if channel.ChannelInfo.IsMultiKey { + beforeStatus := channel.Status + // Protect map writes with the same per-channel lock used by readers + pollingLock := GetChannelPollingLock(channelId) + pollingLock.Lock() + handlerMultiKeyUpdate(channel, usingKey, status, reason) + pollingLock.Unlock() + 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.SaveWithoutKey() + if err != nil { + common.SysLog(fmt.Sprintf("failed to update channel status: channel_id=%d, status=%d, error=%v", channel.Id, status, err)) + 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.SysLog(fmt.Sprintf("failed to update abilities: channel_id=%d, tag=%s, error=%v", channel.Id, channel.GetTag(), err)) + } + } + } + } 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.SysLog(fmt.Sprintf("failed to update channel used quota: channel_id=%d, delta_quota=%d, error=%v", id, quota, err)) + } +} + +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 := common.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 := common.Unmarshal([]byte(*channel.Setting), &setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal setting: channel_id=%d, error=%v", channel.Id, err)) + channel.Setting = nil // 清空设置以避免后续错误 + _ = channel.Save() // 保存修改 + } + } + return setting +} + +func (channel *Channel) SetSetting(setting dto.ChannelSettings) { + settingBytes, err := common.Marshal(setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to marshal setting: channel_id=%d, error=%v", channel.Id, err)) + return + } + channel.Setting = common.GetPointer[string](string(settingBytes)) +} + +func (channel *Channel) GetOtherSettings() dto.ChannelOtherSettings { + setting := dto.ChannelOtherSettings{} + if channel.OtherSettings != "" { + err := common.UnmarshalJsonStr(channel.OtherSettings, &setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal setting: channel_id=%d, error=%v", channel.Id, err)) + channel.OtherSettings = "{}" // 清空设置以避免后续错误 + _ = channel.Save() // 保存修改 + } + } + return setting +} + +func (channel *Channel) SetOtherSettings(setting dto.ChannelOtherSettings) { + settingBytes, err := common.Marshal(setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to marshal setting: channel_id=%d, error=%v", channel.Id, err)) + return + } + channel.OtherSettings = string(settingBytes) +} + +func (channel *Channel) GetParamOverride() map[string]interface{} { + paramOverride := make(map[string]interface{}) + if channel.ParamOverride != nil && *channel.ParamOverride != "" { + err := common.Unmarshal([]byte(*channel.ParamOverride), ¶mOverride) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal param override: channel_id=%d, error=%v", channel.Id, err)) + } + } + return paramOverride +} + +func (channel *Channel) GetHeaderOverride() map[string]interface{} { + headerOverride := make(map[string]interface{}) + if channel.HeaderOverride != nil && *channel.HeaderOverride != "" { + err := common.Unmarshal([]byte(*channel.HeaderOverride), &headerOverride) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal header override: channel_id=%d, error=%v", channel.Id, err)) + } + } + return headerOverride +} + +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 0000000000000000000000000000000000000000..f5bab66514151576b1642b8eba64fdbeb0ff1bab --- /dev/null +++ b/model/channel_cache.go @@ -0,0 +1,284 @@ +package model + +import ( + "errors" + "fmt" + "math/rand" + "one-api/common" + "one-api/constant" + "one-api/setting" + "one-api/setting/ratio_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 + for i, channel := range newChannelId2channel { + if channel.ChannelInfo.IsMultiKey { + channel.Keys = channel.GetKeys() + if channel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { + if oldChannel, ok := channelsIDM[i]; ok { + // 存在旧的渠道,如果是多key且轮询,保留轮询索引信息 + if oldChannel.ChannelInfo.IsMultiKey && oldChannel.ChannelInfo.MultiKeyMode == constant.MultiKeyModePolling { + channel.ChannelInfo.MultiKeyPollingIndex = oldChannel.ChannelInfo.MultiKeyPollingIndex + } + } + } + } + } + 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 + } + } + return channel, selectGroup, nil +} + +func getRandomSatisfiedChannel(group string, model string, retry int) (*Channel, error) { + // if memory cache is disabled, get channel directly from database + if !common.MemoryCacheEnabled { + return GetRandomSatisfiedChannel(group, model, retry) + } + + channelSyncLock.RLock() + defer channelSyncLock.RUnlock() + + // First, try to find channels with the exact model name. + channels := group2model2channels[group][model] + + // If no channels found, try to find channels with the normalized model name. + if len(channels) == 0 { + normalizedModel := ratio_setting.FormatMatchingModelName(model) + channels = group2model2channels[group][normalizedModel] + } + + if len(channels) == 0 { + return nil, nil + } + + 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) + } + 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) + } + 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 + } + if status != common.ChannelStatusEnabled { + // delete the channel from group2model2channels + for group, model2channels := range group2model2channels { + for model, channels := range model2channels { + for i, channelId := range channels { + if channelId == id { + // remove the channel from the slice + group2model2channels[group][model] = append(channels[:i], channels[i+1:]...) + break + } + } + } + } + } +} + +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 0000000000000000000000000000000000000000..f13bd853ab8fd725ae2988fbddc82f51fdcaab49 --- /dev/null +++ b/model/log.go @@ -0,0 +1,408 @@ +package model + +import ( + "context" + "fmt" + "one-api/common" + "one-api/logger" + "one-api/types" + "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.SysLog("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{}) { + logger.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 { + logger.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"` + 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) { + if !common.LogConsumeEnabled { + return + } + logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params))) + 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 { + logger.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 + } + + channelIds := types.NewSet[int]() + for _, log := range logs { + if log.ChannelId != 0 { + channelIds.Add(log.ChannelId) + } + } + + if channelIds.Len() > 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.Items()).Find(&channels).Error; err != nil { + return logs, total, err + } + channelMap := make(map[int]string, len(channels)) + 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 0000000000000000000000000000000000000000..a6f46a7fdf622f60452cec28b725485d52316e8c --- /dev/null +++ b/model/main.go @@ -0,0 +1,477 @@ +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 + // MySQL charset/collation startup check: ensure Chinese-capable charset + if common.UsingMySQL { + if err := checkMySQLChineseSupport(DB); err != nil { + panic(err) + } + } + 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 + // If log DB is MySQL, also ensure Chinese-capable charset + if common.LogSqlType == common.DatabaseTypeMySQL { + if err := checkMySQLChineseSupport(LOG_DB); err != nil { + panic(err) + } + } + 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 { + err := DB.AutoMigrate( + &Channel{}, + &Token{}, + &User{}, + &PasskeyCredential{}, + &Option{}, + &Redemption{}, + &Ability{}, + &Log{}, + &Midjourney{}, + &TopUp{}, + &QuotaData{}, + &Task{}, + &Model{}, + &Vendor{}, + &PrefillGroup{}, + &Setup{}, + &TwoFA{}, + &TwoFABackupCode{}, + ) + 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"}, + {&PasskeyCredential{}, "PasskeyCredential"}, + {&Option{}, "Option"}, + {&Redemption{}, "Redemption"}, + {&Ability{}, "Ability"}, + {&Log{}, "Log"}, + {&Midjourney{}, "Midjourney"}, + {&TopUp{}, "TopUp"}, + {&QuotaData{}, "QuotaData"}, + {&Task{}, "Task"}, + {&Model{}, "Model"}, + {&Vendor{}, "Vendor"}, + {&PrefillGroup{}, "PrefillGroup"}, + {&Setup{}, "Setup"}, + {&TwoFA{}, "TwoFA"}, + {&TwoFABackupCode{}, "TwoFABackupCode"}, + } + // 动态计算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) +} + +// checkMySQLChineseSupport ensures the MySQL connection and current schema +// default charset/collation can store Chinese characters. It allows common +// Chinese-capable charsets (utf8mb4, utf8, gbk, big5, gb18030) and panics otherwise. +func checkMySQLChineseSupport(db *gorm.DB) error { + // 仅检测:当前库默认字符集/排序规则 + 各表的排序规则(隐含字符集) + + // Read current schema defaults + var schemaCharset, schemaCollation string + err := db.Raw("SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = DATABASE()").Row().Scan(&schemaCharset, &schemaCollation) + if err != nil { + return fmt.Errorf("读取当前库默认字符集/排序规则失败 / Failed to read schema default charset/collation: %v", err) + } + + toLower := func(s string) string { return strings.ToLower(s) } + // Allowed charsets that can store Chinese text + allowedCharsets := map[string]string{ + "utf8mb4": "utf8mb4_", + "utf8": "utf8_", + "gbk": "gbk_", + "big5": "big5_", + "gb18030": "gb18030_", + } + isChineseCapable := func(cs, cl string) bool { + csLower := toLower(cs) + clLower := toLower(cl) + if prefix, ok := allowedCharsets[csLower]; ok { + if clLower == "" { + return true + } + return strings.HasPrefix(clLower, prefix) + } + // 如果仅提供了排序规则,尝试按排序规则前缀判断 + for _, prefix := range allowedCharsets { + if strings.HasPrefix(clLower, prefix) { + return true + } + } + return false + } + + // 1) 当前库默认值必须支持中文 + if !isChineseCapable(schemaCharset, schemaCollation) { + return fmt.Errorf("当前库默认字符集/排序规则不支持中文:schema(%s/%s)。请将库设置为 utf8mb4/utf8/gbk/big5/gb18030 / Schema default charset/collation is not Chinese-capable: schema(%s/%s). Please set to utf8mb4/utf8/gbk/big5/gb18030", + schemaCharset, schemaCollation, schemaCharset, schemaCollation) + } + + // 2) 所有物理表的排序规则(隐含字符集)必须支持中文 + type tableInfo struct { + Name string + Collation *string + } + var tables []tableInfo + if err := db.Raw("SELECT TABLE_NAME, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_TYPE = 'BASE TABLE'").Scan(&tables).Error; err != nil { + return fmt.Errorf("读取表排序规则失败 / Failed to read table collations: %v", err) + } + + var badTables []string + for _, t := range tables { + // NULL 或空表示继承库默认设置,已在上面校验库默认,视为通过 + if t.Collation == nil || *t.Collation == "" { + continue + } + cl := *t.Collation + // 仅凭排序规则判断是否中文可用 + ok := false + lower := strings.ToLower(cl) + for _, prefix := range allowedCharsets { + if strings.HasPrefix(lower, prefix) { + ok = true + break + } + } + if !ok { + badTables = append(badTables, fmt.Sprintf("%s(%s)", t.Name, cl)) + } + } + + if len(badTables) > 0 { + // 限制输出数量以避免日志过长 + maxShow := 20 + shown := badTables + if len(shown) > maxShow { + shown = shown[:maxShow] + } + return fmt.Errorf( + "存在不支持中文的表,请修复其排序规则/字符集。示例(最多展示 %d 项):%v / Found tables not Chinese-capable. Please fix their collation/charset. Examples (showing up to %d): %v", + maxShow, shown, maxShow, shown, + ) + } + return nil +} + +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 0000000000000000000000000000000000000000..363a9162cee6ba7d489850f3a8bcfca3646ff5ef --- /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/missing_models.go b/model/missing_models.go new file mode 100644 index 0000000000000000000000000000000000000000..2b8ac4cdc792afe09ce0759bde79da9997f76c23 --- /dev/null +++ b/model/missing_models.go @@ -0,0 +1,30 @@ +package model + +// GetMissingModels returns model names that are referenced in the system +func GetMissingModels() ([]string, error) { + // 1. 获取所有已启用模型(去重) + models := GetEnabledModels() + if len(models) == 0 { + return []string{}, nil + } + + // 2. 查询已有的元数据模型名 + var existing []string + if err := DB.Model(&Model{}).Where("model_name IN ?", models).Pluck("model_name", &existing).Error; err != nil { + return nil, err + } + + existingSet := make(map[string]struct{}, len(existing)) + for _, e := range existing { + existingSet[e] = struct{}{} + } + + // 3. 收集缺失模型 + var missing []string + for _, name := range models { + if _, ok := existingSet[name]; !ok { + missing = append(missing, name) + } + } + return missing, nil +} diff --git a/model/model_extra.go b/model/model_extra.go new file mode 100644 index 0000000000000000000000000000000000000000..ce55e94ebabcc737f865733e96531e1a4fa2f688 --- /dev/null +++ b/model/model_extra.go @@ -0,0 +1,31 @@ +package model + +func GetModelEnableGroups(modelName string) []string { + // 确保缓存最新 + GetPricing() + + if modelName == "" { + return make([]string, 0) + } + + modelEnableGroupsLock.RLock() + groups, ok := modelEnableGroups[modelName] + modelEnableGroupsLock.RUnlock() + if !ok { + return make([]string, 0) + } + return groups +} + +// GetModelQuotaTypes 返回指定模型的计费类型集合(来自缓存) +func GetModelQuotaTypes(modelName string) []int { + GetPricing() + + modelEnableGroupsLock.RLock() + quota, ok := modelQuotaTypeMap[modelName] + modelEnableGroupsLock.RUnlock() + if !ok { + return []int{} + } + return []int{quota} +} diff --git a/model/model_meta.go b/model/model_meta.go new file mode 100644 index 0000000000000000000000000000000000000000..c80cfd40cea614ba2a85674450c47bb8dc01325a --- /dev/null +++ b/model/model_meta.go @@ -0,0 +1,147 @@ +package model + +import ( + "one-api/common" + "strconv" + + "gorm.io/gorm" +) + +const ( + NameRuleExact = iota + NameRulePrefix + NameRuleContains + NameRuleSuffix +) + +type BoundChannel struct { + Name string `json:"name"` + Type int `json:"type"` +} + +type Model struct { + Id int `json:"id"` + ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:uk_model_name_delete_at,priority:1"` + Description string `json:"description,omitempty" gorm:"type:text"` + Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` + Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` + VendorID int `json:"vendor_id,omitempty" gorm:"index"` + Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` + Status int `json:"status" gorm:"default:1"` + SyncOfficial int `json:"sync_official" gorm:"default:1"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_model_name_delete_at,priority:2"` + + BoundChannels []BoundChannel `json:"bound_channels,omitempty" gorm:"-"` + EnableGroups []string `json:"enable_groups,omitempty" gorm:"-"` + QuotaTypes []int `json:"quota_types,omitempty" gorm:"-"` + NameRule int `json:"name_rule" gorm:"default:0"` + + MatchedModels []string `json:"matched_models,omitempty" gorm:"-"` + MatchedCount int `json:"matched_count,omitempty" gorm:"-"` +} + +func (mi *Model) Insert() error { + now := common.GetTimestamp() + mi.CreatedTime = now + mi.UpdatedTime = now + return DB.Create(mi).Error +} + +func IsModelNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&Model{}).Where("model_name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + +func (mi *Model) Update() error { + mi.UpdatedTime = common.GetTimestamp() + return DB.Session(&gorm.Session{AllowGlobalUpdate: false, FullSaveAssociations: false}). + Model(&Model{}). + Where("id = ?", mi.Id). + Omit("created_time"). + Select("*"). + Updates(mi).Error +} + +func (mi *Model) Delete() error { + return DB.Delete(mi).Error +} + +func GetVendorModelCounts() (map[int64]int64, error) { + var stats []struct { + VendorID int64 + Count int64 + } + if err := DB.Model(&Model{}). + Select("vendor_id as vendor_id, count(*) as count"). + Group("vendor_id"). + Scan(&stats).Error; err != nil { + return nil, err + } + m := make(map[int64]int64, len(stats)) + for _, s := range stats { + m[s.VendorID] = s.Count + } + return m, nil +} + +func GetAllModels(offset int, limit int) ([]*Model, error) { + var models []*Model + err := DB.Order("id DESC").Offset(offset).Limit(limit).Find(&models).Error + return models, err +} + +func GetBoundChannelsByModelsMap(modelNames []string) (map[string][]BoundChannel, error) { + result := make(map[string][]BoundChannel) + if len(modelNames) == 0 { + return result, nil + } + type row struct { + Model string + Name string + Type int + } + var rows []row + err := DB.Table("channels"). + Select("abilities.model as model, channels.name as name, channels.type as type"). + Joins("JOIN abilities ON abilities.channel_id = channels.id"). + Where("abilities.model IN ? AND abilities.enabled = ?", modelNames, true). + Distinct(). + Scan(&rows).Error + if err != nil { + return nil, err + } + for _, r := range rows { + result[r.Model] = append(result[r.Model], BoundChannel{Name: r.Name, Type: r.Type}) + } + return result, nil +} + +func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) { + var models []*Model + db := DB.Model(&Model{}) + if keyword != "" { + like := "%" + keyword + "%" + db = db.Where("model_name LIKE ? OR description LIKE ? OR tags LIKE ?", like, like, like) + } + if vendor != "" { + if vid, err := strconv.Atoi(vendor); err == nil { + db = db.Where("models.vendor_id = ?", vid) + } else { + db = db.Joins("JOIN vendors ON vendors.id = models.vendor_id").Where("vendors.name LIKE ?", "%"+vendor+"%") + } + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := db.Order("models.id DESC").Offset(offset).Limit(limit).Find(&models).Error; err != nil { + return nil, 0, err + } + return models, total, nil +} diff --git a/model/option.go b/model/option.go new file mode 100644 index 0000000000000000000000000000000000000000..6fb59627a196a3f9b806dd5598b74e51607c9811 --- /dev/null +++ b/model/option.go @@ -0,0 +1,457 @@ +package model + +import ( + "one-api/common" + "one-api/setting" + "one-api/setting/config" + "one-api/setting/operation_setting" + "one-api/setting/ratio_setting" + "one-api/setting/system_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"] = system_setting.WorkerUrl + common.OptionMap["WorkerValidKey"] = system_setting.WorkerValidKey + common.OptionMap["WorkerAllowHttpImageRequestEnabled"] = strconv.FormatBool(system_setting.WorkerAllowHttpImageRequestEnabled) + common.OptionMap["PayAddress"] = "" + common.OptionMap["CustomCallbackAddress"] = "" + common.OptionMap["EpayId"] = "" + common.OptionMap["EpayKey"] = "" + common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64) + common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64) + common.OptionMap["MinTopUp"] = strconv.Itoa(operation_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["StripePromotionCodesEnabled"] = strconv.FormatBool(setting.StripePromotionCodesEnabled) + 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"] = operation_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["ImageRatio"] = ratio_setting.ImageRatio2JSONString() + common.OptionMap["AudioRatio"] = ratio_setting.AudioRatio2JSONString() + common.OptionMap["AudioCompletionRatio"] = ratio_setting.AudioCompletionRatio2JSONString() + 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.SysLog("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": + system_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": + system_setting.ServerAddress = value + case "WorkerUrl": + system_setting.WorkerUrl = value + case "WorkerValidKey": + system_setting.WorkerValidKey = value + case "PayAddress": + operation_setting.PayAddress = value + case "Chats": + err = setting.UpdateChatsByJsonString(value) + case "AutoGroups": + err = setting.UpdateAutoGroupsByJsonString(value) + case "CustomCallbackAddress": + operation_setting.CustomCallbackAddress = value + case "EpayId": + operation_setting.EpayId = value + case "EpayKey": + operation_setting.EpayKey = value + case "Price": + operation_setting.Price, _ = strconv.ParseFloat(value, 64) + case "USDExchangeRate": + operation_setting.USDExchangeRate, _ = strconv.ParseFloat(value, 64) + case "MinTopUp": + operation_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 "StripePromotionCodesEnabled": + setting.StripePromotionCodesEnabled = value == "true" + 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 "LinuxDOMinimumTrustLevel": + common.LinuxDOMinimumTrustLevel, _ = strconv.Atoi(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 "ImageRatio": + err = ratio_setting.UpdateImageRatioByJSONString(value) + case "AudioRatio": + err = ratio_setting.UpdateAudioRatioByJSONString(value) + case "AudioCompletionRatio": + err = ratio_setting.UpdateAudioCompletionRatioByJSONString(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 = operation_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/passkey.go b/model/passkey.go new file mode 100644 index 0000000000000000000000000000000000000000..c2556c450ecb383130d4d268d8cc5ac1320c27d7 --- /dev/null +++ b/model/passkey.go @@ -0,0 +1,209 @@ +package model + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "one-api/common" + "strings" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "gorm.io/gorm" +) + +var ( + ErrPasskeyNotFound = errors.New("passkey credential not found") + ErrFriendlyPasskeyNotFound = errors.New("Passkey 验证失败,请重试或联系管理员") +) + +type PasskeyCredential struct { + ID int `json:"id" gorm:"primaryKey"` + UserID int `json:"user_id" gorm:"uniqueIndex;not null"` + CredentialID string `json:"credential_id" gorm:"type:varchar(512);uniqueIndex;not null"` // base64 encoded + PublicKey string `json:"public_key" gorm:"type:text;not null"` // base64 encoded + AttestationType string `json:"attestation_type" gorm:"type:varchar(255)"` + AAGUID string `json:"aaguid" gorm:"type:varchar(512)"` // base64 encoded + SignCount uint32 `json:"sign_count" gorm:"default:0"` + CloneWarning bool `json:"clone_warning"` + UserPresent bool `json:"user_present"` + UserVerified bool `json:"user_verified"` + BackupEligible bool `json:"backup_eligible"` + BackupState bool `json:"backup_state"` + Transports string `json:"transports" gorm:"type:text"` + Attachment string `json:"attachment" gorm:"type:varchar(32)"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +func (p *PasskeyCredential) TransportList() []protocol.AuthenticatorTransport { + if p == nil || strings.TrimSpace(p.Transports) == "" { + return nil + } + var transports []string + if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil { + return nil + } + result := make([]protocol.AuthenticatorTransport, 0, len(transports)) + for _, transport := range transports { + result = append(result, protocol.AuthenticatorTransport(transport)) + } + return result +} + +func (p *PasskeyCredential) SetTransports(list []protocol.AuthenticatorTransport) { + if len(list) == 0 { + p.Transports = "" + return + } + stringList := make([]string, len(list)) + for i, transport := range list { + stringList[i] = string(transport) + } + encoded, err := json.Marshal(stringList) + if err != nil { + return + } + p.Transports = string(encoded) +} + +func (p *PasskeyCredential) ToWebAuthnCredential() webauthn.Credential { + flags := webauthn.CredentialFlags{ + UserPresent: p.UserPresent, + UserVerified: p.UserVerified, + BackupEligible: p.BackupEligible, + BackupState: p.BackupState, + } + + credID, _ := base64.StdEncoding.DecodeString(p.CredentialID) + pubKey, _ := base64.StdEncoding.DecodeString(p.PublicKey) + aaguid, _ := base64.StdEncoding.DecodeString(p.AAGUID) + + return webauthn.Credential{ + ID: credID, + PublicKey: pubKey, + AttestationType: p.AttestationType, + Transport: p.TransportList(), + Flags: flags, + Authenticator: webauthn.Authenticator{ + AAGUID: aaguid, + SignCount: p.SignCount, + CloneWarning: p.CloneWarning, + Attachment: protocol.AuthenticatorAttachment(p.Attachment), + }, + } +} + +func NewPasskeyCredentialFromWebAuthn(userID int, credential *webauthn.Credential) *PasskeyCredential { + if credential == nil { + return nil + } + passkey := &PasskeyCredential{ + UserID: userID, + CredentialID: base64.StdEncoding.EncodeToString(credential.ID), + PublicKey: base64.StdEncoding.EncodeToString(credential.PublicKey), + AttestationType: credential.AttestationType, + AAGUID: base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID), + SignCount: credential.Authenticator.SignCount, + CloneWarning: credential.Authenticator.CloneWarning, + UserPresent: credential.Flags.UserPresent, + UserVerified: credential.Flags.UserVerified, + BackupEligible: credential.Flags.BackupEligible, + BackupState: credential.Flags.BackupState, + Attachment: string(credential.Authenticator.Attachment), + } + passkey.SetTransports(credential.Transport) + return passkey +} + +func (p *PasskeyCredential) ApplyValidatedCredential(credential *webauthn.Credential) { + if credential == nil || p == nil { + return + } + p.CredentialID = base64.StdEncoding.EncodeToString(credential.ID) + p.PublicKey = base64.StdEncoding.EncodeToString(credential.PublicKey) + p.AttestationType = credential.AttestationType + p.AAGUID = base64.StdEncoding.EncodeToString(credential.Authenticator.AAGUID) + p.SignCount = credential.Authenticator.SignCount + p.CloneWarning = credential.Authenticator.CloneWarning + p.UserPresent = credential.Flags.UserPresent + p.UserVerified = credential.Flags.UserVerified + p.BackupEligible = credential.Flags.BackupEligible + p.BackupState = credential.Flags.BackupState + p.Attachment = string(credential.Authenticator.Attachment) + p.SetTransports(credential.Transport) +} + +func GetPasskeyByUserID(userID int) (*PasskeyCredential, error) { + if userID == 0 { + common.SysLog("GetPasskeyByUserID: empty user ID") + return nil, ErrFriendlyPasskeyNotFound + } + var credential PasskeyCredential + if err := DB.Where("user_id = ?", userID).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // 未找到记录是正常情况(用户未绑定),返回 ErrPasskeyNotFound 而不记录日志 + return nil, ErrPasskeyNotFound + } + // 只有真正的数据库错误才记录日志 + common.SysLog(fmt.Sprintf("GetPasskeyByUserID: database error for user %d: %v", userID, err)) + return nil, ErrFriendlyPasskeyNotFound + } + return &credential, nil +} + +func GetPasskeyByCredentialID(credentialID []byte) (*PasskeyCredential, error) { + if len(credentialID) == 0 { + common.SysLog("GetPasskeyByCredentialID: empty credential ID") + return nil, ErrFriendlyPasskeyNotFound + } + + credIDStr := base64.StdEncoding.EncodeToString(credentialID) + var credential PasskeyCredential + if err := DB.Where("credential_id = ?", credIDStr).First(&credential).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: passkey not found for credential ID length %d", len(credentialID))) + return nil, ErrFriendlyPasskeyNotFound + } + common.SysLog(fmt.Sprintf("GetPasskeyByCredentialID: database error for credential ID: %v", err)) + return nil, ErrFriendlyPasskeyNotFound + } + + return &credential, nil +} + +func UpsertPasskeyCredential(credential *PasskeyCredential) error { + if credential == nil { + common.SysLog("UpsertPasskeyCredential: nil credential provided") + return fmt.Errorf("Passkey 保存失败,请重试") + } + return DB.Transaction(func(tx *gorm.DB) error { + // 使用Unscoped()进行硬删除,避免唯一索引冲突 + if err := tx.Unscoped().Where("user_id = ?", credential.UserID).Delete(&PasskeyCredential{}).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to delete existing credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + if err := tx.Create(credential).Error; err != nil { + common.SysLog(fmt.Sprintf("UpsertPasskeyCredential: failed to create credential for user %d: %v", credential.UserID, err)) + return fmt.Errorf("Passkey 保存失败,请重试") + } + return nil + }) +} + +func DeletePasskeyByUserID(userID int) error { + if userID == 0 { + common.SysLog("DeletePasskeyByUserID: empty user ID") + return fmt.Errorf("删除失败,请重试") + } + // 使用Unscoped()进行硬删除,避免唯一索引冲突 + if err := DB.Unscoped().Where("user_id = ?", userID).Delete(&PasskeyCredential{}).Error; err != nil { + common.SysLog(fmt.Sprintf("DeletePasskeyByUserID: failed to delete passkey for user %d: %v", userID, err)) + return fmt.Errorf("删除失败,请重试") + } + return nil +} diff --git a/model/prefill_group.go b/model/prefill_group.go new file mode 100644 index 0000000000000000000000000000000000000000..e88b6e985db083860697b547e7059ded062a343f --- /dev/null +++ b/model/prefill_group.go @@ -0,0 +1,126 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "one-api/common" + + "gorm.io/gorm" +) + +// PrefillGroup 用于存储可复用的“组”信息,例如模型组、标签组、端点组等。 +// Name 字段保持唯一,用于在前端下拉框中展示。 +// Type 字段用于区分组的类别,可选值如:model、tag、endpoint。 +// Items 字段使用 JSON 数组保存对应类型的字符串集合,示例: +// ["gpt-4o", "gpt-3.5-turbo"] +// 设计遵循 3NF,避免冗余,提供灵活扩展能力。 + +// JSONValue 基于 json.RawMessage 实现,支持从数据库的 []byte 和 string 两种类型读取 +type JSONValue json.RawMessage + +// Value 实现 driver.Valuer 接口,用于数据库写入 +func (j JSONValue) Value() (driver.Value, error) { + if j == nil { + return nil, nil + } + return []byte(j), nil +} + +// Scan 实现 sql.Scanner 接口,兼容不同驱动返回的类型 +func (j *JSONValue) Scan(value interface{}) error { + switch v := value.(type) { + case nil: + *j = nil + return nil + case []byte: + // 拷贝底层字节,避免保留底层缓冲区 + b := make([]byte, len(v)) + copy(b, v) + *j = JSONValue(b) + return nil + case string: + *j = JSONValue([]byte(v)) + return nil + default: + // 其他类型尝试序列化为 JSON + b, err := json.Marshal(v) + if err != nil { + return err + } + *j = JSONValue(b) + return nil + } +} + +// MarshalJSON 确保在对外编码时与 json.RawMessage 行为一致 +func (j JSONValue) MarshalJSON() ([]byte, error) { + if j == nil { + return []byte("null"), nil + } + return j, nil +} + +// UnmarshalJSON 确保在对外解码时与 json.RawMessage 行为一致 +func (j *JSONValue) UnmarshalJSON(data []byte) error { + if data == nil { + *j = nil + return nil + } + b := make([]byte, len(data)) + copy(b, data) + *j = JSONValue(b) + return nil +} + +type PrefillGroup struct { + Id int `json:"id"` + Name string `json:"name" gorm:"size:64;not null;uniqueIndex:uk_prefill_name,where:deleted_at IS NULL"` + Type string `json:"type" gorm:"size:32;index;not null"` + Items JSONValue `json:"items" gorm:"type:json"` + Description string `json:"description,omitempty" gorm:"type:varchar(255)"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// Insert 新建组 +func (g *PrefillGroup) Insert() error { + now := common.GetTimestamp() + g.CreatedTime = now + g.UpdatedTime = now + return DB.Create(g).Error +} + +// IsPrefillGroupNameDuplicated 检查组名称是否重复(排除自身 ID) +func IsPrefillGroupNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&PrefillGroup{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + +// Update 更新组 +func (g *PrefillGroup) Update() error { + g.UpdatedTime = common.GetTimestamp() + return DB.Save(g).Error +} + +// DeleteByID 根据 ID 删除组 +func DeletePrefillGroupByID(id int) error { + return DB.Delete(&PrefillGroup{}, id).Error +} + +// GetAllPrefillGroups 获取全部组,可按类型过滤(为空则返回全部) +func GetAllPrefillGroups(groupType string) ([]*PrefillGroup, error) { + var groups []*PrefillGroup + query := DB.Model(&PrefillGroup{}) + if groupType != "" { + query = query.Where("type = ?", groupType) + } + if err := query.Order("updated_time DESC").Find(&groups).Error; err != nil { + return nil, err + } + return groups, nil +} diff --git a/model/pricing.go b/model/pricing.go new file mode 100644 index 0000000000000000000000000000000000000000..4cb82ba9e2bde406ac93d1be24611e40edddef5a --- /dev/null +++ b/model/pricing.go @@ -0,0 +1,312 @@ +package model + +import ( + "encoding/json" + "fmt" + "strings" + + "one-api/common" + "one-api/constant" + "one-api/setting/ratio_setting" + "one-api/types" + "sync" + "time" +) + +type Pricing struct { + ModelName string `json:"model_name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + Tags string `json:"tags,omitempty"` + VendorID int `json:"vendor_id,omitempty"` + 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"` +} + +type PricingVendor struct { + ID int `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` +} + +var ( + pricingMap []Pricing + vendorsList []PricingVendor + supportedEndpointMap map[string]common.EndpointInfo + lastGetPricingTime time.Time + updatePricingLock sync.Mutex + + // 缓存映射:模型名 -> 启用分组 / 计费类型 + modelEnableGroups = make(map[string][]string) + modelQuotaTypeMap = make(map[string]int) + modelEnableGroupsLock = sync.RWMutex{} +) + +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 +} + +// GetVendors 返回当前定价接口使用到的供应商信息 +func GetVendors() []PricingVendor { + if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { + // 保证先刷新一次 + GetPricing() + } + return vendorsList +} + +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.SysLog(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err)) + return + } + // 预加载模型元数据与供应商一次,避免循环查询 + var allMeta []Model + _ = DB.Find(&allMeta).Error + metaMap := make(map[string]*Model) + prefixList := make([]*Model, 0) + suffixList := make([]*Model, 0) + containsList := make([]*Model, 0) + for i := range allMeta { + m := &allMeta[i] + if m.NameRule == NameRuleExact { + metaMap[m.ModelName] = m + } else { + switch m.NameRule { + case NameRulePrefix: + prefixList = append(prefixList, m) + case NameRuleSuffix: + suffixList = append(suffixList, m) + case NameRuleContains: + containsList = append(containsList, m) + } + } + } + + // 将非精确规则模型匹配到 metaMap + for _, m := range prefixList { + for _, pricingModel := range enableAbilities { + if strings.HasPrefix(pricingModel.Model, m.ModelName) { + if _, exists := metaMap[pricingModel.Model]; !exists { + metaMap[pricingModel.Model] = m + } + } + } + } + for _, m := range suffixList { + for _, pricingModel := range enableAbilities { + if strings.HasSuffix(pricingModel.Model, m.ModelName) { + if _, exists := metaMap[pricingModel.Model]; !exists { + metaMap[pricingModel.Model] = m + } + } + } + } + for _, m := range containsList { + for _, pricingModel := range enableAbilities { + if strings.Contains(pricingModel.Model, m.ModelName) { + if _, exists := metaMap[pricingModel.Model]; !exists { + metaMap[pricingModel.Model] = m + } + } + } + } + + // 预加载供应商 + var vendors []Vendor + _ = DB.Find(&vendors).Error + vendorMap := make(map[int]*Vendor) + for i := range vendors { + vendorMap[vendors[i].Id] = &vendors[i] + } + + // 初始化默认供应商映射 + initDefaultVendorMapping(metaMap, vendorMap, enableAbilities) + + // 构建对前端友好的供应商列表 + vendorsList = make([]PricingVendor, 0, len(vendorMap)) + for _, v := range vendorMap { + vendorsList = append(vendorsList, PricingVendor{ + ID: v.Id, + Name: v.Name, + Description: v.Description, + Icon: v.Icon, + }) + } + + 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 := modelSupportEndpointsStr[ability.Model] + 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 + } + + // 再补充模型自定义端点 + for modelName, meta := range metaMap { + if strings.TrimSpace(meta.Endpoints) == "" { + continue + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { + endpoints := modelSupportEndpointsStr[modelName] + for k := range raw { + if !common.StringsContains(endpoints, k) { + endpoints = append(endpoints, k) + } + } + modelSupportEndpointsStr[modelName] = 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 + } + + // 构建全局 supportedEndpointMap(默认 + 自定义覆盖) + supportedEndpointMap = make(map[string]common.EndpointInfo) + // 1. 默认端点 + for _, endpoints := range modelSupportEndpointTypes { + for _, et := range endpoints { + if info, ok := common.GetDefaultEndpointInfo(et); ok { + if _, exists := supportedEndpointMap[string(et)]; !exists { + supportedEndpointMap[string(et)] = info + } + } + } + } + // 2. 自定义端点(models 表)覆盖默认 + for _, meta := range metaMap { + if strings.TrimSpace(meta.Endpoints) == "" { + continue + } + var raw map[string]interface{} + if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { + for k, v := range raw { + switch val := v.(type) { + case string: + supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"} + case map[string]interface{}: + ep := common.EndpointInfo{Method: "POST"} + if p, ok := val["path"].(string); ok { + ep.Path = p + } + if m, ok := val["method"].(string); ok { + ep.Method = strings.ToUpper(m) + } + supportedEndpointMap[k] = ep + default: + // ignore unsupported types + } + } + } + } + + pricingMap = make([]Pricing, 0) + for model, groups := range modelGroupsMap { + pricing := Pricing{ + ModelName: model, + EnableGroup: groups.Items(), + SupportedEndpointTypes: modelSupportEndpointTypes[model], + } + + // 补充模型元数据(描述、标签、供应商、状态) + if meta, ok := metaMap[model]; ok { + // 若模型被禁用(status!=1),则直接跳过,不返回给前端 + if meta.Status != 1 { + continue + } + pricing.Description = meta.Description + pricing.Icon = meta.Icon + pricing.Tags = meta.Tags + pricing.VendorID = meta.VendorID + } + 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) + } + + // 刷新缓存映射,供高并发快速查询 + modelEnableGroupsLock.Lock() + modelEnableGroups = make(map[string][]string) + modelQuotaTypeMap = make(map[string]int) + for _, p := range pricingMap { + modelEnableGroups[p.ModelName] = p.EnableGroup + modelQuotaTypeMap[p.ModelName] = p.QuotaType + } + modelEnableGroupsLock.Unlock() + + lastGetPricingTime = time.Now() +} + +// GetSupportedEndpointMap 返回全局端点到路径的映射 +func GetSupportedEndpointMap() map[string]common.EndpointInfo { + return supportedEndpointMap +} diff --git a/model/pricing_default.go b/model/pricing_default.go new file mode 100644 index 0000000000000000000000000000000000000000..976aefb1e49a04c16a6fce96e6c5678dc91f5824 --- /dev/null +++ b/model/pricing_default.go @@ -0,0 +1,128 @@ +package model + +import ( + "strings" +) + +// 简化的供应商映射规则 +var defaultVendorRules = map[string]string{ + "gpt": "OpenAI", + "dall-e": "OpenAI", + "whisper": "OpenAI", + "o1": "OpenAI", + "o3": "OpenAI", + "claude": "Anthropic", + "gemini": "Google", + "moonshot": "Moonshot", + "kimi": "Moonshot", + "chatglm": "智谱", + "glm-": "智谱", + "qwen": "阿里巴巴", + "deepseek": "DeepSeek", + "abab": "MiniMax", + "ernie": "百度", + "spark": "讯飞", + "hunyuan": "腾讯", + "command": "Cohere", + "@cf/": "Cloudflare", + "360": "360", + "yi": "零一万物", + "jina": "Jina", + "mistral": "Mistral", + "grok": "xAI", + "llama": "Meta", + "doubao": "字节跳动", + "kling": "快手", + "jimeng": "即梦", + "vidu": "Vidu", +} + +// 供应商默认图标映射 +var defaultVendorIcons = map[string]string{ + "OpenAI": "OpenAI", + "Anthropic": "Claude.Color", + "Google": "Gemini.Color", + "Moonshot": "Moonshot", + "智谱": "Zhipu.Color", + "阿里巴巴": "Qwen.Color", + "DeepSeek": "DeepSeek.Color", + "MiniMax": "Minimax.Color", + "百度": "Wenxin.Color", + "讯飞": "Spark.Color", + "腾讯": "Hunyuan.Color", + "Cohere": "Cohere.Color", + "Cloudflare": "Cloudflare.Color", + "360": "Ai360.Color", + "零一万物": "Yi.Color", + "Jina": "Jina", + "Mistral": "Mistral.Color", + "xAI": "XAI", + "Meta": "Ollama", + "字节跳动": "Doubao.Color", + "快手": "Kling.Color", + "即梦": "Jimeng.Color", + "Vidu": "Vidu", + "微软": "AzureAI", + "Microsoft": "AzureAI", + "Azure": "AzureAI", +} + +// initDefaultVendorMapping 简化的默认供应商映射 +func initDefaultVendorMapping(metaMap map[string]*Model, vendorMap map[int]*Vendor, enableAbilities []AbilityWithChannel) { + for _, ability := range enableAbilities { + modelName := ability.Model + if _, exists := metaMap[modelName]; exists { + continue + } + + // 匹配供应商 + vendorID := 0 + modelLower := strings.ToLower(modelName) + for pattern, vendorName := range defaultVendorRules { + if strings.Contains(modelLower, pattern) { + vendorID = getOrCreateVendor(vendorName, vendorMap) + break + } + } + + // 创建模型元数据 + metaMap[modelName] = &Model{ + ModelName: modelName, + VendorID: vendorID, + Status: 1, + NameRule: NameRuleExact, + } + } +} + +// 查找或创建供应商 +func getOrCreateVendor(vendorName string, vendorMap map[int]*Vendor) int { + // 查找现有供应商 + for id, vendor := range vendorMap { + if vendor.Name == vendorName { + return id + } + } + + // 创建新供应商 + newVendor := &Vendor{ + Name: vendorName, + Status: 1, + Icon: getDefaultVendorIcon(vendorName), + } + + if err := newVendor.Insert(); err != nil { + return 0 + } + + vendorMap[newVendor.Id] = newVendor + return newVendor.Id +} + +// 获取供应商默认图标 +func getDefaultVendorIcon(vendorName string) string { + if icon, exists := defaultVendorIcons[vendorName]; exists { + return icon + } + return "" +} diff --git a/model/pricing_refresh.go b/model/pricing_refresh.go new file mode 100644 index 0000000000000000000000000000000000000000..362b5b0a298a2ef07c1c3e9793a1be7ba69709fe --- /dev/null +++ b/model/pricing_refresh.go @@ -0,0 +1,14 @@ +package model + +// RefreshPricing 强制立即重新计算与定价相关的缓存。 +// 该方法用于需要最新数据的内部管理 API, +// 因此会绕过默认的 1 分钟延迟刷新。 +func RefreshPricing() { + updatePricingLock.Lock() + defer updatePricingLock.Unlock() + + modelSupportEndpointsLock.Lock() + defer modelSupportEndpointsLock.Unlock() + + updatePricing() +} diff --git a/model/redemption.go b/model/redemption.go new file mode 100644 index 0000000000000000000000000000000000000000..6e48bc61ff8d5fab6db7bde8a63fec1efaf6ba93 --- /dev/null +++ b/model/redemption.go @@ -0,0 +1,196 @@ +package model + +import ( + "errors" + "fmt" + "one-api/common" + "one-api/logger" + "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", logger.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 0000000000000000000000000000000000000000..daf4d32c7456e32e9a375210e014a77d02750098 --- /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 0000000000000000000000000000000000000000..e490112be1dc989810d360f1b75b8c02f7ef35fd --- /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(191);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.RelayInfo) *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 0000000000000000000000000000000000000000..a42d832d51d3d2076ed76c95854d6e966f93dadc --- /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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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 0000000000000000000000000000000000000000..42367357516390e54555b8f5e0fa86f8032e5e23 --- /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 0000000000000000000000000000000000000000..6c60bab07f1ff1c1b8391dc5f6ab48d5e0a7843b --- /dev/null +++ b/model/topup.go @@ -0,0 +1,101 @@ +package model + +import ( + "errors" + "fmt" + "one-api/common" + "one-api/logger" + + "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", logger.FormatQuota(int(quota)), topUp.Amount)) + + return nil +} diff --git a/model/twofa.go b/model/twofa.go new file mode 100644 index 0000000000000000000000000000000000000000..53a663bf7d6cbac3cbcf19fa8d2eea500a36be2d --- /dev/null +++ b/model/twofa.go @@ -0,0 +1,322 @@ +package model + +import ( + "errors" + "fmt" + "one-api/common" + "time" + + "gorm.io/gorm" +) + +var ErrTwoFANotEnabled = errors.New("用户未启用2FA") + +// TwoFA 用户2FA设置表 +type TwoFA struct { + Id int `json:"id" gorm:"primaryKey"` + UserId int `json:"user_id" gorm:"unique;not null;index"` + Secret string `json:"-" gorm:"type:varchar(255);not null"` // TOTP密钥,不返回给前端 + IsEnabled bool `json:"is_enabled"` + FailedAttempts int `json:"failed_attempts" gorm:"default:0"` + LockedUntil *time.Time `json:"locked_until,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// TwoFABackupCode 备用码使用记录表 +type TwoFABackupCode struct { + Id int `json:"id" gorm:"primaryKey"` + UserId int `json:"user_id" gorm:"not null;index"` + CodeHash string `json:"-" gorm:"type:varchar(255);not null"` // 备用码哈希 + IsUsed bool `json:"is_used"` + UsedAt *time.Time `json:"used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// GetTwoFAByUserId 根据用户ID获取2FA设置 +func GetTwoFAByUserId(userId int) (*TwoFA, error) { + if userId == 0 { + return nil, errors.New("用户ID不能为空") + } + + var twoFA TwoFA + err := DB.Where("user_id = ?", userId).First(&twoFA).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil // 返回nil表示未设置2FA + } + return nil, err + } + + return &twoFA, nil +} + +// IsTwoFAEnabled 检查用户是否启用了2FA +func IsTwoFAEnabled(userId int) bool { + twoFA, err := GetTwoFAByUserId(userId) + if err != nil || twoFA == nil { + return false + } + return twoFA.IsEnabled +} + +// CreateTwoFA 创建2FA设置 +func (t *TwoFA) Create() error { + // 检查用户是否已存在2FA设置 + existing, err := GetTwoFAByUserId(t.UserId) + if err != nil { + return err + } + if existing != nil { + return errors.New("用户已存在2FA设置") + } + + // 验证用户存在 + var user User + if err := DB.First(&user, t.UserId).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("用户不存在") + } + return err + } + + return DB.Create(t).Error +} + +// Update 更新2FA设置 +func (t *TwoFA) Update() error { + if t.Id == 0 { + return errors.New("2FA记录ID不能为空") + } + return DB.Save(t).Error +} + +// Delete 删除2FA设置 +func (t *TwoFA) Delete() error { + if t.Id == 0 { + return errors.New("2FA记录ID不能为空") + } + + // 使用事务确保原子性 + return DB.Transaction(func(tx *gorm.DB) error { + // 同时删除相关的备用码记录(硬删除) + if err := tx.Unscoped().Where("user_id = ?", t.UserId).Delete(&TwoFABackupCode{}).Error; err != nil { + return err + } + + // 硬删除2FA记录 + return tx.Unscoped().Delete(t).Error + }) +} + +// ResetFailedAttempts 重置失败尝试次数 +func (t *TwoFA) ResetFailedAttempts() error { + t.FailedAttempts = 0 + t.LockedUntil = nil + return t.Update() +} + +// IncrementFailedAttempts 增加失败尝试次数 +func (t *TwoFA) IncrementFailedAttempts() error { + t.FailedAttempts++ + + // 检查是否需要锁定 + if t.FailedAttempts >= common.MaxFailAttempts { + lockUntil := time.Now().Add(time.Duration(common.LockoutDuration) * time.Second) + t.LockedUntil = &lockUntil + } + + return t.Update() +} + +// IsLocked 检查账户是否被锁定 +func (t *TwoFA) IsLocked() bool { + if t.LockedUntil == nil { + return false + } + return time.Now().Before(*t.LockedUntil) +} + +// CreateBackupCodes 创建备用码 +func CreateBackupCodes(userId int, codes []string) error { + return DB.Transaction(func(tx *gorm.DB) error { + // 先删除现有的备用码 + if err := tx.Where("user_id = ?", userId).Delete(&TwoFABackupCode{}).Error; err != nil { + return err + } + + // 创建新的备用码记录 + for _, code := range codes { + hashedCode, err := common.HashBackupCode(code) + if err != nil { + return err + } + + backupCode := TwoFABackupCode{ + UserId: userId, + CodeHash: hashedCode, + IsUsed: false, + } + + if err := tx.Create(&backupCode).Error; err != nil { + return err + } + } + + return nil + }) +} + +// ValidateBackupCode 验证并使用备用码 +func ValidateBackupCode(userId int, code string) (bool, error) { + if !common.ValidateBackupCode(code) { + return false, errors.New("验证码或备用码不正确") + } + + normalizedCode := common.NormalizeBackupCode(code) + + // 查找未使用的备用码 + var backupCodes []TwoFABackupCode + if err := DB.Where("user_id = ? AND is_used = false", userId).Find(&backupCodes).Error; err != nil { + return false, err + } + + // 验证备用码 + for _, bc := range backupCodes { + if common.ValidatePasswordAndHash(normalizedCode, bc.CodeHash) { + // 标记为已使用 + now := time.Now() + bc.IsUsed = true + bc.UsedAt = &now + + if err := DB.Save(&bc).Error; err != nil { + return false, err + } + + return true, nil + } + } + + return false, nil +} + +// GetUnusedBackupCodeCount 获取未使用的备用码数量 +func GetUnusedBackupCodeCount(userId int) (int, error) { + var count int64 + err := DB.Model(&TwoFABackupCode{}).Where("user_id = ? AND is_used = false", userId).Count(&count).Error + return int(count), err +} + +// DisableTwoFA 禁用用户的2FA +func DisableTwoFA(userId int) error { + twoFA, err := GetTwoFAByUserId(userId) + if err != nil { + return err + } + if twoFA == nil { + return ErrTwoFANotEnabled + } + + // 删除2FA设置和备用码 + return twoFA.Delete() +} + +// EnableTwoFA 启用2FA +func (t *TwoFA) Enable() error { + t.IsEnabled = true + t.FailedAttempts = 0 + t.LockedUntil = nil + return t.Update() +} + +// ValidateTOTPAndUpdateUsage 验证TOTP并更新使用记录 +func (t *TwoFA) ValidateTOTPAndUpdateUsage(code string) (bool, error) { + // 检查是否被锁定 + if t.IsLocked() { + return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05")) + } + + // 验证TOTP码 + if !common.ValidateTOTPCode(t.Secret, code) { + // 增加失败次数 + if err := t.IncrementFailedAttempts(); err != nil { + common.SysLog("更新2FA失败次数失败: " + err.Error()) + } + return false, nil + } + + // 验证成功,重置失败次数并更新最后使用时间 + now := time.Now() + t.FailedAttempts = 0 + t.LockedUntil = nil + t.LastUsedAt = &now + + if err := t.Update(); err != nil { + common.SysLog("更新2FA使用记录失败: " + err.Error()) + } + + return true, nil +} + +// ValidateBackupCodeAndUpdateUsage 验证备用码并更新使用记录 +func (t *TwoFA) ValidateBackupCodeAndUpdateUsage(code string) (bool, error) { + // 检查是否被锁定 + if t.IsLocked() { + return false, fmt.Errorf("账户已被锁定,请在%v后重试", t.LockedUntil.Format("2006-01-02 15:04:05")) + } + + // 验证备用码 + valid, err := ValidateBackupCode(t.UserId, code) + if err != nil { + return false, err + } + + if !valid { + // 增加失败次数 + if err := t.IncrementFailedAttempts(); err != nil { + common.SysLog("更新2FA失败次数失败: " + err.Error()) + } + return false, nil + } + + // 验证成功,重置失败次数并更新最后使用时间 + now := time.Now() + t.FailedAttempts = 0 + t.LockedUntil = nil + t.LastUsedAt = &now + + if err := t.Update(); err != nil { + common.SysLog("更新2FA使用记录失败: " + err.Error()) + } + + return true, nil +} + +// GetTwoFAStats 获取2FA统计信息(管理员使用) +func GetTwoFAStats() (map[string]interface{}, error) { + var totalUsers, enabledUsers int64 + + // 总用户数 + if err := DB.Model(&User{}).Count(&totalUsers).Error; err != nil { + return nil, err + } + + // 启用2FA的用户数 + if err := DB.Model(&TwoFA{}).Where("is_enabled = true").Count(&enabledUsers).Error; err != nil { + return nil, err + } + + enabledRate := float64(0) + if totalUsers > 0 { + enabledRate = float64(enabledUsers) / float64(totalUsers) * 100 + } + + return map[string]interface{}{ + "total_users": totalUsers, + "enabled_users": enabledUsers, + "enabled_rate": fmt.Sprintf("%.1f%%", enabledRate), + }, nil +} diff --git a/model/usedata.go b/model/usedata.go new file mode 100644 index 0000000000000000000000000000000000000000..7404cdd6df6e78a2e5518fb6359e1a9ee1abce21 --- /dev/null +++ b/model/usedata.go @@ -0,0 +1,127 @@ +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() { + 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 0000000000000000000000000000000000000000..a1c0d2347ad2cdd0c8cd5c57647627adfc03cf29 --- /dev/null +++ b/model/user.go @@ -0,0 +1,917 @@ +package model + +import ( + "encoding/json" + "errors" + "fmt" + "one-api/common" + "one-api/dto" + "one-api/logger" + "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=20"` + 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.SysLog("failed to unmarshal setting: " + err.Error()) + } + } + return setting +} + +func (user *User) SetSetting(setting dto.UserSetting) { + settingBytes, err := json.Marshal(setting) + if err != nil { + common.SysLog("failed to marshal setting: " + err.Error()) + return + } + user.Setting = string(settingBytes) +} + +// 根据用户角色生成默认的边栏配置 +func generateDefaultSidebarConfigForRole(userRole int) string { + defaultConfig := map[string]interface{}{} + + // 聊天区域 - 所有用户都可以访问 + defaultConfig["chat"] = map[string]interface{}{ + "enabled": true, + "playground": true, + "chat": true, + } + + // 控制台区域 - 所有用户都可以访问 + defaultConfig["console"] = map[string]interface{}{ + "enabled": true, + "detail": true, + "token": true, + "log": true, + "midjourney": true, + "task": true, + } + + // 个人中心区域 - 所有用户都可以访问 + defaultConfig["personal"] = map[string]interface{}{ + "enabled": true, + "topup": true, + "personal": true, + } + + // 管理员区域 - 根据角色决定 + if userRole == common.RoleAdminUser { + // 管理员可以访问管理员区域,但不能访问系统设置 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": false, // 管理员不能访问系统设置 + } + } else if userRole == common.RoleRootUser { + // 超级管理员可以访问所有功能 + defaultConfig["admin"] = map[string]interface{}{ + "enabled": true, + "channel": true, + "models": true, + "redemption": true, + "user": true, + "setting": true, + } + } + // 普通用户不包含admin区域 + + // 转换为JSON字符串 + configBytes, err := json.Marshal(defaultConfig) + if err != nil { + common.SysLog("生成默认边栏配置失败: " + err.Error()) + return "" + } + + return string(configBytes) +} + +// 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!", logger.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) + + // 初始化用户设置,包括默认的边栏配置 + if user.Setting == "" { + defaultSetting := dto.UserSetting{} + // 这里暂时不设置SidebarModules,因为需要在用户创建后根据角色设置 + user.SetSetting(defaultSetting) + } + + result := DB.Create(user) + if result.Error != nil { + return result.Error + } + + // 用户创建成功后,根据角色初始化边栏配置 + // 需要重新获取用户以确保有正确的ID和Role + var createdUser User + if err := DB.Where("username = ?", user.Username).First(&createdUser).Error; err == nil { + // 生成基于角色的默认边栏配置 + defaultSidebarConfig := generateDefaultSidebarConfigForRole(createdUser.Role) + if defaultSidebarConfig != "" { + currentSetting := createdUser.GetSetting() + currentSetting.SidebarModules = defaultSidebarConfig + createdUser.SetSetting(currentSetting) + createdUser.Update(false) + common.SysLog(fmt.Sprintf("为新用户 %s (角色: %d) 初始化边栏配置", createdUser.Username, createdUser.Role)) + } + } + + if common.QuotaForNewUser > 0 { + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("新用户注册赠送 %s", logger.LogQuota(common.QuotaForNewUser))) + } + if inviterId != 0 { + if common.QuotaForInvitee > 0 { + _ = IncreaseUserQuota(user.Id, common.QuotaForInvitee, true) + RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", logger.LogQuota(common.QuotaForInvitee))) + } + if common.QuotaForInviter > 0 { + //_ = IncreaseUserQuota(inviterId, common.QuotaForInviter) + RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", logger.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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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 0000000000000000000000000000000000000000..d60dbe018427c67bb149241fa2dc5db4d42e5d57 --- /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.SysLog("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.SysLog("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 0000000000000000000000000000000000000000..6471a07dc5b0c792dfad0d94ecc8a2793b288ed1 --- /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.SysLog("failed to batch update user quota: " + err.Error()) + } + case BatchUpdateTypeTokenQuota: + err := increaseTokenQuota(key, value) + if err != nil { + common.SysLog("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/model/vendor_meta.go b/model/vendor_meta.go new file mode 100644 index 0000000000000000000000000000000000000000..1e80df21a7e5af38f3e3ce1f115c33ab1cbaec29 --- /dev/null +++ b/model/vendor_meta.go @@ -0,0 +1,88 @@ +package model + +import ( + "one-api/common" + + "gorm.io/gorm" +) + +// Vendor 用于存储供应商信息,供模型引用 +// Name 唯一,用于在模型中关联 +// Icon 采用 @lobehub/icons 的图标名,前端可直接渲染 +// Status 预留字段,1 表示启用 +// 本表同样遵循 3NF 设计范式 + +type Vendor struct { + Id int `json:"id"` + Name string `json:"name" gorm:"size:128;not null;uniqueIndex:uk_vendor_name_delete_at,priority:1"` + Description string `json:"description,omitempty" gorm:"type:text"` + Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` + Status int `json:"status" gorm:"default:1"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + UpdatedTime int64 `json:"updated_time" gorm:"bigint"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index;uniqueIndex:uk_vendor_name_delete_at,priority:2"` +} + +// Insert 创建新的供应商记录 +func (v *Vendor) Insert() error { + now := common.GetTimestamp() + v.CreatedTime = now + v.UpdatedTime = now + return DB.Create(v).Error +} + +// IsVendorNameDuplicated 检查供应商名称是否重复(排除自身 ID) +func IsVendorNameDuplicated(id int, name string) (bool, error) { + if name == "" { + return false, nil + } + var cnt int64 + err := DB.Model(&Vendor{}).Where("name = ? AND id <> ?", name, id).Count(&cnt).Error + return cnt > 0, err +} + +// Update 更新供应商记录 +func (v *Vendor) Update() error { + v.UpdatedTime = common.GetTimestamp() + return DB.Save(v).Error +} + +// Delete 软删除供应商 +func (v *Vendor) Delete() error { + return DB.Delete(v).Error +} + +// GetVendorByID 根据 ID 获取供应商 +func GetVendorByID(id int) (*Vendor, error) { + var v Vendor + err := DB.First(&v, id).Error + if err != nil { + return nil, err + } + return &v, nil +} + +// GetAllVendors 获取全部供应商(分页) +func GetAllVendors(offset int, limit int) ([]*Vendor, error) { + var vendors []*Vendor + err := DB.Offset(offset).Limit(limit).Find(&vendors).Error + return vendors, err +} + +// SearchVendors 按关键字搜索供应商 +func SearchVendors(keyword string, offset int, limit int) ([]*Vendor, int64, error) { + db := DB.Model(&Vendor{}) + if keyword != "" { + like := "%" + keyword + "%" + db = db.Where("name LIKE ? OR description LIKE ?", like, like) + } + var total int64 + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + var vendors []*Vendor + if err := db.Offset(offset).Limit(limit).Order("id DESC").Find(&vendors).Error; err != nil { + return nil, 0, err + } + return vendors, total, nil +} diff --git a/one-api.service b/one-api.service new file mode 100644 index 0000000000000000000000000000000000000000..855006a79efd5798b6ad44b24c615523ab33f869 --- /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 0000000000000000000000000000000000000000..de22ed6b363297c9fc6731b73564e01a75a91d82 --- /dev/null +++ b/relay/audio_handler.go @@ -0,0 +1,73 @@ +package relay + +import ( + "errors" + "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 AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + audioReq, ok := info.Request.(*dto.AudioRequest) + if !ok { + return types.NewError(errors.New("invalid request type"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(audioReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to AudioRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + ioReader, err := adaptor.ConvertAudioRequest(c, info, *request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + resp, err := adaptor.DoRequest(c, info, 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(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + postConsumeQuota(c, info, usage.(*dto.Usage), "") + + return nil +} diff --git a/relay/channel/adapter.go b/relay/channel/adapter.go new file mode 100644 index 0000000000000000000000000000000000000000..8a7ef24d91c1ff10f6acbfc08c74c326fcd086b3 --- /dev/null +++ b/relay/channel/adapter.go @@ -0,0 +1,51 @@ +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) + ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) +} + +type TaskAdaptor interface { + Init(info *relaycommon.RelayInfo) + + ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError + + BuildRequestURL(info *relaycommon.RelayInfo) (string, error) + BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error + BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) + + DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) + DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (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 0000000000000000000000000000000000000000..f7de961248eb9c5ac82651bb2eb1b647dd917ac4 --- /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 0000000000000000000000000000000000000000..5e2337a168db15167785934d6c0e9d2039c0d96e --- /dev/null +++ b/relay/channel/ali/adaptor.go @@ -0,0 +1,180 @@ +package ali + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/claude" + "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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + return req, nil +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + var fullRequestURL string + switch info.RelayFormat { + case types.RelayFormatClaude: + fullRequestURL = fmt.Sprintf("%s/api/v2/apps/claude-code-proxy/v1/messages", info.ChannelBaseUrl) + default: + switch info.RelayMode { + case constant.RelayModeEmbeddings: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.ChannelBaseUrl) + case constant.RelayModeRerank: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl) + case constant.RelayModeImagesGenerations: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl) + case constant.RelayModeImagesEdits: + fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) + case constant.RelayModeCompletions: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/completions", info.ChannelBaseUrl) + default: + fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/chat/completions", info.ChannelBaseUrl) + } + } + + 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")) + } + if info.RelayMode == constant.RelayModeImagesGenerations { + req.Set("X-DashScope-Async", "enable") + } + if info.RelayMode == constant.RelayModeImagesEdits { + req.Set("Content-Type", "application/json") + } + 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") + } + // docs: https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2712216 + // fix: InternalError.Algo.InvalidParameter: The value of the enable_thinking parameter is restricted to True. + if strings.Contains(request.Model, "thinking") { + request.EnableThinking = true + request.Stream = true + info.IsStream = true + } + // 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) { + if info.RelayMode == constant.RelayModeImagesGenerations { + aliRequest, err := oaiImage2Ali(request) + if err != nil { + return nil, fmt.Errorf("convert image request failed: %w", err) + } + return aliRequest, nil + } else if info.RelayMode == constant.RelayModeImagesEdits { + // ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416 + // 如果用户使用表单,则需要解析表单数据 + if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + aliRequest, err := oaiFormEdit2AliImageEdit(c, info, request) + if err != nil { + return nil, fmt.Errorf("convert image edit form request failed: %w", err) + } + return aliRequest, nil + } else { + aliRequest, err := oaiImage2Ali(request) + if err != nil { + return nil, fmt.Errorf("convert image request failed: %w", err) + } + return aliRequest, nil + } + } + return nil, fmt.Errorf("unsupported image relay mode: %d", info.RelayMode) +} + +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.RelayFormat { + case types.RelayFormatClaude: + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } else { + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + default: + switch info.RelayMode { + case constant.RelayModeImagesGenerations: + err, usage = aliImageHandler(c, resp, info) + case constant.RelayModeImagesEdits: + err, usage = aliImageEditHandler(c, resp, info) + case constant.RelayModeRerank: + err, usage = RerankHandler(c, resp, info) + default: + adaptor := openai.Adaptor{} + usage, err = adaptor.DoResponse(c, resp, info) + } + return usage, err + } +} + +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 0000000000000000000000000000000000000000..e9a22a0c5276e6c8f207213153c98c2e93821e39 --- /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 0000000000000000000000000000000000000000..ff7c34ce976f2f7155079432df5587aabb7ee2aa --- /dev/null +++ b/relay/channel/ali/dto.go @@ -0,0 +1,138 @@ +package ali + +import "one-api/dto" + +type AliMessage struct { + Content any `json:"content"` + Role string `json:"role"` +} + +type AliMediaContent struct { + Image string `json:"image,omitempty"` + Text string `json:"text,omitempty"` +} + +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"` + Choices []map[string]any `json:"choices,omitempty"` +} + +type AliResponse struct { + Output AliOutput `json:"output"` + Usage AliUsage `json:"usage"` + AliError +} + +type AliImageRequest struct { + Model string `json:"model"` + Input any `json:"input"` + Parameters any `json:"parameters,omitempty"` + ResponseFormat string `json:"response_format,omitempty"` +} + +type AliImageParameters struct { + Size string `json:"size,omitempty"` + N int `json:"n,omitempty"` + Steps string `json:"steps,omitempty"` + Scale string `json:"scale,omitempty"` + Watermark *bool `json:"watermark,omitempty"` +} + +type AliImageInput struct { + Prompt string `json:"prompt,omitempty"` + NegativePrompt string `json:"negative_prompt,omitempty"` + Messages []AliMessage `json:"messages,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 0000000000000000000000000000000000000000..e73e3d27e9bfb2e3f25a1dbcb90316228cd2e68c --- /dev/null +++ b/relay/channel/ali/image.go @@ -0,0 +1,339 @@ +package ali + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/logger" + relaycommon "one-api/relay/common" + "one-api/service" + "one-api/types" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) { + var imageRequest AliImageRequest + imageRequest.Model = request.Model + imageRequest.ResponseFormat = request.ResponseFormat + logger.LogJson(context.Background(), "oaiImage2Ali request extra", request.Extra) + if request.Extra != nil { + if val, ok := request.Extra["parameters"]; ok { + err := common.Unmarshal(val, &imageRequest.Parameters) + if err != nil { + return nil, fmt.Errorf("invalid parameters field: %w", err) + } + } + if val, ok := request.Extra["input"]; ok { + err := common.Unmarshal(val, &imageRequest.Input) + if err != nil { + return nil, fmt.Errorf("invalid input field: %w", err) + } + } + } + + if imageRequest.Parameters == nil { + imageRequest.Parameters = AliImageParameters{ + Size: strings.Replace(request.Size, "x", "*", -1), + N: int(request.N), + Watermark: request.Watermark, + } + } + + if imageRequest.Input == nil { + imageRequest.Input = AliImageInput{ + Prompt: request.Prompt, + } + } + + return &imageRequest, nil +} + +func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (*AliImageRequest, error) { + var imageRequest AliImageRequest + imageRequest.Model = request.Model + imageRequest.ResponseFormat = request.ResponseFormat + + mf := c.Request.MultipartForm + if mf == nil { + if _, err := c.MultipartForm(); err != nil { + return nil, fmt.Errorf("failed to parse image edit form request: %w", err) + } + mf = c.Request.MultipartForm + } + + var imageFiles []*multipart.FileHeader + var exists bool + + // First check for standard "image" field + if imageFiles, exists = mf.File["image"]; !exists || len(imageFiles) == 0 { + // If not found, check for "image[]" field + if imageFiles, exists = mf.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 mf.File { + if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { + foundArrayImages = true + imageFiles = append(imageFiles, files...) + } + } + + // If no image fields found at all + if !foundArrayImages && (len(imageFiles) == 0) { + return nil, errors.New("image is required") + } + } + } + + if len(imageFiles) == 0 { + return nil, errors.New("image is required") + } + + if len(imageFiles) > 1 { + return nil, errors.New("only one image is supported for qwen edit") + } + + // 获取base64编码的图片 + var imageBase64s []string + for _, file := range imageFiles { + image, err := file.Open() + if err != nil { + return nil, errors.New("failed to open image file") + } + + // 读取文件内容 + imageData, err := io.ReadAll(image) + if err != nil { + return nil, errors.New("failed to read image file") + } + + // 获取MIME类型 + mimeType := http.DetectContentType(imageData) + + // 编码为base64 + base64Data := base64.StdEncoding.EncodeToString(imageData) + + // 构造data URL格式 + dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, base64Data) + imageBase64s = append(imageBase64s, dataURL) + image.Close() + } + + //dto.MediaContent{} + mediaContents := make([]AliMediaContent, len(imageBase64s)) + for i, b64 := range imageBase64s { + mediaContents[i] = AliMediaContent{ + Image: b64, + } + } + mediaContents = append(mediaContents, AliMediaContent{ + Text: request.Prompt, + }) + imageRequest.Input = AliImageInput{ + Messages: []AliMessage{ + { + Role: "user", + Content: mediaContents, + }, + }, + } + imageRequest.Parameters = AliImageParameters{ + Watermark: request.Watermark, + } + return &imageRequest, nil +} + +func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) { + url := fmt.Sprintf("%s/api/v1/tasks/%s", info.ChannelBaseUrl, 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.SysLog("updateTask client.Do err: " + err.Error()) + return &aliResponse, err, nil + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(resp.Body) + + var response AliResponse + err = common.Unmarshal(responseBody, &response) + if err != nil { + common.SysLog("updateTask NewDecoder err: " + err.Error()) + return &aliResponse, err, nil + } + + return &response, nil, responseBody +} + +func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (*AliResponse, []byte, error) { + waitSeconds := 10 + step := 0 + maxStep := 20 + + var taskResponse AliResponse + var responseBody []byte + + for { + logger.LogDebug(c, fmt.Sprintf("asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)) + step++ + rsp, err, body := updateTask(info, taskID) + responseBody = body + if err != nil { + logger.LogWarn(c, "asyncTaskWait UpdateTask err: "+err.Error()) + time.Sleep(time.Duration(waitSeconds) * time.Second) + continue + } + + 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, originBody []byte, 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 { + logger.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: "", + }) + } + var mapResponse map[string]any + _ = common.Unmarshal(originBody, &mapResponse) + imageResponse.Extra = mapResponse + 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.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + service.CloseResponseBodyGracefully(resp) + err = common.Unmarshal(responseBody, &aliTaskResponse) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil + } + + if aliTaskResponse.Message != "" { + logger.LogError(c, "ali_async_task_failed: "+aliTaskResponse.Message) + return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil + } + + aliResponse, originRespBody, err := asyncTaskWait(c, 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, originRespBody, info, responseFormat) + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return nil, &dto.Usage{} +} + +func aliImageEditHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { + var aliResponse AliResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + + service.CloseResponseBodyGracefully(resp) + err = common.Unmarshal(responseBody, &aliResponse) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil + } + + if aliResponse.Message != "" { + logger.LogError(c, "ali_task_failed: "+aliResponse.Message) + return types.NewError(errors.New(aliResponse.Message), types.ErrorCodeBadResponse), nil + } + var fullTextResponse dto.ImageResponse + if len(aliResponse.Output.Choices) > 0 { + fullTextResponse = dto.ImageResponse{ + Created: info.StartTime.Unix(), + Data: []dto.ImageData{ + { + Url: aliResponse.Output.Choices[0]["message"].(map[string]any)["content"].([]any)[0].(map[string]any)["image"].(string), + B64Json: "", + }, + }, + } + } + + var mapResponse map[string]any + _ = common.Unmarshal(responseBody, &mapResponse) + fullTextResponse.Extra = mapResponse + jsonResponse, err := common.Marshal(fullTextResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody), nil + } + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return nil, &dto.Usage{} +} diff --git a/relay/channel/ali/rerank.go b/relay/channel/ali/rerank.go new file mode 100644 index 0000000000000000000000000000000000000000..084c2f70e015f0304959f214c3a3e0cbcd7b8e54 --- /dev/null +++ b/relay/channel/ali/rerank.go @@ -0,0 +1,74 @@ +package ali + +import ( + "encoding/json" + "io" + "net/http" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/service" + "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.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + service.CloseResponseBodyGracefully(resp) + + var aliResponse AliRerankResponse + err = json.Unmarshal(responseBody, &aliResponse) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), 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 0000000000000000000000000000000000000000..43a925b5694cbc9b1856aa3d3ca667925de52b1f --- /dev/null +++ b/relay/channel/ali/text.go @@ -0,0 +1,207 @@ +package ali + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/relay/helper" + "one-api/service" + "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.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil + } + + service.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.SysLog("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.SysLog("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 + } + }) + service.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.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + service.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &aliResponse) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), 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 0000000000000000000000000000000000000000..f69f1793419345853d24db319d4866ba09b84086 --- /dev/null +++ b/relay/channel/api_request.go @@ -0,0 +1,302 @@ +package channel + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + common2 "one-api/common" + "one-api/logger" + "one-api/relay/common" + "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "one-api/setting/operation_setting" + "one-api/types" + "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) + } + headers := req.Header + headerOverride := make(map[string]string) + for k, v := range info.HeadersOverride { + if str, ok := v.(string); ok { + headerOverride[k] = str + } else { + return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid) + } + } + for key, value := range headerOverride { + headers.Set(key, value) + } + err = a.SetupRequestHeader(c, &headers, 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")) + headers := req.Header + headerOverride := make(map[string]string) + for k, v := range info.HeadersOverride { + if str, ok := v.(string); ok { + headerOverride[k] = str + } else { + return nil, types.NewError(err, types.ErrorCodeChannelHeaderOverrideInvalid) + } + } + for key, value := range headerOverride { + headers.Set(key, value) + } + err = a.SetupRequestHeader(c, &headers, 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 { + logger.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 && !info.DisablePing { + 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 { + logger.LogError(c, "do request failed: "+err.Error()) + return nil, types.NewError(err, types.ErrorCodeDoRequestFailed, types.ErrOptionWithHideErrMsg("upstream error: do request failed")) + } + 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.RelayInfo, 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) + 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 0000000000000000000000000000000000000000..2d265d24249b002390cec1757e25e00b3fc64e46 --- /dev/null +++ b/relay/channel/aws/adaptor.go @@ -0,0 +1,126 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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 { + anthropicBeta := c.Request.Header.Get("anthropic-beta") + if anthropicBeta != "" { + req.Set("anthropic-beta", anthropicBeta) + } + 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") + } + // 检查是否为Nova模型 + if isNovaModel(request.Model) { + novaReq := convertToNovaRequest(request) + c.Set("request_model", request.Model) + c.Set("converted_request", novaReq) + c.Set("is_nova_model", true) + return novaReq, nil + } + + // 原有的Claude模型处理逻辑 + var claudeReq *dto.ClaudeRequest + var err error + claudeReq, err = claude.RequestOpenAI2ClaudeMessage(c, *request) + if err != nil { + return nil, err + } + c.Set("request_model", claudeReq.Model) + c.Set("converted_request", claudeReq) + c.Set("is_nova_model", false) + 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 0000000000000000000000000000000000000000..01092243f063744bc188ed87fbc79457c4e4195c --- /dev/null +++ b/relay/channel/aws/constants.go @@ -0,0 +1,128 @@ +package aws + +import "strings" + +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", + "claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-v1:0", + "claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0", + // Nova models + "nova-micro-v1:0": "amazon.nova-micro-v1:0", + "nova-lite-v1:0": "amazon.nova-lite-v1:0", + "nova-pro-v1:0": "amazon.nova-pro-v1:0", + "nova-premier-v1:0": "amazon.nova-premier-v1:0", + "nova-canvas-v1:0": "amazon.nova-canvas-v1:0", + "nova-reel-v1:0": "amazon.nova-reel-v1:0", + "nova-reel-v1:1": "amazon.nova-reel-v1:1", + "nova-sonic-v1:0": "amazon.nova-sonic-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, + }, + "anthropic.claude-opus-4-1-20250805-v1:0": { + "us": true, + }, + "anthropic.claude-sonnet-4-5-20250929-v1:0": { + "us": true, + "ap": true, + "eu": true, + }, + // Nova models - all support three major regions + "amazon.nova-micro-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-lite-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-pro-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-premier-v1:0": { + "us": true, + }, + "amazon.nova-canvas-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-reel-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, + "amazon.nova-reel-v1:1": { + "us": true, + }, + "amazon.nova-sonic-v1:0": { + "us": true, + "eu": true, + "apac": true, + }, +} + +var awsRegionCrossModelPrefixMap = map[string]string{ + "us": "us", + "eu": "eu", + "ap": "apac", +} + +var ChannelName = "aws" + +// 判断是否为Nova模型 +func isNovaModel(modelId string) bool { + return strings.HasPrefix(modelId, "nova-") +} diff --git a/relay/channel/aws/dto.go b/relay/channel/aws/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..0f506713df0c1de2626abc136f766689193d29cf --- /dev/null +++ b/relay/channel/aws/dto.go @@ -0,0 +1,125 @@ +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, + } +} + +// NovaMessage Nova模型使用messages-v1格式 +type NovaMessage struct { + Role string `json:"role"` + Content []NovaContent `json:"content"` +} + +type NovaContent struct { + Text string `json:"text"` +} + +type NovaRequest struct { + SchemaVersion string `json:"schemaVersion"` // 请求版本,例如 "1.0" + Messages []NovaMessage `json:"messages"` // 对话消息列表 + InferenceConfig *NovaInferenceConfig `json:"inferenceConfig,omitempty"` // 推理配置,可选 +} + +type NovaInferenceConfig struct { + MaxTokens int `json:"maxTokens,omitempty"` // 最大生成的 token 数 + Temperature float64 `json:"temperature,omitempty"` // 随机性 (默认 0.7, 范围 0-1) + TopP float64 `json:"topP,omitempty"` // nucleus sampling (默认 0.9, 范围 0-1) + TopK int `json:"topK,omitempty"` // 限制候选 token 数 (默认 50, 范围 0-128) + StopSequences []string `json:"stopSequences,omitempty"` // 停止生成的序列 +} + +// 转换OpenAI请求为Nova格式 +func convertToNovaRequest(req *dto.GeneralOpenAIRequest) *NovaRequest { + novaMessages := make([]NovaMessage, len(req.Messages)) + for i, msg := range req.Messages { + novaMessages[i] = NovaMessage{ + Role: msg.Role, + Content: []NovaContent{{Text: msg.StringContent()}}, + } + } + + novaReq := &NovaRequest{ + SchemaVersion: "messages-v1", + Messages: novaMessages, + } + + // 设置推理配置 + if req.MaxTokens != 0 || (req.Temperature != nil && *req.Temperature != 0) || req.TopP != 0 || req.TopK != 0 || req.Stop != nil { + novaReq.InferenceConfig = &NovaInferenceConfig{} + if req.MaxTokens != 0 { + novaReq.InferenceConfig.MaxTokens = int(req.MaxTokens) + } + if req.Temperature != nil && *req.Temperature != 0 { + novaReq.InferenceConfig.Temperature = *req.Temperature + } + if req.TopP != 0 { + novaReq.InferenceConfig.TopP = req.TopP + } + if req.TopK != 0 { + novaReq.InferenceConfig.TopK = req.TopK + } + if req.Stop != nil { + if stopSequences := parseStopSequences(req.Stop); len(stopSequences) > 0 { + novaReq.InferenceConfig.StopSequences = stopSequences + } + } + } + + return novaReq +} + +// parseStopSequences 解析停止序列,支持字符串或字符串数组 +func parseStopSequences(stop any) []string { + if stop == nil { + return nil + } + + switch v := stop.(type) { + case string: + if v != "" { + return []string{v} + } + case []string: + return v + case []interface{}: + var sequences []string + for _, item := range v { + if str, ok := item.(string); ok && str != "" { + sequences = append(sequences, str) + } + } + return sequences + } + return nil +} diff --git a/relay/channel/aws/relay-aws.go b/relay/channel/aws/relay-aws.go new file mode 100644 index 0000000000000000000000000000000000000000..95d1725fce4bb57ab810db8f4d73b15f51fb45e5 --- /dev/null +++ b/relay/channel/aws/relay-aws.go @@ -0,0 +1,295 @@ +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" + "github.com/aws/smithy-go/auth/bearer" +) + +func newAwsClient(c *gin.Context, info *relaycommon.RelayInfo) (*bedrockruntime.Client, error) { + awsSecret := strings.Split(info.ApiKey, "|") + var client *bedrockruntime.Client + switch len(awsSecret) { + case 2: + apiKey := awsSecret[0] + region := awsSecret[1] + client = bedrockruntime.New(bedrockruntime.Options{ + Region: region, + BearerAuthTokenProvider: bearer.StaticTokenProvider{Token: bearer.Token{Value: apiKey}}, + }) + case 3: + ak := awsSecret[0] + sk := awsSecret[1] + region := awsSecret[2] + client = bedrockruntime.New(bedrockruntime.Options{ + Region: region, + Credentials: aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(ak, sk, "")), + }) + default: + return nil, errors.New("invalid aws secret key") + } + + 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")) + // 检查是否为Nova模型 + isNova, _ := c.Get("is_nova_model") + if isNova == true { + // Nova模型也支持跨区域 + awsRegionPrefix := awsRegionPrefix(awsCli.Options().Region) + canCrossRegion := awsModelCanCrossRegion(awsModelId, awsRegionPrefix) + if canCrossRegion { + awsModelId = awsModelCrossRegion(awsModelId, awsRegionPrefix) + } + return handleNovaRequest(c, awsCli, info, awsModelId) + } + + // 原有的Claude处理逻辑 + 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 = common.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.NewOpenAIError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), nil + } + + claudeInfo := &claude.ClaudeResponseInfo{ + ResponseId: helper.GetResponseID(c), + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + ResponseText: strings.Builder{}, + Usage: &dto.Usage{}, + } + + // 复制上游 Content-Type 到客户端响应头 + if awsResp.ContentType != nil && *awsResp.ContentType != "" { + c.Writer.Header().Set("Content-Type", *awsResp.ContentType) + } + + handlerErr := claude.HandleClaudeResponseData(c, info, claudeInfo, nil, 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 = common.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.NewOpenAIError(errors.Wrap(err, "InvokeModelWithResponseStream"), types.ErrorCodeAwsInvokeError, http.StatusInternalServerError), 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 +} + +// Nova模型处理函数 +func handleNovaRequest(c *gin.Context, awsCli *bedrockruntime.Client, info *relaycommon.RelayInfo, awsModelId string) (*types.NewAPIError, *dto.Usage) { + novaReq_, ok := c.Get("converted_request") + if !ok { + return types.NewError(errors.New("nova request not found"), types.ErrorCodeInvalidRequest), nil + } + novaReq := novaReq_.(*NovaRequest) + + // 使用InvokeModel API,但使用Nova格式的请求体 + awsReq := &bedrockruntime.InvokeModelInput{ + ModelId: aws.String(awsModelId), + Accept: aws.String("application/json"), + ContentType: aws.String("application/json"), + } + + reqBody, err := json.Marshal(novaReq) + if err != nil { + return types.NewError(errors.Wrap(err, "marshal nova request"), types.ErrorCodeBadResponseBody), nil + } + awsReq.Body = reqBody + + awsResp, err := awsCli.InvokeModel(c.Request.Context(), awsReq) + if err != nil { + return types.NewError(errors.Wrap(err, "InvokeModel"), types.ErrorCodeChannelAwsClientError), nil + } + + // 解析Nova响应 + var novaResp struct { + Output struct { + Message struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } `json:"message"` + } `json:"output"` + Usage struct { + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + TotalTokens int `json:"totalTokens"` + } `json:"usage"` + } + + if err := json.Unmarshal(awsResp.Body, &novaResp); err != nil { + return types.NewError(errors.Wrap(err, "unmarshal nova response"), types.ErrorCodeBadResponseBody), nil + } + + // 构造OpenAI格式响应 + response := dto.OpenAITextResponse{ + Id: helper.GetResponseID(c), + Object: "chat.completion", + Created: common.GetTimestamp(), + Model: info.UpstreamModelName, + Choices: []dto.OpenAITextResponseChoice{{ + Index: 0, + Message: dto.Message{ + Role: "assistant", + Content: novaResp.Output.Message.Content[0].Text, + }, + FinishReason: "stop", + }}, + Usage: dto.Usage{ + PromptTokens: novaResp.Usage.InputTokens, + CompletionTokens: novaResp.Usage.OutputTokens, + TotalTokens: novaResp.Usage.TotalTokens, + }, + } + + c.JSON(http.StatusOK, response) + return nil, &response.Usage +} diff --git a/relay/channel/baidu/adaptor.go b/relay/channel/baidu/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..c4211d20e001fc5e84a0abd59a497cf302768936 --- /dev/null +++ b/relay/channel/baidu/adaptor.go @@ -0,0 +1,169 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl, 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 0000000000000000000000000000000000000000..847663ff2d5b99f08e724b0c37a81e40e2bbc368 --- /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 0000000000000000000000000000000000000000..cd035cbf9cceb9707e6a3dcc4282274a32ecdd06 --- /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 0000000000000000000000000000000000000000..3431754345ae378fea571823c9ba6da5761dc3a5 --- /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.GetMaxTokens() != 0 { + maxTokens := int(request.GetMaxTokens()) + if request.GetMaxTokens() == 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.SysLog("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.SysLog("error sending stream response: " + err.Error()) + } + return true + }) + service.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 + } + service.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 + } + service.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 0000000000000000000000000000000000000000..3a2fddbe10bb1220adc69378c87779757d501619 --- /dev/null +++ b/relay/channel/baidu_v2/adaptor.go @@ -0,0 +1,129 @@ +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/relay/constant" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +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 constant.RelayModeChatCompletions: + return fmt.Sprintf("%s/v2/chat/completions", info.ChannelBaseUrl), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/v2/embeddings", info.ChannelBaseUrl), nil + case constant.RelayModeImagesGenerations: + return fmt.Sprintf("%s/v2/images/generations", info.ChannelBaseUrl), nil + case constant.RelayModeImagesEdits: + return fmt.Sprintf("%s/v2/images/edits", info.ChannelBaseUrl), nil + case constant.RelayModeRerank: + return fmt.Sprintf("%s/v2/rerank", info.ChannelBaseUrl), 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) + 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 + if len(request.WebSearch) == 0 { + 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 + } + return request, 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) { + //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) { + adaptor := openai.Adaptor{} + usage, err = adaptor.DoResponse(c, resp, info) + 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 0000000000000000000000000000000000000000..1bd94e37d6efe84825a08fef6f18358b64b2252e --- /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 0000000000000000000000000000000000000000..1bd112dec20ca22b0803032c4729776ec3fdd14d --- /dev/null +++ b/relay/channel/claude/adaptor.go @@ -0,0 +1,127 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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) { + baseURL := "" + if a.RequestMode == RequestModeMessage { + baseURL = fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl) + } else { + baseURL = fmt.Sprintf("%s/v1/complete", info.ChannelBaseUrl) + } + if info.IsClaudeBetaQuery { + baseURL = baseURL + "?beta=true" + } + return 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) + anthropicBeta := c.Request.Header.Get("anthropic-beta") + if anthropicBeta != "" { + req.Set("anthropic-beta", anthropicBeta) + } + 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(c, *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 { + return ClaudeStreamHandler(c, resp, info, a.RequestMode) + } else { + return ClaudeHandler(c, resp, info, a.RequestMode) + } + 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 0000000000000000000000000000000000000000..991ffba9c0efb3e6b0f93f59c594f748c2aeaaa0 --- /dev/null +++ b/relay/channel/claude/constants.go @@ -0,0 +1,26 @@ +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", + "claude-opus-4-1-20250805", + "claude-opus-4-1-20250805-thinking", + "claude-sonnet-4-5-20250929", + "claude-sonnet-4-5-20250929-thinking", +} + +var ChannelName = "claude" diff --git a/relay/channel/claude/dto.go b/relay/channel/claude/dto.go new file mode 100644 index 0000000000000000000000000000000000000000..00391d303f19cf5338ba80b292adcdc57b761086 --- /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 0000000000000000000000000000000000000000..b43c13f0e2965d1571ddb704f64d2699768c04fa --- /dev/null +++ b/relay/channel/claude/relay-claude.go @@ -0,0 +1,831 @@ +package claude + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/logger" + "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 "length" + 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(c *gin.Context, 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.GetMaxTokens(), + 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 + // 初始化system消息数组,用于累积多个system消息 + var systemMessages []dto.ClaudeMediaMessage + + for _, message := range formatMessages { + if message.Role == "system" { + // 根据Claude API规范,system字段使用数组格式更有通用性 + if message.IsStringContent() { + systemMessages = append(systemMessages, dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](message.StringContent()), + }) + } else { + // 支持复合内容的system消息(虽然不常见,但需要考虑完整性) + for _, ctx := range message.ParseContent() { + if ctx.Type == "text" { + systemMessages = append(systemMessages, dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](ctx.Text), + }) + } + // 未来可以在这里扩展对图片等其他类型的支持 + } + } + } 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(c, imageUrl.Url, "formatting image for Claude") + 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.SysLog("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) + } + } + + // 设置累积的system消息 + if len(systemMessages) > 0 { + claudeRequest.System = systemMessages + } + + 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 { + // 如果是文本块,尽可能发送首段文本(若存在) + if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != 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.SysLog("error unmarshalling stream response: " + err.Error()) + return types.NewError(err, types.ErrorCodeBadResponseBody) + } + if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { + return types.WithClaudeError(*claudeError, http.StatusInternalServerError) + } + if info.RelayFormat == types.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 == types.RelayFormatOpenAI { + response := StreamResponseClaude2OpenAI(requestMode, &claudeResponse) + + if !FormatClaudeResponseInfo(requestMode, &claudeResponse, response, claudeInfo) { + return nil + } + + err = helper.ObjectData(c, response) + if err != nil { + logger.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.SysLog("claude response usage is not complete, maybe upstream error") + } + claudeInfo.Usage = service.ResponseText2Usage(claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) + } + } + + if info.RelayFormat == types.RelayFormatClaude { + // + } else if info.RelayFormat == types.RelayFormatOpenAI { + if info.ShouldIncludeUsage { + response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, *claudeInfo.Usage) + err := helper.ObjectData(c, response) + if err != nil { + common.SysLog("send final response failed: " + err.Error()) + } + } + helper.Done(c) + } +} + +func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*dto.Usage, *types.NewAPIError) { + 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 nil, err + } + + HandleStreamFinalResponse(c, info, claudeInfo, requestMode) + return claudeInfo.Usage, nil +} + +func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claudeInfo *ClaudeResponseInfo, httpResp *http.Response, 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 claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" { + return types.WithClaudeError(*claudeError, 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 types.RelayFormatOpenAI: + openaiResponse := ResponseClaude2OpenAI(requestMode, &claudeResponse) + openaiResponse.Usage = *claudeInfo.Usage + responseData, err = json.Marshal(openaiResponse) + if err != nil { + return types.NewError(err, types.ErrorCodeBadResponseBody) + } + case types.RelayFormatClaude: + responseData = data + } + + if claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 { + c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests) + } + + service.IOCopyBytesGracefully(c, httpResp, responseData) + return nil +} + +func ClaudeHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, requestMode int) (*dto.Usage, *types.NewAPIError) { + defer service.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 nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + if common.DebugEnabled { + println("responseBody: ", string(responseBody)) + } + handleErr := HandleClaudeResponseData(c, info, claudeInfo, resp, responseBody, requestMode) + if handleErr != nil { + return nil, handleErr + } + return claudeInfo.Usage, nil +} + +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 0000000000000000000000000000000000000000..01bdc38236189bfaf9976db620443f7916432300 --- /dev/null +++ b/relay/channel/cloudflare/adaptor.go @@ -0,0 +1,135 @@ +package cloudflare + +import ( + "bytes" + "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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl, info.ApiVersion), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1/embeddings", info.ChannelBaseUrl, info.ApiVersion), nil + case constant.RelayModeResponses: + return fmt.Sprintf("%s/client/v4/accounts/%s/ai/v1/responses", info.ChannelBaseUrl, info.ApiVersion), nil + default: + return fmt.Sprintf("%s/client/v4/accounts/%s/ai/run/%s", info.ChannelBaseUrl, 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) { + return request, nil +} + +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.RelayModeResponses: + if info.IsStream { + usage, err = openai.OaiResponsesStreamHandler(c, info, resp) + } else { + usage, err = openai.OaiResponsesHandler(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 0000000000000000000000000000000000000000..3d6bf33df13f43ff1ff74ff34dda12523b206072 --- /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 0000000000000000000000000000000000000000..096681233b2ed877de451943947cfb3861b9b35d --- /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 uint `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 0000000000000000000000000000000000000000..e8ab94c1f16787cc6739d9343a7b45841fcffce9 --- /dev/null +++ b/relay/channel/cloudflare/relay_cloudflare.go @@ -0,0 +1,150 @@ +package cloudflare + +import ( + "bufio" + "encoding/json" + "io" + "net/http" + "one-api/dto" + "one-api/logger" + 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 { + logger.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 { + logger.LogError(c, "error_rendering_stream_response: "+err.Error()) + } + } + + if err := scanner.Err(); err != nil { + logger.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 { + logger.LogError(c, "error_rendering_final_usage_response: "+err.Error()) + } + } + helper.Done(c) + + service.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 + } + service.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 + } + service.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 0000000000000000000000000000000000000000..ed9edd89dabf72b521b8f51c6c1a28c7114ce783 --- /dev/null +++ b/relay/channel/cohere/adaptor.go @@ -0,0 +1,99 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl), nil + } else { + return fmt.Sprintf("%s/v1/chat", info.ChannelBaseUrl), 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 0000000000000000000000000000000000000000..e1255bda60bc838e2b0e8523d6afe252db653fde --- /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 0000000000000000000000000000000000000000..94546ab1af74e06bbe2b0eb809f83abb89146426 --- /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 uint `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 0000000000000000000000000000000000000000..33a66c24033b99dc6b44dbdb7b187916456e760e --- /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.SysLog("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.SysLog("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) + } + service.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) + } + service.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 0000000000000000000000000000000000000000..a5c2fa6bb9ff8caa5601b3ab863c0331fdafa438 --- /dev/null +++ b/relay/channel/coze/adaptor.go @@ -0,0 +1,138 @@ +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 { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *common.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +// 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.ChannelBaseUrl), 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 0000000000000000000000000000000000000000..c79af56d9e31f75daf4e7809afaf7558bab36ce5 --- /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 0000000000000000000000000000000000000000..38c0101ee6bcaf08f0b932fbdcfb49104b411903 --- /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 0000000000000000000000000000000000000000..2fe435cf01356abfba8c93e076de27acb369d98e --- /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) + } + service.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.SysLog("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.SysLog("error_unmarshalling_stream_response: " + err.Error()) + return + } + + var content string + err = json.Unmarshal(messageData.Content, &content) + if err != nil { + common.SysLog("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.SysLog("error_unmarshalling_stream_response: " + err.Error()) + return + } + + common.SysLog(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.ChannelBaseUrl) + + 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.ChannelBaseUrl) + + 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 0000000000000000000000000000000000000000..c11bc1a6125212e0e2d780f126b33db2fda77932 --- /dev/null +++ b/relay/channel/deepseek/adaptor.go @@ -0,0 +1,114 @@ +package deepseek + +import ( + "errors" + "fmt" + "github.com/gin-gonic/gin" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/claude" + "one-api/relay/channel/openai" + relaycommon "one-api/relay/common" + "one-api/relay/constant" + "one-api/types" + "strings" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := claude.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +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.ChannelBaseUrl + switch info.RelayFormat { + case types.RelayFormatClaude: + return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil + default: + if !strings.HasSuffix(info.ChannelBaseUrl, "/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.ChannelBaseUrl), 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) { + switch info.RelayFormat { + case types.RelayFormatClaude: + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } else { + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + default: + adaptor := openai.Adaptor{} + return adaptor.DoResponse(c, resp, info) + } +} + +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 0000000000000000000000000000000000000000..e6b705b010e79de737b87a3644e2afcc65f73216 --- /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 0000000000000000000000000000000000000000..954c24dcfa79af3e1aa5aca19d02afdf6897baad --- /dev/null +++ b/relay/channel/dify/adaptor.go @@ -0,0 +1,120 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl), nil + case BotTypeCompletion: + return fmt.Sprintf("%s/v1/completion-messages", info.ChannelBaseUrl), nil + case BotTypeAgent: + fallthrough + default: + return fmt.Sprintf("%s/v1/chat-messages", info.ChannelBaseUrl), 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 0000000000000000000000000000000000000000..5b3f64cc891b45d0134e5bbd261c5f1d4e27b7af --- /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 0000000000000000000000000000000000000000..fb59ff0d0071341cb7da00644dc54860038f3106 --- /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 0000000000000000000000000000000000000000..1850aed3fe81c7fbee44d608a11ebb8e1bcd5764 --- /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.ChannelBaseUrl) + 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.SysLog("failed to decode base64: " + err.Error()) + return nil + } + + // Create temporary file + tempFile, err := os.CreateTemp("", "dify-upload-*") + if err != nil { + common.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog("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.SysLog(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) + } + service.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 0000000000000000000000000000000000000000..0f40bf74e80b960a03fbc865f3cb8567ffd48493 --- /dev/null +++ b/relay/channel/gemini/adaptor.go @@ -0,0 +1,254 @@ +package gemini + +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/setting/model_setting" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + if len(request.Contents) > 0 { + for i, content := range request.Contents { + if i == 0 { + if request.Contents[0].Role == "" { + request.Contents[0].Role = "user" + } + } + for _, part := range content.Parts { + if part.FileData != nil { + if part.FileData.MimeType == "" && strings.Contains(part.FileData.FileUri, "www.youtube.com") { + part.FileData.MimeType = "video/webm" + } + } + } + } + } + return request, nil +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + oaiReq, err := adaptor.ConvertClaudeRequest(c, info, req) + if err != nil { + return nil, err + } + return a.ConvertOpenAIRequest(c, info, oaiReq.(*dto.GeneralOpenAIRequest)) +} + +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 but allow user to specify aspect ratio + aspectRatio := "1:1" // default aspect ratio + size := strings.TrimSpace(request.Size) + if size != "" { + if strings.Contains(size, ":") { + aspectRatio = size + } else { + switch size { + case "1024x1024": + aspectRatio = "1:1" + case "1024x1792": + aspectRatio = "9:16" + case "1792x1024": + aspectRatio = "16:9" + } + } + } + + // build gemini imagen request + geminiRequest := dto.GeminiImageRequest{ + Instances: []dto.GeminiImageInstance{ + { + Prompt: request.Prompt, + }, + }, + Parameters: dto.GeminiImageParameters{ + SampleCount: int(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.ChannelBaseUrl, version, info.UpstreamModelName), nil + } + + if strings.HasPrefix(info.UpstreamModelName, "text-embedding") || + strings.HasPrefix(info.UpstreamModelName, "embedding") || + strings.HasPrefix(info.UpstreamModelName, "gemini-embedding") { + action := "embedContent" + if info.IsGeminiBatchEmbedding { + action = "batchEmbedContents" + } + return fmt.Sprintf("%s/%s/models/%s:%s", info.ChannelBaseUrl, version, info.UpstreamModelName, action), nil + } + + action := "generateContent" + if info.IsStream { + action = "streamGenerateContent?alt=sse" + if info.RelayMode == constant.RelayModeGemini { + info.DisablePing = true + } + } + return fmt.Sprintf("%s/%s/models/%s:%s", info.ChannelBaseUrl, 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(c, *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") + } + // We always build a batch-style payload with `requests`, so ensure we call the + // batch endpoint upstream to avoid payload/endpoint mismatches. + info.IsGeminiBatchEmbedding = true + // process all inputs + geminiRequests := make([]map[string]interface{}, 0, len(inputs)) + for _, input := range inputs { + geminiRequest := map[string]interface{}{ + "model": fmt.Sprintf("models/%s", info.UpstreamModelName), + "content": dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ + { + Text: input, + }, + }, + }, + } + + // 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", "gemini-embedding-exp-03-07", "gemini-embedding-001": + // Only newer models introduced after 2024 support OutputDimensionality + if request.Dimensions > 0 { + geminiRequest["outputDimensionality"] = request.Dimensions + } + } + geminiRequests = append(geminiRequests, geminiRequest) + } + + return map[string]interface{}{ + "requests": geminiRequests, + }, 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 strings.Contains(info.RequestURLPath, ":embedContent") || + strings.Contains(info.RequestURLPath, ":batchEmbedContents") { + return NativeGeminiEmbeddingHandler(c, resp, info) + } + 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) + } + +} + +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 0000000000000000000000000000000000000000..d509344d0ad6e272440f02858da4c2cc44cfcb65 --- /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/relay-gemini-native.go b/relay/channel/gemini/relay-gemini-native.go new file mode 100644 index 0000000000000000000000000000000000000000..296a1e8c7b713c9c1ce6cb008d55f1edccce9ce5 --- /dev/null +++ b/relay/channel/gemini/relay-gemini-native.go @@ -0,0 +1,175 @@ +package gemini + +import ( + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/logger" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + + "github.com/pkg/errors" + + "github.com/gin-gonic/gin" +) + +func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + // 读取响应体 + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if common.DebugEnabled { + println(string(responseBody)) + } + + // 解析为 Gemini 原生响应格式 + var geminiResponse dto.GeminiChatResponse + err = common.Unmarshal(responseBody, &geminiResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // 计算使用量(基于 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 + } + } + + service.IOCopyBytesGracefully(c, resp, responseBody) + + return &usage, nil +} + +func NativeGeminiEmbeddingHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if common.DebugEnabled { + println(string(responseBody)) + } + + usage := &dto.Usage{ + PromptTokens: info.PromptTokens, + TotalTokens: info.PromptTokens, + } + + if info.IsGeminiBatchEmbedding { + var geminiResponse dto.GeminiBatchEmbeddingResponse + err = common.Unmarshal(responseBody, &geminiResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + } else { + var geminiResponse dto.GeminiEmbeddingResponse + err = common.Unmarshal(responseBody, &geminiResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + } + + service.IOCopyBytesGracefully(c, resp, responseBody) + + 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 dto.GeminiChatResponse + err := common.UnmarshalJsonStr(data, &geminiResponse) + if err != nil { + logger.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 { + logger.LogError(c, err.Error()) + } + info.SendResponseCount++ + return true + }) + + if info.SendResponseCount == 0 { + return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError) + } + + 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 0000000000000000000000000000000000000000..5a1214781512b3e04feb3f7f41ed9980fd527e34 --- /dev/null +++ b/relay/channel/gemini/relay-gemini.go @@ -0,0 +1,1193 @@ +package gemini + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/logger" + "one-api/relay/channel/openai" + 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" +) + +// https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference?hl=zh-cn#blob +var geminiSupportedMimeTypes = map[string]bool{ + "application/pdf": true, + "audio/mpeg": true, + "audio/mp3": true, + "audio/wav": true, + "image/png": true, + "image/jpeg": true, + "image/webp": 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 +) + +func isNew25ProModel(modelName string) bool { + return 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") +} + +func is25FlashLiteModel(modelName string) bool { + return strings.HasPrefix(modelName, "gemini-2.5-flash-lite") +} + +// clampThinkingBudget 根据模型名称将预算限制在允许的范围内 +func clampThinkingBudget(modelName string, budget int) int { + isNew25Pro := isNew25ProModel(modelName) + is25FlashLite := is25FlashLiteModel(modelName) + + 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 +} + +// "effort": "high" - Allocates a large portion of tokens for reasoning (approximately 80% of max_tokens) +// "effort": "medium" - Allocates a moderate portion of tokens (approximately 50% of max_tokens) +// "effort": "low" - Allocates a smaller portion of tokens (approximately 20% of max_tokens) +func clampThinkingBudgetByEffort(modelName string, effort string) int { + isNew25Pro := isNew25ProModel(modelName) + is25FlashLite := is25FlashLiteModel(modelName) + + maxBudget := 0 + if is25FlashLite { + maxBudget = flash25LiteMaxBudget + } + if isNew25Pro { + maxBudget = pro25MaxBudget + } else { + maxBudget = flash25MaxBudget + } + switch effort { + case "high": + maxBudget = maxBudget * 80 / 100 + case "medium": + maxBudget = maxBudget * 50 / 100 + case "low": + maxBudget = maxBudget * 20 / 100 + } + return clampThinkingBudget(modelName, maxBudget) +} + +func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) { + 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 = &dto.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 = &dto.GeminiThinkingConfig{ + IncludeThoughts: true, + } + } else { + geminiRequest.GenerationConfig.ThinkingConfig = &dto.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 len(oaiRequest) > 0 { + // 如果有reasoningEffort参数,则根据其值设置思考预算 + geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = common.GetPointer(clampThinkingBudgetByEffort(modelName, oaiRequest[0].ReasoningEffort)) + } + } + } + } else if strings.HasSuffix(modelName, "-nothinking") { + if !isNew25Pro { + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(0), + } + } + } + } +} + +// Setting safety to the lowest possible values since Gemini is already powerless enough +func CovertGemini2OpenAI(c *gin.Context, textRequest dto.GeneralOpenAIRequest, info *relaycommon.RelayInfo) (*dto.GeminiChatRequest, error) { + + geminiRequest := dto.GeminiChatRequest{ + Contents: make([]dto.GeminiChatContent, 0, len(textRequest.Messages)), + GenerationConfig: dto.GeminiChatGenerationConfig{ + Temperature: textRequest.Temperature, + TopP: textRequest.TopP, + MaxOutputTokens: textRequest.GetMaxTokens(), + Seed: int64(textRequest.Seed), + }, + } + + if model_setting.IsGeminiModelSupportImagine(info.UpstreamModelName) { + geminiRequest.GenerationConfig.ResponseModalities = []string{ + "TEXT", + "IMAGE", + } + } + + adaptorWithExtraBody := false + + if len(textRequest.ExtraBody) > 0 { + if !strings.HasSuffix(info.UpstreamModelName, "-nothinking") { + var extraBody map[string]interface{} + if err := common.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil { + return nil, fmt.Errorf("invalid extra body: %w", err) + } + // eg. {"google":{"thinking_config":{"thinking_budget":5324,"include_thoughts":true}}} + if googleBody, ok := extraBody["google"].(map[string]interface{}); ok { + adaptorWithExtraBody = true + if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok { + if budget, ok := thinkingConfig["thinking_budget"].(float64); ok { + budgetInt := int(budget) + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + ThinkingBudget: common.GetPointer(budgetInt), + IncludeThoughts: true, + } + } else { + geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ + IncludeThoughts: true, + } + } + } + } + } + } + + if !adaptorWithExtraBody { + ThinkingAdaptor(&geminiRequest, info, textRequest) + } + + safetySettings := make([]dto.GeminiChatSafetySettings, 0, len(SafetySettingList)) + for _, category := range SafetySettingList { + safetySettings = append(safetySettings, dto.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 + urlContext := 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.Name == "urlContext" { + urlContext = 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) + } + geminiTools := geminiRequest.GetTools() + if codeExecution { + geminiTools = append(geminiTools, dto.GeminiChatTool{ + CodeExecution: make(map[string]string), + }) + } + if googleSearch { + geminiTools = append(geminiTools, dto.GeminiChatTool{ + GoogleSearch: make(map[string]string), + }) + } + if urlContext { + geminiTools = append(geminiTools, dto.GeminiChatTool{ + URLContext: make(map[string]string), + }) + } + if len(functions) > 0 { + geminiTools = append(geminiTools, dto.GeminiChatTool{ + FunctionDeclarations: functions, + }) + } + geminiRequest.SetTools(geminiTools) + } + + if textRequest.ResponseFormat != nil && (textRequest.ResponseFormat.Type == "json_schema" || textRequest.ResponseFormat.Type == "json_object") { + geminiRequest.GenerationConfig.ResponseMimeType = "application/json" + + if len(textRequest.ResponseFormat.JsonSchema) > 0 { + // 先将json.RawMessage解析 + var jsonSchema dto.FormatJsonSchema + if err := common.Unmarshal(textRequest.ResponseFormat.JsonSchema, &jsonSchema); err == nil { + cleanedSchema := removeAdditionalPropertiesWithDepth(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, dto.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 := &dto.GeminiFunctionResponse{ + Name: name, + Response: contentMap, + } + + *parts = append(*parts, dto.GeminiPart{ + FunctionResponse: functionResp, + }) + continue + } + var parts []dto.GeminiPart + content := dto.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 := dto.GeminiPart{ + FunctionCall: &dto.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, dto.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(c, part.GetImageMedia().Url, "formatting image for Gemini") + 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, dto.GeminiPart{ + InlineData: &dto.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, dto.GeminiPart{ + InlineData: &dto.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, dto.GeminiPart{ + InlineData: &dto.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, dto.GeminiPart{ + InlineData: &dto.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 = &dto.GeminiChatContent{ + Parts: []dto.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 *dto.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 *dto.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.InlineData != nil { + // 媒体内容 + if strings.HasPrefix(part.InlineData.MimeType, "image") { + imgText := "![image](data:" + part.InlineData.MimeType + ";base64," + part.InlineData.Data + ")" + texts = append(texts, imgText) + } else { + // 其他媒体类型,直接显示链接 + texts = append(texts, fmt.Sprintf("[media](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data)) + } + } else 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 *dto.GeminiChatResponse) (*dto.ChatCompletionsStreamResponse, bool) { + choices := make([]dto.ChatCompletionsStreamResponseChoice, 0, len(geminiResponse.Candidates)) + isStop := 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) + } + } 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 +} + +func handleStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error { + streamData, err := common.Marshal(resp) + if err != nil { + return fmt.Errorf("failed to marshal stream response: %w", err) + } + err = openai.HandleStreamFormat(c, info, string(streamData), info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + if err != nil { + return fmt.Errorf("failed to handle stream format: %w", err) + } + return nil +} + +func handleFinalStream(c *gin.Context, info *relaycommon.RelayInfo, resp *dto.ChatCompletionsStreamResponse) error { + streamData, err := common.Marshal(resp) + if err != nil { + return fmt.Errorf("failed to marshal stream response: %w", err) + } + openai.HandleFinalResponse(c, info, string(streamData), resp.Id, resp.Created, resp.Model, resp.GetSystemFingerprint(), resp.Usage, false) + return nil +} + +func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + // responseText := "" + id := helper.GetResponseID(c) + createAt := common.GetTimestamp() + responseText := strings.Builder{} + var usage = &dto.Usage{} + var imageCount int + finishReason := constant.FinishReasonStop + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + var geminiResponse dto.GeminiChatResponse + err := common.UnmarshalJsonStr(data, &geminiResponse) + if err != nil { + logger.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) + } + } + } + + response, isStop := streamResponseGeminiChat2OpenAI(&geminiResponse) + + 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 + } + } + } + logger.LogDebug(c, fmt.Sprintf("info.SendResponseCount = %d", info.SendResponseCount)) + if info.SendResponseCount == 0 { + // send first response + emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil) + if response.IsToolCall() { + emptyResponse.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse, 1) + emptyResponse.Choices[0].Delta.ToolCalls[0] = *response.GetFirstToolCall() + emptyResponse.Choices[0].Delta.ToolCalls[0].Function.Arguments = "" + finishReason = constant.FinishReasonToolCalls + err = handleStream(c, info, emptyResponse) + if err != nil { + logger.LogError(c, err.Error()) + } + + response.ClearToolCalls() + if response.IsFinished() { + response.Choices[0].FinishReason = nil + } + } else { + err = handleStream(c, info, emptyResponse) + if err != nil { + logger.LogError(c, err.Error()) + } + } + } + + err = handleStream(c, info, response) + if err != nil { + logger.LogError(c, err.Error()) + } + if isStop { + _ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason)) + } + return true + }) + + if info.SendResponseCount == 0 { + // 空补全,报错不计费 + // empty response, throw an error + return nil, types.NewOpenAIError(errors.New("no response received from Gemini API"), types.ErrorCodeEmptyResponse, http.StatusInternalServerError) + } + + if imageCount != 0 { + if usage.CompletionTokens == 0 { + usage.CompletionTokens = imageCount * 258 + } + } + + usage.PromptTokensDetails.TextTokens = usage.PromptTokens + usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens + + if usage.CompletionTokens == 0 { + str := responseText.String() + if len(str) > 0 { + usage = service.ResponseText2Usage(responseText.String(), info.UpstreamModelName, info.PromptTokens) + } else { + // 空补全,不需要使用量 + usage = &dto.Usage{} + } + } + + response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage) + err := handleFinalStream(c, info, response) + if err != nil { + common.SysLog("send final response failed: " + err.Error()) + } + //if info.RelayFormat == relaycommon.RelayFormatOpenAI { + // 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.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + if common.DebugEnabled { + println(string(responseBody)) + } + var geminiResponse dto.GeminiChatResponse + err = common.Unmarshal(responseBody, &geminiResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if len(geminiResponse.Candidates) == 0 { + return nil, types.NewOpenAIError(errors.New("no candidates returned"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + 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 + + switch info.RelayFormat { + case types.RelayFormatOpenAI: + responseBody, err = common.Marshal(fullTextResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + case types.RelayFormatClaude: + claudeResp := service.ResponseOpenAI2Claude(fullTextResponse, info) + claudeRespStr, err := common.Marshal(claudeResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + responseBody = claudeRespStr + case types.RelayFormatGemini: + break + } + + service.IOCopyBytesGracefully(c, resp, responseBody) + + return &usage, nil +} + +func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, types.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + var geminiResponse dto.GeminiBatchEmbeddingResponse + if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { + return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // convert to openai format response + openAIResponse := dto.OpenAIEmbeddingResponse{ + Object: "list", + Data: make([]dto.OpenAIEmbeddingResponseItem, 0, len(geminiResponse.Embeddings)), + Model: info.UpstreamModelName, + } + + for i, embedding := range geminiResponse.Embeddings { + openAIResponse.Data = append(openAIResponse.Data, dto.OpenAIEmbeddingResponseItem{ + Object: "embedding", + Embedding: embedding.Values, + Index: i, + }) + } + + // 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.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return usage, nil +} + +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.NewOpenAIError(readErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var geminiResponse dto.GeminiImageResponse + if jsonErr := common.Unmarshal(responseBody, &geminiResponse); jsonErr != nil { + return nil, types.NewOpenAIError(jsonErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if len(geminiResponse.Predictions) == 0 { + return nil, types.NewOpenAIError(errors.New("no images generated"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // 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 +} diff --git a/relay/channel/jimeng/adaptor.go b/relay/channel/jimeng/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..4b48f4d98c6af4e79fc273c5756e6270366730e7 --- /dev/null +++ b/relay/channel/jimeng/adaptor.go @@ -0,0 +1,142 @@ +package jimeng + +import ( + "encoding/json" + "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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl), 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 0000000000000000000000000000000000000000..74fad49cc515f913a085eadde7c30b9f196cfcd8 --- /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 0000000000000000000000000000000000000000..f061a63b5eb4e3b59f6defec6ccb247b581735cf --- /dev/null +++ b/relay/channel/jimeng/image.go @@ -0,0 +1,89 @@ +package jimeng + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/service" + "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.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + + err = json.Unmarshal(responseBody, &jimengResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // 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 0000000000000000000000000000000000000000..8d18f45d69c876385ecb32336dfdadf006a54867 --- /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/logger" + "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 + } + logger.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 0000000000000000000000000000000000000000..8e4cf7cb918aaa25a9519db3faf7bf7a01041513 --- /dev/null +++ b/relay/channel/jina/adaptor.go @@ -0,0 +1,98 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeEmbeddings { + return fmt.Sprintf("%s/v1/embeddings", info.ChannelBaseUrl), 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) { + request.EncodingFormat = "" + 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 0000000000000000000000000000000000000000..009cfaf27d6e8f0a7275c76f544da9429232e872 --- /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 0000000000000000000000000000000000000000..783296fb6b2eec274a8a14c9b9527645c797aa13 --- /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 0000000000000000000000000000000000000000..12b390c897bfd2f84c1b5e703291279f40110dc7 --- /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 0000000000000000000000000000000000000000..9a1a8180a50b964300139336361c18b14afeea86 --- /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 0000000000000000000000000000000000000000..9581ba85a3623bffeb7db1aa94e050fdfc78ff54 --- /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.ChannelBaseUrl), nil +} diff --git a/relay/channel/mistral/adaptor.go b/relay/channel/mistral/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..b8fe1890f8c85f70c4c6338480be9eacf8d7fcd0 --- /dev/null +++ b/relay/channel/mistral/adaptor.go @@ -0,0 +1,93 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl, 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 0000000000000000000000000000000000000000..2fb408dbac7b72e26b9d17eb1ce9f60535993cfa --- /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 0000000000000000000000000000000000000000..0545e5e56f2243e978609e60f6ae2e93c44d9430 --- /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.GetMaxTokens(), + 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 0000000000000000000000000000000000000000..bdb7d21ea386f1ee7a7c5105715e2c129570ea5a --- /dev/null +++ b/relay/channel/mokaai/adaptor.go @@ -0,0 +1,111 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl, 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 0000000000000000000000000000000000000000..01eb60e7c913e8dc31bbec213732c6eb09f08b2c --- /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" diff --git a/relay/channel/mokaai/relay-mokaai.go b/relay/channel/mokaai/relay-mokaai.go new file mode 100644 index 0000000000000000000000000000000000000000..97c210e457268136ec23e38ea90d07275607667d --- /dev/null +++ b/relay/channel/mokaai/relay-mokaai.go @@ -0,0 +1,83 @@ +package mokaai + +import ( + "encoding/json" + "io" + "net/http" + "one-api/common" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/service" + "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) + } + service.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) + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return &fullTextResponse.Usage, nil +} diff --git a/relay/channel/moonshot/adaptor.go b/relay/channel/moonshot/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..d4d047e55263e1359d755e5fc5fb2c1f1abd5409 --- /dev/null +++ b/relay/channel/moonshot/adaptor.go @@ -0,0 +1,110 @@ +package moonshot + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/claude" + "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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := claude.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not supported") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertImageRequest(c, info, request) +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch info.RelayFormat { + case types.RelayFormatClaude: + return fmt.Sprintf("%s/anthropic/v1/messages", info.ChannelBaseUrl), nil + default: + if info.RelayMode == constant.RelayModeRerank { + return fmt.Sprintf("%s/v1/rerank", info.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeEmbeddings { + return fmt.Sprintf("%s/v1/embeddings", info.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeChatCompletions { + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeCompletions { + return fmt.Sprintf("%s/v1/completions", info.ChannelBaseUrl), nil + } + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), 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 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.RelayFormat { + case types.RelayFormatClaude: + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } else { + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + default: + adaptor := openai.Adaptor{} + return adaptor.DoResponse(c, resp, info) + } +} + +func (a *Adaptor) GetModelList() []string { + return ModelList +} + +func (a *Adaptor) GetChannelName() string { + return ChannelName +} diff --git a/relay/channel/moonshot/constants.go b/relay/channel/moonshot/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..6bb77a44f48476f0edf782304240565b621ffab3 --- /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 0000000000000000000000000000000000000000..e7b7ffabfacb71f065c637a81744a019512b7538 --- /dev/null +++ b/relay/channel/ollama/adaptor.go @@ -0,0 +1,96 @@ +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" + "strings" + + "github.com/gin-gonic/gin" +) + +type Adaptor struct { +} + +func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { return nil, errors.New("not implemented") } + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { + openaiAdaptor := openai.Adaptor{} + openaiRequest, err := openaiAdaptor.ConvertClaudeRequest(c, info, request) + if err != nil { + return nil, err + } + openaiRequest.(*dto.GeneralOpenAIRequest).StreamOptions = &dto.StreamOptions{ + IncludeUsage: true, + } + // map to ollama chat request (Claude -> OpenAI -> Ollama chat) + return openAIChatToOllamaChat(c, openaiRequest.(*dto.GeneralOpenAIRequest)) +} + +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) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { 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 == relayconstant.RelayModeEmbeddings { return info.ChannelBaseUrl + "/api/embed", nil } + if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { return info.ChannelBaseUrl + "/api/generate", nil } + return info.ChannelBaseUrl + "/api/chat", 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") } + // decide generate or chat + if strings.Contains(info.RequestURLPath, "/v1/completions") || info.RelayMode == relayconstant.RelayModeCompletions { + return openAIToGenerate(c, request) + } + return openAIChatToOllamaChat(c, 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) { 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 relayconstant.RelayModeEmbeddings: + return ollamaEmbeddingHandler(c, info, resp) + default: + if info.IsStream { + return ollamaStreamHandler(c, info, resp) + } + return ollamaChatHandler(c, info, resp) + } +} + +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 0000000000000000000000000000000000000000..e4f5702c26da2a237bf5aca6bb9b9c7f816e40c5 --- /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 0000000000000000000000000000000000000000..01758ab6e625bef954ce0dec6e09cf7d407f774c --- /dev/null +++ b/relay/channel/ollama/dto.go @@ -0,0 +1,70 @@ +package ollama + +import ( + "encoding/json" +) + +type OllamaChatMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + Images []string `json:"images,omitempty"` + ToolCalls []OllamaToolCall `json:"tool_calls,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Thinking json.RawMessage `json:"thinking,omitempty"` +} + +type OllamaToolFunction struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters interface{} `json:"parameters,omitempty"` +} + +type OllamaTool struct { + Type string `json:"type"` + Function OllamaToolFunction `json:"function"` +} + +type OllamaToolCall struct { + Function struct { + Name string `json:"name"` + Arguments interface{} `json:"arguments"` + } `json:"function"` +} + +type OllamaChatRequest struct { + Model string `json:"model"` + Messages []OllamaChatMessage `json:"messages"` + Tools interface{} `json:"tools,omitempty"` + Format interface{} `json:"format,omitempty"` + Stream bool `json:"stream,omitempty"` + Options map[string]any `json:"options,omitempty"` + KeepAlive interface{} `json:"keep_alive,omitempty"` + Think json.RawMessage `json:"think,omitempty"` +} + +type OllamaGenerateRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt,omitempty"` + Suffix string `json:"suffix,omitempty"` + Images []string `json:"images,omitempty"` + Format interface{} `json:"format,omitempty"` + Stream bool `json:"stream,omitempty"` + Options map[string]any `json:"options,omitempty"` + KeepAlive interface{} `json:"keep_alive,omitempty"` + Think json.RawMessage `json:"think,omitempty"` +} + +type OllamaEmbeddingRequest struct { + Model string `json:"model"` + Input interface{} `json:"input"` + Options map[string]any `json:"options,omitempty"` + Dimensions int `json:"dimensions,omitempty"` +} + +type OllamaEmbeddingResponse struct { + Error string `json:"error,omitempty"` + Model string `json:"model"` + Embeddings [][]float64 `json:"embeddings"` + PromptEvalCount int `json:"prompt_eval_count,omitempty"` +} + diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go new file mode 100644 index 0000000000000000000000000000000000000000..359dfd4454b728c8fa190a90b8a947b29fa07ffa --- /dev/null +++ b/relay/channel/ollama/relay-ollama.go @@ -0,0 +1,190 @@ +package ollama + +import ( + "encoding/json" + "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 openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaChatRequest, error) { + chatReq := &OllamaChatRequest{ + Model: r.Model, + Stream: r.Stream, + Options: map[string]any{}, + Think: r.Think, + } + if r.ResponseFormat != nil { + if r.ResponseFormat.Type == "json" { + chatReq.Format = "json" + } else if r.ResponseFormat.Type == "json_schema" { + if len(r.ResponseFormat.JsonSchema) > 0 { + var schema any + _ = json.Unmarshal(r.ResponseFormat.JsonSchema, &schema) + chatReq.Format = schema + } + } + } + + // options mapping + if r.Temperature != nil { chatReq.Options["temperature"] = r.Temperature } + if r.TopP != 0 { chatReq.Options["top_p"] = r.TopP } + if r.TopK != 0 { chatReq.Options["top_k"] = r.TopK } + if r.FrequencyPenalty != 0 { chatReq.Options["frequency_penalty"] = r.FrequencyPenalty } + if r.PresencePenalty != 0 { chatReq.Options["presence_penalty"] = r.PresencePenalty } + if r.Seed != 0 { chatReq.Options["seed"] = int(r.Seed) } + if mt := r.GetMaxTokens(); mt != 0 { chatReq.Options["num_predict"] = int(mt) } + + if r.Stop != nil { + switch v := r.Stop.(type) { + case string: + chatReq.Options["stop"] = []string{v} + case []string: + chatReq.Options["stop"] = v + case []any: + arr := make([]string,0,len(v)) + for _, i := range v { if s,ok:=i.(string); ok { arr = append(arr,s) } } + if len(arr)>0 { chatReq.Options["stop"] = arr } + } + } + + if len(r.Tools) > 0 { + tools := make([]OllamaTool,0,len(r.Tools)) + for _, t := range r.Tools { + tools = append(tools, OllamaTool{Type: "function", Function: OllamaToolFunction{Name: t.Function.Name, Description: t.Function.Description, Parameters: t.Function.Parameters}}) + } + chatReq.Tools = tools + } + + chatReq.Messages = make([]OllamaChatMessage,0,len(r.Messages)) + for _, m := range r.Messages { + var textBuilder strings.Builder + var images []string + if m.IsStringContent() { + textBuilder.WriteString(m.StringContent()) + } else { + parts := m.ParseContent() + for _, part := range parts { + if part.Type == dto.ContentTypeImageURL { + img := part.GetImageMedia() + if img != nil && img.Url != "" { + var base64Data string + if strings.HasPrefix(img.Url, "http") { + fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat") + if err != nil { return nil, err } + base64Data = fileData.Base64Data + } else if strings.HasPrefix(img.Url, "data:") { + if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) { base64Data = img.Url[idx+1:] } + } else { + base64Data = img.Url + } + if base64Data != "" { images = append(images, base64Data) } + } + } else if part.Type == dto.ContentTypeText { + textBuilder.WriteString(part.Text) + } + } + } + cm := OllamaChatMessage{Role: m.Role, Content: textBuilder.String()} + if len(images)>0 { cm.Images = images } + if m.Role == "tool" && m.Name != nil { cm.ToolName = *m.Name } + if m.ToolCalls != nil && len(m.ToolCalls) > 0 { + parsed := m.ParseToolCalls() + if len(parsed) > 0 { + calls := make([]OllamaToolCall,0,len(parsed)) + for _, tc := range parsed { + var args interface{} + if tc.Function.Arguments != "" { _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) } + if args==nil { args = map[string]any{} } + oc := OllamaToolCall{} + oc.Function.Name = tc.Function.Name + oc.Function.Arguments = args + calls = append(calls, oc) + } + cm.ToolCalls = calls + } + } + chatReq.Messages = append(chatReq.Messages, cm) + } + return chatReq, nil +} + +// openAIToGenerate converts OpenAI completions request to Ollama generate +func openAIToGenerate(c *gin.Context, r *dto.GeneralOpenAIRequest) (*OllamaGenerateRequest, error) { + gen := &OllamaGenerateRequest{ + Model: r.Model, + Stream: r.Stream, + Options: map[string]any{}, + Think: r.Think, + } + // Prompt may be in r.Prompt (string or []any) + if r.Prompt != nil { + switch v := r.Prompt.(type) { + case string: + gen.Prompt = v + case []any: + var sb strings.Builder + for _, it := range v { if s,ok:=it.(string); ok { sb.WriteString(s) } } + gen.Prompt = sb.String() + default: + gen.Prompt = fmt.Sprintf("%v", r.Prompt) + } + } + if r.Suffix != nil { if s,ok:=r.Suffix.(string); ok { gen.Suffix = s } } + if r.ResponseFormat != nil { + if r.ResponseFormat.Type == "json" { gen.Format = "json" } else if r.ResponseFormat.Type == "json_schema" { var schema any; _ = json.Unmarshal(r.ResponseFormat.JsonSchema,&schema); gen.Format=schema } + } + if r.Temperature != nil { gen.Options["temperature"] = r.Temperature } + if r.TopP != 0 { gen.Options["top_p"] = r.TopP } + if r.TopK != 0 { gen.Options["top_k"] = r.TopK } + if r.FrequencyPenalty != 0 { gen.Options["frequency_penalty"] = r.FrequencyPenalty } + if r.PresencePenalty != 0 { gen.Options["presence_penalty"] = r.PresencePenalty } + if r.Seed != 0 { gen.Options["seed"] = int(r.Seed) } + if mt := r.GetMaxTokens(); mt != 0 { gen.Options["num_predict"] = int(mt) } + if r.Stop != nil { + switch v := r.Stop.(type) { + case string: gen.Options["stop"] = []string{v} + case []string: gen.Options["stop"] = v + case []any: arr:=make([]string,0,len(v)); for _,i:= range v { if s,ok:=i.(string); ok { arr=append(arr,s) } }; if len(arr)>0 { gen.Options["stop"]=arr } + } + } + return gen, nil +} + +func requestOpenAI2Embeddings(r dto.EmbeddingRequest) *OllamaEmbeddingRequest { + opts := map[string]any{} + if r.Temperature != nil { opts["temperature"] = r.Temperature } + if r.TopP != 0 { opts["top_p"] = r.TopP } + if r.FrequencyPenalty != 0 { opts["frequency_penalty"] = r.FrequencyPenalty } + if r.PresencePenalty != 0 { opts["presence_penalty"] = r.PresencePenalty } + if r.Seed != 0 { opts["seed"] = int(r.Seed) } + if r.Dimensions != 0 { opts["dimensions"] = r.Dimensions } + input := r.ParseInput() + if len(input)==1 { return &OllamaEmbeddingRequest{Model:r.Model, Input: input[0], Options: opts, Dimensions:r.Dimensions} } + return &OllamaEmbeddingRequest{Model:r.Model, Input: input, Options: opts, Dimensions:r.Dimensions} +} + +func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + var oResp OllamaEmbeddingResponse + body, err := io.ReadAll(resp.Body) + if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } + service.CloseResponseBodyGracefully(resp) + if err = common.Unmarshal(body, &oResp); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } + if oResp.Error != "" { return nil, types.NewOpenAIError(fmt.Errorf("ollama error: %s", oResp.Error), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } + data := make([]dto.OpenAIEmbeddingResponseItem,0,len(oResp.Embeddings)) + for i, emb := range oResp.Embeddings { data = append(data, dto.OpenAIEmbeddingResponseItem{Index:i,Object:"embedding",Embedding:emb}) } + usage := &dto.Usage{PromptTokens: oResp.PromptEvalCount, CompletionTokens:0, TotalTokens: oResp.PromptEvalCount} + embResp := &dto.OpenAIEmbeddingResponse{Object:"list", Data:data, Model: info.UpstreamModelName, Usage:*usage} + out, _ := common.Marshal(embResp) + service.IOCopyBytesGracefully(c, resp, out) + return usage, nil +} + diff --git a/relay/channel/ollama/stream.go b/relay/channel/ollama/stream.go new file mode 100644 index 0000000000000000000000000000000000000000..7b325c5d51829cd95c25d5187e3c3849fcae705e --- /dev/null +++ b/relay/channel/ollama/stream.go @@ -0,0 +1,210 @@ +package ollama + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/logger" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +type ollamaChatStreamChunk struct { + Model string `json:"model"` + CreatedAt string `json:"created_at"` + // chat + Message *struct { + Role string `json:"role"` + Content string `json:"content"` + Thinking json.RawMessage `json:"thinking"` + ToolCalls []struct { + Function struct { + Name string `json:"name"` + Arguments interface{} `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + // generate + Response string `json:"response"` + Done bool `json:"done"` + DoneReason string `json:"done_reason"` + TotalDuration int64 `json:"total_duration"` + LoadDuration int64 `json:"load_duration"` + PromptEvalCount int `json:"prompt_eval_count"` + EvalCount int `json:"eval_count"` + PromptEvalDuration int64 `json:"prompt_eval_duration"` + EvalDuration int64 `json:"eval_duration"` +} + +func toUnix(ts string) int64 { + if ts == "" { return time.Now().Unix() } + // try time.RFC3339 or with nanoseconds + t, err := time.Parse(time.RFC3339Nano, ts) + if err != nil { t2, err2 := time.Parse(time.RFC3339, ts); if err2==nil { return t2.Unix() }; return time.Now().Unix() } + return t.Unix() +} + +func ollamaStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + if resp == nil || resp.Body == nil { return nil, types.NewOpenAIError(fmt.Errorf("empty response"), types.ErrorCodeBadResponse, http.StatusBadRequest) } + defer service.CloseResponseBodyGracefully(resp) + + helper.SetEventStreamHeaders(c) + scanner := bufio.NewScanner(resp.Body) + usage := &dto.Usage{} + var model = info.UpstreamModelName + var responseId = common.GetUUID() + var created = time.Now().Unix() + var toolCallIndex int + start := helper.GenerateStartEmptyResponse(responseId, created, model, nil) + if data, err := common.Marshal(start); err == nil { _ = helper.StringData(c, string(data)) } + + for scanner.Scan() { + line := scanner.Text() + line = strings.TrimSpace(line) + if line == "" { continue } + var chunk ollamaChatStreamChunk + if err := json.Unmarshal([]byte(line), &chunk); err != nil { + logger.LogError(c, "ollama stream json decode error: "+err.Error()+" line="+line) + return usage, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if chunk.Model != "" { model = chunk.Model } + created = toUnix(chunk.CreatedAt) + + if !chunk.Done { + // delta content + var content string + if chunk.Message != nil { content = chunk.Message.Content } else { content = chunk.Response } + delta := dto.ChatCompletionsStreamResponse{ + Id: responseId, + Object: "chat.completion.chunk", + Created: created, + Model: model, + Choices: []dto.ChatCompletionsStreamResponseChoice{ { + Index: 0, + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ Role: "assistant" }, + } }, + } + if content != "" { delta.Choices[0].Delta.SetContentString(content) } + if chunk.Message != nil && len(chunk.Message.Thinking) > 0 { + raw := strings.TrimSpace(string(chunk.Message.Thinking)) + if raw != "" && raw != "null" { delta.Choices[0].Delta.SetReasoningContent(raw) } + } + // tool calls + if chunk.Message != nil && len(chunk.Message.ToolCalls) > 0 { + delta.Choices[0].Delta.ToolCalls = make([]dto.ToolCallResponse,0,len(chunk.Message.ToolCalls)) + for _, tc := range chunk.Message.ToolCalls { + // arguments -> string + argBytes, _ := json.Marshal(tc.Function.Arguments) + toolId := fmt.Sprintf("call_%d", toolCallIndex) + tr := dto.ToolCallResponse{ID:toolId, Type:"function", Function: dto.FunctionResponse{Name: tc.Function.Name, Arguments: string(argBytes)}} + tr.SetIndex(toolCallIndex) + toolCallIndex++ + delta.Choices[0].Delta.ToolCalls = append(delta.Choices[0].Delta.ToolCalls, tr) + } + } + if data, err := common.Marshal(delta); err == nil { _ = helper.StringData(c, string(data)) } + continue + } + // done frame + // finalize once and break loop + usage.PromptTokens = chunk.PromptEvalCount + usage.CompletionTokens = chunk.EvalCount + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + finishReason := chunk.DoneReason + if finishReason == "" { finishReason = "stop" } + // emit stop delta + if stop := helper.GenerateStopResponse(responseId, created, model, finishReason); stop != nil { + if data, err := common.Marshal(stop); err == nil { _ = helper.StringData(c, string(data)) } + } + // emit usage frame + if final := helper.GenerateFinalUsageResponse(responseId, created, model, *usage); final != nil { + if data, err := common.Marshal(final); err == nil { _ = helper.StringData(c, string(data)) } + } + // send [DONE] + helper.Done(c) + break + } + if err := scanner.Err(); err != nil && err != io.EOF { logger.LogError(c, "ollama stream scan error: "+err.Error()) } + return usage, nil +} + +// non-stream handler for chat/generate +func ollamaChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + body, err := io.ReadAll(resp.Body) + if err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) } + service.CloseResponseBodyGracefully(resp) + raw := string(body) + if common.DebugEnabled { println("ollama non-stream raw resp:", raw) } + + lines := strings.Split(raw, "\n") + var ( + aggContent strings.Builder + reasoningBuilder strings.Builder + lastChunk ollamaChatStreamChunk + parsedAny bool + ) + for _, ln := range lines { + ln = strings.TrimSpace(ln) + if ln == "" { continue } + var ck ollamaChatStreamChunk + if err := json.Unmarshal([]byte(ln), &ck); err != nil { + if len(lines) == 1 { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } + continue + } + parsedAny = true + lastChunk = ck + if ck.Message != nil && len(ck.Message.Thinking) > 0 { + raw := strings.TrimSpace(string(ck.Message.Thinking)) + if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } + } + if ck.Message != nil && ck.Message.Content != "" { aggContent.WriteString(ck.Message.Content) } else if ck.Response != "" { aggContent.WriteString(ck.Response) } + } + + if !parsedAny { + var single ollamaChatStreamChunk + if err := json.Unmarshal(body, &single); err != nil { return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) } + lastChunk = single + if single.Message != nil { + if len(single.Message.Thinking) > 0 { raw := strings.TrimSpace(string(single.Message.Thinking)); if raw != "" && raw != "null" { reasoningBuilder.WriteString(raw) } } + aggContent.WriteString(single.Message.Content) + } else { aggContent.WriteString(single.Response) } + } + + model := lastChunk.Model + if model == "" { model = info.UpstreamModelName } + created := toUnix(lastChunk.CreatedAt) + usage := &dto.Usage{PromptTokens: lastChunk.PromptEvalCount, CompletionTokens: lastChunk.EvalCount, TotalTokens: lastChunk.PromptEvalCount + lastChunk.EvalCount} + content := aggContent.String() + finishReason := lastChunk.DoneReason + if finishReason == "" { finishReason = "stop" } + + msg := dto.Message{Role: "assistant", Content: contentPtr(content)} + if rc := reasoningBuilder.String(); rc != "" { msg.ReasoningContent = rc } + full := dto.OpenAITextResponse{ + Id: common.GetUUID(), + Model: model, + Object: "chat.completion", + Created: created, + Choices: []dto.OpenAITextResponseChoice{ { + Index: 0, + Message: msg, + FinishReason: finishReason, + } }, + Usage: *usage, + } + out, _ := common.Marshal(full) + service.IOCopyBytesGracefully(c, resp, out) + return usage, nil +} + +func contentPtr(s string) *string { if s=="" { return nil }; return &s } diff --git a/relay/channel/openai/adaptor.go b/relay/channel/openai/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..adea21f76b70ead34e4a0755a0577327b6b0b014 --- /dev/null +++ b/relay/channel/openai/adaptor.go @@ -0,0 +1,627 @@ +package openai + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "one-api/common" + "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/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 +} + +// parseReasoningEffortFromModelSuffix 从模型名称中解析推理级别 +// support OAI models: o1-mini/o3-mini/o4-mini/o1/o3 etc... +// minimal effort only available in gpt-5 +func parseReasoningEffortFromModelSuffix(model string) (string, string) { + effortSuffixes := []string{"-high", "-minimal", "-low", "-medium"} + for _, suffix := range effortSuffixes { + if strings.HasSuffix(model, suffix) { + effort := strings.TrimPrefix(suffix, "-") + originModel := strings.TrimSuffix(model, suffix) + return effort, originModel + } + } + return "", model +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + // 使用 service.GeminiToOpenAIRequest 转换请求格式 + openaiRequest, err := service.GeminiToOpenAIRequest(request, info) + if err != nil { + return nil, err + } + return a.ConvertOpenAIRequest(c, info, openaiRequest) +} + +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) + //} + //if common.DebugEnabled { + // bodyBytes := []byte(common.GetJsonString(request)) + // err := os.WriteFile(fmt.Sprintf("claude_request_%s.txt", c.GetString(common.RequestIdKey)), bodyBytes, 0644) + // if err != nil { + // println(fmt.Sprintf("failed to save request body to file: %v", err)) + // } + //} + aiRequest, err := service.ClaudeToOpenAIRequest(*request, info) + if err != nil { + return nil, err + } + //if common.DebugEnabled { + // println(fmt.Sprintf("convert claude to openai request result: %s", common.GetJsonString(aiRequest))) + // // Save request body to file for debugging + // bodyBytes := []byte(common.GetJsonString(aiRequest)) + // err = os.WriteFile(fmt.Sprintf("claude_to_openai_request_%s.txt", c.GetString(common.RequestIdKey)), bodyBytes, 0644) + // if err != nil { + // println(fmt.Sprintf("failed to save request body to file: %v", err)) + // } + //} + if info.SupportStreamOptions && info.IsStream { + 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.RelayMode == relayconstant.RelayModeRealtime { + if strings.HasPrefix(info.ChannelBaseUrl, "https://") { + baseUrl := strings.TrimPrefix(info.ChannelBaseUrl, "https://") + baseUrl = "wss://" + baseUrl + info.ChannelBaseUrl = baseUrl + } else if strings.HasPrefix(info.ChannelBaseUrl, "http://") { + baseUrl := strings.TrimPrefix(info.ChannelBaseUrl, "http://") + baseUrl = "ws://" + baseUrl + info.ChannelBaseUrl = 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/") + + if info.RelayFormat == types.RelayFormatClaude { + task = strings.TrimPrefix(task, "messages") + task = "chat/completions" + task + } + + // 特殊处理 responses API + if info.RelayMode == relayconstant.RelayModeResponses { + responsesApiVersion := "preview" + + subUrl := "/openai/v1/responses" + if strings.Contains(info.ChannelBaseUrl, "cognitiveservices.azure.com") { + subUrl = "/openai/responses" + responsesApiVersion = apiVersion + } + + if info.ChannelOtherSettings.AzureResponsesVersion != "" { + responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion + } + + requestURL = fmt.Sprintf("%s?api-version=%s", subUrl, responsesApiVersion) + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, 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.ChannelBaseUrl, requestURL, info.ChannelType), nil + case constant.ChannelTypeMiniMax: + return minimax.GetRequestURL(info) + case constant.ChannelTypeCustom: + url := info.ChannelBaseUrl + url = strings.Replace(url, "{model}", info.UpstreamModelName, -1) + return url, nil + default: + if info.RelayFormat == types.RelayFormatClaude || info.RelayFormat == types.RelayFormatGemini { + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil + } + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, 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}`) + } + // 适配 OpenRouter 的 thinking 后缀 + if strings.HasSuffix(info.UpstreamModelName, "-thinking") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") + request.Model = info.UpstreamModelName + if len(request.Reasoning) == 0 { + reasoning := map[string]any{ + "enabled": true, + } + if request.ReasoningEffort != "" && request.ReasoningEffort != "none" { + reasoning["effort"] = request.ReasoningEffort + } + marshal, err := common.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("error marshalling reasoning: %w", err) + } + request.Reasoning = marshal + } + // 清空多余的ReasoningEffort + request.ReasoningEffort = "" + } else { + if len(request.Reasoning) == 0 { + // 适配 OpenAI 的 ReasoningEffort 格式 + if request.ReasoningEffort != "" { + reasoning := map[string]any{ + "enabled": true, + } + if request.ReasoningEffort != "none" { + reasoning["effort"] = request.ReasoningEffort + marshal, err := common.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("error marshalling reasoning: %w", err) + } + request.Reasoning = marshal + } + } + } + request.ReasoningEffort = "" + } + + // https://docs.anthropic.com/en/api/openai-sdk#extended-thinking-support + // 没有做排除3.5Haiku等,要出问题再加吧,最佳兼容性(不是 + if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") { + var thinking dto.Thinking // Claude标准Thinking格式 + if err := json.Unmarshal(request.THINKING, &thinking); err != nil { + return nil, fmt.Errorf("error Unmarshal thinking: %w", err) + } + + // 只有当 thinking.Type 是 "enabled" 时才处理 + if thinking.Type == "enabled" { + // 检查 BudgetTokens 是否为 nil + if thinking.BudgetTokens == nil { + return nil, fmt.Errorf("BudgetTokens is nil when thinking is enabled") + } + + reasoning := openrouter.RequestReasoning{ + MaxTokens: *thinking.BudgetTokens, + } + + marshal, err := common.Marshal(reasoning) + if err != nil { + return nil, fmt.Errorf("error marshalling reasoning: %w", err) + } + + request.Reasoning = marshal + } + + // 清空 THINKING + request.THINKING = nil + } + + } + if strings.HasPrefix(info.UpstreamModelName, "o") || strings.HasPrefix(info.UpstreamModelName, "gpt-5") { + if request.MaxCompletionTokens == 0 && request.MaxTokens != 0 { + request.MaxCompletionTokens = request.MaxTokens + request.MaxTokens = 0 + } + + if strings.HasPrefix(info.UpstreamModelName, "o") { + request.Temperature = nil + } + + if strings.HasPrefix(info.UpstreamModelName, "gpt-5") { + if info.UpstreamModelName != "gpt-5-chat-latest" { + request.Temperature = nil + } + } + + // 转换模型推理力度后缀 + effort, originModel := parseReasoningEffortFromModelSuffix(info.UpstreamModelName) + if effort != "" { + request.ReasoningEffort = effort + info.UpstreamModelName = originModel + request.Model = originModel + } + + info.ReasoningEffort = request.ReasoningEffort + + // o系列模型developer适配(o1-mini除外) + if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "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) + // 使用已解析的 multipart 表单,避免重复解析 + mf := c.Request.MultipartForm + if mf == nil { + if _, err := c.MultipartForm(); err != nil { + return nil, errors.New("failed to parse multipart form") + } + mf = c.Request.MultipartForm + } + + // 写入所有非文件字段 + if mf != nil { + for key, values := range mf.Value { + if key == "model" { + continue + } + for _, value := range values { + writer.WriteField(key, value) + } + } + } + + if mf != nil && mf.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 = mf.File["image"]; !exists || len(imageFiles) == 0 { + // If not found, check for "image[]" field + if imageFiles, exists = mf.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 mf.File { + if strings.HasPrefix(fieldName, "image[") && len(files) > 0 { + foundArrayImages = true + imageFiles = append(imageFiles, files...) + } + } + + // 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) + } + + // 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) + } + + // 复制完立即关闭,避免在循环内使用 defer 占用资源 + _ = file.Close() + } + + // Handle mask file if present + if maskFiles, exists := mf.File["mask"]; exists && len(maskFiles) > 0 { + maskFile, err := maskFiles[0].Open() + if err != nil { + return nil, errors.New("failed to open mask file") + } + // 复制完立即关闭,避免在循环内使用 defer 占用资源 + + // 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") + } + _ = maskFile.Close() + } + } else { + return nil, errors.New("no multipart form data found") + } + + // 关闭 multipart 编写器以设置分界线 + writer.Close() + c.Request.Header.Set("Content-Type", writer.FormDataContentType()) + return &requestBody, 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) { + // 转换模型推理力度后缀 + effort, originModel := parseReasoningEffortFromModelSuffix(request.Model) + if effort != "" { + if request.Reasoning == nil { + request.Reasoning = &dto.Reasoning{ + Effort: effort, + } + } else { + request.Reasoning.Effort = effort + } + request.Model = originModel + } + 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.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.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 0000000000000000000000000000000000000000..1ae9efcdfc2f6a1485390cdeb17988f09f1ecb23 --- /dev/null +++ b/relay/channel/openai/constant.go @@ -0,0 +1,47 @@ +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", + "gpt-4.1", "gpt-4.1-2025-04-14", + "gpt-4.1-mini", "gpt-4.1-mini-2025-04-14", + "gpt-4.1-nano", "gpt-4.1-nano-2025-04-14", + "o1", "o1-2024-12-17", + "o1-preview", "o1-preview-2024-09-12", + "o1-mini", "o1-mini-2024-09-12", + "o1-pro", "o1-pro-2025-03-19", + "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", + "o3", "o3-2025-04-16", + "o3-pro", "o3-pro-2025-06-10", + "o3-deep-research", "o3-deep-research-2025-06-26", + "o4-mini", "o4-mini-2025-04-16", + "o4-mini-deep-research", "o4-mini-deep-research-2025-06-26", + "gpt-5", "gpt-5-2025-08-07", "gpt-5-chat-latest", + "gpt-5-mini", "gpt-5-mini-2025-08-07", + "gpt-5-nano", "gpt-5-nano-2025-08-07", + "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", "gpt-image-1", + "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 0000000000000000000000000000000000000000..c5ffe65552608f2ce85666c187808210b4045991 --- /dev/null +++ b/relay/channel/openai/helper.go @@ -0,0 +1,260 @@ +package openai + +import ( + "encoding/json" + "one-api/common" + "one-api/dto" + "one-api/logger" + relaycommon "one-api/relay/common" + relayconstant "one-api/relay/constant" + "one-api/relay/helper" + "one-api/service" + "one-api/types" + "strings" + + "github.com/samber/lo" + + "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 types.RelayFormatOpenAI: + return sendStreamData(c, info, data, forceFormat, thinkToContent) + case types.RelayFormatClaude: + return handleClaudeFormat(c, data, info) + case types.RelayFormatGemini: + return handleGeminiFormat(c, data, info) + } + return nil +} + +func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.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 handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo) error { + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(data), &streamResponse); err != nil { + logger.LogError(c, "failed to unmarshal stream response: "+err.Error()) + return err + } + + geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info) + + // 如果返回 nil,表示没有实际内容,跳过发送 + if geminiResponse == nil { + return nil + } + + geminiResponseStr, err := common.Marshal(geminiResponse) + if err != nil { + logger.LogError(c, "failed to marshal gemini response: "+err.Error()) + return err + } + + // send gemini format response + c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)}) + _ = helper.FlushWriter(c) + 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.SysLog("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.SysLog("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.SysLog("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 = lo.SomeBy(lastStreamResponse.Choices, func(choice dto.ChatCompletionsStreamResponseChoice) bool { + return choice.Delta.GetContentString() != "" || choice.Delta.GetReasoningContent() != "" + }) + } + } + + 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 types.RelayFormatOpenAI: + if info.ShouldIncludeUsage && !containStreamUsage { + response := helper.GenerateFinalUsageResponse(responseId, createAt, model, *usage) + response.SetSystemFingerprint(systemFingerprint) + helper.ObjectData(c, response) + } + helper.Done(c) + + case types.RelayFormatClaude: + info.ClaudeConvertInfo.Done = true + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + return + } + + info.ClaudeConvertInfo.Usage = usage + + claudeResponses := service.StreamResponseOpenAI2Claude(&streamResponse, info) + for _, resp := range claudeResponses { + _ = helper.ClaudeData(c, *resp) + } + + case types.RelayFormatGemini: + var streamResponse dto.ChatCompletionsStreamResponse + if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + return + } + + // 这里处理的是 openai 最后一个流响应,其 delta 为空,有 finish_reason 字段 + // 因此相比较于 google 官方的流响应,由 openai 转换而来会多一个 parts 为空,finishReason 为 STOP 的响应 + // 而包含最后一段文本输出的响应(倒数第二个)的 finishReason 为 null + // 暂不知是否有程序会不兼容。 + + geminiResponse := service.StreamResponseOpenAI2Gemini(&streamResponse, info) + + // openai 流响应开头的空数据 + if geminiResponse == nil { + return + } + + geminiResponseStr, err := common.Marshal(geminiResponse) + if err != nil { + common.SysLog("error marshalling gemini response: " + err.Error()) + return + } + + // 发送最终的 Gemini 响应 + c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)}) + _ = helper.FlushWriter(c) + } +} + +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 0000000000000000000000000000000000000000..6bc2c93693b2759fcc9d37038cfe72345e262e7b --- /dev/null +++ b/relay/channel/openai/relay-openai.go @@ -0,0 +1,635 @@ +package openai + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "math" + "mime/multipart" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/logger" + "one-api/relay/channel/openrouter" + 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 { + logger.LogError(c, "invalid response or response body") + return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError) + } + + defer service.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 lastStreamData string + + helper.StreamScannerHandler(c, resp, info, func(data string) bool { + if lastStreamData != "" { + err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + if err != nil { + common.SysLog("error handling stream format: " + err.Error()) + } + } + if len(data) > 0 { + lastStreamData = data + streamItems = append(streamItems, data) + } + return true + }) + + // 处理最后的响应 + shouldSendLastResp := true + if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage, + &containStreamUsage, info, &shouldSendLastResp); err != nil { + logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData)) + } + + if info.RelayFormat == types.RelayFormatOpenAI { + if shouldSendLastResp { + _ = sendStreamData(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) + } + } + + // 处理token计算 + if err := processTokens(info.RelayMode, streamItems, &responseTextBuilder, &toolCount); err != nil { + logger.LogError(c, "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 service.CloseResponseBodyGracefully(resp) + + var simpleResponse dto.OpenAITextResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + if common.DebugEnabled { + println("upstream response body:", string(responseBody)) + } + // Unmarshal to simpleResponse + if info.ChannelType == constant.ChannelTypeOpenRouter && info.ChannelOtherSettings.IsOpenRouterEnterprise() { + // 尝试解析为 openrouter enterprise + var enterpriseResponse openrouter.OpenRouterEnterpriseResponse + err = common.Unmarshal(responseBody, &enterpriseResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if enterpriseResponse.Success { + responseBody = enterpriseResponse.Data + } else { + logger.LogError(c, fmt.Sprintf("openrouter enterprise response success=false, data: %s", enterpriseResponse.Data)) + return nil, types.NewOpenAIError(fmt.Errorf("openrouter response success=false"), types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + } + + err = common.Unmarshal(responseBody, &simpleResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + if oaiError := simpleResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + } + + forceFormat := false + if info.ChannelSetting.ForceFormat { + forceFormat = true + } + + usageModified := false + if simpleResponse.Usage.PromptTokens == 0 { + completionTokens := simpleResponse.Usage.CompletionTokens + if 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, + } + usageModified = true + } + + switch info.RelayFormat { + case types.RelayFormatOpenAI: + if usageModified { + var bodyMap map[string]interface{} + err = common.Unmarshal(responseBody, &bodyMap) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + bodyMap["usage"] = simpleResponse.Usage + responseBody, _ = common.Marshal(bodyMap) + } + if forceFormat { + responseBody, err = common.Marshal(simpleResponse) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + } else { + break + } + case types.RelayFormatClaude: + claudeResp := service.ResponseOpenAI2Claude(&simpleResponse, info) + claudeRespStr, err := common.Marshal(claudeResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + responseBody = claudeRespStr + case types.RelayFormatGemini: + geminiResp := service.ResponseOpenAI2Gemini(&simpleResponse, info) + geminiRespStr, err := common.Marshal(geminiResp) + if err != nil { + return nil, types.NewError(err, types.ErrorCodeBadResponseBody) + } + responseBody = geminiRespStr + } + + service.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 service.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 { + logger.LogError(c, err.Error()) + } + return usage +} + +func OpenaiSTTHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo, responseFormat string) (*types.NewAPIError, *dto.Usage) { + defer service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError), nil + } + // 写入新的 response body + service.IOCopyBytesGracefully(c, resp, responseBody) + + var responseData struct { + Usage *dto.Usage `json:"usage"` + } + if err := json.Unmarshal(responseBody, &responseData); err == nil && responseData.Usage != nil { + if responseData.Usage.TotalTokens > 0 { + usage := responseData.Usage + if usage.PromptTokens == 0 { + usage.PromptTokens = usage.InputTokens + } + if usage.CompletionTokens == 0 { + usage.CompletionTokens = usage.OutputTokens + } + return nil, usage + } + } + + audioTokens, err := countAudioTokens(c) + if err != nil { + return types.NewError(err, types.ErrorCodeCountTokenFailed), nil + } + 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 + } + logger.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 + } + logger.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 + } + logger.LogInfo(c, fmt.Sprintf("realtime streaming sumUsage: %v", sumUsage)) + logger.LogInfo(c, fmt.Sprintf("realtime streaming localUsage: %v", localUsage)) + logger.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 + } + logger.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 + logger.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 service.CloseResponseBodyGracefully(resp) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + + var usageResp dto.SimpleResponse + err = common.Unmarshal(responseBody, &usageResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + + // 写入新的 response body + service.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 0000000000000000000000000000000000000000..78a0c2854cf0f85138d9c73790650dc2f6fcc74a --- /dev/null +++ b/relay/channel/openai/relay_responses.go @@ -0,0 +1,149 @@ +package openai + +import ( + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/logger" + 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 service.CloseResponseBodyGracefully(resp) + + // read response body + var responsesResponse dto.OpenAIResponsesResponse + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + err = common.Unmarshal(responseBody, &responsesResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + if oaiError := responsesResponse.GetOpenAIError(); oaiError != nil && oaiError.Type != "" { + return nil, types.WithOpenAIError(*oaiError, resp.StatusCode) + } + + if responsesResponse.HasImageGenerationCall() { + c.Set("image_generation_call", true) + c.Set("image_generation_call_quality", responsesResponse.GetQuality()) + c.Set("image_generation_call_size", responsesResponse.GetSize()) + } + + // 写入新的 response body + service.IOCopyBytesGracefully(c, resp, responseBody) + + // compute usage + usage := dto.Usage{} + if responsesResponse.Usage != nil { + usage.PromptTokens = responsesResponse.Usage.InputTokens + usage.CompletionTokens = responsesResponse.Usage.OutputTokens + usage.TotalTokens = responsesResponse.Usage.TotalTokens + if responsesResponse.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens + } + } + if info == nil || info.ResponsesUsageInfo == nil || info.ResponsesUsageInfo.BuiltInTools == nil { + return &usage, nil + } + // 解析 Tools 用量 + for _, tool := range responsesResponse.Tools { + buildToolinfo, ok := info.ResponsesUsageInfo.BuiltInTools[common.Interface2String(tool["type"])] + if !ok || buildToolinfo == nil { + logger.LogError(c, fmt.Sprintf("BuiltInTools not found for tool type: %v", tool["type"])) + continue + } + buildToolinfo.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 { + logger.LogError(c, "invalid response or response body") + return nil, types.NewError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse) + } + + defer service.CloseResponseBodyGracefully(resp) + + 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": + if streamResponse.Response != nil { + if streamResponse.Response.Usage != nil { + if streamResponse.Response.Usage.InputTokens != 0 { + usage.PromptTokens = streamResponse.Response.Usage.InputTokens + } + if streamResponse.Response.Usage.OutputTokens != 0 { + usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens + } + if streamResponse.Response.Usage.TotalTokens != 0 { + usage.TotalTokens = streamResponse.Response.Usage.TotalTokens + } + if streamResponse.Response.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens + } + } + if streamResponse.Response.HasImageGenerationCall() { + c.Set("image_generation_call", true) + c.Set("image_generation_call_quality", streamResponse.Response.GetQuality()) + c.Set("image_generation_call_size", streamResponse.Response.GetSize()) + } + } + case "response.output_text.delta": + // 处理输出文本 + responseTextBuilder.WriteString(streamResponse.Delta) + case dto.ResponsesOutputTypeItemDone: + // 函数调用处理 + if streamResponse.Item != nil { + switch streamResponse.Item.Type { + case dto.BuildInCallWebSearchCall: + if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil { + if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil { + webSearchTool.CallCount++ + } + } + } + } + } + } else { + logger.LogError(c, "failed to unmarshal stream response: "+err.Error()) + } + return true + }) + + if usage.CompletionTokens == 0 { + // 计算输出文本的 token 数量 + tempStr := responseTextBuilder.String() + if len(tempStr) > 0 { + // 非正常结束,使用输出文本的 token 数量 + completionTokens := service.CountTextToken(tempStr, info.UpstreamModelName) + usage.CompletionTokens = completionTokens + } + } + + if usage.PromptTokens == 0 && usage.CompletionTokens != 0 { + usage.PromptTokens = info.PromptTokens + } + + usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens + + return usage, nil +} diff --git a/relay/channel/openrouter/constant.go b/relay/channel/openrouter/constant.go new file mode 100644 index 0000000000000000000000000000000000000000..26889beac27fb89a92e284d8dbde4dcb052947ee --- /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 0000000000000000000000000000000000000000..31b18a6daafeb8fd84971f1b97da73b0db3e9752 --- /dev/null +++ b/relay/channel/openrouter/dto.go @@ -0,0 +1,16 @@ +package openrouter + +import "encoding/json" + +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 +} + +type OpenRouterEnterpriseResponse struct { + Data json.RawMessage `json:"data"` + Success bool `json:"success"` +} diff --git a/relay/channel/palm/adaptor.go b/relay/channel/palm/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..55e5f0ae09312b02f84ccbf220b63683c8134ab3 --- /dev/null +++ b/relay/channel/palm/adaptor.go @@ -0,0 +1,96 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl), 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 0000000000000000000000000000000000000000..26f9fff94d286220646851be09d7bd0ac04b4771 --- /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 0000000000000000000000000000000000000000..2a6d581d8af0cab7295e9050c5ac07daa96a9bf6 --- /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 0000000000000000000000000000000000000000..c9ec4c4a499c51edbc6fce2927f464be411d8e14 --- /dev/null +++ b/relay/channel/palm/relay-palm.go @@ -0,0 +1,138 @@ +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 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.SysLog("error reading stream response: " + err.Error()) + stopChan <- true + return + } + service.CloseResponseBodyGracefully(resp) + var palmResponse PaLMChatResponse + err = json.Unmarshal(responseBody, &palmResponse) + if err != nil { + common.SysLog("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.SysLog("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 + } + }) + service.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.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + var palmResponse PaLMChatResponse + err = json.Unmarshal(responseBody, &palmResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + 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) + service.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 0000000000000000000000000000000000000000..0bfa09f94a86585498a40042025d49b72f2dffa0 --- /dev/null +++ b/relay/channel/perplexity/adaptor.go @@ -0,0 +1,97 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl), 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 0000000000000000000000000000000000000000..a692a2d9ef6dadf37a5cfef77c5df5b9a77a8bda --- /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 0000000000000000000000000000000000000000..ebb2b078ab2ee7644d8c3c04d6af96279f2087dc --- /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.GetMaxTokens(), + } +} diff --git a/relay/channel/siliconflow/adaptor.go b/relay/channel/siliconflow/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..0d2fe7d26f2a393c516f0f2acce930f79e7491bb --- /dev/null +++ b/relay/channel/siliconflow/adaptor.go @@ -0,0 +1,111 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + //TODO implement me + return nil, errors.New("not supported") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertImageRequest(c, info, request) +} + +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.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeEmbeddings { + return fmt.Sprintf("%s/v1/embeddings", info.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeChatCompletions { + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil + } else if info.RelayMode == constant.RelayModeCompletions { + return fmt.Sprintf("%s/v1/completions", info.ChannelBaseUrl), nil + } + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), 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 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.RelayModeEmbeddings: + usage, err = openai.OpenaiHandler(c, info, resp) + case constant.RelayModeCompletions: + fallthrough + case constant.RelayModeChatCompletions: + fallthrough + 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/siliconflow/constant.go b/relay/channel/siliconflow/constant.go new file mode 100644 index 0000000000000000000000000000000000000000..9fc455dded031f6b5ba24f8bebdede469d3e9fdc --- /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 0000000000000000000000000000000000000000..0683cc7e544c035966cab3b7e48f09cfd72cd162 --- /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 0000000000000000000000000000000000000000..e5a9fd69207fcd4391b57d24350015c9d03888aa --- /dev/null +++ b/relay/channel/siliconflow/relay-siliconflow.go @@ -0,0 +1,44 @@ +package siliconflow + +import ( + "encoding/json" + "io" + "net/http" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/service" + "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.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + var siliconflowResp SFRerankResponse + err = json.Unmarshal(responseBody, &siliconflowResp) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + 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) + service.IOCopyBytesGracefully(c, resp, jsonResponse) + return usage, nil +} diff --git a/relay/channel/submodel/adaptor.go b/relay/channel/submodel/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..cff8bc08f06d6d9d33f98bb206ab773aa06ec9b5 --- /dev/null +++ b/relay/channel/submodel/adaptor.go @@ -0,0 +1,86 @@ +package submodel + +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) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, 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 request, nil +} + +func (a *Adaptor) ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) { + return nil, errors.New("submodel channel: endpoint not supported") +} + +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/submodel/constants.go b/relay/channel/submodel/constants.go new file mode 100644 index 0000000000000000000000000000000000000000..c59bcca3ea8efd3ef300bcc484e451668c9e3791 --- /dev/null +++ b/relay/channel/submodel/constants.go @@ -0,0 +1,16 @@ +package submodel + +var ModelList = []string{ + "NousResearch/Hermes-4-405B-FP8", + "Qwen/Qwen3-235B-A22B-Thinking-2507", + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", + "Qwen/Qwen3-235B-A22B-Instruct-2507", + "zai-org/GLM-4.5-FP8", + "openai/gpt-oss-120b", + "deepseek-ai/DeepSeek-R1-0528", + "deepseek-ai/DeepSeek-R1", + "deepseek-ai/DeepSeek-V3-0324", + "deepseek-ai/DeepSeek-V3.1", +} + +const ChannelName = "submodel" \ No newline at end of file diff --git a/relay/channel/task/jimeng/adaptor.go b/relay/channel/task/jimeng/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..c7f648329daf86b65adda273ab6db027d54c5780 --- /dev/null +++ b/relay/channel/task/jimeng/adaptor.go @@ -0,0 +1,404 @@ +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/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"` + Frames int `json:"frames,omitempty"` +} + +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.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + + // 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.RelayInfo) (taskErr *dto.TaskError) { + // Accept only POST /v1/video/generations as "generate" action. + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + if isNewAPIRelay(info.ApiKey) { + return fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncSubmitTask&Version=2022-08-31", a.baseURL), nil + } + 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.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if isNewAPIRelay(info.ApiKey) { + req.Header.Set("Authorization", "Bearer "+info.ApiKey) + } else { + return a.signRequest(req, a.accessKey, a.secretKey) + } + return nil +} + +// BuildRequestBody converts request into Jimeng specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (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.RelayInfo, 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.RelayInfo) (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) + if isNewAPIRelay(key) { + uri = fmt.Sprintf("%s/jimeng/?Action=CVSync2AsyncGetResult&Version=2022-08-31", a.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") + + if isNewAPIRelay(key) { + req.Header.Set("Authorization", "Bearer "+key) + } else { + 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: req.Model, + Prompt: req.Prompt, + } + + switch req.Duration { + case 10: + r.Frames = 241 // 24*10+1 = 241 + default: + r.Frames = 121 // 24*5+1 = 121 + } + + // Handle one-of image_urls or binary_data_base64 + if req.HasImage() { + if strings.HasPrefix(req.Images[0], "http") { + r.ImageUrls = req.Images + } else { + r.BinaryDataBase64 = req.Images + } + } + 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") + } + + // 即梦视频3.0 ReqKey转换 + // https://www.volcengine.com/docs/85621/1792707 + if strings.Contains(r.ReqKey, "jimeng_v30") { + if len(r.ImageUrls) > 1 { + // 多张图片:首尾帧生成 + r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_tail_v30", 1) + } else if len(r.ImageUrls) == 1 { + // 单张图片:图生视频 + r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_i2v_first_v30", 1) + } else { + // 无图片:文生视频 + r.ReqKey = strings.Replace(r.ReqKey, "jimeng_v30", "jimeng_t2v_v30", 1) + } + } + + 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 +} + +func isNewAPIRelay(apiKey string) bool { + return strings.HasPrefix(apiKey, "sk-") +} diff --git a/relay/channel/task/kling/adaptor.go b/relay/channel/task/kling/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..46e12f6d1a62b18930260c7ec9e9d0eb474c8fc6 --- /dev/null +++ b/relay/channel/task/kling/adaptor.go @@ -0,0 +1,371 @@ +package kling + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/model" + "strings" + "time" + + "github.com/samber/lo" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt" + "github.com/pkg/errors" + + "one-api/constant" + "one-api/dto" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" +) + +// ============================ +// Request / Response structures +// ============================ + +type TrajectoryPoint struct { + X int `json:"x"` + Y int `json:"y"` +} + +type DynamicMask struct { + Mask string `json:"mask,omitempty"` + Trajectories []TrajectoryPoint `json:"trajectories,omitempty"` +} + +type CameraConfig struct { + Horizontal float64 `json:"horizontal,omitempty"` + Vertical float64 `json:"vertical,omitempty"` + Pan float64 `json:"pan,omitempty"` + Tilt float64 `json:"tilt,omitempty"` + Roll float64 `json:"roll,omitempty"` + Zoom float64 `json:"zoom,omitempty"` +} + +type CameraControl struct { + Type string `json:"type,omitempty"` + Config *CameraConfig `json:"config,omitempty"` +} + +type requestPayload struct { + Prompt string `json:"prompt,omitempty"` + Image string `json:"image,omitempty"` + ImageTail string `json:"image_tail,omitempty"` + NegativePrompt string `json:"negative_prompt,omitempty"` + Mode string `json:"mode,omitempty"` + Duration string `json:"duration,omitempty"` + AspectRatio string `json:"aspect_ratio,omitempty"` + ModelName string `json:"model_name,omitempty"` + Model string `json:"model,omitempty"` // Compatible with upstreams that only recognize "model" + CfgScale float64 `json:"cfg_scale,omitempty"` + StaticMask string `json:"static_mask,omitempty"` + DynamicMasks []DynamicMask `json:"dynamic_masks,omitempty"` + CameraControl *CameraControl `json:"camera_control,omitempty"` + CallbackUrl string `json:"callback_url,omitempty"` + ExternalTaskId string `json:"external_task_id,omitempty"` +} + +type responsePayload struct { + Code int `json:"code"` + Message string `json:"message"` + TaskId string `json:"task_id"` + 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 + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey + + // apiKey format: "access_key|secret_key" +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + // Use the standard validation method for TaskSubmitReq + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + path := lo.Ternary(info.Action == constant.TaskActionGenerate, "/v1/videos/image2video", "/v1/videos/text2video") + + if isNewAPIRelay(info.ApiKey) { + return fmt.Sprintf("%s/kling%s", a.baseURL, path), nil + } + + 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.RelayInfo) 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.RelayInfo) (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, err + } + if body.Image == "" && body.ImageTail == "" { + c.Set("action", constant.TaskActionTextGenerate) + } + 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.RelayInfo, 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.RelayInfo) (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 kResp responsePayload + err = json.Unmarshal(responseBody, &kResp) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError) + return + } + if kResp.Code != 0 { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf(kResp.Message), "task_failed", http.StatusBadRequest) + return + } + kResp.TaskId = kResp.Data.TaskId + c.JSON(http.StatusOK, kResp) + return kResp.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") + } + 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) + if isNewAPIRelay(key) { + url = fmt.Sprintf("%s/kling%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 *relaycommon.TaskSubmitReq) (*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, + Model: req.Model, // Keep consistent with model_name, double writing improves compatibility + CfgScale: 0.5, + StaticMask: "", + DynamicMasks: []DynamicMask{}, + CameraControl: nil, + CallbackUrl: "", + ExternalTaskId: "", + } + 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.createJWTTokenWithKey(a.apiKey) +} + +//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.createJWTTokenWithKey(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])) +//} + +func (a *TaskAdaptor) createJWTTokenWithKey(apiKey string) (string, error) { + if isNewAPIRelay(apiKey) { + return apiKey, nil // new api relay + } + keyParts := strings.Split(apiKey, "|") + if len(keyParts) != 2 { + return "", errors.New("invalid api_key, required format is accessKey|secretKey") + } + accessKey := strings.TrimSpace(keyParts[0]) + if len(keyParts) == 1 { + return accessKey, nil + } + secretKey := strings.TrimSpace(keyParts[1]) + 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) { + taskInfo := &relaycommon.TaskInfo{} + resPayload := responsePayload{} + err := json.Unmarshal(respBody, &resPayload) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal response body") + } + 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 +} + +func isNewAPIRelay(apiKey string) bool { + return strings.HasPrefix(apiKey, "sk-") +} diff --git a/relay/channel/task/suno/adaptor.go b/relay/channel/task/suno/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..5f49fd3e766e5d79ee553ef26834c6b63da40ec7 --- /dev/null +++ b/relay/channel/task/suno/adaptor.go @@ -0,0 +1,177 @@ +package suno + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "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" + + "github.com/gin-gonic/gin" +) + +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.RelayInfo) { + a.ChannelType = info.ChannelType +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (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.RelayInfo) (string, error) { + baseURL := info.ChannelBaseUrl + 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.RelayInfo) 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.RelayInfo) (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.RelayInfo, 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.RelayInfo) (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.SysLog(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 0000000000000000000000000000000000000000..08dbc9caa6ac18b94f2e7d3daabc5317a3584e1c --- /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/task/vertex/adaptor.go b/relay/channel/task/vertex/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..7326b44c6a13f14c385a6fd0bf2d75e9eedb0b79 --- /dev/null +++ b/relay/channel/task/vertex/adaptor.go @@ -0,0 +1,355 @@ +package vertex + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "one-api/model" + "regexp" + "strings" + + "github.com/gin-gonic/gin" + + "one-api/constant" + "one-api/dto" + "one-api/relay/channel" + vertexcore "one-api/relay/channel/vertex" + relaycommon "one-api/relay/common" + "one-api/service" +) + +// ============================ +// Request / Response structures +// ============================ + +type requestPayload struct { + Instances []map[string]any `json:"instances"` + Parameters map[string]any `json:"parameters,omitempty"` +} + +type submitResponse struct { + Name string `json:"name"` +} + +type operationVideo struct { + MimeType string `json:"mimeType"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + Encoding string `json:"encoding"` +} + +type operationResponse struct { + Name string `json:"name"` + Done bool `json:"done"` + Response struct { + Type string `json:"@type"` + RaiMediaFilteredCount int `json:"raiMediaFilteredCount"` + Videos []operationVideo `json:"videos"` + BytesBase64Encoded string `json:"bytesBase64Encoded"` + Encoding string `json:"encoding"` + Video string `json:"video"` + } `json:"response"` + Error struct { + Message string `json:"message"` + } `json:"error"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + apiKey string + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl + a.apiKey = info.ApiKey +} + +// ValidateRequestAndSetAction parses body, validates fields and sets default action. +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + // Use the standard validation method for TaskSubmitReq + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionTextGenerate) +} + +// BuildRequestURL constructs the upstream URL. +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + adc := &vertexcore.Credentials{} + if err := json.Unmarshal([]byte(a.apiKey), adc); err != nil { + return "", fmt.Errorf("failed to decode credentials: %w", err) + } + modelName := info.OriginModelName + if modelName == "" { + modelName = "veo-3.0-generate-001" + } + + region := vertexcore.GetModelRegion(info.ApiVersion, modelName) + if strings.TrimSpace(region) == "" { + region = "global" + } + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:predictLongRunning", + adc.ProjectID, + modelName, + ), nil + } + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:predictLongRunning", + region, + adc.ProjectID, + region, + modelName, + ), nil +} + +// BuildRequestHeader sets required headers. +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + adc := &vertexcore.Credentials{} + if err := json.Unmarshal([]byte(a.apiKey), adc); err != nil { + return fmt.Errorf("failed to decode credentials: %w", err) + } + + token, err := vertexcore.AcquireAccessToken(*adc, "") + if err != nil { + return fmt.Errorf("failed to acquire access token: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("x-goog-user-project", adc.ProjectID) + return nil +} + +// BuildRequestBody converts request into Vertex specific format. +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) { + v, ok := c.Get("task_request") + if !ok { + return nil, fmt.Errorf("request not found in context") + } + req := v.(relaycommon.TaskSubmitReq) + + body := requestPayload{ + Instances: []map[string]any{{"prompt": req.Prompt}}, + Parameters: map[string]any{}, + } + if req.Metadata != nil { + if v, ok := req.Metadata["storageUri"]; ok { + body.Parameters["storageUri"] = v + } + if v, ok := req.Metadata["sampleCount"]; ok { + body.Parameters["sampleCount"] = v + } + } + if _, ok := body.Parameters["sampleCount"]; !ok { + body.Parameters["sampleCount"] = 1 + } + + 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.RelayInfo, 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.RelayInfo) (taskID string, taskData []byte, taskErr *dto.TaskError) { + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, service.TaskErrorWrapper(err, "read_response_body_failed", http.StatusInternalServerError) + } + _ = resp.Body.Close() + + var s submitResponse + if err := json.Unmarshal(responseBody, &s); err != nil { + return "", nil, service.TaskErrorWrapper(err, "unmarshal_response_failed", http.StatusInternalServerError) + } + if strings.TrimSpace(s.Name) == "" { + return "", nil, service.TaskErrorWrapper(fmt.Errorf("missing operation name"), "invalid_response", http.StatusInternalServerError) + } + localID := encodeLocalTaskID(s.Name) + c.JSON(http.StatusOK, gin.H{"task_id": localID}) + return localID, responseBody, nil +} + +func (a *TaskAdaptor) GetModelList() []string { return []string{"veo-3.0-generate-001"} } +func (a *TaskAdaptor) GetChannelName() string { return "vertex" } + +// 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") + } + upstreamName, err := decodeLocalTaskID(taskID) + if err != nil { + return nil, fmt.Errorf("decode task_id failed: %w", err) + } + region := extractRegionFromOperationName(upstreamName) + if region == "" { + region = "us-central1" + } + project := extractProjectFromOperationName(upstreamName) + modelName := extractModelFromOperationName(upstreamName) + if project == "" || modelName == "" { + return nil, fmt.Errorf("cannot extract project/model from operation name") + } + var url string + if region == "global" { + url = fmt.Sprintf("https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:fetchPredictOperation", project, modelName) + } else { + url = fmt.Sprintf("https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:fetchPredictOperation", region, project, region, modelName) + } + payload := map[string]string{"operationName": upstreamName} + data, err := json.Marshal(payload) + if err != nil { + return nil, err + } + adc := &vertexcore.Credentials{} + if err := json.Unmarshal([]byte(key), adc); err != nil { + return nil, fmt.Errorf("failed to decode credentials: %w", err) + } + token, err := vertexcore.AcquireAccessToken(*adc, "") + if err != nil { + return nil, fmt.Errorf("failed to acquire access token: %w", err) + } + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("x-goog-user-project", adc.ProjectID) + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + var op operationResponse + if err := json.Unmarshal(respBody, &op); err != nil { + return nil, fmt.Errorf("unmarshal operation response failed: %w", err) + } + ti := &relaycommon.TaskInfo{} + if op.Error.Message != "" { + ti.Status = model.TaskStatusFailure + ti.Reason = op.Error.Message + ti.Progress = "100%" + return ti, nil + } + if !op.Done { + ti.Status = model.TaskStatusInProgress + ti.Progress = "50%" + return ti, nil + } + ti.Status = model.TaskStatusSuccess + ti.Progress = "100%" + if len(op.Response.Videos) > 0 { + v0 := op.Response.Videos[0] + if v0.BytesBase64Encoded != "" { + mime := strings.TrimSpace(v0.MimeType) + if mime == "" { + enc := strings.TrimSpace(v0.Encoding) + if enc == "" { + enc = "mp4" + } + if strings.Contains(enc, "/") { + mime = enc + } else { + mime = "video/" + enc + } + } + ti.Url = "data:" + mime + ";base64," + v0.BytesBase64Encoded + return ti, nil + } + } + if op.Response.BytesBase64Encoded != "" { + enc := strings.TrimSpace(op.Response.Encoding) + if enc == "" { + enc = "mp4" + } + mime := enc + if !strings.Contains(enc, "/") { + mime = "video/" + enc + } + ti.Url = "data:" + mime + ";base64," + op.Response.BytesBase64Encoded + return ti, nil + } + if op.Response.Video != "" { // some variants use `video` as base64 + enc := strings.TrimSpace(op.Response.Encoding) + if enc == "" { + enc = "mp4" + } + mime := enc + if !strings.Contains(enc, "/") { + mime = "video/" + enc + } + ti.Url = "data:" + mime + ";base64," + op.Response.Video + return ti, nil + } + return ti, nil +} + +// ============================ +// helpers +// ============================ + +func encodeLocalTaskID(name string) string { + return base64.RawURLEncoding.EncodeToString([]byte(name)) +} + +func decodeLocalTaskID(local string) (string, error) { + b, err := base64.RawURLEncoding.DecodeString(local) + if err != nil { + return "", err + } + return string(b), nil +} + +var regionRe = regexp.MustCompile(`locations/([a-z0-9-]+)/`) + +func extractRegionFromOperationName(name string) string { + m := regionRe.FindStringSubmatch(name) + if len(m) == 2 { + return m[1] + } + return "" +} + +var modelRe = regexp.MustCompile(`models/([^/]+)/operations/`) + +func extractModelFromOperationName(name string) string { + m := modelRe.FindStringSubmatch(name) + if len(m) == 2 { + return m[1] + } + idx := strings.Index(name, "models/") + if idx >= 0 { + s := name[idx+len("models/"):] + if p := strings.Index(s, "/operations/"); p > 0 { + return s[:p] + } + } + return "" +} + +var projectRe = regexp.MustCompile(`projects/([^/]+)/locations/`) + +func extractProjectFromOperationName(name string) string { + m := projectRe.FindStringSubmatch(name) + if len(m) == 2 { + return m[1] + } + return "" +} diff --git a/relay/channel/task/vidu/adaptor.go b/relay/channel/task/vidu/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..c25221f1b798cb94636cfbdf7084964d8cf8bf2a --- /dev/null +++ b/relay/channel/task/vidu/adaptor.go @@ -0,0 +1,258 @@ +package vidu + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/gin-gonic/gin" + + "one-api/constant" + "one-api/dto" + "one-api/model" + "one-api/relay/channel" + relaycommon "one-api/relay/common" + "one-api/service" + + "github.com/pkg/errors" +) + +// ============================ +// Request / Response structures +// ============================ + +type requestPayload struct { + Model string `json:"model"` + Images []string `json:"images"` + Prompt string `json:"prompt,omitempty"` + Duration int `json:"duration,omitempty"` + Seed int `json:"seed,omitempty"` + Resolution string `json:"resolution,omitempty"` + MovementAmplitude string `json:"movement_amplitude,omitempty"` + Bgm bool `json:"bgm,omitempty"` + Payload string `json:"payload,omitempty"` + CallbackUrl string `json:"callback_url,omitempty"` +} + +type responsePayload struct { + TaskId string `json:"task_id"` + State string `json:"state"` + Model string `json:"model"` + Images []string `json:"images"` + Prompt string `json:"prompt"` + Duration int `json:"duration"` + Seed int `json:"seed"` + Resolution string `json:"resolution"` + Bgm bool `json:"bgm"` + MovementAmplitude string `json:"movement_amplitude"` + Payload string `json:"payload"` + CreatedAt string `json:"created_at"` +} + +type taskResultResponse struct { + State string `json:"state"` + ErrCode string `json:"err_code"` + Credits int `json:"credits"` + Payload string `json:"payload"` + Creations []creation `json:"creations"` +} + +type creation struct { + ID string `json:"id"` + URL string `json:"url"` + CoverURL string `json:"cover_url"` +} + +// ============================ +// Adaptor implementation +// ============================ + +type TaskAdaptor struct { + ChannelType int + baseURL string +} + +func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + a.baseURL = info.ChannelBaseUrl +} + +func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + return relaycommon.ValidateBasicTaskRequest(c, info, constant.TaskActionGenerate) +} + +func (a *TaskAdaptor) BuildRequestBody(c *gin.Context, _ *relaycommon.RelayInfo) (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, err + } + + if len(body.Images) == 0 { + c.Set("action", constant.TaskActionTextGenerate) + } + + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + return bytes.NewReader(data), nil +} + +func (a *TaskAdaptor) BuildRequestURL(info *relaycommon.RelayInfo) (string, error) { + var path string + switch info.Action { + case constant.TaskActionGenerate: + path = "/img2video" + case constant.TaskActionFirstTailGenerate: + path = "/start-end2video" + case constant.TaskActionReferenceGenerate: + path = "/reference2video" + default: + path = "/text2video" + } + return fmt.Sprintf("%s/ent/v2%s", a.baseURL, path), nil +} + +func (a *TaskAdaptor) BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Token "+info.ApiKey) + return nil +} + +func (a *TaskAdaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) { + if action := c.GetString("action"); action != "" { + info.Action = action + } + return channel.DoTaskApiRequest(a, c, info, requestBody) +} + +func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, _ *relaycommon.RelayInfo) (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 vResp responsePayload + err = json.Unmarshal(responseBody, &vResp) + if err != nil { + taskErr = service.TaskErrorWrapper(errors.Wrap(err, fmt.Sprintf("%s", responseBody)), "unmarshal_response_failed", http.StatusInternalServerError) + return + } + + if vResp.State == "failed" { + taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("task failed"), "task_failed", http.StatusBadRequest) + return + } + + c.JSON(http.StatusOK, vResp) + return vResp.TaskId, responseBody, nil +} + +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") + } + + url := fmt.Sprintf("%s/ent/v2/tasks/%s/creations", baseUrl, taskID) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Token "+key) + + return service.GetHttpClient().Do(req) +} + +func (a *TaskAdaptor) GetModelList() []string { + return []string{"viduq1", "vidu2.0", "vidu1.5"} +} + +func (a *TaskAdaptor) GetChannelName() string { + return "vidu" +} + +// ============================ +// helpers +// ============================ + +func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { + r := requestPayload{ + Model: defaultString(req.Model, "viduq1"), + Images: req.Images, + Prompt: req.Prompt, + Duration: defaultInt(req.Duration, 5), + Resolution: defaultString(req.Size, "1080p"), + MovementAmplitude: "auto", + Bgm: false, + } + 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 defaultString(value, defaultValue string) string { + if value == "" { + return defaultValue + } + return value +} + +func defaultInt(value, defaultValue int) int { + if value == 0 { + return defaultValue + } + return value +} + +func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { + taskInfo := &relaycommon.TaskInfo{} + + var taskResp taskResultResponse + err := json.Unmarshal(respBody, &taskResp) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal response body") + } + + state := taskResp.State + switch state { + case "created", "queueing": + taskInfo.Status = model.TaskStatusSubmitted + case "processing": + taskInfo.Status = model.TaskStatusInProgress + case "success": + taskInfo.Status = model.TaskStatusSuccess + if len(taskResp.Creations) > 0 { + taskInfo.Url = taskResp.Creations[0].URL + } + case "failed": + taskInfo.Status = model.TaskStatusFailure + if taskResp.ErrCode != "" { + taskInfo.Reason = taskResp.ErrCode + } + default: + return nil, fmt.Errorf("unknown task state: %s", state) + } + + return taskInfo, nil +} diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..a4c630a5bdc32ed20600aafd995dcac298e0d73e --- /dev/null +++ b/relay/channel/tencent/adaptor.go @@ -0,0 +1,118 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl), 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 0000000000000000000000000000000000000000..4fccfadf07f258f2fbea8d2bd15108daab036fcf --- /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 0000000000000000000000000000000000000000..c50a2a3eba9c400b23ef62bfb5cfae3ce09f2634 --- /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 0000000000000000000000000000000000000000..784e7b5c0e237b7d7c41266fb4ce487bd2b5ee19 --- /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.SysLog("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.SysLog(err.Error()) + } + } + + if err := scanner.Err(); err != nil { + common.SysLog("error reading stream: " + err.Error()) + } + + helper.Done(c) + + service.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.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &tencentSb) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + 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) + service.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 0000000000000000000000000000000000000000..6dfaa33b3e15a332fbd4af1fa80c3eb3a08b11af --- /dev/null +++ b/relay/channel/vertex/adaptor.go @@ -0,0 +1,348 @@ +package vertex + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "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", + "claude-opus-4-1-20250805": "claude-opus-4-1@20250805", + "claude-sonnet-4-5-20250929": "claude-sonnet-4-5@20250929", +} + +const anthropicVersion = "vertex-2023-10-16" + +type Adaptor struct { + RequestMode int + AccountCredentials Credentials +} + +func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { + geminiAdaptor := gemini.Adaptor{} + return geminiAdaptor.ConvertGeminiRequest(c, info, request) +} + +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) { + geminiAdaptor := gemini.Adaptor{} + return geminiAdaptor.ConvertImageRequest(c, info, request) +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + if strings.HasPrefix(info.UpstreamModelName, "claude") { + a.RequestMode = RequestModeClaude + } else if strings.Contains(info.UpstreamModelName, "llama") { + a.RequestMode = RequestModeLlama + } else { + a.RequestMode = RequestModeGemini + } +} + +func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix string) (string, error) { + region := GetModelRegion(info.ApiVersion, info.OriginModelName) + if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey { + adc := &Credentials{} + if err := common.Unmarshal([]byte(info.ApiKey), adc); err != nil { + return "", fmt.Errorf("failed to decode credentials file: %w", err) + } + a.AccountCredentials = *adc + + 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 + } + + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", + adc.ProjectID, + modelName, + 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, + modelName, + suffix, + ), nil + } + } else { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + modelName, + suffix, + info.ApiKey, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + region, + modelName, + suffix, + info.ApiKey, + ), nil + } + } +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + 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 strings.HasPrefix(info.UpstreamModelName, "imagen") { + suffix = "predict" + } + return a.getRequestUrl(info, info.UpstreamModelName, suffix) + } 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 + } + return a.getRequestUrl(info, model, suffix) + } else if a.RequestMode == RequestModeLlama { + return a.getRequestUrl(info, "", "") + } + 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) + if info.ChannelOtherSettings.VertexKeyType != dto.VertexKeyTypeAPIKey { + accessToken, err := getAccessToken(a, info) + if err != nil { + return err + } + req.Set("Authorization", "Bearer "+accessToken) + } + if a.AccountCredentials.ProjectID != "" { + req.Set("x-goog-user-project", a.AccountCredentials.ProjectID) + } + 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 == RequestModeGemini && strings.HasPrefix(info.UpstreamModelName, "imagen") { + prompt := "" + for _, m := range request.Messages { + if m.Role == "user" { + prompt = m.StringContent() + if prompt != "" { + break + } + } + } + if prompt == "" { + if p, ok := request.Prompt.(string); ok { + prompt = p + } + } + if prompt == "" { + return nil, errors.New("prompt is required for image generation") + } + + imgReq := dto.ImageRequest{ + Model: request.Model, + Prompt: prompt, + N: 1, + Size: "1024x1024", + } + if request.N > 0 { + imgReq.N = uint(request.N) + } + if request.Size != "" { + imgReq.Size = request.Size + } + if len(request.ExtraBody) > 0 { + var extra map[string]any + if err := json.Unmarshal(request.ExtraBody, &extra); err == nil { + if n, ok := extra["n"].(float64); ok && n > 0 { + imgReq.N = uint(n) + } + if size, ok := extra["size"].(string); ok { + imgReq.Size = size + } + // accept aspectRatio in extra body (top-level or under parameters) + if ar, ok := extra["aspectRatio"].(string); ok && ar != "" { + imgReq.Size = ar + } + if params, ok := extra["parameters"].(map[string]any); ok { + if ar, ok := params["aspectRatio"].(string); ok && ar != "" { + imgReq.Size = ar + } + } + } + } + c.Set("request_model", request.Model) + return a.ConvertImageRequest(c, info, imgReq) + } + if a.RequestMode == RequestModeClaude { + claudeReq, err := claude.RequestOpenAI2ClaudeMessage(c, *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(c, *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: + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + case RequestModeGemini: + if info.RelayMode == constant.RelayModeGemini { + return gemini.GeminiTextGenerationStreamHandler(c, info, resp) + } else { + return gemini.GeminiChatStreamHandler(c, info, resp) + } + case RequestModeLlama: + return openai.OaiStreamHandler(c, info, resp) + } + } else { + switch a.RequestMode { + case RequestModeClaude: + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + case RequestModeGemini: + if info.RelayMode == constant.RelayModeGemini { + return gemini.GeminiTextGenerationHandler(c, info, resp) + } else { + if strings.HasPrefix(info.UpstreamModelName, "imagen") { + return gemini.GeminiImageHandler(c, info, resp) + } + return gemini.GeminiChatHandler(c, info, resp) + } + case RequestModeLlama: + return 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 0000000000000000000000000000000000000000..e47a43a2ebe209d9316c17fae4d696c8e2a4bace --- /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 0000000000000000000000000000000000000000..97b4d35103a3bda7d9cd2f7cb27a07b23cc00007 --- /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 0000000000000000000000000000000000000000..14b2ca857bc369280bf0a67f56702494a12f04d8 --- /dev/null +++ b/relay/channel/vertex/relay-vertex.go @@ -0,0 +1,22 @@ +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 { + if v, ok := m["default"]; ok { + return v.(string) + } + return "global" + } + } + return other +} diff --git a/relay/channel/vertex/service_account.go b/relay/channel/vertex/service_account.go new file mode 100644 index 0000000000000000000000000000000000000000..de52e37f4cd012efd7b5023e8aef60189c4f1632 --- /dev/null +++ b/relay/channel/vertex/service_account.go @@ -0,0 +1,182 @@ +package vertex + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "net/http" + "net/url" + relaycommon "one-api/relay/common" + "one-api/service" + "strings" + + "github.com/bytedance/gopkg/cache/asynccache" + "github.com/golang-jwt/jwt" + + "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) { + var cacheKey string + if info.ChannelIsMultiKey { + cacheKey = fmt.Sprintf("access-token-%d-%d", info.ChannelId, info.ChannelMultiKeyIndex) + } else { + 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) +} + +func AcquireAccessToken(creds Credentials, proxy string) (string, error) { + signedJWT, err := createSignedJWT(creds.ClientEmail, creds.PrivateKey) + if err != nil { + return "", fmt.Errorf("failed to create signed JWT: %w", err) + } + return exchangeJwtForAccessTokenWithProxy(signedJWT, proxy) +} + +func exchangeJwtForAccessTokenWithProxy(signedJWT string, proxy string) (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 proxy != "" { + client, err = service.NewProxyHttpClient(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 0000000000000000000000000000000000000000..0284c9645b29523a7bbde134308a01ffc372711b --- /dev/null +++ b/relay/channel/volcengine/adaptor.go @@ -0,0 +1,273 @@ +package volcengine + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + channelconstant "one-api/constant" + "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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + adaptor := openai.Adaptor{} + return adaptor.ConvertClaudeRequest(c, info, req) +} + +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.RelayModeImagesGenerations: + return request, nil + 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) { + // 支持自定义域名,如果未设置则使用默认域名 + baseUrl := info.ChannelBaseUrl + if baseUrl == "" { + baseUrl = channelconstant.ChannelBaseURLs[channelconstant.ChannelTypeVolcEngine] + } + + switch info.RelayFormat { + case types.RelayFormatClaude: + if strings.HasPrefix(info.UpstreamModelName, "bot") { + return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil + } + return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil + default: + switch info.RelayMode { + case constant.RelayModeChatCompletions: + if strings.HasPrefix(info.UpstreamModelName, "bot") { + return fmt.Sprintf("%s/api/v3/bots/chat/completions", baseUrl), nil + } + return fmt.Sprintf("%s/api/v3/chat/completions", baseUrl), nil + case constant.RelayModeEmbeddings: + return fmt.Sprintf("%s/api/v3/embeddings", baseUrl), nil + case constant.RelayModeImagesGenerations: + return fmt.Sprintf("%s/api/v3/images/generations", baseUrl), nil + case constant.RelayModeImagesEdits: + return fmt.Sprintf("%s/api/v3/images/edits", baseUrl), nil + case constant.RelayModeRerank: + return fmt.Sprintf("%s/api/v3/rerank", 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") + } + // 适配 方舟deepseek混合模型 的 thinking 后缀 + if strings.HasSuffix(info.UpstreamModelName, "-thinking") && strings.HasPrefix(info.UpstreamModelName, "deepseek") { + info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") + request.Model = info.UpstreamModelName + request.THINKING = json.RawMessage(`{"type": "enabled"}`) + } + 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) { + adaptor := openai.Adaptor{} + usage, err = adaptor.DoResponse(c, resp, info) + 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 0000000000000000000000000000000000000000..7edd9b30e6fc1f7b82d61f8d71bb9b3f82fcb5c0 --- /dev/null +++ b/relay/channel/volcengine/constants.go @@ -0,0 +1,19 @@ +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", + "doubao-seedream-4-0-250828", + "seedream-4-0-250828", + "doubao-seedance-1-0-pro-250528", + "seedance-1-0-pro-250528", + "doubao-seed-1-6-thinking-250715", + "seed-1-6-thinking-250715", +} + +var ChannelName = "volcengine" diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..e16d293585a98b644a221f45b98270ec80cc3ab5 --- /dev/null +++ b/relay/channel/xai/adaptor.go @@ -0,0 +1,133 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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: int(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.ChannelBaseUrl, 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 0000000000000000000000000000000000000000..e66827dbe18249a51a8d5f713c0ba1766b5f50f5 --- /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 0000000000000000000000000000000000000000..0444dd7e06d11862dd6fda1ab1acc07127d75acf --- /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 0000000000000000000000000000000000000000..88d34334a3db4953902f7d16efedad4c5258f80b --- /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.SysLog("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.SysLog(err.Error()) + } + return true + }) + + if !containStreamUsage { + usage = service.ResponseText2Usage(responseTextBuilder.String(), info.UpstreamModelName, info.PromptTokens) + usage.CompletionTokens += toolCount * 7 + } + + helper.Done(c) + service.CloseResponseBodyGracefully(resp) + return usage, nil +} + +func xAIHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + defer service.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) + } + + service.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 0000000000000000000000000000000000000000..5815af253e3803a1cbe2d3cc1e38dfd1fa709b28 --- /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 0000000000000000000000000000000000000000..6eb63ddb6476222488cd462c9e36f040235f3ab4 --- /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 0000000000000000000000000000000000000000..6004a282ae289165e32fc77a5e885d245a0a7d65 --- /dev/null +++ b/relay/channel/xunfei/adaptor.go @@ -0,0 +1,104 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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 0000000000000000000000000000000000000000..5095185d13b4c3a2485a513d7c0bd2ce4a5bbbbc --- /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 0000000000000000000000000000000000000000..41086aed043a56962410a513c164f5398aa5eff4 --- /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 0000000000000000000000000000000000000000..1f1fc6efd56458eeb29169fdc07d95fcd058e316 --- /dev/null +++ b/relay/channel/xunfei/relay-xunfei.go @@ -0,0 +1,290 @@ +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.GetMaxTokens() + 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.SysLog("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() { + defer func() { + conn.Close() + }() + for { + _, msg, err := conn.ReadMessage() + if err != nil { + common.SysLog("error reading stream response: " + err.Error()) + break + } + var response XunfeiChatResponse + err = json.Unmarshal(msg, &response) + if err != nil { + common.SysLog("error unmarshalling stream response: " + err.Error()) + break + } + dataChan <- response + if response.Payload.Choices.Status == 2 { + if err != nil { + common.SysLog("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 0000000000000000000000000000000000000000..4899251898df46ee049af141b0f1d4206e1204b2 --- /dev/null +++ b/relay/channel/zhipu/adaptor.go @@ -0,0 +1,101 @@ +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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +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.ChannelBaseUrl, 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 0000000000000000000000000000000000000000..fd888cc2ee07083d27a56499f8d3f1fedb613fcd --- /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 0000000000000000000000000000000000000000..13e8a0ac51d9ea0e71930f49b2a9a27eb0de4aff --- /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 0000000000000000000000000000000000000000..09c825cec882caa45e21145d28758f1b9388d5a7 --- /dev/null +++ b/relay/channel/zhipu/relay-zhipu.go @@ -0,0 +1,246 @@ +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/service" + "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.SysLog("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.SysLog("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.SysLog("error unmarshalling stream response: " + err.Error()) + return true + } + response, zhipuUsage := streamMetaResponseZhipu2OpenAI(&zhipuResponse) + jsonResponse, err := json.Marshal(response) + if err != nil { + common.SysLog("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 + } + }) + service.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.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.CloseResponseBodyGracefully(resp) + err = json.Unmarshal(responseBody, &zhipuResponse) + if err != nil { + return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + 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 0000000000000000000000000000000000000000..2a426c0913815fb644b0cad00b6699aa22c98cf5 --- /dev/null +++ b/relay/channel/zhipu_4v/adaptor.go @@ -0,0 +1,111 @@ +package zhipu_4v + +import ( + "errors" + "fmt" + "io" + "net/http" + "one-api/dto" + "one-api/relay/channel" + "one-api/relay/channel/claude" + "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) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { + //TODO implement me + return nil, errors.New("not implemented") +} + +func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) { + return req, 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.RelayFormat { + case types.RelayFormatClaude: + return fmt.Sprintf("%s/api/anthropic/v1/messages", info.ChannelBaseUrl), nil + default: + switch info.RelayMode { + case relayconstant.RelayModeEmbeddings: + return fmt.Sprintf("%s/api/paas/v4/embeddings", info.ChannelBaseUrl), nil + default: + return fmt.Sprintf("%s/api/paas/v4/chat/completions", info.ChannelBaseUrl), 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 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) { + switch info.RelayFormat { + case types.RelayFormatClaude: + if info.IsStream { + return claude.ClaudeStreamHandler(c, resp, info, claude.RequestModeMessage) + } else { + return claude.ClaudeHandler(c, resp, info, claude.RequestModeMessage) + } + default: + adaptor := openai.Adaptor{} + return adaptor.DoResponse(c, resp, info) + } +} + +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 0000000000000000000000000000000000000000..64e02401d828b67d060d7b502f8f313962160f7f --- /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 0000000000000000000000000000000000000000..bc2c7ffb5a322472efa49b244e6e8790d62ce9ef --- /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 0000000000000000000000000000000000000000..baafeb6fda47fe1c6054a95d1e64da771900af78 --- /dev/null +++ b/relay/channel/zhipu_4v/relay-zhipu_v4.go @@ -0,0 +1,55 @@ +package zhipu_4v + +import ( + "one-api/dto" + "strings" +) + +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.GetMaxTokens(), + Stop: Stop, + Tools: request.Tools, + ToolChoice: request.ToolChoice, + THINKING: request.THINKING, + } +} diff --git a/relay/claude_handler.go b/relay/claude_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..05093bddd162d4ef6c25969d65cf44e806ac3dec --- /dev/null +++ b/relay/claude_handler.go @@ -0,0 +1,157 @@ +package relay + +import ( + "bytes" + "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" + "strings" + + "github.com/gin-gonic/gin" +) + +func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + + info.InitChannelMeta(c) + + claudeReq, ok := info.Request.(*dto.ClaudeRequest) + + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected *dto.ClaudeRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(claudeReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to ClaudeRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + if request.MaxTokens == 0 { + request.MaxTokens = uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model)) + } + + if model_setting.GetClaudeSettings().ThinkingAdapterEnabled && + strings.HasSuffix(request.Model, "-thinking") { + if request.Thinking == nil { + // 因为BudgetTokens 必须大于1024 + if request.MaxTokens < 1280 { + request.MaxTokens = 1280 + } + + // BudgetTokens 为 max_tokens 的 80% + request.Thinking = &dto.Thinking{ + Type: "enabled", + BudgetTokens: common.GetPointer[int](int(float64(request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), + } + // TODO: 临时处理 + // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking + request.TopP = 0 + request.Temperature = common.GetPointer[float64](1.0) + } + request.Model = strings.TrimSuffix(request.Model, "-thinking") + info.UpstreamModelName = request.Model + } + + if info.ChannelSetting.SystemPrompt != "" { + if request.System == nil { + request.SetStringSystem(info.ChannelSetting.SystemPrompt) + } else if info.ChannelSetting.SystemPromptOverride { + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) + if request.IsStringSystem() { + existing := strings.TrimSpace(request.GetStringSystem()) + if existing == "" { + request.SetStringSystem(info.ChannelSetting.SystemPrompt) + } else { + request.SetStringSystem(info.ChannelSetting.SystemPrompt + "\n" + existing) + } + } else { + systemContents := request.ParseSystem() + newSystem := dto.ClaudeMediaMessage{Type: dto.ContentTypeText} + newSystem.SetText(info.ChannelSetting.SystemPrompt) + if len(systemContents) == 0 { + request.System = []dto.ClaudeMediaMessage{newSystem} + } else { + request.System = append([]dto.ClaudeMediaMessage{newSystem}, systemContents...) + } + } + } + } + + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + if common.DebugEnabled { + println("requestBody: ", string(jsonData)) + } + requestBody = bytes.NewBuffer(jsonData) + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + + if resp != nil { + httpResp = resp.(*http.Response) + info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + //log.Printf("usage: %v", usage) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + service.PostClaudeConsumeQuota(c, info, usage.(*dto.Usage)) + return nil +} diff --git a/relay/common/override.go b/relay/common/override.go new file mode 100644 index 0000000000000000000000000000000000000000..d8ed9bb5a08352249682c8be4bf450dc2ce65d18 --- /dev/null +++ b/relay/common/override.go @@ -0,0 +1,435 @@ +package common + +import ( + "encoding/json" + "fmt" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "regexp" + "strconv" + "strings" +) + +type ConditionOperation struct { + Path string `json:"path"` // JSON路径 + Mode string `json:"mode"` // full, prefix, suffix, contains, gt, gte, lt, lte + Value interface{} `json:"value"` // 匹配的值 + Invert bool `json:"invert"` // 反选功能,true表示取反结果 + PassMissingKey bool `json:"pass_missing_key"` // 未获取到json key时的行为 +} + +type ParamOperation struct { + Path string `json:"path"` + Mode string `json:"mode"` // delete, set, move, prepend, append + Value interface{} `json:"value"` + KeepOrigin bool `json:"keep_origin"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + Conditions []ConditionOperation `json:"conditions,omitempty"` // 条件列表 + Logic string `json:"logic,omitempty"` // AND, OR (默认OR) +} + +func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}) ([]byte, error) { + if len(paramOverride) == 0 { + return jsonData, nil + } + + // 尝试断言为操作格式 + if operations, ok := tryParseOperations(paramOverride); ok { + // 使用新方法 + result, err := applyOperations(string(jsonData), operations) + return []byte(result), err + } + + // 直接使用旧方法 + return applyOperationsLegacy(jsonData, paramOverride) +} + +func tryParseOperations(paramOverride map[string]interface{}) ([]ParamOperation, bool) { + // 检查是否包含 "operations" 字段 + if opsValue, exists := paramOverride["operations"]; exists { + if opsSlice, ok := opsValue.([]interface{}); ok { + var operations []ParamOperation + for _, op := range opsSlice { + if opMap, ok := op.(map[string]interface{}); ok { + operation := ParamOperation{} + + // 断言必要字段 + if path, ok := opMap["path"].(string); ok { + operation.Path = path + } + if mode, ok := opMap["mode"].(string); ok { + operation.Mode = mode + } else { + return nil, false // mode 是必需的 + } + + // 可选字段 + if value, exists := opMap["value"]; exists { + operation.Value = value + } + if keepOrigin, ok := opMap["keep_origin"].(bool); ok { + operation.KeepOrigin = keepOrigin + } + if from, ok := opMap["from"].(string); ok { + operation.From = from + } + if to, ok := opMap["to"].(string); ok { + operation.To = to + } + if logic, ok := opMap["logic"].(string); ok { + operation.Logic = logic + } else { + operation.Logic = "OR" // 默认为OR + } + + // 解析条件 + if conditions, exists := opMap["conditions"]; exists { + if condSlice, ok := conditions.([]interface{}); ok { + for _, cond := range condSlice { + if condMap, ok := cond.(map[string]interface{}); ok { + condition := ConditionOperation{} + if path, ok := condMap["path"].(string); ok { + condition.Path = path + } + if mode, ok := condMap["mode"].(string); ok { + condition.Mode = mode + } + if value, ok := condMap["value"]; ok { + condition.Value = value + } + if invert, ok := condMap["invert"].(bool); ok { + condition.Invert = invert + } + if passMissingKey, ok := condMap["pass_missing_key"].(bool); ok { + condition.PassMissingKey = passMissingKey + } + operation.Conditions = append(operation.Conditions, condition) + } + } + } + } + + operations = append(operations, operation) + } else { + return nil, false + } + } + return operations, true + } + } + + return nil, false +} + +func checkConditions(jsonStr string, conditions []ConditionOperation, logic string) (bool, error) { + if len(conditions) == 0 { + return true, nil // 没有条件,直接通过 + } + results := make([]bool, len(conditions)) + for i, condition := range conditions { + result, err := checkSingleCondition(jsonStr, condition) + if err != nil { + return false, err + } + results[i] = result + } + + if strings.ToUpper(logic) == "AND" { + for _, result := range results { + if !result { + return false, nil + } + } + return true, nil + } else { + for _, result := range results { + if result { + return true, nil + } + } + return false, nil + } +} + +func checkSingleCondition(jsonStr string, condition ConditionOperation) (bool, error) { + // 处理负数索引 + path := processNegativeIndex(jsonStr, condition.Path) + value := gjson.Get(jsonStr, path) + if !value.Exists() { + if condition.PassMissingKey { + return true, nil + } + return false, nil + } + + // 利用gjson的类型解析 + targetBytes, err := json.Marshal(condition.Value) + if err != nil { + return false, fmt.Errorf("failed to marshal condition value: %v", err) + } + targetValue := gjson.ParseBytes(targetBytes) + + result, err := compareGjsonValues(value, targetValue, strings.ToLower(condition.Mode)) + if err != nil { + return false, fmt.Errorf("comparison failed for path %s: %v", condition.Path, err) + } + + if condition.Invert { + result = !result + } + return result, nil +} + +func processNegativeIndex(jsonStr string, path string) string { + re := regexp.MustCompile(`\.(-\d+)`) + matches := re.FindAllStringSubmatch(path, -1) + + if len(matches) == 0 { + return path + } + + result := path + for _, match := range matches { + negIndex := match[1] + index, _ := strconv.Atoi(negIndex) + + arrayPath := strings.Split(path, negIndex)[0] + if strings.HasSuffix(arrayPath, ".") { + arrayPath = arrayPath[:len(arrayPath)-1] + } + + array := gjson.Get(jsonStr, arrayPath) + if array.IsArray() { + length := len(array.Array()) + actualIndex := length + index + if actualIndex >= 0 && actualIndex < length { + result = strings.Replace(result, match[0], "."+strconv.Itoa(actualIndex), 1) + } + } + } + + return result +} + +// compareGjsonValues 直接比较两个gjson.Result,支持所有比较模式 +func compareGjsonValues(jsonValue, targetValue gjson.Result, mode string) (bool, error) { + switch mode { + case "full": + return compareEqual(jsonValue, targetValue) + case "prefix": + return strings.HasPrefix(jsonValue.String(), targetValue.String()), nil + case "suffix": + return strings.HasSuffix(jsonValue.String(), targetValue.String()), nil + case "contains": + return strings.Contains(jsonValue.String(), targetValue.String()), nil + case "gt": + return compareNumeric(jsonValue, targetValue, "gt") + case "gte": + return compareNumeric(jsonValue, targetValue, "gte") + case "lt": + return compareNumeric(jsonValue, targetValue, "lt") + case "lte": + return compareNumeric(jsonValue, targetValue, "lte") + default: + return false, fmt.Errorf("unsupported comparison mode: %s", mode) + } +} + +func compareEqual(jsonValue, targetValue gjson.Result) (bool, error) { + // 对布尔值特殊处理 + if (jsonValue.Type == gjson.True || jsonValue.Type == gjson.False) && + (targetValue.Type == gjson.True || targetValue.Type == gjson.False) { + return jsonValue.Bool() == targetValue.Bool(), nil + } + + // 如果类型不同,报错 + if jsonValue.Type != targetValue.Type { + return false, fmt.Errorf("compare for different types, got %v and %v", jsonValue.Type, targetValue.Type) + } + + switch jsonValue.Type { + case gjson.True, gjson.False: + return jsonValue.Bool() == targetValue.Bool(), nil + case gjson.Number: + return jsonValue.Num == targetValue.Num, nil + case gjson.String: + return jsonValue.String() == targetValue.String(), nil + default: + return jsonValue.String() == targetValue.String(), nil + } +} + +func compareNumeric(jsonValue, targetValue gjson.Result, operator string) (bool, error) { + // 只有数字类型才支持数值比较 + if jsonValue.Type != gjson.Number || targetValue.Type != gjson.Number { + return false, fmt.Errorf("numeric comparison requires both values to be numbers, got %v and %v", jsonValue.Type, targetValue.Type) + } + + jsonNum := jsonValue.Num + targetNum := targetValue.Num + + switch operator { + case "gt": + return jsonNum > targetNum, nil + case "gte": + return jsonNum >= targetNum, nil + case "lt": + return jsonNum < targetNum, nil + case "lte": + return jsonNum <= targetNum, nil + default: + return false, fmt.Errorf("unsupported numeric operator: %s", operator) + } +} + +// applyOperationsLegacy 原参数覆盖方法 +func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}) ([]byte, error) { + reqMap := make(map[string]interface{}) + err := json.Unmarshal(jsonData, &reqMap) + if err != nil { + return nil, err + } + + for key, value := range paramOverride { + reqMap[key] = value + } + + return json.Marshal(reqMap) +} + +func applyOperations(jsonStr string, operations []ParamOperation) (string, error) { + result := jsonStr + for _, op := range operations { + // 检查条件是否满足 + ok, err := checkConditions(result, op.Conditions, op.Logic) + if err != nil { + return "", err + } + if !ok { + continue // 条件不满足,跳过当前操作 + } + // 处理路径中的负数索引 + opPath := processNegativeIndex(result, op.Path) + opFrom := processNegativeIndex(result, op.From) + opTo := processNegativeIndex(result, op.To) + + switch op.Mode { + case "delete": + result, err = sjson.Delete(result, opPath) + case "set": + if op.KeepOrigin && gjson.Get(result, opPath).Exists() { + continue + } + result, err = sjson.Set(result, opPath, op.Value) + case "move": + result, err = moveValue(result, opFrom, opTo) + case "prepend": + result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, true) + case "append": + result, err = modifyValue(result, opPath, op.Value, op.KeepOrigin, false) + default: + return "", fmt.Errorf("unknown operation: %s", op.Mode) + } + if err != nil { + return "", fmt.Errorf("operation %s failed: %v", op.Mode, err) + } + } + return result, nil +} + +func moveValue(jsonStr, fromPath, toPath string) (string, error) { + sourceValue := gjson.Get(jsonStr, fromPath) + if !sourceValue.Exists() { + return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath) + } + result, err := sjson.Set(jsonStr, toPath, sourceValue.Value()) + if err != nil { + return "", err + } + return sjson.Delete(result, fromPath) +} + +func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) { + current := gjson.Get(jsonStr, path) + switch { + case current.IsArray(): + return modifyArray(jsonStr, path, value, isPrepend) + case current.Type == gjson.String: + return modifyString(jsonStr, path, value, isPrepend) + case current.Type == gjson.JSON: + return mergeObjects(jsonStr, path, value, keepOrigin) + } + return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type) +} + +func modifyArray(jsonStr, path string, value interface{}, isPrepend bool) (string, error) { + current := gjson.Get(jsonStr, path) + var newArray []interface{} + // 添加新值 + addValue := func() { + if arr, ok := value.([]interface{}); ok { + newArray = append(newArray, arr...) + } else { + newArray = append(newArray, value) + } + } + // 添加原值 + addOriginal := func() { + current.ForEach(func(_, val gjson.Result) bool { + newArray = append(newArray, val.Value()) + return true + }) + } + if isPrepend { + addValue() + addOriginal() + } else { + addOriginal() + addValue() + } + return sjson.Set(jsonStr, path, newArray) +} + +func modifyString(jsonStr, path string, value interface{}, isPrepend bool) (string, error) { + current := gjson.Get(jsonStr, path) + valueStr := fmt.Sprintf("%v", value) + var newStr string + if isPrepend { + newStr = valueStr + current.String() + } else { + newStr = current.String() + valueStr + } + return sjson.Set(jsonStr, path, newStr) +} + +func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (string, error) { + current := gjson.Get(jsonStr, path) + var currentMap, newMap map[string]interface{} + + // 解析当前值 + if err := json.Unmarshal([]byte(current.Raw), ¤tMap); err != nil { + return "", err + } + // 解析新值 + switch v := value.(type) { + case map[string]interface{}: + newMap = v + default: + jsonBytes, _ := json.Marshal(v) + if err := json.Unmarshal(jsonBytes, &newMap); err != nil { + return "", err + } + } + // 合并 + result := make(map[string]interface{}) + for k, v := range currentMap { + result[k] = v + } + for k, v := range newMap { + if !keepOrigin || result[k] == nil { + result[k] = v + } + } + return sjson.Set(jsonStr, path, result) +} diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go new file mode 100644 index 0000000000000000000000000000000000000000..52162ad64668c40e44758edd29d721fc4260cfe7 --- /dev/null +++ b/relay/common/relay_info.go @@ -0,0 +1,509 @@ +package common + +import ( + "errors" + "fmt" + "one-api/common" + "one-api/constant" + "one-api/dto" + relayconstant "one-api/relay/constant" + "one-api/types" + "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 +} + +type RerankerInfo struct { + Documents []any + ReturnDocuments bool +} + +type BuildInToolInfo struct { + ToolName string + CallCount int + SearchContextSize string +} + +type ResponsesUsageInfo struct { + BuiltInTools map[string]*BuildInToolInfo +} + +type ChannelMeta struct { + ChannelType int + ChannelId int + ChannelIsMultiKey bool + ChannelMultiKeyIndex int + ChannelBaseUrl string + ApiType int + ApiVersion string + ApiKey string + Organization string + ChannelCreateTime int64 + ParamOverride map[string]interface{} + HeadersOverride map[string]interface{} + ChannelSetting dto.ChannelSettings + ChannelOtherSettings dto.ChannelOtherSettings + UpstreamModelName string + IsModelMapped bool + SupportStreamOptions bool // 是否支持流式选项 +} + +type RelayInfo struct { + TokenId int + TokenKey string + UserId int + UsingGroup string // 使用的分组 + UserGroup string // 用户所在分组 + TokenUnlimited bool + StartTime time.Time + FirstResponseTime time.Time + isFirstResponse bool + //SendLastReasoningResponse bool + IsStream bool + IsGeminiBatchEmbedding bool + IsPlayground bool + UsePrice bool + RelayMode int + OriginModelName string + RequestURLPath string + PromptTokens int + ShouldIncludeUsage bool + DisablePing bool // 是否禁止向下游发送自定义 Ping + ClientWs *websocket.Conn + TargetWs *websocket.Conn + InputAudioFormat string + OutputAudioFormat string + RealtimeTools []dto.RealTimeTool + IsFirstRequest bool + AudioUsage bool + ReasoningEffort string + UserSetting dto.UserSetting + UserEmail string + UserQuota int + RelayFormat types.RelayFormat + SendResponseCount int + FinalPreConsumedQuota int // 最终预消耗的配额 + IsClaudeBetaQuery bool // /v1/messages?beta=true + + PriceData types.PriceData + + Request dto.Request + + ThinkingContentInfo + *ClaudeConvertInfo + *RerankerInfo + *ResponsesUsageInfo + *ChannelMeta + *TaskRelayInfo +} + +func (info *RelayInfo) InitChannelMeta(c *gin.Context) { + channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType) + paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride) + headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride) + apiType, _ := common.ChannelType2APIType(channelType) + channelMeta := &ChannelMeta{ + ChannelType: channelType, + ChannelId: common.GetContextKeyInt(c, constant.ContextKeyChannelId), + ChannelIsMultiKey: common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey), + ChannelMultiKeyIndex: common.GetContextKeyInt(c, constant.ContextKeyChannelMultiKeyIndex), + ChannelBaseUrl: common.GetContextKeyString(c, constant.ContextKeyChannelBaseUrl), + 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, + HeadersOverride: headerOverride, + UpstreamModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel), + IsModelMapped: false, + SupportStreamOptions: false, + } + + if channelType == constant.ChannelTypeAzure { + channelMeta.ApiVersion = GetAPIVersion(c) + } + if channelType == constant.ChannelTypeVertexAi { + channelMeta.ApiVersion = c.GetString("region") + } + + channelSetting, ok := common.GetContextKeyType[dto.ChannelSettings](c, constant.ContextKeyChannelSetting) + if ok { + channelMeta.ChannelSetting = channelSetting + } + + channelOtherSettings, ok := common.GetContextKeyType[dto.ChannelOtherSettings](c, constant.ContextKeyChannelOtherSetting) + if ok { + channelMeta.ChannelOtherSettings = channelOtherSettings + } + + if streamSupportedChannels[channelMeta.ChannelType] { + channelMeta.SupportStreamOptions = true + } + + info.ChannelMeta = channelMeta + + // reset some fields based on channel meta + // 重置某些字段,例如模型名称等 + if info.Request != nil { + info.Request.SetModelName(info.OriginModelName) + } +} + +func (info *RelayInfo) ToString() string { + if info == nil { + return "RelayInfo" + } + + // Basic info + b := &strings.Builder{} + fmt.Fprintf(b, "RelayInfo{ ") + fmt.Fprintf(b, "RelayFormat: %s, ", info.RelayFormat) + fmt.Fprintf(b, "RelayMode: %d, ", info.RelayMode) + fmt.Fprintf(b, "IsStream: %t, ", info.IsStream) + fmt.Fprintf(b, "IsPlayground: %t, ", info.IsPlayground) + fmt.Fprintf(b, "RequestURLPath: %q, ", info.RequestURLPath) + fmt.Fprintf(b, "OriginModelName: %q, ", info.OriginModelName) + fmt.Fprintf(b, "PromptTokens: %d, ", info.PromptTokens) + fmt.Fprintf(b, "ShouldIncludeUsage: %t, ", info.ShouldIncludeUsage) + fmt.Fprintf(b, "DisablePing: %t, ", info.DisablePing) + fmt.Fprintf(b, "SendResponseCount: %d, ", info.SendResponseCount) + fmt.Fprintf(b, "FinalPreConsumedQuota: %d, ", info.FinalPreConsumedQuota) + + // User & token info (mask secrets) + fmt.Fprintf(b, "User{ Id: %d, Email: %q, Group: %q, UsingGroup: %q, Quota: %d }, ", + info.UserId, common.MaskEmail(info.UserEmail), info.UserGroup, info.UsingGroup, info.UserQuota) + fmt.Fprintf(b, "Token{ Id: %d, Unlimited: %t, Key: ***masked*** }, ", info.TokenId, info.TokenUnlimited) + + // Time info + latencyMs := info.FirstResponseTime.Sub(info.StartTime).Milliseconds() + fmt.Fprintf(b, "Timing{ Start: %s, FirstResponse: %s, LatencyMs: %d }, ", + info.StartTime.Format(time.RFC3339Nano), info.FirstResponseTime.Format(time.RFC3339Nano), latencyMs) + + // Audio / realtime + if info.InputAudioFormat != "" || info.OutputAudioFormat != "" || len(info.RealtimeTools) > 0 || info.AudioUsage { + fmt.Fprintf(b, "Realtime{ AudioUsage: %t, InFmt: %q, OutFmt: %q, Tools: %d }, ", + info.AudioUsage, info.InputAudioFormat, info.OutputAudioFormat, len(info.RealtimeTools)) + } + + // Reasoning + if info.ReasoningEffort != "" { + fmt.Fprintf(b, "ReasoningEffort: %q, ", info.ReasoningEffort) + } + + // Price data (non-sensitive) + if info.PriceData.UsePrice { + fmt.Fprintf(b, "PriceData{ %s }, ", info.PriceData.ToSetting()) + } + + // Channel metadata (mask ApiKey) + if info.ChannelMeta != nil { + cm := info.ChannelMeta + fmt.Fprintf(b, "ChannelMeta{ Type: %d, Id: %d, IsMultiKey: %t, MultiKeyIndex: %d, BaseURL: %q, ApiType: %d, ApiVersion: %q, Organization: %q, CreateTime: %d, UpstreamModelName: %q, IsModelMapped: %t, SupportStreamOptions: %t, ApiKey: ***masked*** }, ", + cm.ChannelType, cm.ChannelId, cm.ChannelIsMultiKey, cm.ChannelMultiKeyIndex, cm.ChannelBaseUrl, cm.ApiType, cm.ApiVersion, cm.Organization, cm.ChannelCreateTime, cm.UpstreamModelName, cm.IsModelMapped, cm.SupportStreamOptions) + } + + // Responses usage info (non-sensitive) + if info.ResponsesUsageInfo != nil && len(info.ResponsesUsageInfo.BuiltInTools) > 0 { + fmt.Fprintf(b, "ResponsesTools{ ") + first := true + for name, tool := range info.ResponsesUsageInfo.BuiltInTools { + if !first { + fmt.Fprintf(b, ", ") + } + first = false + if tool != nil { + fmt.Fprintf(b, "%s: calls=%d", name, tool.CallCount) + } else { + fmt.Fprintf(b, "%s: calls=0", name) + } + } + fmt.Fprintf(b, " }, ") + } + + fmt.Fprintf(b, "}") + return b.String() +} + +// 定义支持流式选项的通道类型 +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 := genBaseRelayInfo(c, nil) + info.RelayFormat = types.RelayFormatOpenAIRealtime + info.ClientWs = ws + info.InputAudioFormat = "pcm16" + info.OutputAudioFormat = "pcm16" + info.IsFirstRequest = true + return info +} + +func GenRelayInfoClaude(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatClaude + info.ShouldIncludeUsage = false + info.ClaudeConvertInfo = &ClaudeConvertInfo{ + LastMessagesType: LastMessageTypeNone, + } + if c.Query("beta") == "true" { + info.IsClaudeBetaQuery = true + } + return info +} + +func GenRelayInfoRerank(c *gin.Context, request *dto.RerankRequest) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayMode = relayconstant.RelayModeRerank + info.RelayFormat = types.RelayFormatRerank + info.RerankerInfo = &RerankerInfo{ + Documents: request.Documents, + ReturnDocuments: request.GetReturnDocuments(), + } + return info +} + +func GenRelayInfoOpenAIAudio(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatOpenAIAudio + return info +} + +func GenRelayInfoEmbedding(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatEmbedding + return info +} + +func GenRelayInfoResponses(c *gin.Context, request *dto.OpenAIResponsesRequest) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayMode = relayconstant.RelayModeResponses + info.RelayFormat = types.RelayFormatOpenAIResponses + + info.ResponsesUsageInfo = &ResponsesUsageInfo{ + BuiltInTools: make(map[string]*BuildInToolInfo), + } + if len(request.Tools) > 0 { + for _, tool := range request.GetToolsMap() { + 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 + } + } + } + return info +} + +func GenRelayInfoGemini(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatGemini + info.ShouldIncludeUsage = false + + return info +} + +func GenRelayInfoImage(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatOpenAIImage + return info +} + +func GenRelayInfoOpenAI(c *gin.Context, request dto.Request) *RelayInfo { + info := genBaseRelayInfo(c, request) + info.RelayFormat = types.RelayFormatOpenAI + return info +} + +func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { + + //channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType) + //channelId := common.GetContextKeyInt(c, constant.ContextKeyChannelId) + //paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride) + + startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime) + if startTime.IsZero() { + startTime = time.Now() + } + + isStream := false + + if request != nil { + isStream = request.IsStream(c) + } + + // firstResponseTime = time.Now() - 1 second + + info := &RelayInfo{ + Request: request, + + UserId: common.GetContextKeyInt(c, constant.ContextKeyUserId), + UsingGroup: common.GetContextKeyString(c, constant.ContextKeyUsingGroup), + UserGroup: common.GetContextKeyString(c, constant.ContextKeyUserGroup), + UserQuota: common.GetContextKeyInt(c, constant.ContextKeyUserQuota), + UserEmail: common.GetContextKeyString(c, constant.ContextKeyUserEmail), + + OriginModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel), + PromptTokens: common.GetContextKeyInt(c, constant.ContextKeyPromptTokens), + + TokenId: common.GetContextKeyInt(c, constant.ContextKeyTokenId), + TokenKey: common.GetContextKeyString(c, constant.ContextKeyTokenKey), + TokenUnlimited: common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited), + + isFirstResponse: true, + RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path), + RequestURLPath: c.Request.URL.String(), + IsStream: isStream, + + StartTime: startTime, + FirstResponseTime: startTime.Add(-time.Second), + ThinkingContentInfo: ThinkingContentInfo{ + IsFirstThinkingContent: true, + SendLastThinkingContent: false, + }, + } + + if info.RelayMode == relayconstant.RelayModeUnknown { + info.RelayMode = c.GetInt("relay_mode") + } + + if strings.HasPrefix(c.Request.URL.Path, "/pg") { + info.IsPlayground = true + info.RequestURLPath = strings.TrimPrefix(info.RequestURLPath, "/pg") + info.RequestURLPath = "/v1" + info.RequestURLPath + } + + userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting) + if ok { + info.UserSetting = userSetting + } + + return info +} + +func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, ws *websocket.Conn) (*RelayInfo, error) { + switch relayFormat { + case types.RelayFormatOpenAI: + return GenRelayInfoOpenAI(c, request), nil + case types.RelayFormatOpenAIAudio: + return GenRelayInfoOpenAIAudio(c, request), nil + case types.RelayFormatOpenAIImage: + return GenRelayInfoImage(c, request), nil + case types.RelayFormatOpenAIRealtime: + return GenRelayInfoWs(c, ws), nil + case types.RelayFormatClaude: + return GenRelayInfoClaude(c, request), nil + case types.RelayFormatRerank: + if request, ok := request.(*dto.RerankRequest); ok { + return GenRelayInfoRerank(c, request), nil + } + return nil, errors.New("request is not a RerankRequest") + case types.RelayFormatGemini: + return GenRelayInfoGemini(c, request), nil + case types.RelayFormatEmbedding: + return GenRelayInfoEmbedding(c, request), nil + case types.RelayFormatOpenAIResponses: + if request, ok := request.(*dto.OpenAIResponsesRequest); ok { + return GenRelayInfoResponses(c, request), nil + } + return nil, errors.New("request is not a OpenAIResponsesRequest") + case types.RelayFormatTask: + return genBaseRelayInfo(c, nil), nil + case types.RelayFormatMjProxy: + return genBaseRelayInfo(c, nil), nil + default: + return nil, errors.New("invalid relay format") + } +} + +func (info *RelayInfo) SetPromptTokens(promptTokens int) { + info.PromptTokens = promptTokens +} + +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 { + Action string + OriginTaskID string + + ConsumeQuota bool +} + +type TaskSubmitReq struct { + Prompt string `json:"prompt"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + Image string `json:"image,omitempty"` + Images []string `json:"images,omitempty"` + Size string `json:"size,omitempty"` + Duration int `json:"duration,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` +} + +func (t TaskSubmitReq) GetPrompt() string { + return t.Prompt +} + +func (t TaskSubmitReq) HasImage() bool { + return len(t.Images) > 0 +} + +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 0000000000000000000000000000000000000000..3b27242a27510d42fe1673ea9b30d408a7abb7c4 --- /dev/null +++ b/relay/common/relay_utils.go @@ -0,0 +1,96 @@ +package common + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "strings" + + "github.com/gin-gonic/gin" +) + +type HasPrompt interface { + GetPrompt() string +} + +type HasImage interface { + HasImage() bool +} + +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 +} + +func createTaskError(err error, code string, statusCode int, localError bool) *dto.TaskError { + return &dto.TaskError{ + Code: code, + Message: err.Error(), + StatusCode: statusCode, + LocalError: localError, + Error: err, + } +} + +func storeTaskRequest(c *gin.Context, info *RelayInfo, action string, requestObj interface{}) { + info.Action = action + c.Set("task_request", requestObj) +} + +func validatePrompt(prompt string) *dto.TaskError { + if strings.TrimSpace(prompt) == "" { + return createTaskError(fmt.Errorf("prompt is required"), "invalid_request", http.StatusBadRequest, true) + } + return nil +} + +func ValidateBasicTaskRequest(c *gin.Context, info *RelayInfo, action string) *dto.TaskError { + var req TaskSubmitReq + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + return createTaskError(err, "invalid_request", http.StatusBadRequest, true) + } + + if taskErr := validatePrompt(req.Prompt); taskErr != nil { + return taskErr + } + + if len(req.Images) == 0 && strings.TrimSpace(req.Image) != "" { + // 兼容单图上传 + req.Images = []string{req.Image} + } + + if req.HasImage() { + action = constant.TaskActionGenerate + if info.ChannelType == constant.ChannelTypeVidu { + // vidu 增加 首尾帧生视频和参考图生视频 + if len(req.Images) == 2 { + action = constant.TaskActionFirstTailGenerate + } else if len(req.Images) > 2 { + action = constant.TaskActionReferenceGenerate + } + } + } + + storeTaskRequest(c, info, action, req) + return nil +} diff --git a/relay/common_handler/rerank.go b/relay/common_handler/rerank.go new file mode 100644 index 0000000000000000000000000000000000000000..8c13e2cee07796952aab2f67dddf707222f64287 --- /dev/null +++ b/relay/common_handler/rerank.go @@ -0,0 +1,74 @@ +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/service" + "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.NewOpenAIError(err, types.ErrorCodeReadResponseBodyFailed, http.StatusInternalServerError) + } + service.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.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + 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.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError) + } + 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/compatible_handler.go b/relay/compatible_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..846d818b269db9bc4044c621d7e4da1bfea38630 --- /dev/null +++ b/relay/compatible_handler.go @@ -0,0 +1,461 @@ +package relay + +import ( + "bytes" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/logger" + "one-api/model" + relaycommon "one-api/relay/common" + "one-api/relay/helper" + "one-api/service" + "one-api/setting/model_setting" + "one-api/setting/operation_setting" + "one-api/types" + "strings" + "time" + + "github.com/shopspring/decimal" + + "github.com/gin-gonic/gin" +) + +func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + textReq, ok := info.Request.(*dto.GeneralOpenAIRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected dto.GeneralOpenAIRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(textReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to GeneralOpenAIRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + if request.WebSearchOptions != nil { + c.Set("chat_completion_web_search_context_size", request.WebSearchOptions.SearchContextSize) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + includeUsage := true + // 判断用户是否需要返回使用情况 + if request.StreamOptions != nil { + includeUsage = request.StreamOptions.IncludeUsage + } + + // 如果不支持StreamOptions,将StreamOptions设置为nil + if !info.SupportStreamOptions || !request.Stream { + request.StreamOptions = nil + } else { + // 如果支持StreamOptions,且请求中没有设置StreamOptions,根据配置文件设置StreamOptions + if constant.ForceStreamOption { + request.StreamOptions = &dto.StreamOptions{ + IncludeUsage: true, + } + } + } + + info.ShouldIncludeUsage = includeUsage + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + var requestBody io.Reader + + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + if common.DebugEnabled { + println("requestBody: ", string(body)) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + if info.ChannelSetting.SystemPrompt != "" { + // 如果有系统提示,则将其添加到请求中 + request, ok := convertedRequest.(*dto.GeneralOpenAIRequest) + if ok { + containSystemPrompt := false + for _, message := range request.Messages { + if message.Role == request.GetSystemRoleName() { + containSystemPrompt = true + break + } + } + if !containSystemPrompt { + // 如果没有系统提示,则添加系统提示 + systemMessage := dto.Message{ + Role: request.GetSystemRoleName(), + Content: info.ChannelSetting.SystemPrompt, + } + request.Messages = append([]dto.Message{systemMessage}, request.Messages...) + } else if info.ChannelSetting.SystemPromptOverride { + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) + // 如果有系统提示,且允许覆盖,则拼接到前面 + for i, message := range request.Messages { + if message.Role == request.GetSystemRoleName() { + if message.IsStringContent() { + request.Messages[i].SetStringContent(info.ChannelSetting.SystemPrompt + "\n" + message.StringContent()) + } else { + contents := message.ParseContent() + contents = append([]dto.MediaContent{ + { + Type: dto.ContentTypeText, + Text: info.ChannelSetting.SystemPrompt, + }, + }, contents...) + request.Messages[i].Content = contents + } + break + } + } + } + } + } + + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + logger.LogDebug(c, fmt.Sprintf("text request body: %s", string(jsonData))) + + requestBody = bytes.NewBuffer(jsonData) + } + + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, info, 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) + info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return newApiErr + } + } + + usage, newApiErr := adaptor.DoResponse(c, httpResp, info) + if newApiErr != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newApiErr, statusCodeMappingStr) + return newApiErr + } + + if strings.HasPrefix(info.OriginModelName, "gpt-4o-audio") { + service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") + } else { + postConsumeQuota(c, info, usage.(*dto.Usage), "") + } + return nil +} + +func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, 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 + cachedCreationTokens := usage.PromptTokensDetails.CachedCreationTokens + + modelName := relayInfo.OriginModelName + + tokenName := ctx.GetString("token_name") + completionRatio := relayInfo.PriceData.CompletionRatio + cacheRatio := relayInfo.PriceData.CacheRatio + imageRatio := relayInfo.PriceData.ImageRatio + modelRatio := relayInfo.PriceData.ModelRatio + groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio + modelPrice := relayInfo.PriceData.ModelPrice + cachedCreationRatio := relayInfo.PriceData.CacheCreationRatio + + // 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)) + dCachedCreationTokens := decimal.NewFromInt(int64(cachedCreationTokens)) + dCompletionRatio := decimal.NewFromFloat(completionRatio) + dCacheRatio := decimal.NewFromFloat(cacheRatio) + dImageRatio := decimal.NewFromFloat(imageRatio) + dModelRatio := decimal.NewFromFloat(modelRatio) + dGroupRatio := decimal.NewFromFloat(groupRatio) + dModelPrice := decimal.NewFromFloat(modelPrice) + dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio) + 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 dImageGenerationCallQuota decimal.Decimal + var imageGenerationCallPrice float64 + if ctx.GetBool("image_generation_call") { + imageGenerationCallPrice = operation_setting.GetGPTImage1PriceOnceCall(ctx.GetString("image_generation_call_quality"), ctx.GetString("image_generation_call_size")) + dImageGenerationCallQuota = decimal.NewFromFloat(imageGenerationCallPrice).Mul(dGroupRatio).Mul(dQuotaPerUnit) + extraContent += fmt.Sprintf("Image Generation Call 花费 %s", dImageGenerationCallQuota.String()) + } + + var quotaCalculateDecimal decimal.Decimal + + var audioInputQuota decimal.Decimal + var audioInputPrice float64 + if !relayInfo.PriceData.UsePrice { + baseTokens := dPromptTokens + // 减去 cached tokens + var cachedTokensWithRatio decimal.Decimal + if !dCacheTokens.IsZero() { + baseTokens = baseTokens.Sub(dCacheTokens) + cachedTokensWithRatio = dCacheTokens.Mul(dCacheRatio) + } + var dCachedCreationTokensWithRatio decimal.Decimal + if !dCachedCreationTokens.IsZero() { + baseTokens = baseTokens.Sub(dCachedCreationTokens) + dCachedCreationTokensWithRatio = dCachedCreationTokens.Mul(dCachedCreationRatio) + } + + // 减去 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). + Add(dCachedCreationTokensWithRatio) + + 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) + // 添加 image generation call 计费 + quotaCalculateDecimal = quotaCalculateDecimal.Add(dImageGenerationCallQuota) + + quota := int(quotaCalculateDecimal.Round(0).IntPart()) + 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("(可能是上游超时)") + logger.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, relayInfo.FinalPreConsumedQuota)) + } else { + if !ratio.IsZero() && quota == 0 { + quota = 1 + } + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + quotaDelta := quota - relayInfo.FinalPreConsumedQuota + + //logger.LogInfo(ctx, fmt.Sprintf("request quota delta: %s", logger.FormatQuota(quotaDelta))) + + if quotaDelta > 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } else if quotaDelta < 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(-quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } + + if quotaDelta != 0 { + err := service.PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true) + if err != nil { + logger.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, relayInfo.PriceData.GroupRatioInfo.GroupSpecialRatio) + if imageTokens != 0 { + other["image"] = true + other["image_ratio"] = imageRatio + other["image_output"] = imageTokens + } + if cachedCreationTokens != 0 { + other["cache_creation_tokens"] = cachedCreationTokens + other["cache_creation_ratio"] = cachedCreationRatio + } + 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 + } + if !dImageGenerationCallQuota.IsZero() { + other["image_generation_call"] = true + other["image_generation_call_price"] = imageGenerationCallPrice + } + 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, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) +} diff --git a/relay/constant/relay_mode.go b/relay/constant/relay_mode.go new file mode 100644 index 0000000000000000000000000000000000000000..503f0fcc12bf246859d01dfac7aefef6d7ebf6a6 --- /dev/null +++ b/relay/constant/relay_mode.go @@ -0,0 +1,146 @@ +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 + + RelayModeVideoFetchByID + RelayModeVideoSubmit + + 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 + } else if strings.HasPrefix(path, "/mj") { + relayMode = Path2RelayModeMidjourney(path) + } + 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 +} diff --git a/relay/embedding_handler.go b/relay/embedding_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..d26b9c0a87d855bd6bb5a98d9352c15ae533ac28 --- /dev/null +++ b/relay/embedding_handler.go @@ -0,0 +1,76 @@ +package relay + +import ( + "bytes" + "encoding/json" + "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 EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + embeddingReq, ok := info.Request.(*dto.EmbeddingRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected *dto.EmbeddingRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(embeddingReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to EmbeddingRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + convertedRequest, err := adaptor.ConvertEmbeddingRequest(c, info, *request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := json.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + requestBody := bytes.NewBuffer(jsonData) + statusCodeMappingStr := c.GetString("status_code_mapping") + resp, err := adaptor.DoRequest(c, info, 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(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + postConsumeQuota(c, info, usage.(*dto.Usage), "") + return nil +} diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..be59227231e559b70f3236bc98d9603d2a2d2f57 --- /dev/null +++ b/relay/gemini_handler.go @@ -0,0 +1,293 @@ +package relay + +import ( + "bytes" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/logger" + "one-api/relay/channel/gemini" + 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 isNoThinkingRequest(req *dto.GeminiChatRequest) bool { + if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { + configBudget := req.GenerationConfig.ThinkingConfig.ThinkingBudget + if configBudget != nil && *configBudget == 0 { + // 如果思考预算为 0,则认为是非思考请求 + return true + } + } + 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, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + geminiReq, ok := info.Request.(*dto.GeminiChatRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected *dto.GeminiChatRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(geminiReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to GeminiChatRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + // model mapped 模型映射 + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { + if isNoThinkingRequest(request) { + // check is thinking + if !strings.Contains(info.OriginModelName, "-nothinking") { + // try to get no thinking model price + noThinkingModelName := info.OriginModelName + "-nothinking" + containPrice := helper.ContainPriceOrRatio(noThinkingModelName) + if containPrice { + info.OriginModelName = noThinkingModelName + info.UpstreamModelName = noThinkingModelName + } + } + } + if request.GenerationConfig.ThinkingConfig == nil { + gemini.ThinkingAdaptor(request, info) + } + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + + adaptor.Init(info) + + if info.ChannelSetting.SystemPrompt != "" { + if request.SystemInstructions == nil { + request.SystemInstructions = &dto.GeminiChatContent{ + Parts: []dto.GeminiPart{ + {Text: info.ChannelSetting.SystemPrompt}, + }, + } + } else if len(request.SystemInstructions.Parts) == 0 { + request.SystemInstructions.Parts = []dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}} + } else if info.ChannelSetting.SystemPromptOverride { + common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) + merged := false + for i := range request.SystemInstructions.Parts { + if request.SystemInstructions.Parts[i].Text == "" { + continue + } + request.SystemInstructions.Parts[i].Text = info.ChannelSetting.SystemPrompt + "\n" + request.SystemInstructions.Parts[i].Text + merged = true + break + } + if !merged { + request.SystemInstructions.Parts = append([]dto.GeminiPart{{Text: info.ChannelSetting.SystemPrompt}}, request.SystemInstructions.Parts...) + } + } + } + + // Clean up empty system instruction + if request.SystemInstructions != nil { + hasContent := false + for _, part := range request.SystemInstructions.Parts { + if part.Text != "" { + hasContent = true + break + } + } + if !hasContent { + request.SystemInstructions = nil + } + } + + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + requestBody = bytes.NewReader(body) + } else { + // 使用 ConvertGeminiRequest 转换请求格式 + convertedRequest, err := adaptor.ConvertGeminiRequest(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + logger.LogDebug(c, "Gemini request body: "+string(jsonData)) + + requestBody = bytes.NewReader(jsonData) + } + + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + logger.LogError(c, "Do gemini request failed: "+err.Error()) + 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) + info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), info) + if openaiErr != nil { + service.ResetStatusCode(openaiErr, statusCodeMappingStr) + return openaiErr + } + + postConsumeQuota(c, info, usage.(*dto.Usage), "") + return nil +} + +func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + isBatch := strings.HasSuffix(c.Request.URL.Path, "batchEmbedContents") + info.IsGeminiBatchEmbedding = isBatch + + var req dto.Request + var err error + var inputTexts []string + + if isBatch { + batchRequest := &dto.GeminiBatchEmbeddingRequest{} + err = common.UnmarshalBodyReusable(c, batchRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + req = batchRequest + for _, r := range batchRequest.Requests { + for _, part := range r.Content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + } + } + } else { + singleRequest := &dto.GeminiEmbeddingRequest{} + err = common.UnmarshalBodyReusable(c, singleRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + req = singleRequest + for _, part := range singleRequest.Content.Parts { + if part.Text != "" { + inputTexts = append(inputTexts, part.Text) + } + } + } + + err = helper.ModelMappedHelper(c, info, req) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + var requestBody io.Reader + jsonData, err := common.Marshal(req) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + reqMap := make(map[string]interface{}) + _ = common.Unmarshal(jsonData, &reqMap) + for key, value := range info.ParamOverride { + reqMap[key] = value + } + jsonData, err = common.Marshal(reqMap) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + requestBody = bytes.NewReader(jsonData) + + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + logger.LogError(c, "Do gemini request failed: "+err.Error()) + 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(c.Request.Context(), httpResp, false) + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, openaiErr := adaptor.DoResponse(c, resp.(*http.Response), info) + if openaiErr != nil { + service.ResetStatusCode(openaiErr, statusCodeMappingStr) + return openaiErr + } + + postConsumeQuota(c, info, usage.(*dto.Usage), "") + return nil +} diff --git a/relay/helper/common.go b/relay/helper/common.go new file mode 100644 index 0000000000000000000000000000000000000000..e85907e662bb39e40a995d9784d7068f8778d2f6 --- /dev/null +++ b/relay/helper/common.go @@ -0,0 +1,183 @@ +package helper + +import ( + "errors" + "fmt" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/logger" + "one-api/types" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func FlushWriter(c *gin.Context) error { + if c.Writer == nil { + return nil + } + if flusher, ok := c.Writer.(http.Flusher); ok { + flusher.Flush() + return nil + } + return errors.New("streaming error: flusher not found") +} + +func SetEventStreamHeaders(c *gin.Context) { + // 检查是否已经设置过头部 + if _, exists := c.Get("event_stream_headers_set"); exists { + return + } + + // 设置标志,表示头部已经设置过 + c.Set("event_stream_headers_set", true) + + 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") +} + +func ClaudeData(c *gin.Context, resp dto.ClaudeResponse) error { + jsonData, err := common.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)}) + } + _ = FlushWriter(c) + 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)}) + _ = FlushWriter(c) +} + +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)}) + _ = FlushWriter(c) +} + +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}) + _ = FlushWriter(c) + return nil +} + +func PingData(c *gin.Context) error { + c.Writer.Write([]byte(": PING\n\n")) + _ = FlushWriter(c) + 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 { + logger.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 := common.Marshal(object) + if err != nil { + return fmt.Errorf("error marshalling object: %w", err) + } + if ws == nil { + logger.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) { + if ws == nil { + return + } + 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 GenerateStartEmptyResponse(id string, createAt int64, model string, systemFingerprint *string) *dto.ChatCompletionsStreamResponse { + return &dto.ChatCompletionsStreamResponse{ + Id: id, + Object: "chat.completion.chunk", + Created: createAt, + Model: model, + SystemFingerprint: systemFingerprint, + Choices: []dto.ChatCompletionsStreamResponseChoice{ + { + Delta: dto.ChatCompletionsStreamResponseChoiceDelta{ + Role: "assistant", + Content: common.GetPointer(""), + }, + }, + }, + } +} + +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 0000000000000000000000000000000000000000..cda81d4ceec42f81f577343823da7c7b59546daa --- /dev/null +++ b/relay/helper/model_mapped.go @@ -0,0 +1,57 @@ +package helper + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "one-api/dto" + "one-api/relay/common" +) + +func ModelMappedHelper(c *gin.Context, info *common.RelayInfo, request dto.Request) 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 { + request.SetModelName(info.UpstreamModelName) + } + return nil +} diff --git a/relay/helper/price.go b/relay/helper/price.go new file mode 100644 index 0000000000000000000000000000000000000000..63e3a596728f21843e2c2503ff9604b0875b6679 --- /dev/null +++ b/relay/helper/price.go @@ -0,0 +1,143 @@ +package helper + +import ( + "fmt" + "one-api/common" + relaycommon "one-api/relay/common" + "one-api/setting/ratio_setting" + "one-api/types" + + "github.com/gin-gonic/gin" +) + +// 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) types.GroupRatioInfo { + groupRatioInfo := types.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, meta *types.TokenCountMeta) (types.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 + var audioRatio float64 + var audioCompletionRatio float64 + if !usePrice { + preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota) + if meta.MaxTokens != 0 { + preConsumedTokens += meta.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 types.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) + audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) + audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) + ratio := modelRatio * groupRatioInfo.GroupRatio + preConsumedQuota = int(float64(preConsumedTokens) * ratio) + } else { + if meta.ImagePriceRatio != 0 { + modelPrice = modelPrice * meta.ImagePriceRatio + } + preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) + } + + priceData := types.PriceData{ + ModelPrice: modelPrice, + ModelRatio: modelRatio, + CompletionRatio: completionRatio, + GroupRatioInfo: groupRatioInfo, + UsePrice: usePrice, + CacheRatio: cacheRatio, + ImageRatio: imageRatio, + AudioRatio: audioRatio, + AudioCompletionRatio: audioCompletionRatio, + CacheCreationRatio: cacheCreationRatio, + ShouldPreConsumedQuota: preConsumedQuota, + } + + if common.DebugEnabled { + println(fmt.Sprintf("model_price_helper result: %s", priceData.ToSetting())) + } + info.PriceData = priceData + return priceData, nil +} + +// ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task) +func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types.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 := types.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 0000000000000000000000000000000000000000..14778286c3379fc4a175571c2424576b927ecd9d --- /dev/null +++ b/relay/helper/stream_scanner.go @@ -0,0 +1,262 @@ +package helper + +import ( + "bufio" + "context" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/logger" + 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 + + 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 && !info.DisablePing + 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): + logger.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 { + logger.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 { + logger.LogError(c, "ping data error: "+err.Error()) + return + } + if common.DebugEnabled { + println("ping data sent") + } + case <-time.After(10 * time.Second): + logger.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: + logger.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 { + logger.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): + logger.LogError(c, "data handler timeout") + return + case <-ctx.Done(): + return + case <-stopChan: + return + } + } else { + // done, 处理完成标志,直接退出停止读取剩余数据防止出错 + if common.DebugEnabled { + println("received [DONE], stopping scanner") + } + return + } + } + + if err := scanner.Err(); err != nil { + if err != io.EOF { + logger.LogError(c, "scanner error: "+err.Error()) + } + } + }) + + // 主循环等待完成或超时 + select { + case <-ticker.C: + // 超时处理逻辑 + logger.LogError(c, "streaming timeout") + case <-stopChan: + // 正常结束 + logger.LogInfo(c, "streaming finished") + case <-c.Request.Context().Done(): + // 客户端断开连接 + logger.LogInfo(c, "client disconnected") + } +} diff --git a/relay/helper/valid_request.go b/relay/helper/valid_request.go new file mode 100644 index 0000000000000000000000000000000000000000..66213b01fdfd3d639163faf4a9189a9677410fde --- /dev/null +++ b/relay/helper/valid_request.go @@ -0,0 +1,318 @@ +package helper + +import ( + "errors" + "fmt" + "math" + "one-api/common" + "one-api/dto" + "one-api/logger" + relayconstant "one-api/relay/constant" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +func GetAndValidateRequest(c *gin.Context, format types.RelayFormat) (request dto.Request, err error) { + relayMode := relayconstant.Path2RelayMode(c.Request.URL.Path) + + switch format { + case types.RelayFormatOpenAI: + request, err = GetAndValidateTextRequest(c, relayMode) + case types.RelayFormatGemini: + if strings.Contains(c.Request.URL.Path, ":embedContent") || strings.Contains(c.Request.URL.Path, ":batchEmbedContents") { + request, err = GetAndValidateGeminiEmbeddingRequest(c) + } else { + request, err = GetAndValidateGeminiRequest(c) + } + case types.RelayFormatClaude: + request, err = GetAndValidateClaudeRequest(c) + case types.RelayFormatOpenAIResponses: + request, err = GetAndValidateResponsesRequest(c) + + case types.RelayFormatOpenAIImage: + request, err = GetAndValidOpenAIImageRequest(c, relayMode) + case types.RelayFormatEmbedding: + request, err = GetAndValidateEmbeddingRequest(c, relayMode) + case types.RelayFormatRerank: + request, err = GetAndValidateRerankRequest(c) + case types.RelayFormatOpenAIAudio: + request, err = GetAndValidAudioRequest(c, relayMode) + case types.RelayFormatOpenAIRealtime: + request = &dto.BaseRequest{} + default: + return nil, fmt.Errorf("unsupported relay format: %s", format) + } + return request, err +} + +func GetAndValidAudioRequest(c *gin.Context, relayMode int) (*dto.AudioRequest, error) { + audioRequest := &dto.AudioRequest{} + err := common.UnmarshalBodyReusable(c, audioRequest) + if err != nil { + return nil, err + } + switch relayMode { + case relayconstant.RelayModeAudioSpeech: + if audioRequest.Model == "" { + return nil, errors.New("model is required") + } + 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 GetAndValidateRerankRequest(c *gin.Context) (*dto.RerankRequest, error) { + var rerankRequest *dto.RerankRequest + err := common.UnmarshalBodyReusable(c, &rerankRequest) + if err != nil { + logger.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error())) + return nil, types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + if rerankRequest.Query == "" { + return nil, types.NewError(fmt.Errorf("query is empty"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + if len(rerankRequest.Documents) == 0 { + return nil, types.NewError(fmt.Errorf("documents is empty"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + return rerankRequest, nil +} + +func GetAndValidateEmbeddingRequest(c *gin.Context, relayMode int) (*dto.EmbeddingRequest, error) { + var embeddingRequest *dto.EmbeddingRequest + err := common.UnmarshalBodyReusable(c, &embeddingRequest) + if err != nil { + logger.LogError(c, fmt.Sprintf("getAndValidateTextRequest failed: %s", err.Error())) + return nil, types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + if embeddingRequest.Input == nil { + return nil, fmt.Errorf("input is empty") + } + if relayMode == relayconstant.RelayModeModerations && embeddingRequest.Model == "" { + embeddingRequest.Model = "omni-moderation-latest" + } + if relayMode == relayconstant.RelayModeEmbeddings && embeddingRequest.Model == "" { + embeddingRequest.Model = c.Param("model") + } + return embeddingRequest, nil +} + +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 request.Input == nil { + return nil, errors.New("input is required") + } + return request, nil +} + +func GetAndValidOpenAIImageRequest(c *gin.Context, relayMode int) (*dto.ImageRequest, error) { + imageRequest := &dto.ImageRequest{} + + switch relayMode { + case relayconstant.RelayModeImagesEdits: + if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") { + _, err := c.MultipartForm() + if err != nil { + return nil, fmt.Errorf("failed to parse image edit form request: %w", err) + } + formData := c.Request.PostForm + imageRequest.Prompt = formData.Get("prompt") + imageRequest.Model = formData.Get("model") + imageRequest.N = uint(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 + } + + watermark := formData.Has("watermark") + if watermark { + imageRequest.Watermark = &watermark + } + break + } + fallthrough + default: + err := common.UnmarshalBodyReusable(c, imageRequest) + if err != nil { + return nil, err + } + + if imageRequest.Model == "" { + //imageRequest.Model = "dall-e-3" + return nil, errors.New("model is required") + } + + 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 + } + } + + return imageRequest, nil +} + +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") + } + + //if textRequest.Stream { + // relayInfo.IsStream = true + //} + + return textRequest, nil +} + +func GetAndValidateTextRequest(c *gin.Context, relayMode int) (*dto.GeneralOpenAIRequest, error) { + textRequest := &dto.GeneralOpenAIRequest{} + err := common.UnmarshalBodyReusable(c, textRequest) + if err != nil { + return nil, err + } + + if relayMode == relayconstant.RelayModeModerations && textRequest.Model == "" { + textRequest.Model = "text-moderation-latest" + } + if 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 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") + } + } + return textRequest, nil +} + +func GetAndValidateGeminiRequest(c *gin.Context) (*dto.GeminiChatRequest, error) { + request := &dto.GeminiChatRequest{} + err := common.UnmarshalBodyReusable(c, request) + if err != nil { + return nil, err + } + if len(request.Contents) == 0 { + return nil, errors.New("contents is required") + } + + //if c.Query("alt") == "sse" { + // relayInfo.IsStream = true + //} + + return request, nil +} + +func GetAndValidateGeminiEmbeddingRequest(c *gin.Context) (*dto.GeminiEmbeddingRequest, error) { + request := &dto.GeminiEmbeddingRequest{} + err := common.UnmarshalBodyReusable(c, request) + if err != nil { + return nil, err + } + return request, nil +} diff --git a/relay/image_handler.go b/relay/image_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..98db3e856c459b79db00d9fdfd0e883479f6e737 --- /dev/null +++ b/relay/image_handler.go @@ -0,0 +1,128 @@ +package relay + +import ( + "bytes" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/logger" + 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 ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + imageReq, ok := info.Request.(*dto.ImageRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected dto.ImageRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(imageReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to ImageRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + var requestBody io.Reader + + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertImageRequest(c, info, *request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed) + } + + switch convertedRequest.(type) { + case *bytes.Buffer: + requestBody = convertedRequest.(io.Reader) + default: + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + if common.DebugEnabled { + logger.LogDebug(c, fmt.Sprintf("image request body: %s", string(jsonData))) + } + requestBody = bytes.NewBuffer(jsonData) + } + } + + statusCodeMappingStr := c.GetString("status_code_mapping") + + resp, err := adaptor.DoRequest(c, info, requestBody) + if err != nil { + return types.NewOpenAIError(err, types.ErrorCodeDoRequestFailed, http.StatusInternalServerError) + } + var httpResp *http.Response + if resp != nil { + httpResp = resp.(*http.Response) + info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") + if httpResp.StatusCode != http.StatusOK { + newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + if usage.(*dto.Usage).TotalTokens == 0 { + usage.(*dto.Usage).TotalTokens = int(request.N) + } + if usage.(*dto.Usage).PromptTokens == 0 { + usage.(*dto.Usage).PromptTokens = int(request.N) + } + + quality := "standard" + if request.Quality == "hd" { + quality = "hd" + } + + var logContent string + + if len(request.Size) > 0 { + logContent = fmt.Sprintf("大小 %s, 品质 %s, 张数 %d", request.Size, quality, request.N) + } + + postConsumeQuota(c, info, usage.(*dto.Usage), logContent) + return nil +} diff --git a/relay/mjproxy_handler.go b/relay/mjproxy_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..08d390e7db3e88d42e3dca2307e4dc3ce24a67d1 --- /dev/null +++ b/relay/mjproxy_handler.go @@ -0,0 +1,659 @@ +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" + "one-api/setting/system_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 = system_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, info *relaycommon.RelayInfo) *dto.MidjourneyResponse { + var swapFaceRequest dto.SwapFaceRequest + err := common.UnmarshalBodyReusable(c, &swapFaceRequest) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "bind_request_body_failed") + } + + info.InitChannelMeta(c) + + 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, info) + + userQuota, err := model.GetUserQuota(info.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(info, priceData.Quota, 0, true) + if err != nil { + common.SysLog("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, info.UserId, model.RecordConsumeLogParams{ + ChannelId: info.ChannelId, + ModelName: modelName, + TokenName: tokenName, + Quota: priceData.Quota, + Content: logContent, + TokenId: info.TokenId, + Group: info.UsingGroup, + Other: other, + }) + model.UpdateUserUsedQuotaAndRequestCount(info.UserId, priceData.Quota) + model.UpdateChannelUsedQuota(info.ChannelId, priceData.Quota) + } + }() + midjResponse := &mjResp.Response + midjourneyTask := &model.Midjourney{ + UserId: info.UserId, + Code: midjResponse.Code, + Action: constant.MjActionSwapFace, + MjId: midjResponse.Result, + Prompt: "InsightFace", + PromptEn: "", + Description: midjResponse.Description, + State: "", + SubmitTime: info.StartTime.UnixNano() / int64(time.Millisecond), + 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") + } + service.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, relayInfo *relaycommon.RelayInfo) *dto.MidjourneyResponse { + consumeQuota := true + var midjRequest dto.MidjourneyRequest + err := common.UnmarshalBodyReusable(c, &midjRequest) + if err != nil { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "bind_request_body_failed") + } + + relayInfo.InitChannelMeta(c) + + if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyAction { // midjourney plus,需要从customId中获取任务信息 + mjErr := service.CoverPlusActionToNormalAction(&midjRequest) + if mjErr != nil { + return mjErr + } + relayInfo.RelayMode = relayconstant.RelayModeMidjourneyChange + } + if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyVideo { + midjRequest.Action = constant.MjActionVideo + } + + if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyImagine { //绘画任务,此类任务可重复 + if midjRequest.Prompt == "" { + return service.MidjourneyErrorWrapper(constant.MjRequestError, "prompt_is_required") + } + midjRequest.Action = constant.MjActionImagine + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyDescribe { //按图生文任务,此类任务可重复 + midjRequest.Action = constant.MjActionDescribe + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyEdits { //编辑任务,此类任务可重复 + midjRequest.Action = constant.MjActionEdits + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyShorten { //缩短任务,此类任务可重复,plus only + midjRequest.Action = constant.MjActionShorten + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyBlend { //绘画任务,此类任务可重复 + midjRequest.Action = constant.MjActionBlend + } else if relayInfo.RelayMode == relayconstant.RelayModeMidjourneyUpload { //绘画任务,此类任务可重复 + midjRequest.Action = constant.MjActionUpload + } else if midjRequest.TaskId != "" { //放大、变换任务,此类任务,如果重复且已有结果,远端api会直接返回最终结果 + mjId := "" + if relayInfo.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 relayInfo.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 relayInfo.RelayMode == relayconstant.RelayModeMidjourneyModal { + //if midjRequest.MaskBase64 == "" { + // return service.MidjourneyErrorWrapper(constant.MjRequestError, "mask_base64_is_required") + //} + mjId = midjRequest.TaskId + midjRequest.Action = constant.MjActionModal + } else if relayInfo.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(relayInfo.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" && relayInfo.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(relayInfo.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.SysLog("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: relayInfo.ChannelId, + ModelName: modelName, + TokenName: tokenName, + Quota: priceData.Quota, + Content: logContent, + TokenId: relayInfo.TokenId, + Group: relayInfo.UsingGroup, + Other: other, + }) + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, priceData.Quota) + model.UpdateChannelUsedQuota(relayInfo.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: relayInfo.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.SysLog("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_adaptor.go b/relay/relay_adaptor.go new file mode 100644 index 0000000000000000000000000000000000000000..828ec9b181b813471f73cfdc5897a48f988a780c --- /dev/null +++ b/relay/relay_adaptor.go @@ -0,0 +1,140 @@ +package relay + +import ( + "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/moonshot" + "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" + taskvertex "one-api/relay/channel/task/vertex" + taskVidu "one-api/relay/channel/task/vidu" + "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" + "strconv" + "one-api/relay/channel/submodel" + "github.com/gin-gonic/gin" +) + +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{} + case constant.APITypeMoonshot: + return &moonshot.Adaptor{} // Moonshot uses Claude API + case constant.APITypeSubmodel: + return &submodel.Adaptor{} + } + return nil +} + +func GetTaskPlatform(c *gin.Context) constant.TaskPlatform { + channelType := c.GetInt("channel_type") + if channelType > 0 { + return constant.TaskPlatform(strconv.Itoa(channelType)) + } + return constant.TaskPlatform(c.GetString("platform")) +} + +func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { + switch platform { + //case constant.APITypeAIProxyLibrary: + // return &aiproxy.Adaptor{} + case constant.TaskPlatformSuno: + return &suno.TaskAdaptor{} + } + if channelType, err := strconv.ParseInt(string(platform), 10, 64); err == nil { + switch channelType { + case constant.ChannelTypeKling: + return &kling.TaskAdaptor{} + case constant.ChannelTypeJimeng: + return &taskjimeng.TaskAdaptor{} + case constant.ChannelTypeVertexAi: + return &taskvertex.TaskAdaptor{} + case constant.ChannelTypeVidu: + return &taskVidu.TaskAdaptor{} + } + } + return nil +} diff --git a/relay/relay_task.go b/relay/relay_task.go new file mode 100644 index 0000000000000000000000000000000000000000..18decc00552d121beded9321111a05bac0a5d9a0 --- /dev/null +++ b/relay/relay_task.go @@ -0,0 +1,386 @@ +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" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +/* +Task 任务通过平台、Action 区分任务 +*/ +func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.TaskError) { + info.InitChannelMeta(c) + // ensure TaskRelayInfo is initialized to avoid nil dereference when accessing embedded fields + if info.TaskRelayInfo == nil { + info.TaskRelayInfo = &relaycommon.TaskRelayInfo{} + } + platform := constant.TaskPlatform(c.GetString("platform")) + if platform == "" { + platform = GetTaskPlatform(c) + } + + info.InitChannelMeta(c) + adaptor := GetTaskAdaptor(platform) + if adaptor == nil { + return service.TaskErrorWrapperLocal(fmt.Errorf("invalid api platform: %s", platform), "invalid_api_platform", http.StatusBadRequest) + } + adaptor.Init(info) + // get & validate taskRequest 获取并验证文本请求 + taskErr = adaptor.ValidateRequestAndSetAction(c, info) + if taskErr != nil { + return + } + + modelName := info.OriginModelName + if modelName == "" { + modelName = service.CoverTaskActionToModelName(platform, info.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(info.UsingGroup) + var ratio float64 + userGroupRatio, hasUserGroupRatio := ratio_setting.GetGroupGroupRatio(info.UserGroup, info.UsingGroup) + if hasUserGroupRatio { + ratio = modelPrice * userGroupRatio + } else { + ratio = modelPrice * groupRatio + } + userQuota, err := model.GetUserQuota(info.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 info.OriginTaskID != "" { + originTask, exist, err := model.GetByTaskId(info.UserId, info.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 != info.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)) + + info.ChannelBaseUrl = channel.GetBaseURL() + info.ChannelId = originTask.ChannelId + } + } + + // build body + requestBody, err := adaptor.BuildRequestBody(c, info) + if err != nil { + taskErr = service.TaskErrorWrapper(err, "build_request_failed", http.StatusInternalServerError) + return + } + // do request + resp, err := adaptor.DoRequest(c, info, 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 info.ConsumeQuota && taskErr == nil { + + err := service.PostConsumeQuota(info, quota, 0, true) + if err != nil { + common.SysLog("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, info.Action) + other := make(map[string]interface{}) + other["model_price"] = modelPrice + other["group_ratio"] = groupRatio + if hasUserGroupRatio { + other["user_group_ratio"] = userGroupRatio + } + model.RecordConsumeLog(c, info.UserId, model.RecordConsumeLogParams{ + ChannelId: info.ChannelId, + ModelName: modelName, + TokenName: tokenName, + Quota: quota, + Content: logContent, + TokenId: info.TokenId, + Group: info.UsingGroup, + Other: other, + }) + model.UpdateUserUsedQuotaAndRequestCount(info.UserId, quota) + model.UpdateChannelUsedQuota(info.ChannelId, quota) + } + } + }() + + taskID, taskData, taskErr := adaptor.DoResponse(c, resp, info) + if taskErr != nil { + return + } + info.ConsumeQuota = true + // insert task + task := model.InitTask(platform, info) + task.TaskID = taskID + task.Quota = quota + task.Data = taskData + task.Action = info.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.RelayModeVideoFetchByID: 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 + } + if len(respBody) == 0 { + respBody = []byte("{\"code\":\"success\",\"data\":null}") + } + + 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") + if taskId == "" { + taskId = c.GetString("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 + } + + func() { + channelModel, err2 := model.GetChannelById(originTask.ChannelId, true) + if err2 != nil { + return + } + if channelModel.Type != constant.ChannelTypeVertexAi { + return + } + baseURL := constant.ChannelBaseURLs[channelModel.Type] + if channelModel.GetBaseURL() != "" { + baseURL = channelModel.GetBaseURL() + } + adaptor := GetTaskAdaptor(constant.TaskPlatform(strconv.Itoa(channelModel.Type))) + if adaptor == nil { + return + } + resp, err2 := adaptor.FetchTask(baseURL, channelModel.Key, map[string]any{ + "task_id": originTask.TaskID, + "action": originTask.Action, + }) + if err2 != nil || resp == nil { + return + } + defer resp.Body.Close() + body, err2 := io.ReadAll(resp.Body) + if err2 != nil { + return + } + ti, err2 := adaptor.ParseTaskResult(body) + if err2 == nil && ti != nil { + if ti.Status != "" { + originTask.Status = model.TaskStatus(ti.Status) + } + if ti.Progress != "" { + originTask.Progress = ti.Progress + } + if ti.Url != "" { + originTask.FailReason = ti.Url + } + _ = originTask.Update() + var raw map[string]any + _ = json.Unmarshal(body, &raw) + format := "mp4" + if respObj, ok := raw["response"].(map[string]any); ok { + if vids, ok := respObj["videos"].([]any); ok && len(vids) > 0 { + if v0, ok := vids[0].(map[string]any); ok { + if mt, ok := v0["mimeType"].(string); ok && mt != "" { + if strings.Contains(mt, "mp4") { + format = "mp4" + } else { + format = mt + } + } + } + } + } + status := "processing" + switch originTask.Status { + case model.TaskStatusSuccess: + status = "succeeded" + case model.TaskStatusFailure: + status = "failed" + case model.TaskStatusQueued, model.TaskStatusSubmitted: + status = "queued" + } + out := map[string]any{ + "error": nil, + "format": format, + "metadata": nil, + "status": status, + "task_id": originTask.TaskID, + "url": originTask.FailReason, + } + respBody, _ = json.Marshal(dto.TaskResponse[any]{ + Code: "success", + Data: out, + }) + } + }() + + if len(respBody) == 0 { + 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 0000000000000000000000000000000000000000..3c6d3ea30e46cd08a6da0e67d7f07df433ac3644 --- /dev/null +++ b/relay/rerank_handler.go @@ -0,0 +1,99 @@ +package relay + +import ( + "bytes" + "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" + + "github.com/gin-gonic/gin" +) + +func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + rerankReq, ok := info.Request.(*dto.RerankRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected dto.RerankRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(rerankReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to ImageRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertRerankRequest(c, info.RelayMode, *request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + if common.DebugEnabled { + println(fmt.Sprintf("Rerank request body: %s", string(jsonData))) + } + requestBody = bytes.NewBuffer(jsonData) + } + + resp, err := adaptor.DoRequest(c, info, 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(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + postConsumeQuota(c, info, usage.(*dto.Usage), "") + return nil +} diff --git a/relay/responses_handler.go b/relay/responses_handler.go new file mode 100644 index 0000000000000000000000000000000000000000..ad421349f6c1fe9df881bcdb79a7c19a10db76a8 --- /dev/null +++ b/relay/responses_handler.go @@ -0,0 +1,105 @@ +package relay + +import ( + "bytes" + "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 ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + responsesReq, ok := info.Request.(*dto.OpenAIResponsesRequest) + if !ok { + return types.NewErrorWithStatusCode(fmt.Errorf("invalid request type, expected dto.OpenAIResponsesRequest, got %T", info.Request), types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + + request, err := common.DeepCopy(responsesReq) + if err != nil { + return types.NewError(fmt.Errorf("failed to copy request to GeneralOpenAIRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) + } + + err = helper.ModelMappedHelper(c, info, request) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) + } + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + var requestBody io.Reader + if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled { + body, err := common.GetRequestBody(c) + if err != nil { + return types.NewError(err, types.ErrorCodeReadRequestBodyFailed, types.ErrOptionWithSkipRetry()) + } + requestBody = bytes.NewBuffer(body) + } else { + convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *request) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + jsonData, err := common.Marshal(convertedRequest) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override + if len(info.ParamOverride) > 0 { + jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) + if err != nil { + return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) + } + } + + if common.DebugEnabled { + println("requestBody: ", string(jsonData)) + } + requestBody = bytes.NewBuffer(jsonData) + } + + var httpResp *http.Response + resp, err := adaptor.DoRequest(c, info, 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(c.Request.Context(), httpResp, false) + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + } + + usage, newAPIError := adaptor.DoResponse(c, httpResp, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + + if strings.HasPrefix(info.OriginModelName, "gpt-4o-audio") { + service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") + } else { + postConsumeQuota(c, info, usage.(*dto.Usage), "") + } + return nil +} diff --git a/relay/websocket.go b/relay/websocket.go new file mode 100644 index 0000000000000000000000000000000000000000..2612c6f79bc5d85cb5fc88b16162f461bbf28676 --- /dev/null +++ b/relay/websocket.go @@ -0,0 +1,45 @@ +package relay + +import ( + "fmt" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/service" + "one-api/types" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +func WssHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) { + info.InitChannelMeta(c) + + adaptor := GetAdaptor(info.ApiType) + if adaptor == nil { + return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) + } + adaptor.Init(info) + //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, info, nil) + if err != nil { + return types.NewError(err, types.ErrorCodeDoRequestFailed) + } + + if resp != nil { + info.TargetWs = resp.(*websocket.Conn) + defer info.TargetWs.Close() + } + + usage, newAPIError := adaptor.DoResponse(c, nil, info) + if newAPIError != nil { + // reset status code 重置状态码 + service.ResetStatusCode(newAPIError, statusCodeMappingStr) + return newAPIError + } + service.PostWssConsumeQuota(c, info, info.UpstreamModelName, usage.(*dto.RealtimeUsage), "") + return nil +} diff --git a/router/api-router.go b/router/api-router.go new file mode 100644 index 0000000000000000000000000000000000000000..c161c94f14b9822af2ed599098bdba9ceedbf241 --- /dev/null +++ b/router/api-router.go @@ -0,0 +1,252 @@ +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.EmailVerificationRateLimit(), 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) + + // Universal secure verification routes + apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) + apiRouter.GET("/verify/status", middleware.UserAuth(), controller.GetVerificationStatus) + + userRoute := apiRouter.Group("/user") + { + userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) + userRoute.POST("/login", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Login) + userRoute.POST("/login/2fa", middleware.CriticalRateLimit(), controller.Verify2FALogin) + userRoute.POST("/passkey/login/begin", middleware.CriticalRateLimit(), controller.PasskeyLoginBegin) + userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish) + //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("/passkey", controller.PasskeyStatus) + selfRoute.POST("/passkey/register/begin", controller.PasskeyRegisterBegin) + selfRoute.POST("/passkey/register/finish", controller.PasskeyRegisterFinish) + selfRoute.POST("/passkey/verify/begin", controller.PasskeyVerifyBegin) + selfRoute.POST("/passkey/verify/finish", controller.PasskeyVerifyFinish) + selfRoute.DELETE("/passkey", controller.PasskeyDelete) + selfRoute.GET("/aff", controller.GetAffCode) + selfRoute.GET("/topup/info", controller.GetTopUpInfo) + 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) + + // 2FA routes + selfRoute.GET("/2fa/status", controller.Get2FAStatus) + selfRoute.POST("/2fa/setup", controller.Setup2FA) + selfRoute.POST("/2fa/enable", controller.Enable2FA) + selfRoute.POST("/2fa/disable", controller.Disable2FA) + selfRoute.POST("/2fa/backup_codes", controller.RegenerateBackupCodes) + } + + 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) + adminRoute.DELETE("/:id/reset_passkey", controller.AdminResetPasskey) + + // Admin 2FA routes + adminRoute.GET("/2fa/stats", controller.Admin2FAStats) + adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA) + } + } + 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.POST("/:id/key", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey) + 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) + channelRoute.POST("/multi_key/manage", controller.ManageMultiKeys) + } + 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) + } + + usageRoute := apiRouter.Group("/usage") + usageRoute.Use(middleware.CriticalRateLimit()) + { + tokenUsageRoute := usageRoute.Group("/token") + tokenUsageRoute.Use(middleware.TokenAuth()) + { + tokenUsageRoute.GET("/", controller.GetTokenUsage) + } + } + + 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) + } + + prefillGroupRoute := apiRouter.Group("/prefill_group") + prefillGroupRoute.Use(middleware.AdminAuth()) + { + prefillGroupRoute.GET("/", controller.GetPrefillGroups) + prefillGroupRoute.POST("/", controller.CreatePrefillGroup) + prefillGroupRoute.PUT("/", controller.UpdatePrefillGroup) + prefillGroupRoute.DELETE("/:id", controller.DeletePrefillGroup) + } + + 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) + } + + vendorRoute := apiRouter.Group("/vendors") + vendorRoute.Use(middleware.AdminAuth()) + { + vendorRoute.GET("/", controller.GetAllVendors) + vendorRoute.GET("/search", controller.SearchVendors) + vendorRoute.GET("/:id", controller.GetVendorMeta) + vendorRoute.POST("/", controller.CreateVendorMeta) + vendorRoute.PUT("/", controller.UpdateVendorMeta) + vendorRoute.DELETE("/:id", controller.DeleteVendorMeta) + } + + modelsRoute := apiRouter.Group("/models") + modelsRoute.Use(middleware.AdminAuth()) + { + modelsRoute.GET("/sync_upstream/preview", controller.SyncUpstreamPreview) + modelsRoute.POST("/sync_upstream", controller.SyncUpstreamModels) + modelsRoute.GET("/missing", controller.GetMissingModels) + modelsRoute.GET("/", controller.GetAllModelsMeta) + modelsRoute.GET("/search", controller.SearchModelsMeta) + modelsRoute.GET("/:id", controller.GetModelMeta) + modelsRoute.POST("/", controller.CreateModelMeta) + modelsRoute.PUT("/", controller.UpdateModelMeta) + modelsRoute.DELETE("/:id", controller.DeleteModelMeta) + } + } +} diff --git a/router/dashboard.go b/router/dashboard.go new file mode 100644 index 0000000000000000000000000000000000000000..48f365340cb43c4496e8432e6302fd839087d2a0 --- /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 0000000000000000000000000000000000000000..f6acdc11b63a1e9cb634fb38ae2f144554dd45a2 --- /dev/null +++ b/router/main.go @@ -0,0 +1,32 @@ +package router + +import ( + "embed" + "fmt" + "net/http" + "one-api/common" + "os" + "strings" + + "github.com/gin-gonic/gin" +) + +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 0000000000000000000000000000000000000000..a1fcd62bab41f9e65a050e2668a5a0f583ed7193 --- /dev/null +++ b/router/relay-router.go @@ -0,0 +1,205 @@ +package router + +import ( + "one-api/constant" + "one-api/controller" + "one-api/middleware" + "one-api/relay" + "one-api/types" + + "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("", func(c *gin.Context) { + switch { + case c.GetHeader("x-api-key") != "" && c.GetHeader("anthropic-version") != "": + controller.ListModels(c, constant.ChannelTypeAnthropic) + case c.GetHeader("x-goog-api-key") != "" || c.Query("key") != "": // 单独的适配 + controller.RetrieveModel(c, constant.ChannelTypeGemini) + default: + controller.ListModels(c, constant.ChannelTypeOpenAI) + } + }) + + modelsRouter.GET("/:model", func(c *gin.Context) { + switch { + case c.GetHeader("x-api-key") != "" && c.GetHeader("anthropic-version") != "": + controller.RetrieveModel(c, constant.ChannelTypeAnthropic) + default: + controller.RetrieveModel(c, constant.ChannelTypeOpenAI) + } + }) + } + + geminiRouter := router.Group("/v1beta/models") + geminiRouter.Use(middleware.TokenAuth()) + { + geminiRouter.GET("", func(c *gin.Context) { + controller.ListModels(c, constant.ChannelTypeGemini) + }) + } + + geminiCompatibleRouter := router.Group("/v1beta/openai/models") + geminiCompatibleRouter.Use(middleware.TokenAuth()) + { + geminiCompatibleRouter.GET("", func(c *gin.Context) { + controller.ListModels(c, constant.ChannelTypeOpenAI) + }) + } + + 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 路由(统一到 Relay) + wsRouter := relayV1Router.Group("") + wsRouter.Use(middleware.Distribute()) + wsRouter.GET("/realtime", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIRealtime) + }) + } + { + //http router + httpRouter := relayV1Router.Group("") + httpRouter.Use(middleware.Distribute()) + + // claude related routes + httpRouter.POST("/messages", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatClaude) + }) + + // chat related routes + httpRouter.POST("/completions", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAI) + }) + httpRouter.POST("/chat/completions", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAI) + }) + + // response related routes + httpRouter.POST("/responses", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIResponses) + }) + + // image related routes + httpRouter.POST("/edits", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIImage) + }) + httpRouter.POST("/images/generations", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIImage) + }) + httpRouter.POST("/images/edits", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIImage) + }) + + // embedding related routes + httpRouter.POST("/embeddings", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatEmbedding) + }) + + // audio related routes + httpRouter.POST("/audio/transcriptions", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIAudio) + }) + httpRouter.POST("/audio/translations", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIAudio) + }) + httpRouter.POST("/audio/speech", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAIAudio) + }) + + // rerank related routes + httpRouter.POST("/rerank", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatRerank) + }) + + // gemini relay routes + httpRouter.POST("/engines/:model/embeddings", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatGemini) + }) + httpRouter.POST("/models/*path", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatGemini) + }) + + // other relay routes + httpRouter.POST("/moderations", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatOpenAI) + }) + + // not implemented + httpRouter.POST("/images/variations", controller.RelayNotImplemented) + 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) + } + + 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", func(c *gin.Context) { + controller.Relay(c, types.RelayFormatGemini) + }) + } +} + +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 0000000000000000000000000000000000000000..5b22205cfc1f49e6adac474fe7a125054f8b070a --- /dev/null +++ b/router/video-router.go @@ -0,0 +1,34 @@ +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) + klingV1Router.GET("/videos/text2video/:task_id", controller.RelayTask) + klingV1Router.GET("/videos/image2video/:task_id", controller.RelayTask) + } + + // Jimeng official API routes - direct mapping to official API format + jimengOfficialGroup := router.Group("jimeng") + jimengOfficialGroup.Use(middleware.JimengRequestConvert(), middleware.TokenAuth(), middleware.Distribute()) + { + // Maps to: /?Action=CVSync2AsyncSubmitTask&Version=2022-08-31 and /?Action=CVSync2AsyncGetResult&Version=2022-08-31 + jimengOfficialGroup.POST("/", controller.RelayTask) + } +} diff --git a/router/web-router.go b/router/web-router.go new file mode 100644 index 0000000000000000000000000000000000000000..c19d0b83e7ee3682f539f104c1a8e6417b4f8bac --- /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 0000000000000000000000000000000000000000..68622e0eab19f956d229f940825ca43fd6df1888 --- /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/channel.go b/service/channel.go new file mode 100644 index 0000000000000000000000000000000000000000..db040f608c31699d036edcb650602dd19630d57c --- /dev/null +++ b/service/channel.go @@ -0,0 +1,109 @@ +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) { + common.SysLog(fmt.Sprintf("通道「%s」(#%d)发生错误,准备禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, reason)) + + // 检查是否启用自动禁用功能 + if !channelError.AutoBan { + common.SysLog(fmt.Sprintf("通道「%s」(#%d)未启用自动禁用功能,跳过禁用操作", channelError.ChannelName, channelError.ChannelId)) + return + } + + 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.IsSkipRetryError(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 0000000000000000000000000000000000000000..4b2bf66dc938bbed7d00020d9d61c573371bda2e --- /dev/null +++ b/service/convert.go @@ -0,0 +1,815 @@ +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 + toolName := mediaMsg.Name + if toolName == "" { + toolName = claudeRequest.SearchToolNameByToolCallId(mediaMsg.ToolUseId) + } + oaiToolMessage := dto.Message{ + Role: "tool", + Name: &toolName, + 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 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() { + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeTools + resp := &dto.ClaudeResponse{ + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Id: openAIResponse.GetFirstToolCall().ID, + Type: "tool_use", + Name: openAIResponse.GetFirstToolCall().Function.Name, + Input: map[string]interface{}{}, + }, + } + resp.SetIndex(0) + claudeResponses = append(claudeResponses, resp) + } else { + + } + // 判断首个响应是否存在内容(非标准的 OpenAI 响应) + if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.GetContentString()) > 0 { + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_start", + ContentBlock: &dto.ClaudeMediaMessage{ + Type: "text", + Text: common.GetPointer[string](""), + }, + }) + claudeResponses = append(claudeResponses, &dto.ClaudeResponse{ + Index: &info.ClaudeConvertInfo.Index, + Type: "content_block_delta", + Delta: &dto.ClaudeMediaMessage{ + Type: "text_delta", + Text: common.GetPointer[string](openAIResponse.Choices[0].Delta.GetContentString()), + }, + }) + info.ClaudeConvertInfo.LastMessagesType = relaycommon.LastMessageTypeText + } + return claudeResponses + } + + if len(openAIResponse.Choices) == 0 { + // no choices + // 可能为非标准的 OpenAI 响应,判断是否已经完成 + 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", + }) + } + return claudeResponses + } else { + chosenChoice := openAIResponse.Choices[0] + if chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != "" { + // should be done + info.FinishReason = *chosenChoice.FinishReason + if !info.Done { + 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) + if choice.FinishReason == "tool_calls" { + for _, toolUse := range choice.Message.ParseToolCalls() { + claudeContent := dto.ClaudeMediaMessage{} + claudeContent.Type = "tool_use" + claudeContent.Id = toolUse.ID + claudeContent.Name = toolUse.Function.Name + var mapParams map[string]interface{} + if err := common.Unmarshal([]byte(toolUse.Function.Arguments), &mapParams); err == nil { + claudeContent.Input = mapParams + } else { + claudeContent.Input = toolUse.Function.Arguments + } + contents = append(contents, claudeContent) + } + } else { + claudeContent := dto.ClaudeMediaMessage{} + 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 "length": + fallthrough + 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) +} + +func GeminiToOpenAIRequest(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo) (*dto.GeneralOpenAIRequest, error) { + openaiRequest := &dto.GeneralOpenAIRequest{ + Model: info.UpstreamModelName, + Stream: info.IsStream, + } + + // 转换 messages + var messages []dto.Message + for _, content := range geminiRequest.Contents { + message := dto.Message{ + Role: convertGeminiRoleToOpenAI(content.Role), + } + + // 处理 parts + var mediaContents []dto.MediaContent + var toolCalls []dto.ToolCallRequest + for _, part := range content.Parts { + if part.Text != "" { + mediaContent := dto.MediaContent{ + Type: "text", + Text: part.Text, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.InlineData != nil { + mediaContent := dto.MediaContent{ + Type: "image_url", + ImageUrl: &dto.MessageImageUrl{ + Url: fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data), + Detail: "auto", + MimeType: part.InlineData.MimeType, + }, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.FileData != nil { + mediaContent := dto.MediaContent{ + Type: "image_url", + ImageUrl: &dto.MessageImageUrl{ + Url: part.FileData.FileUri, + Detail: "auto", + MimeType: part.FileData.MimeType, + }, + } + mediaContents = append(mediaContents, mediaContent) + } else if part.FunctionCall != nil { + // 处理 Gemini 的工具调用 + toolCall := dto.ToolCallRequest{ + ID: fmt.Sprintf("call_%d", len(toolCalls)+1), // 生成唯一ID + Type: "function", + Function: dto.FunctionRequest{ + Name: part.FunctionCall.FunctionName, + Arguments: toJSONString(part.FunctionCall.Arguments), + }, + } + toolCalls = append(toolCalls, toolCall) + } else if part.FunctionResponse != nil { + // 处理 Gemini 的工具响应,创建单独的 tool 消息 + toolMessage := dto.Message{ + Role: "tool", + ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)), // 使用对应的调用ID + } + toolMessage.SetStringContent(toJSONString(part.FunctionResponse.Response)) + messages = append(messages, toolMessage) + } + } + + // 设置消息内容 + if len(toolCalls) > 0 { + // 如果有工具调用,设置工具调用 + message.SetToolCalls(toolCalls) + } else if len(mediaContents) == 1 && mediaContents[0].Type == "text" { + // 如果只有一个文本内容,直接设置字符串 + message.Content = mediaContents[0].Text + } else if len(mediaContents) > 0 { + // 如果有多个内容或包含媒体,设置为数组 + message.SetMediaContent(mediaContents) + } + + // 只有当消息有内容或工具调用时才添加 + if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 { + messages = append(messages, message) + } + } + + openaiRequest.Messages = messages + + if geminiRequest.GenerationConfig.Temperature != nil { + openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature + } + if geminiRequest.GenerationConfig.TopP > 0 { + openaiRequest.TopP = geminiRequest.GenerationConfig.TopP + } + if geminiRequest.GenerationConfig.TopK > 0 { + openaiRequest.TopK = int(geminiRequest.GenerationConfig.TopK) + } + if geminiRequest.GenerationConfig.MaxOutputTokens > 0 { + openaiRequest.MaxTokens = geminiRequest.GenerationConfig.MaxOutputTokens + } + // gemini stop sequences 最多 5 个,openai stop 最多 4 个 + if len(geminiRequest.GenerationConfig.StopSequences) > 0 { + openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:4] + } + if geminiRequest.GenerationConfig.CandidateCount > 0 { + openaiRequest.N = geminiRequest.GenerationConfig.CandidateCount + } + + // 转换工具调用 + if len(geminiRequest.GetTools()) > 0 { + var tools []dto.ToolCallRequest + for _, tool := range geminiRequest.GetTools() { + if tool.FunctionDeclarations != nil { + // 将 Gemini 的 FunctionDeclarations 转换为 OpenAI 的 ToolCallRequest + functionDeclarations, ok := tool.FunctionDeclarations.([]dto.FunctionRequest) + if ok { + for _, function := range functionDeclarations { + openAITool := dto.ToolCallRequest{ + Type: "function", + Function: dto.FunctionRequest{ + Name: function.Name, + Description: function.Description, + Parameters: function.Parameters, + }, + } + tools = append(tools, openAITool) + } + } + } + } + if len(tools) > 0 { + openaiRequest.Tools = tools + } + } + + // gemini system instructions + if geminiRequest.SystemInstructions != nil { + // 将系统指令作为第一条消息插入 + systemMessage := dto.Message{ + Role: "system", + Content: extractTextFromGeminiParts(geminiRequest.SystemInstructions.Parts), + } + openaiRequest.Messages = append([]dto.Message{systemMessage}, openaiRequest.Messages...) + } + + return openaiRequest, nil +} + +func convertGeminiRoleToOpenAI(geminiRole string) string { + switch geminiRole { + case "user": + return "user" + case "model": + return "assistant" + case "function": + return "function" + default: + return "user" + } +} + +func extractTextFromGeminiParts(parts []dto.GeminiPart) string { + var texts []string + for _, part := range parts { + if part.Text != "" { + texts = append(texts, part.Text) + } + } + return strings.Join(texts, "\n") +} + +// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式 +func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse { + geminiResponse := &dto.GeminiChatResponse{ + Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)), + PromptFeedback: dto.GeminiChatPromptFeedback{ + SafetyRatings: []dto.GeminiChatSafetyRating{}, + }, + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: openAIResponse.PromptTokens, + CandidatesTokenCount: openAIResponse.CompletionTokens, + TotalTokenCount: openAIResponse.PromptTokens + openAIResponse.CompletionTokens, + }, + } + + for _, choice := range openAIResponse.Choices { + candidate := dto.GeminiChatCandidate{ + Index: int64(choice.Index), + SafetyRatings: []dto.GeminiChatSafetyRating{}, + } + + // 设置结束原因 + var finishReason string + switch choice.FinishReason { + case "stop": + finishReason = "STOP" + case "length": + finishReason = "MAX_TOKENS" + case "content_filter": + finishReason = "SAFETY" + case "tool_calls": + finishReason = "STOP" + default: + finishReason = "STOP" + } + candidate.FinishReason = &finishReason + + // 转换消息内容 + content := dto.GeminiChatContent{ + Role: "model", + Parts: make([]dto.GeminiPart, 0), + } + + // 处理工具调用 + toolCalls := choice.Message.ParseToolCalls() + if len(toolCalls) > 0 { + for _, toolCall := range toolCalls { + // 解析参数 + var args map[string]interface{} + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + args = map[string]interface{}{"arguments": toolCall.Function.Arguments} + } + } else { + args = make(map[string]interface{}) + } + + part := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ + FunctionName: toolCall.Function.Name, + Arguments: args, + }, + } + content.Parts = append(content.Parts, part) + } + } else { + // 处理文本内容 + textContent := choice.Message.StringContent() + if textContent != "" { + part := dto.GeminiPart{ + Text: textContent, + } + content.Parts = append(content.Parts, part) + } + } + + candidate.Content = content + geminiResponse.Candidates = append(geminiResponse.Candidates, candidate) + } + + return geminiResponse +} + +// StreamResponseOpenAI2Gemini 将 OpenAI 流式响应转换为 Gemini 格式 +func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamResponse, info *relaycommon.RelayInfo) *dto.GeminiChatResponse { + // 检查是否有实际内容或结束标志 + hasContent := false + hasFinishReason := false + for _, choice := range openAIResponse.Choices { + if len(choice.Delta.GetContentString()) > 0 || (choice.Delta.ToolCalls != nil && len(choice.Delta.ToolCalls) > 0) { + hasContent = true + } + if choice.FinishReason != nil { + hasFinishReason = true + } + } + + // 如果没有实际内容且没有结束标志,跳过。主要针对 openai 流响应开头的空数据 + if !hasContent && !hasFinishReason { + return nil + } + + geminiResponse := &dto.GeminiChatResponse{ + Candidates: make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices)), + PromptFeedback: dto.GeminiChatPromptFeedback{ + SafetyRatings: []dto.GeminiChatSafetyRating{}, + }, + UsageMetadata: dto.GeminiUsageMetadata{ + PromptTokenCount: info.PromptTokens, + CandidatesTokenCount: 0, // 流式响应中可能没有完整的 usage 信息 + TotalTokenCount: info.PromptTokens, + }, + } + + for _, choice := range openAIResponse.Choices { + candidate := dto.GeminiChatCandidate{ + Index: int64(choice.Index), + SafetyRatings: []dto.GeminiChatSafetyRating{}, + } + + // 设置结束原因 + if choice.FinishReason != nil { + var finishReason string + switch *choice.FinishReason { + case "stop": + finishReason = "STOP" + case "length": + finishReason = "MAX_TOKENS" + case "content_filter": + finishReason = "SAFETY" + case "tool_calls": + finishReason = "STOP" + default: + finishReason = "STOP" + } + candidate.FinishReason = &finishReason + } + + // 转换消息内容 + content := dto.GeminiChatContent{ + Role: "model", + Parts: make([]dto.GeminiPart, 0), + } + + // 处理工具调用 + if choice.Delta.ToolCalls != nil { + for _, toolCall := range choice.Delta.ToolCalls { + // 解析参数 + var args map[string]interface{} + if toolCall.Function.Arguments != "" { + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { + args = map[string]interface{}{"arguments": toolCall.Function.Arguments} + } + } else { + args = make(map[string]interface{}) + } + + part := dto.GeminiPart{ + FunctionCall: &dto.FunctionCall{ + FunctionName: toolCall.Function.Name, + Arguments: args, + }, + } + content.Parts = append(content.Parts, part) + } + } else { + // 处理文本内容 + textContent := choice.Delta.GetContentString() + if textContent != "" { + part := dto.GeminiPart{ + Text: textContent, + } + content.Parts = append(content.Parts, part) + } + } + + candidate.Content = content + geminiResponse.Candidates = append(geminiResponse.Candidates, candidate) + } + + return geminiResponse +} diff --git a/service/download.go b/service/download.go new file mode 100644 index 0000000000000000000000000000000000000000..28c1b2c79ff9cc977876508dba9a78f72970852e --- /dev/null +++ b/service/download.go @@ -0,0 +1,69 @@ +package service + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "one-api/common" + "one-api/setting/system_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 !system_setting.EnableWorker() { + return nil, fmt.Errorf("worker not enabled") + } + if !system_setting.WorkerAllowHttpImageRequestEnabled && !strings.HasPrefix(req.URL, "https") { + return nil, fmt.Errorf("only support https url") + } + + // SSRF防护:验证请求URL + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(req.URL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return nil, fmt.Errorf("request reject: %v", err) + } + + workerUrl := system_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, reason ...string) (resp *http.Response, err error) { + if system_setting.EnableWorker() { + common.SysLog(fmt.Sprintf("downloading file from worker: %s, reason: %s", originUrl, strings.Join(reason, ", "))) + req := &WorkerRequest{ + URL: originUrl, + Key: system_setting.WorkerValidKey, + } + return DoWorkerRequest(req) + } else { + // SSRF防护:验证请求URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(originUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return nil, fmt.Errorf("request reject: %v", err) + } + + common.SysLog(fmt.Sprintf("downloading from origin: %s, reason: %s", common.MaskSensitiveInfo(originUrl), strings.Join(reason, ", "))) + return http.Get(originUrl) + } +} diff --git a/service/epay.go b/service/epay.go new file mode 100644 index 0000000000000000000000000000000000000000..a25026ac0eafac2aa2110aba1e0861561fdb4a6b --- /dev/null +++ b/service/epay.go @@ -0,0 +1,13 @@ +package service + +import ( + "one-api/setting/operation_setting" + "one-api/setting/system_setting" +) + +func GetCallbackAddress() string { + if operation_setting.CustomCallbackAddress == "" { + return system_setting.ServerAddress + } + return operation_setting.CustomCallbackAddress +} diff --git a/service/error.go b/service/error.go new file mode 100644 index 0000000000000000000000000000000000000000..f4b82d39e8e6125979f1d01d262f0838dc33c966 --- /dev/null +++ b/service/error.go @@ -0,0 +1,155 @@ +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/logger" + "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 := types.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(ctx context.Context, resp *http.Response, showBodyWhenFail bool) (newApiErr *types.NewAPIError) { + newApiErr = types.InitOpenAIError(types.ErrorCodeBadResponseStatusCode, resp.StatusCode) + + responseBody, err := io.ReadAll(resp.Body) + if err != nil { + return + } + 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 { + if common.DebugEnabled { + logger.LogInfo(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))) + } + 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.NewOpenAIError(errors.New(errResponse.ToMessage()), types.ErrorCodeBadResponseStatusCode, resp.StatusCode) + } + return +} + +func ResetStatusCode(newApiErr *types.NewAPIError, statusCodeMappingStr string) { + if statusCodeMappingStr == "" || statusCodeMappingStr == "{}" { + return + } + statusCodeMapping := make(map[string]string) + err := common.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 0000000000000000000000000000000000000000..9be2f0970516b5cb4877c9a907dde1af0cfd97fa --- /dev/null +++ b/service/file_decoder.go @@ -0,0 +1,265 @@ +package service + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "net/http" + "one-api/common" + "one-api/constant" + "one-api/logger" + "one-api/types" + "strings" + + "github.com/gin-gonic/gin" +) + +// GetFileTypeFromUrl 获取文件类型,返回 mime type, 例如 image/jpeg, image/png, image/gif, image/bmp, image/tiff, application/pdf +// 如果获取失败,返回 application/octet-stream +func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, error) { + response, err := DoDownloadRequest(url, []string{"get_mime_type", strings.Join(reason, ", ")}...) + if err != nil { + common.SysLog(fmt.Sprintf("fail to get file type from url: %s, error: %s", url, err.Error())) + return "", err + } + defer response.Body.Close() + + if response.StatusCode != 200 { + logger.LogError(c, fmt.Sprintf("failed to download file from %s, status code: %d", url, response.StatusCode)) + return "", fmt.Errorf("failed to download file, status code: %d", response.StatusCode) + } + + if headerType := strings.TrimSpace(response.Header.Get("Content-Type")); headerType != "" { + if i := strings.Index(headerType, ";"); i != -1 { + headerType = headerType[:i] + } + if headerType != "application/octet-stream" { + return headerType, nil + } + } + + if cd := response.Header.Get("Content-Disposition"); cd != "" { + parts := strings.Split(cd, ";") + for _, part := range parts { + part = strings.TrimSpace(part) + if strings.HasPrefix(strings.ToLower(part), "filename=") { + name := strings.TrimSpace(strings.TrimPrefix(part, "filename=")) + if len(name) > 2 && name[0] == '"' && name[len(name)-1] == '"' { + name = name[1 : len(name)-1] + } + if dot := strings.LastIndex(name, "."); dot != -1 && dot+1 < len(name) { + ext := strings.ToLower(name[dot+1:]) + if ext != "" { + mt := GetMimeTypeByExtension(ext) + if mt != "application/octet-stream" { + return mt, nil + } + } + } + break + } + } + } + + cleanedURL := url + if q := strings.Index(cleanedURL, "?"); q != -1 { + cleanedURL = cleanedURL[:q] + } + if slash := strings.LastIndex(cleanedURL, "/"); slash != -1 && slash+1 < len(cleanedURL) { + last := cleanedURL[slash+1:] + if dot := strings.LastIndex(last, "."); dot != -1 && dot+1 < len(last) { + ext := strings.ToLower(last[dot+1:]) + if ext != "" { + mt := GetMimeTypeByExtension(ext) + if mt != "application/octet-stream" { + return mt, nil + } + } + } + } + + var readData []byte + limits := []int{512, 8 * 1024, 24 * 1024, 64 * 1024} + for _, limit := range limits { + logger.LogDebug(c, fmt.Sprintf("Trying to read %d bytes to determine file type", limit)) + if len(readData) < limit { + need := limit - len(readData) + tmp := make([]byte, need) + n, _ := io.ReadFull(response.Body, tmp) + if n > 0 { + readData = append(readData, tmp[:n]...) + } + } + + if len(readData) == 0 { + continue + } + + sniffed := http.DetectContentType(readData) + if sniffed != "" && sniffed != "application/octet-stream" { + return sniffed, nil + } + + if _, format, err := image.DecodeConfig(bytes.NewReader(readData)); err == nil { + switch strings.ToLower(format) { + case "jpeg", "jpg": + return "image/jpeg", nil + case "png": + return "image/png", nil + case "gif": + return "image/gif", nil + case "bmp": + return "image/bmp", nil + case "tiff": + return "image/tiff", nil + default: + if format != "" { + return "image/" + strings.ToLower(format), nil + } + } + } + } + + // Fallback + return "application/octet-stream", nil +} + +func GetFileBase64FromUrl(c *gin.Context, url string, reason ...string) (*types.LocalFileData, error) { + contextKey := fmt.Sprintf("file_download_%s", common.GenerateHMAC(url)) + + // Check if the file has already been downloaded in this request + if cachedData, exists := c.Get(contextKey); exists { + if common.DebugEnabled { + logger.LogDebug(c, fmt.Sprintf("Using cached file data for URL: %s", url)) + } + return cachedData.(*types.LocalFileData), nil + } + + var maxFileSize = constant.MaxFileDownloadMB * 1024 * 1024 + + resp, err := DoDownloadRequest(url, reason...) + 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" { + logger.LogDebug(c, fmt.Sprintf("MIME type is application/octet-stream for URL: %s", url)) + // 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 + } + } + } + } + } + data := &types.LocalFileData{ + Base64Data: base64Data, + MimeType: mimeType, + Size: int64(len(fileBytes)), + } + // Store the file data in the context to avoid re-downloading + c.Set(contextKey, data) + + return data, 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.go b/service/http.go new file mode 100644 index 0000000000000000000000000000000000000000..f1ec0165feab69fe7b44f8cac9cc4d05e12c758d --- /dev/null +++ b/service/http.go @@ -0,0 +1,59 @@ +package service + +import ( + "bytes" + "fmt" + "io" + "net/http" + "one-api/common" + "one-api/logger" + + "github.com/gin-gonic/gin" +) + +func CloseResponseBodyGracefully(httpResponse *http.Response) { + if httpResponse == nil || httpResponse.Body == nil { + return + } + err := httpResponse.Body.Close() + if err != nil { + common.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 { + logger.LogError(c, fmt.Sprintf("failed to copy response body: %s", err.Error())) + } +} diff --git a/service/http_client.go b/service/http_client.go new file mode 100644 index 0000000000000000000000000000000000000000..dd597b9240e10453d4845fba88420f6720dd2d17 --- /dev/null +++ b/service/http_client.go @@ -0,0 +1,115 @@ +package service + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "one-api/common" + "sync" + "time" + + "golang.org/x/net/proxy" +) + +var ( + httpClient *http.Client + proxyClientLock sync.Mutex + proxyClients = make(map[string]*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 +} + +// ResetProxyClientCache 清空代理客户端缓存,确保下次使用时重新初始化 +func ResetProxyClientCache() { + proxyClientLock.Lock() + defer proxyClientLock.Unlock() + for _, client := range proxyClients { + if transport, ok := client.Transport.(*http.Transport); ok && transport != nil { + transport.CloseIdleConnections() + } + } + proxyClients = make(map[string]*http.Client) +} + +// NewProxyHttpClient 创建支持代理的 HTTP 客户端 +func NewProxyHttpClient(proxyURL string) (*http.Client, error) { + if proxyURL == "" { + return http.DefaultClient, nil + } + + proxyClientLock.Lock() + if client, ok := proxyClients[proxyURL]; ok { + proxyClientLock.Unlock() + return client, nil + } + proxyClientLock.Unlock() + + parsedURL, err := url.Parse(proxyURL) + if err != nil { + return nil, err + } + + switch parsedURL.Scheme { + case "http", "https": + client := &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(parsedURL), + }, + } + client.Timeout = time.Duration(common.RelayTimeout) * time.Second + proxyClientLock.Lock() + proxyClients[proxyURL] = client + proxyClientLock.Unlock() + return client, 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 + } + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.Dial(network, addr) + }, + }, + } + client.Timeout = time.Duration(common.RelayTimeout) * time.Second + proxyClientLock.Lock() + proxyClients[proxyURL] = client + proxyClientLock.Unlock() + return client, nil + + default: + return nil, fmt.Errorf("unsupported proxy scheme: %s, must be http, https, socks5 or socks5h", parsedURL.Scheme) + } +} diff --git a/service/image.go b/service/image.go new file mode 100644 index 0000000000000000000000000000000000000000..13b4eb93f9af9dc22d1f31b083a32e1d288cd7d3 --- /dev/null +++ b/service/image.go @@ -0,0 +1,176 @@ +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:] + } + + if len(base64String) == 0 { + return image.Config{}, "", "", errors.New("base64 string is empty") + } + + // 将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 0000000000000000000000000000000000000000..56dcd8bb976b7c7685f7db1a0fafe7c68c936ebd --- /dev/null +++ b/service/log_info_generate.go @@ -0,0 +1,89 @@ +package service + +import ( + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/types" + + "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 + } + + isSystemPromptOverwritten := common.GetContextKeyBool(ctx, constant.ContextKeySystemPromptOverride) + if isSystemPromptOverwritten { + other["is_system_prompt_overwritten"] = true + } + + 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 types.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 0000000000000000000000000000000000000000..0e73f339671148c091b79910578701768af1be72 --- /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.SysLog("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 + } + 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 0000000000000000000000000000000000000000..46129a8dfb91a2a634da4042248c3904b38522cc --- /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/passkey/service.go b/service/passkey/service.go new file mode 100644 index 0000000000000000000000000000000000000000..a130e73a4fc556666f6916ecbddb86255cd7ca44 --- /dev/null +++ b/service/passkey/service.go @@ -0,0 +1,177 @@ +package passkey + +import ( + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "one-api/common" + "one-api/setting/system_setting" + + "github.com/go-webauthn/webauthn/protocol" + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +const ( + RegistrationSessionKey = "passkey_registration_session" + LoginSessionKey = "passkey_login_session" + VerifySessionKey = "passkey_verify_session" +) + +// BuildWebAuthn constructs a WebAuthn instance using the current passkey settings and request context. +func BuildWebAuthn(r *http.Request) (*webauthn.WebAuthn, error) { + settings := system_setting.GetPasskeySettings() + if settings == nil { + return nil, errors.New("未找到 Passkey 设置") + } + + displayName := strings.TrimSpace(settings.RPDisplayName) + if displayName == "" { + displayName = common.SystemName + } + + origins, err := resolveOrigins(r, settings) + if err != nil { + return nil, err + } + + rpID, err := resolveRPID(r, settings, origins) + if err != nil { + return nil, err + } + + selection := protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + RequireResidentKey: protocol.ResidentKeyRequired(), + UserVerification: protocol.UserVerificationRequirement(settings.UserVerification), + } + if selection.UserVerification == "" { + selection.UserVerification = protocol.VerificationPreferred + } + if attachment := strings.TrimSpace(settings.AttachmentPreference); attachment != "" { + selection.AuthenticatorAttachment = protocol.AuthenticatorAttachment(attachment) + } + + config := &webauthn.Config{ + RPID: rpID, + RPDisplayName: displayName, + RPOrigins: origins, + AuthenticatorSelection: selection, + Debug: common.DebugEnabled, + Timeouts: webauthn.TimeoutsConfig{ + Login: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: 2 * time.Minute, + TimeoutUVD: 2 * time.Minute, + }, + Registration: webauthn.TimeoutConfig{ + Enforce: true, + Timeout: 2 * time.Minute, + TimeoutUVD: 2 * time.Minute, + }, + }, + } + + return webauthn.New(config) +} + +func resolveOrigins(r *http.Request, settings *system_setting.PasskeySettings) ([]string, error) { + originsStr := strings.TrimSpace(settings.Origins) + if originsStr != "" { + originList := strings.Split(originsStr, ",") + origins := make([]string, 0, len(originList)) + for _, origin := range originList { + trimmed := strings.TrimSpace(origin) + if trimmed == "" { + continue + } + if !settings.AllowInsecureOrigin && strings.HasPrefix(strings.ToLower(trimmed), "http://") { + return nil, fmt.Errorf("Passkey 不允许使用不安全的 Origin: %s", trimmed) + } + origins = append(origins, trimmed) + } + if len(origins) == 0 { + // 如果配置了Origins但过滤后为空,使用自动推导 + goto autoDetect + } + return origins, nil + } + +autoDetect: + scheme := detectScheme(r) + if scheme == "http" && !settings.AllowInsecureOrigin && r.Host != "localhost" && r.Host != "127.0.0.1" && !strings.HasPrefix(r.Host, "127.0.0.1:") && !strings.HasPrefix(r.Host, "localhost:") { + return nil, fmt.Errorf("Passkey 仅支持 HTTPS,当前访问: %s://%s,请在 Passkey 设置中允许不安全 Origin 或配置 HTTPS", scheme, r.Host) + } + // 优先使用请求的完整Host(包含端口) + host := r.Host + + // 如果无法从请求获取Host,尝试从ServerAddress获取 + if host == "" && system_setting.ServerAddress != "" { + if parsed, err := url.Parse(system_setting.ServerAddress); err == nil && parsed.Host != "" { + host = parsed.Host + if scheme == "" && parsed.Scheme != "" { + scheme = parsed.Scheme + } + } + } + if host == "" { + return nil, fmt.Errorf("无法确定 Passkey 的 Origin,请在系统设置或 Passkey 设置中指定。当前 Host: '%s', ServerAddress: '%s'", r.Host, system_setting.ServerAddress) + } + if scheme == "" { + scheme = "https" + } + origin := fmt.Sprintf("%s://%s", scheme, host) + return []string{origin}, nil +} + +func resolveRPID(r *http.Request, settings *system_setting.PasskeySettings, origins []string) (string, error) { + rpID := strings.TrimSpace(settings.RPID) + if rpID != "" { + return hostWithoutPort(rpID), nil + } + if len(origins) == 0 { + return "", errors.New("Passkey 未配置 Origin,无法推导 RPID") + } + parsed, err := url.Parse(origins[0]) + if err != nil { + return "", fmt.Errorf("无法解析 Passkey Origin: %w", err) + } + return hostWithoutPort(parsed.Host), nil +} + +func hostWithoutPort(host string) string { + host = strings.TrimSpace(host) + if host == "" { + return "" + } + if strings.Contains(host, ":") { + if host, _, err := net.SplitHostPort(host); err == nil { + return host + } + } + return host +} + +func detectScheme(r *http.Request) string { + if r == nil { + return "" + } + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + parts := strings.Split(proto, ",") + return strings.ToLower(strings.TrimSpace(parts[0])) + } + if r.TLS != nil { + return "https" + } + if r.URL != nil && r.URL.Scheme != "" { + return strings.ToLower(r.URL.Scheme) + } + if r.Header.Get("X-Forwarded-Protocol") != "" { + return strings.ToLower(strings.TrimSpace(r.Header.Get("X-Forwarded-Protocol"))) + } + return "http" +} diff --git a/service/passkey/session.go b/service/passkey/session.go new file mode 100644 index 0000000000000000000000000000000000000000..c8f96fd3dc17fafdee0d07d9dc40fca500ef3290 --- /dev/null +++ b/service/passkey/session.go @@ -0,0 +1,50 @@ +package passkey + +import ( + "encoding/json" + "errors" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +var errSessionNotFound = errors.New("Passkey 会话不存在或已过期") + +func SaveSessionData(c *gin.Context, key string, data *webauthn.SessionData) error { + session := sessions.Default(c) + if data == nil { + session.Delete(key) + return session.Save() + } + payload, err := json.Marshal(data) + if err != nil { + return err + } + session.Set(key, string(payload)) + return session.Save() +} + +func PopSessionData(c *gin.Context, key string) (*webauthn.SessionData, error) { + session := sessions.Default(c) + raw := session.Get(key) + if raw == nil { + return nil, errSessionNotFound + } + session.Delete(key) + _ = session.Save() + var data webauthn.SessionData + switch value := raw.(type) { + case string: + if err := json.Unmarshal([]byte(value), &data); err != nil { + return nil, err + } + case []byte: + if err := json.Unmarshal(value, &data); err != nil { + return nil, err + } + default: + return nil, errors.New("Passkey 会话格式无效") + } + return &data, nil +} diff --git a/service/passkey/user.go b/service/passkey/user.go new file mode 100644 index 0000000000000000000000000000000000000000..64484fecf3790d1c61e4156bdb80a8cee11ca9ec --- /dev/null +++ b/service/passkey/user.go @@ -0,0 +1,71 @@ +package passkey + +import ( + "fmt" + "strconv" + "strings" + + "one-api/model" + + webauthn "github.com/go-webauthn/webauthn/webauthn" +) + +type WebAuthnUser struct { + user *model.User + credential *model.PasskeyCredential +} + +func NewWebAuthnUser(user *model.User, credential *model.PasskeyCredential) *WebAuthnUser { + return &WebAuthnUser{user: user, credential: credential} +} + +func (u *WebAuthnUser) WebAuthnID() []byte { + if u == nil || u.user == nil { + return nil + } + return []byte(strconv.Itoa(u.user.Id)) +} + +func (u *WebAuthnUser) WebAuthnName() string { + if u == nil || u.user == nil { + return "" + } + name := strings.TrimSpace(u.user.Username) + if name == "" { + return fmt.Sprintf("user-%d", u.user.Id) + } + return name +} + +func (u *WebAuthnUser) WebAuthnDisplayName() string { + if u == nil || u.user == nil { + return "" + } + display := strings.TrimSpace(u.user.DisplayName) + if display != "" { + return display + } + return u.WebAuthnName() +} + +func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential { + if u == nil || u.credential == nil { + return nil + } + cred := u.credential.ToWebAuthnCredential() + return []webauthn.Credential{cred} +} + +func (u *WebAuthnUser) ModelUser() *model.User { + if u == nil { + return nil + } + return u.user +} + +func (u *WebAuthnUser) PasskeyCredential() *model.PasskeyCredential { + if u == nil { + return nil + } + return u.credential +} diff --git a/service/pre_consume_quota.go b/service/pre_consume_quota.go new file mode 100644 index 0000000000000000000000000000000000000000..60f77fd3d2e1ce2d0634ed0473585baa28c8265d --- /dev/null +++ b/service/pre_consume_quota.go @@ -0,0 +1,78 @@ +package service + +import ( + "fmt" + "net/http" + "one-api/common" + "one-api/logger" + "one-api/model" + relaycommon "one-api/relay/common" + "one-api/types" + + "github.com/bytedance/gopkg/util/gopool" + "github.com/gin-gonic/gin" +) + +func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) { + if relayInfo.FinalPreConsumedQuota != 0 { + logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota))) + gopool.Go(func() { + relayInfoCopy := *relayInfo + + err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false) + if err != nil { + common.SysLog("error return pre-consumed quota: " + err.Error()) + } + }) + } +} + +// PreConsumeQuota checks if the user has enough quota to pre-consume. +// It returns the pre-consumed quota if successful, or an error if not. +func PreConsumeQuota(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError { + userQuota, err := model.GetUserQuota(relayInfo.UserId, false) + if err != nil { + return types.NewError(err, types.ErrorCodeQueryDataError, types.ErrOptionWithSkipRetry()) + } + if userQuota <= 0 { + return types.NewErrorWithStatusCode(fmt.Errorf("用户额度不足, 剩余额度: %s", logger.FormatQuota(userQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + } + if userQuota-preConsumedQuota < 0 { + return types.NewErrorWithStatusCode(fmt.Errorf("预扣费额度失败, 用户剩余额度: %s, 需要预扣费额度: %s", logger.FormatQuota(userQuota), logger.FormatQuota(preConsumedQuota)), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + } + + trustQuota := common.GetTrustQuota() + + relayInfo.UserQuota = userQuota + if userQuota > trustQuota { + // 用户额度充足,判断令牌额度是否充足 + if !relayInfo.TokenUnlimited { + // 非无限令牌,判断令牌额度是否充足 + tokenQuota := c.GetInt("token_quota") + if tokenQuota > trustQuota { + // 令牌额度充足,信任令牌 + preConsumedQuota = 0 + logger.LogInfo(c, fmt.Sprintf("用户 %d 剩余额度 %s 且令牌 %d 额度 %d 充足, 信任且不需要预扣费", relayInfo.UserId, logger.FormatQuota(userQuota), relayInfo.TokenId, tokenQuota)) + } + } else { + // in this case, we do not pre-consume quota + // because the user has enough quota + preConsumedQuota = 0 + logger.LogInfo(c, fmt.Sprintf("用户 %d 额度充足且为无限额度令牌, 信任且不需要预扣费", relayInfo.UserId)) + } + } + + if preConsumedQuota > 0 { + err := PreConsumeTokenQuota(relayInfo, preConsumedQuota) + if err != nil { + return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog()) + } + err = model.DecreaseUserQuota(relayInfo.UserId, preConsumedQuota) + if err != nil { + return types.NewError(err, types.ErrorCodeUpdateDataError, types.ErrOptionWithSkipRetry()) + } + logger.LogInfo(c, fmt.Sprintf("用户 %d 预扣费 %s, 预扣费后剩余额度: %s", relayInfo.UserId, logger.FormatQuota(preConsumedQuota), logger.FormatQuota(userQuota-preConsumedQuota))) + } + relayInfo.FinalPreConsumedQuota = preConsumedQuota + return nil +} diff --git a/service/quota.go b/service/quota.go new file mode 100644 index 0000000000000000000000000000000000000000..dbf7061b9284120fa1a05d0fc0117b7fccbc9abe --- /dev/null +++ b/service/quota.go @@ -0,0 +1,564 @@ +package service + +import ( + "errors" + "fmt" + "log" + "math" + "one-api/common" + "one-api/constant" + "one-api/dto" + "one-api/logger" + "one-api/model" + relaycommon "one-api/relay/common" + "one-api/setting/ratio_setting" + "one-api/setting/system_setting" + "one-api/types" + "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 hasCustomModelRatio(modelName string, currentRatio float64) bool { + defaultRatio, exists := ratio_setting.GetDefaultModelRatioMap()[modelName] + if !exists { + return true + } + return currentRatio != defaultRatio +} + +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", logger.FormatQuota(userQuota), logger.FormatQuota(quota)) + } + + if !token.UnlimitedQuota && token.RemainQuota < quota { + return fmt.Errorf("token quota is not enough, token remain quota: %s, need quota: %s", logger.FormatQuota(token.RemainQuota), logger.FormatQuota(quota)) + } + + err = PostConsumeQuota(relayInfo, quota, 0, false) + if err != nil { + return err + } + logger.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, 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 := relayInfo.PriceData.ModelRatio + groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio + modelPrice := relayInfo.PriceData.ModelPrice + usePrice := relayInfo.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("(可能是上游超时)") + logger.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, relayInfo.FinalPreConsumedQuota)) + } 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, relayInfo.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, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) +} + +func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage) { + + useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix() + promptTokens := usage.PromptTokens + completionTokens := usage.CompletionTokens + modelName := relayInfo.OriginModelName + + tokenName := ctx.GetString("token_name") + completionRatio := relayInfo.PriceData.CompletionRatio + modelRatio := relayInfo.PriceData.ModelRatio + groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio + modelPrice := relayInfo.PriceData.ModelPrice + cacheRatio := relayInfo.PriceData.CacheRatio + cacheTokens := usage.PromptTokensDetails.CachedTokens + + cacheCreationRatio := relayInfo.PriceData.CacheCreationRatio + cacheCreationTokens := usage.PromptTokensDetails.CachedCreationTokens + + if relayInfo.ChannelType == constant.ChannelTypeOpenRouter { + promptTokens -= cacheTokens + isUsingCustomSettings := relayInfo.PriceData.UsePrice || hasCustomModelRatio(modelName, relayInfo.PriceData.ModelRatio) + if cacheCreationTokens == 0 && relayInfo.PriceData.CacheCreationRatio != 1 && usage.Cost != 0 && !isUsingCustomSettings { + maybeCacheCreationTokens := CalcOpenRouterCacheCreateTokens(*usage, relayInfo.PriceData) + if maybeCacheCreationTokens >= 0 && promptTokens >= maybeCacheCreationTokens { + cacheCreationTokens = maybeCacheCreationTokens + } + } + promptTokens -= cacheCreationTokens + } + + calculateQuota := 0.0 + if !relayInfo.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("(可能是上游出错)") + logger.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, relayInfo.FinalPreConsumedQuota)) + } else { + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + quotaDelta := quota - relayInfo.FinalPreConsumedQuota + + if quotaDelta > 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } else if quotaDelta < 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(-quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } + + if quotaDelta != 0 { + err := PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true) + if err != nil { + logger.LogError(ctx, "error consuming token remain quota: "+err.Error()) + } + } + + other := GenerateClaudeOtherInfo(ctx, relayInfo, modelRatio, groupRatio, completionRatio, + cacheTokens, cacheRatio, cacheCreationTokens, cacheCreationRatio, modelPrice, relayInfo.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, + UseTimeSeconds: int(useTimeSeconds), + IsStream: relayInfo.IsStream, + Group: relayInfo.UsingGroup, + Other: other, + }) + +} + +func CalcOpenRouterCacheCreateTokens(usage dto.Usage, priceData types.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, 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 := relayInfo.PriceData.ModelRatio + groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio + modelPrice := relayInfo.PriceData.ModelPrice + usePrice := relayInfo.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("(可能是上游超时)") + logger.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, relayInfo.FinalPreConsumedQuota)) + } else { + model.UpdateUserUsedQuotaAndRequestCount(relayInfo.UserId, quota) + model.UpdateChannelUsedQuota(relayInfo.ChannelId, quota) + } + + quotaDelta := quota - relayInfo.FinalPreConsumedQuota + + if quotaDelta > 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } else if quotaDelta < 0 { + logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s(实际消耗:%s,预扣费:%s)", + logger.FormatQuota(-quotaDelta), + logger.FormatQuota(quota), + logger.FormatQuota(relayInfo.FinalPreConsumedQuota), + )) + } + + if quotaDelta != 0 { + err := PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true) + if err != nil { + logger.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, relayInfo.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, + 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", logger.FormatQuota(token.RemainQuota), logger.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", system_setting.ServerAddress) + + // 根据通知方式生成不同的内容格式 + var content string + var values []interface{} + + notifyType := userSetting.NotifyType + if notifyType == "" { + notifyType = dto.NotifyTypeEmail + } + + if notifyType == dto.NotifyTypeBark { + // Bark推送使用简短文本,不支持HTML + content = "{{value}},剩余额度:{{value}},请及时充值" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} + } else { + // 默认内容格式,适用于Email和Webhook + content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink} + } + + err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values)) + 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 0000000000000000000000000000000000000000..f01d477f163513a7edecd0901245101e6b1f3ddf --- /dev/null +++ b/service/sensitive.go @@ -0,0 +1,76 @@ +package service + +import ( + "errors" + "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) (bool, []string) { + return SensitiveWordContains(text) +} + +// 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 := getOrBuildAC(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 0000000000000000000000000000000000000000..e0496a1ac7ab32537ea64c30a16c1008d9aef369 --- /dev/null +++ b/service/str.go @@ -0,0 +1,152 @@ +package service + +import ( + "bytes" + "fmt" + "hash/fnv" + "sort" + "strings" + "sync" + + goahocorasick "github.com/anknown/ahocorasick" +) + +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(dict []string) *goahocorasick.Machine { + m := new(goahocorasick.Machine) + runes := readRunes(dict) + if err := m.Build(runes); err != nil { + fmt.Println(err) + return nil + } + return m +} + +var acCache sync.Map + +func acKey(dict []string) string { + if len(dict) == 0 { + return "" + } + normalized := make([]string, 0, len(dict)) + for _, w := range dict { + w = strings.ToLower(strings.TrimSpace(w)) + if w != "" { + normalized = append(normalized, w) + } + } + if len(normalized) == 0 { + return "" + } + sort.Strings(normalized) + hasher := fnv.New64a() + for _, w := range normalized { + hasher.Write([]byte{0}) + hasher.Write([]byte(w)) + } + return fmt.Sprintf("%x", hasher.Sum64()) +} + +func getOrBuildAC(dict []string) *goahocorasick.Machine { + key := acKey(dict) + if key == "" { + return nil + } + if v, ok := acCache.Load(key); ok { + if m, ok2 := v.(*goahocorasick.Machine); ok2 { + return m + } + } + m := InitAc(dict) + if m == nil { + return nil + } + if actual, loaded := acCache.LoadOrStore(key, m); loaded { + if cached, ok := actual.(*goahocorasick.Machine); ok { + return cached + } + } + return m +} + +func readRunes(dict []string) [][]rune { + var runes [][]rune + + for _, word := range dict { + word = strings.ToLower(word) + l := bytes.TrimSpace([]byte(word)) + runes = append(runes, bytes.Runes(l)) + } + + return runes +} + +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 := getOrBuildAC(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 0000000000000000000000000000000000000000..11e4f9c495ea1146ab3ec90a113a792dbe66d81a --- /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 0000000000000000000000000000000000000000..710ef4ff4d6cae0d2e0476ca5519e66f444939f7 --- /dev/null +++ b/service/token_counter.go @@ -0,0 +1,602 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log" + "math" + "one-api/common" + "one-api/constant" + "one-api/dto" + relaycommon "one-api/relay/common" + "one-api/types" + "strings" + "sync" + "unicode/utf8" + + "github.com/gin-gonic/gin" + "github.com/tiktoken-go/tokenizer" + "github.com/tiktoken-go/tokenizer/codec" +) + +// 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(fileMeta *types.FileMeta, model string, stream bool) (int, error) { + if fileMeta == nil { + return 0, fmt.Errorf("image_url_is_nil") + } + + // Defaults for 4o/4.1/4.5 family unless overridden below + baseTokens := 85 + tileTokens := 170 + + // Model classification + lowerModel := strings.ToLower(model) + + // Special cases from existing behavior + if strings.HasPrefix(lowerModel, "glm-4") { + return 1047, nil + } + + // Patch-based models (32x32 patches, capped at 1536, with multiplier) + isPatchBased := false + multiplier := 1.0 + switch { + case strings.Contains(lowerModel, "gpt-4.1-mini"): + isPatchBased = true + multiplier = 1.62 + case strings.Contains(lowerModel, "gpt-4.1-nano"): + isPatchBased = true + multiplier = 2.46 + case strings.HasPrefix(lowerModel, "o4-mini"): + isPatchBased = true + multiplier = 1.72 + case strings.HasPrefix(lowerModel, "gpt-5-mini"): + isPatchBased = true + multiplier = 1.62 + case strings.HasPrefix(lowerModel, "gpt-5-nano"): + isPatchBased = true + multiplier = 2.46 + } + + // Tile-based model tokens and bases per doc + if !isPatchBased { + if strings.HasPrefix(lowerModel, "gpt-4o-mini") { + baseTokens = 2833 + tileTokens = 5667 + } else if strings.HasPrefix(lowerModel, "gpt-5-chat-latest") || (strings.HasPrefix(lowerModel, "gpt-5") && !strings.Contains(lowerModel, "mini") && !strings.Contains(lowerModel, "nano")) { + baseTokens = 70 + tileTokens = 140 + } else if strings.HasPrefix(lowerModel, "o1") || strings.HasPrefix(lowerModel, "o3") || strings.HasPrefix(lowerModel, "o1-pro") { + baseTokens = 75 + tileTokens = 150 + } else if strings.Contains(lowerModel, "computer-use-preview") { + baseTokens = 65 + tileTokens = 129 + } else if strings.Contains(lowerModel, "4.1") || strings.Contains(lowerModel, "4o") || strings.Contains(lowerModel, "4.5") { + baseTokens = 85 + tileTokens = 170 + } + } + + // Respect existing feature flags/short-circuits + if fileMeta.Detail == "low" && !isPatchBased { + return baseTokens, nil + } + if !constant.GetMediaTokenNotStream && !stream { + return 3 * baseTokens, nil + } + // Normalize detail + if fileMeta.Detail == "auto" || fileMeta.Detail == "" { + fileMeta.Detail = "high" + } + // Whether to count image tokens at all + if !constant.GetMediaToken { + return 3 * baseTokens, nil + } + + // Decode image to get dimensions + var config image.Config + var err error + var format string + var b64str string + + if fileMeta.ParsedData != nil { + config, format, b64str, err = DecodeBase64ImageData(fileMeta.ParsedData.Base64Data) + } else { + if strings.HasPrefix(fileMeta.OriginData, "http") { + config, format, err = DecodeUrlImageData(fileMeta.OriginData) + } else { + common.SysLog(fmt.Sprintf("decoding image")) + config, format, b64str, err = DecodeBase64ImageData(fileMeta.OriginData) + } + fileMeta.MimeType = format + } + + if err != nil { + return 0, err + } + + 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", fileMeta.OriginData)) + } + + width := config.Width + height := config.Height + log.Printf("format: %s, width: %d, height: %d", format, width, height) + + if isPatchBased { + // 32x32 patch-based calculation with 1536 cap and model multiplier + ceilDiv := func(a, b int) int { return (a + b - 1) / b } + rawPatchesW := ceilDiv(width, 32) + rawPatchesH := ceilDiv(height, 32) + rawPatches := rawPatchesW * rawPatchesH + if rawPatches > 1536 { + // scale down + area := float64(width * height) + r := math.Sqrt(float64(32*32*1536) / area) + wScaled := float64(width) * r + hScaled := float64(height) * r + // adjust to fit whole number of patches after scaling + adjW := math.Floor(wScaled/32.0) / (wScaled / 32.0) + adjH := math.Floor(hScaled/32.0) / (hScaled / 32.0) + adj := math.Min(adjW, adjH) + if !math.IsNaN(adj) && adj > 0 { + r = r * adj + } + wScaled = float64(width) * r + hScaled = float64(height) * r + patchesW := math.Ceil(wScaled / 32.0) + patchesH := math.Ceil(hScaled / 32.0) + imageTokens := int(patchesW * patchesH) + if imageTokens > 1536 { + imageTokens = 1536 + } + return int(math.Round(float64(imageTokens) * multiplier)), nil + } + // below cap + imageTokens := rawPatches + return int(math.Round(float64(imageTokens) * multiplier)), nil + } + + // Tile-based calculation for 4o/4.1/4.5/o1/o3/etc. + // Step 1: fit within 2048x2048 square + maxSide := math.Max(float64(width), float64(height)) + fitScale := 1.0 + if maxSide > 2048 { + fitScale = maxSide / 2048.0 + } + fitW := int(math.Round(float64(width) / fitScale)) + fitH := int(math.Round(float64(height) / fitScale)) + + // Step 2: scale so that shortest side is exactly 768 + minSide := math.Min(float64(fitW), float64(fitH)) + if minSide == 0 { + return baseTokens, nil + } + shortScale := 768.0 / minSide + finalW := int(math.Round(float64(fitW) * shortScale)) + finalH := int(math.Round(float64(fitH) * shortScale)) + + // Count 512px tiles + tilesW := (finalW + 512 - 1) / 512 + tilesH := (finalH + 512 - 1) / 512 + tiles := tilesW * tilesH + + if common.DebugEnabled { + log.Printf("scaled to: %dx%d, tiles: %d", finalW, finalH, tiles) + } + + return tiles*tileTokens + baseTokens, nil +} + +func CountRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) { + if !constant.GetMediaToken { + return 0, nil + } + if !constant.GetMediaTokenNotStream && !info.IsStream { + return 0, nil + } + if info.RelayFormat == types.RelayFormatOpenAIRealtime { + return 0, nil + } + if meta == nil { + return 0, errors.New("token count meta is nil") + } + + model := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) + tkm := 0 + + if meta.TokenType == types.TokenTypeTextNumber { + tkm += utf8.RuneCountInString(meta.CombineText) + } else { + tkm += CountTextToken(meta.CombineText, model) + } + + if info.RelayFormat == types.RelayFormatOpenAI { + tkm += meta.ToolsCount * 8 + tkm += meta.MessagesCount * 3 // 每条消息的格式化token数量 + tkm += meta.NameCount * 3 + tkm += 3 + } + + shouldFetchFiles := true + + if info.RelayFormat == types.RelayFormatGemini { + shouldFetchFiles = false + } + + if shouldFetchFiles { + for _, file := range meta.Files { + if strings.HasPrefix(file.OriginData, "http") { + mineType, err := GetFileTypeFromUrl(c, file.OriginData, "token_counter") + if err != nil { + return 0, fmt.Errorf("error getting file base64 from url: %v", err) + } + if strings.HasPrefix(mineType, "image/") { + file.FileType = types.FileTypeImage + } else if strings.HasPrefix(mineType, "video/") { + file.FileType = types.FileTypeVideo + } else if strings.HasPrefix(mineType, "audio/") { + file.FileType = types.FileTypeAudio + } else { + file.FileType = types.FileTypeFile + } + file.MimeType = mineType + } else if strings.HasPrefix(file.OriginData, "data:") { + // get mime type from base64 header + parts := strings.SplitN(file.OriginData, ",", 2) + if len(parts) >= 1 { + header := parts[0] + // Extract mime type from "data:mime/type;base64" format + if strings.Contains(header, ":") && strings.Contains(header, ";") { + mimeStart := strings.Index(header, ":") + 1 + mimeEnd := strings.Index(header, ";") + if mimeStart < mimeEnd { + mineType := header[mimeStart:mimeEnd] + if strings.HasPrefix(mineType, "image/") { + file.FileType = types.FileTypeImage + } else if strings.HasPrefix(mineType, "video/") { + file.FileType = types.FileTypeVideo + } else if strings.HasPrefix(mineType, "audio/") { + file.FileType = types.FileTypeAudio + } else { + file.FileType = types.FileTypeFile + } + file.MimeType = mineType + } + } + } + } + } + } + + for i, file := range meta.Files { + switch file.FileType { + case types.FileTypeImage: + if info.RelayFormat == types.RelayFormatGemini { + tkm += 256 + } else { + token, err := getImageToken(file, model, info.IsStream) + if err != nil { + return 0, fmt.Errorf("error counting image token, media index[%d], original data[%s], err: %v", i, file.OriginData, err) + } + tkm += token + } + case types.FileTypeAudio: + tkm += 256 + case types.FileTypeVideo: + tkm += 4096 * 2 + case types.FileTypeFile: + tkm += 4096 + default: + tkm += 4096 // Default case for unknown file types + } + } + + common.SetContextKey(c, constant.ContextKeyPromptTokens, tkm) + 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 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 0000000000000000000000000000000000000000..c232d2b38919364b11d46ed6c821d4f43930918c --- /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 0000000000000000000000000000000000000000..169df3d4794fc703f5978381ee035ad66a85f983 --- /dev/null +++ b/service/user_notify.go @@ -0,0 +1,146 @@ +package service + +import ( + "fmt" + "net/http" + "net/url" + "one-api/common" + "one-api/dto" + "one-api/model" + "one-api/setting/system_setting" + "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.SysLog(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.SysLog(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.SysLog(fmt.Sprintf("user %d has no webhook url, skip sending webhook", userId)) + return nil + } + + // 获取 webhook secret + webhookSecret := userSetting.WebhookSecret + return SendWebhookNotify(webhookURLStr, webhookSecret, data) + case dto.NotifyTypeBark: + barkURL := userSetting.BarkUrl + if barkURL == "" { + common.SysLog(fmt.Sprintf("user %d has no bark url, skip sending bark", userId)) + return nil + } + return sendBarkNotify(barkURL, 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) +} + +func sendBarkNotify(barkURL string, data dto.Notify) error { + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + + // 替换模板变量 + finalURL := strings.ReplaceAll(barkURL, "{{title}}", url.QueryEscape(data.Title)) + finalURL = strings.ReplaceAll(finalURL, "{{content}}", url.QueryEscape(content)) + + // 发送GET请求到Bark + var req *http.Request + var resp *http.Response + var err error + + if system_setting.EnableWorker() { + // 使用worker发送请求 + workerReq := &WorkerRequest{ + URL: finalURL, + Key: system_setting.WorkerValidKey, + Method: http.MethodGet, + Headers: map[string]string{ + "User-Agent": "OneAPI-Bark-Notify/1.0", + }, + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send bark request through worker: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode) + } + } else { + // SSRF防护:验证Bark URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + + // 直接发送请求 + req, err = http.NewRequest(http.MethodGet, finalURL, nil) + if err != nil { + return fmt.Errorf("failed to create bark request: %v", err) + } + + // 设置User-Agent + req.Header.Set("User-Agent", "OneAPI-Bark-Notify/1.0") + + // 发送请求 + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send bark request: %v", err) + } + defer resp.Body.Close() + + // 检查响应状态 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} diff --git a/service/webhook.go b/service/webhook.go new file mode 100644 index 0000000000000000000000000000000000000000..263f35d0cc0d97daddc549f7534e2e6dd0001429 --- /dev/null +++ b/service/webhook.go @@ -0,0 +1,125 @@ +package service + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "one-api/common" + "one-api/dto" + "one-api/setting/system_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 system_setting.EnableWorker() { + // 构建worker请求数据 + workerReq := &WorkerRequest{ + URL: webhookURL, + Key: system_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 { + // SSRF防护:验证Webhook URL(非Worker模式) + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(webhookURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + + 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 0000000000000000000000000000000000000000..1bb3ef0324b0582d9667b4c583fd82b882a7acc0 --- /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 0000000000000000000000000000000000000000..05c9eb25fc66eeaaa1664ea172eab16d356f3305 --- /dev/null +++ b/setting/chat.go @@ -0,0 +1,44 @@ +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}", + }, + { + "流畅阅读": "fluentread", + }, + { + "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.SysLog("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 0000000000000000000000000000000000000000..286242f86dd851ac2852167f2094fae90e55ca8a --- /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 0000000000000000000000000000000000000000..5ca069e5514dba66f16786832d9c73c76ae6eeb7 --- /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 +} diff --git a/setting/console_setting/validation.go b/setting/console_setting/validation.go new file mode 100644 index 0000000000000000000000000000000000000000..19d0e21f1100a2d84a4e1daed7dac0c5908e4762 --- /dev/null +++ b/setting/console_setting/validation.go @@ -0,0 +1,304 @@ +package console_setting + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" + "sort" + "strings" + "time" +) + +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) +} diff --git a/setting/midjourney.go b/setting/midjourney.go new file mode 100644 index 0000000000000000000000000000000000000000..8b6e08336a4ab30420845496160d8240afa8b0c9 --- /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 0000000000000000000000000000000000000000..c8baff14f19ceffe2c93cb2f5432a5238a744b8d --- /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 0000000000000000000000000000000000000000..d41def59bb4d6e1935f3f6718d5b0ff37f0032a1 --- /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 0000000000000000000000000000000000000000..9eccb2158fd3e9b7e6ff005edc1b42da06a99451 --- /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 0000000000000000000000000000000000000000..c68b0314ab2c8b3386b1ca92263b802fba515e23 --- /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/monitor_setting.go b/setting/operation_setting/monitor_setting.go new file mode 100644 index 0000000000000000000000000000000000000000..b24eec0160aaa4a809a709348517a6cbcd70354f --- /dev/null +++ b/setting/operation_setting/monitor_setting.go @@ -0,0 +1,34 @@ +package operation_setting + +import ( + "one-api/setting/config" + "os" + "strconv" +) + +type MonitorSetting struct { + AutoTestChannelEnabled bool `json:"auto_test_channel_enabled"` + AutoTestChannelMinutes int `json:"auto_test_channel_minutes"` +} + +// 默认配置 +var monitorSetting = MonitorSetting{ + AutoTestChannelEnabled: false, + AutoTestChannelMinutes: 10, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("monitor_setting", &monitorSetting) +} + +func GetMonitorSetting() *MonitorSetting { + if os.Getenv("CHANNEL_TEST_FREQUENCY") != "" { + frequency, err := strconv.Atoi(os.Getenv("CHANNEL_TEST_FREQUENCY")) + if err == nil && frequency > 0 { + monitorSetting.AutoTestChannelEnabled = true + monitorSetting.AutoTestChannelMinutes = frequency + } + } + return &monitorSetting +} diff --git a/setting/operation_setting/operation_setting.go b/setting/operation_setting/operation_setting.go new file mode 100644 index 0000000000000000000000000000000000000000..0f7cefc2eafb9d84d6cc1c76a15e14011c3b31b0 --- /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/payment_setting.go b/setting/operation_setting/payment_setting.go new file mode 100644 index 0000000000000000000000000000000000000000..8d85fc3b275d1f38c7d1cccd55a892e19e231a53 --- /dev/null +++ b/setting/operation_setting/payment_setting.go @@ -0,0 +1,23 @@ +package operation_setting + +import "one-api/setting/config" + +type PaymentSetting struct { + AmountOptions []int `json:"amount_options"` + AmountDiscount map[int]float64 `json:"amount_discount"` // 充值金额对应的折扣,例如 100 元 0.9 表示 100 元充值享受 9 折优惠 +} + +// 默认配置 +var paymentSetting = PaymentSetting{ + AmountOptions: []int{10, 20, 50, 100, 200, 500}, + AmountDiscount: map[int]float64{}, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("payment_setting", &paymentSetting) +} + +func GetPaymentSetting() *PaymentSetting { + return &paymentSetting +} diff --git a/setting/operation_setting/payment_setting_old.go b/setting/operation_setting/payment_setting_old.go new file mode 100644 index 0000000000000000000000000000000000000000..3b012361edfae43ec47674407eb03cb351b62527 --- /dev/null +++ b/setting/operation_setting/payment_setting_old.go @@ -0,0 +1,59 @@ +/** +此文件为旧版支付设置文件,如需增加新的参数、变量等,请在 payment_setting.go 中添加 +This file is the old version of the payment settings file. If you need to add new parameters, variables, etc., please add them in payment_setting.go +*/ + +package operation_setting + +import ( + "one-api/common" +) + +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", + }, + { + "name": "自定义1", + "color": "black", + "type": "custom1", + "min_topup": "50", + }, +} + +func UpdatePayMethodsByJsonString(jsonString string) error { + PayMethods = make([]map[string]string, 0) + return common.Unmarshal([]byte(jsonString), &PayMethods) +} + +func PayMethods2JsonString() string { + jsonBytes, err := common.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/operation_setting/tools.go b/setting/operation_setting/tools.go new file mode 100644 index 0000000000000000000000000000000000000000..f3030ef53014bd3e6aaf9ab31d17b6dad16e3534 --- /dev/null +++ b/setting/operation_setting/tools.go @@ -0,0 +1,110 @@ +package operation_setting + +import "strings" + +const ( + // Web search + WebSearchPriceHigh = 25.00 + WebSearchPrice = 10.00 + // File search + FileSearchPrice = 2.5 +) + +const ( + GPTImage1Low1024x1024 = 0.011 + GPTImage1Low1024x1536 = 0.016 + GPTImage1Low1536x1024 = 0.016 + GPTImage1Medium1024x1024 = 0.042 + GPTImage1Medium1024x1536 = 0.063 + GPTImage1Medium1536x1024 = 0.063 + GPTImage1High1024x1024 = 0.167 + GPTImage1High1024x1536 = 0.25 + GPTImage1High1536x1024 = 0.25 +) + +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 + GeminiRoboticsER15InputAudioPrice = 1.00 +) + +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,故在const区域将各size的价格设为一致。 + // gpt-5, gpt-5-mini, gpt-5-nano 和 o 系列模型价格为 10.00 美元/千次调用,产生额外 token 计入 input_tokens + // gpt-4o, gpt-4.1, gpt-4o-mini 和 gpt-4.1-mini 价格为 25.00 美元/千次调用,不产生额外 token + isNormalPriceModel := + strings.HasPrefix(modelName, "o3") || + strings.HasPrefix(modelName, "o4") || + strings.HasPrefix(modelName, "gpt-5") + var priceWebSearchPerThousandCalls float64 + if isNormalPriceModel { + priceWebSearchPerThousandCalls = WebSearchPrice + } else { + priceWebSearchPerThousandCalls = WebSearchPriceHigh + } + 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 + } else if strings.HasPrefix(modelName, "gemini-robotics-er-1.5") { + return GeminiRoboticsER15InputAudioPrice + } + return 0 +} + +func GetGPTImage1PriceOnceCall(quality string, size string) float64 { + prices := map[string]map[string]float64{ + "low": { + "1024x1024": GPTImage1Low1024x1024, + "1024x1536": GPTImage1Low1024x1536, + "1536x1024": GPTImage1Low1536x1024, + }, + "medium": { + "1024x1024": GPTImage1Medium1024x1024, + "1024x1536": GPTImage1Medium1024x1536, + "1536x1024": GPTImage1Medium1536x1024, + }, + "high": { + "1024x1024": GPTImage1High1024x1024, + "1024x1536": GPTImage1High1024x1536, + "1536x1024": GPTImage1High1536x1024, + }, + } + + if qualityMap, exists := prices[quality]; exists { + if price, exists := qualityMap[size]; exists { + return price + } + } + + return GPTImage1High1024x1024 +} diff --git a/setting/payment_stripe.go b/setting/payment_stripe.go new file mode 100644 index 0000000000000000000000000000000000000000..3a397227b0df30e52cd5225802907aba31799a9b --- /dev/null +++ b/setting/payment_stripe.go @@ -0,0 +1,8 @@ +package setting + +var StripeApiSecret = "" +var StripeWebhookSecret = "" +var StripePriceId = "" +var StripeUnitPrice = 8.0 +var StripeMinTopUp = 1 +var StripePromotionCodesEnabled = false diff --git a/setting/rate_limit.go b/setting/rate_limit.go new file mode 100644 index 0000000000000000000000000000000000000000..b99810a0e32b991470ba08a291dbee5a770329fa --- /dev/null +++ b/setting/rate_limit.go @@ -0,0 +1,68 @@ +package setting + +import ( + "encoding/json" + "fmt" + "math" + "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.SysLog("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]) + } + if limits[0] > math.MaxInt32 || limits[1] > math.MaxInt32 { + return fmt.Errorf("group %s [%d, %d] has max rate limits value 2147483647", 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 0000000000000000000000000000000000000000..b53d2c3d1b20ff9c9b0e6913d00ebfeb41337688 --- /dev/null +++ b/setting/ratio_setting/cache_ratio.go @@ -0,0 +1,140 @@ +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, + "gpt-4.1": 0.25, + "gpt-4.1-mini": 0.25, + "gpt-4.1-nano": 0.25, + "gpt-5": 0.1, + "gpt-5-2025-08-07": 0.1, + "gpt-5-chat-latest": 0.1, + "gpt-5-mini": 0.1, + "gpt-5-mini-2025-08-07": 0.1, + "gpt-5-nano": 0.1, + "gpt-5-nano-2025-08-07": 0.1, + "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, + "claude-opus-4-1-20250805": 0.1, + "claude-opus-4-1-20250805-thinking": 0.1, + "claude-sonnet-4-5-20250929": 0.1, + "claude-sonnet-4-5-20250929-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, + "claude-opus-4-1-20250805": 1.25, + "claude-opus-4-1-20250805-thinking": 1.25, + "claude-sonnet-4-5-20250929": 1.25, + "claude-sonnet-4-5-20250929-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.SysLog("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 0000000000000000000000000000000000000000..1c1833b385339223603547fe56579fa8ea5bcbe2 --- /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() +} diff --git a/setting/ratio_setting/exposed_cache.go b/setting/ratio_setting/exposed_cache.go new file mode 100644 index 0000000000000000000000000000000000000000..a6131bebabe10ce72578f4e0460c73e25062bfc2 --- /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) +} diff --git a/setting/ratio_setting/group_ratio.go b/setting/ratio_setting/group_ratio.go new file mode 100644 index 0000000000000000000000000000000000000000..a3fcafd999e479dc80b635e7f4b965d84366ea44 --- /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.SysLog("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.SysLog("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.SysLog("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 0000000000000000000000000000000000000000..608594793a4f54e2991b37e85e6418035500d625 --- /dev/null +++ b/setting/ratio_setting/model_ratio.go @@ -0,0 +1,817 @@ +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-4.1": 1.0, // $2 / 1M tokens + "gpt-4.1-2025-04-14": 1.0, // $2 / 1M tokens + "gpt-4.1-mini": 0.2, // $0.4 / 1M tokens + "gpt-4.1-mini-2025-04-14": 0.2, // $0.4 / 1M tokens + "gpt-4.1-nano": 0.05, // $0.1 / 1M tokens + "gpt-4.1-nano-2025-04-14": 0.05, // $0.1 / 1M tokens + "gpt-image-1": 2.5, // $5 / 1M tokens + "o1": 7.5, // $15 / 1M tokens + "o1-2024-12-17": 7.5, // $15 / 1M tokens + "o1-preview": 7.5, // $15 / 1M tokens + "o1-preview-2024-09-12": 7.5, // $15 / 1M tokens + "o1-mini": 0.55, // $1.1 / 1M tokens + "o1-mini-2024-09-12": 0.55, // $1.1 / 1M tokens + "o1-pro": 75.0, // $150 / 1M tokens + "o1-pro-2025-03-19": 75.0, // $150 / 1M tokens + "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, + "o3": 1.0, // $2 / 1M tokens + "o3-2025-04-16": 1.0, // $2 / 1M tokens + "o3-pro": 10.0, // $20 / 1M tokens + "o3-pro-2025-06-10": 10.0, // $20 / 1M tokens + "o3-deep-research": 5.0, // $10 / 1M tokens + "o3-deep-research-2025-06-26": 5.0, // $10 / 1M tokens + "o4-mini": 0.55, // $1.1 / 1M tokens + "o4-mini-2025-04-16": 0.55, // $1.1 / 1M tokens + "o4-mini-deep-research": 1.0, // $2 / 1M tokens + "o4-mini-deep-research-2025-06-26": 1.0, // $2 / 1M tokens + "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-5": 0.625, + "gpt-5-2025-08-07": 0.625, + "gpt-5-chat-latest": 0.625, + "gpt-5-mini": 0.125, + "gpt-5-mini-2025-08-07": 0.125, + "gpt-5-nano": 0.025, + "gpt-5-nano-2025-08-07": 0.025, + //"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-sonnet-4-5-20250929": 1.5, + "claude-3-opus-20240229": 7.5, // $15 / 1M tokens + "claude-opus-4-20250514": 7.5, + "claude-opus-4-1-20250805": 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-thinking-*": 0.05, + "gemini-2.5-flash-lite-preview-06-17": 0.05, + "gemini-2.5-flash": 0.15, + "gemini-robotics-er-1.5-preview": 0.15, + "gemini-embedding-001": 0.075, + "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, + // submodel + "NousResearch/Hermes-4-405B-FP8": 0.8, + "Qwen/Qwen3-235B-A22B-Thinking-2507": 0.6, + "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": 0.8, + "Qwen/Qwen3-235B-A22B-Instruct-2507": 0.3, + "zai-org/GLM-4.5-FP8": 0.8, + "openai/gpt-oss-120b": 0.5, + "deepseek-ai/DeepSeek-R1-0528": 0.8, + "deepseek-ai/DeepSeek-R1": 0.8, + "deepseek-ai/DeepSeek-V3-0324": 0.8, + "deepseek-ai/DeepSeek-V3.1": 0.8, +} + +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 defaultAudioRatio = map[string]float64{ + "gpt-4o-audio-preview": 16, + "gpt-4o-mini-audio-preview": 66.67, + "gpt-4o-realtime-preview": 8, + "gpt-4o-mini-realtime-preview": 16.67, +} + +var defaultAudioCompletionRatio = map[string]float64{ + "gpt-4o-realtime": 2, + "gpt-4o-mini-realtime": 2, +} + +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() + + // initialize audioRatioMap + audioRatioMapMutex.Lock() + audioRatioMap = defaultAudioRatio + audioRatioMapMutex.Unlock() + + // initialize audioCompletionRatioMap + audioCompletionRatioMapMutex.Lock() + audioCompletionRatioMap = defaultAudioCompletionRatio + audioCompletionRatioMapMutex.Unlock() +} + +func GetModelPriceMap() map[string]float64 { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + return modelPriceMap +} + +func ModelPrice2JSONString() string { + modelPriceMapMutex.RLock() + defer modelPriceMapMutex.RUnlock() + + jsonBytes, err := common.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() + + name = FormatMatchingModelName(name) + + 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 := common.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 = FormatMatchingModelName(name) + + ratio, ok := modelRatioMap[name] + if !ok { + return 37.5, operation_setting.SelfUseModeEnabled, name + } + return ratio, true, name +} + +func DefaultModelRatio2JSONString() string { + jsonBytes, err := common.Marshal(defaultModelRatio) + if err != nil { + common.SysError("error marshalling model ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func GetDefaultModelRatioMap() map[string]float64 { + return defaultModelRatio +} + +func GetDefaultImageRatioMap() map[string]float64 { + return defaultImageRatio +} + +func GetDefaultAudioRatioMap() map[string]float64 { + return defaultAudioRatio +} + +func GetDefaultAudioCompletionRatioMap() map[string]float64 { + return defaultAudioCompletionRatio +} + +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 := common.Unmarshal([]byte(jsonStr), &CompletionRatio) + if err == nil { + InvalidateExposedDataCache() + } + return err +} + +func GetCompletionRatio(name string) float64 { + CompletionRatioMutex.RLock() + defer CompletionRatioMutex.RUnlock() + + name = FormatMatchingModelName(name) + + 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) { + + isReservedModel := strings.HasSuffix(name, "-all") || strings.HasSuffix(name, "-gizmo-*") + if isReservedModel { + return 2, false + } + + if strings.HasPrefix(name, "gpt-") { + if strings.HasPrefix(name, "gpt-4o") { + if name == "gpt-4o-2024-05-13" { + return 3, true + } + return 4, true + } + // gpt-5 匹配 + if strings.HasPrefix(name, "gpt-5") { + return 8, 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") { + return 4, false + } + return 2.5 / 0.3, false + } else if strings.HasPrefix(name, "gemini-robotics-er-1.5") { + return 2.5 / 0.3, false + } + 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 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 { + audioRatioMapMutex.RLock() + defer audioRatioMapMutex.RUnlock() + name = FormatMatchingModelName(name) + if ratio, ok := audioRatioMap[name]; ok { + return ratio + } + return 20 +} + +func GetAudioCompletionRatio(name string) float64 { + audioCompletionRatioMapMutex.RLock() + defer audioCompletionRatioMapMutex.RUnlock() + name = FormatMatchingModelName(name) + if ratio, ok := audioCompletionRatioMap[name]; ok { + + return ratio + } + return 2 +} + +func ModelRatio2JSONString() string { + modelRatioMapMutex.RLock() + defer modelRatioMapMutex.RUnlock() + + jsonBytes, err := common.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 +var ( + audioRatioMap map[string]float64 = nil + audioRatioMapMutex = sync.RWMutex{} +) +var ( + audioCompletionRatioMap map[string]float64 = nil + audioCompletionRatioMapMutex = sync.RWMutex{} +) + +func ImageRatio2JSONString() string { + imageRatioMapMutex.RLock() + defer imageRatioMapMutex.RUnlock() + jsonBytes, err := common.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 common.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 AudioRatio2JSONString() string { + audioRatioMapMutex.RLock() + defer audioRatioMapMutex.RUnlock() + jsonBytes, err := common.Marshal(audioRatioMap) + if err != nil { + common.SysError("error marshalling audio ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateAudioRatioByJSONString(jsonStr string) error { + + tmp := make(map[string]float64) + if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil { + return err + } + audioRatioMapMutex.Lock() + audioRatioMap = tmp + audioRatioMapMutex.Unlock() + InvalidateExposedDataCache() + return nil +} + +func GetAudioRatioCopy() map[string]float64 { + audioRatioMapMutex.RLock() + defer audioRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(audioRatioMap)) + for k, v := range audioRatioMap { + copyMap[k] = v + } + return copyMap +} + +func AudioCompletionRatio2JSONString() string { + audioCompletionRatioMapMutex.RLock() + defer audioCompletionRatioMapMutex.RUnlock() + jsonBytes, err := common.Marshal(audioCompletionRatioMap) + if err != nil { + common.SysError("error marshalling audio completion ratio: " + err.Error()) + } + return string(jsonBytes) +} + +func UpdateAudioCompletionRatioByJSONString(jsonStr string) error { + tmp := make(map[string]float64) + if err := common.Unmarshal([]byte(jsonStr), &tmp); err != nil { + return err + } + audioCompletionRatioMapMutex.Lock() + audioCompletionRatioMap = tmp + audioCompletionRatioMapMutex.Unlock() + InvalidateExposedDataCache() + return nil +} + +func GetAudioCompletionRatioCopy() map[string]float64 { + audioCompletionRatioMapMutex.RLock() + defer audioCompletionRatioMapMutex.RUnlock() + copyMap := make(map[string]float64, len(audioCompletionRatioMap)) + for k, v := range audioCompletionRatioMap { + copyMap[k] = v + } + return copyMap +} + +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 +} + +// 转换模型名,减少渠道必须配置各种带参数模型 +func FormatMatchingModelName(name string) string { + + if strings.HasPrefix(name, "gemini-2.5-flash-lite") { + name = handleThinkingBudgetModel(name, "gemini-2.5-flash-lite", "gemini-2.5-flash-lite-thinking-*") + } else if strings.HasPrefix(name, "gemini-2.5-flash") { + name = handleThinkingBudgetModel(name, "gemini-2.5-flash", "gemini-2.5-flash-thinking-*") + } else if strings.HasPrefix(name, "gemini-2.5-pro") { + name = handleThinkingBudgetModel(name, "gemini-2.5-pro", "gemini-2.5-pro-thinking-*") + } + + if strings.HasPrefix(name, "gpt-4-gizmo") { + name = "gpt-4-gizmo-*" + } + if strings.HasPrefix(name, "gpt-4o-gizmo") { + name = "gpt-4o-gizmo-*" + } + return name +} diff --git a/setting/sensitive.go b/setting/sensitive.go new file mode 100644 index 0000000000000000000000000000000000000000..08a754a2d11111bf23693a5c77441d801241d147 --- /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/fetch_setting.go b/setting/system_setting/fetch_setting.go new file mode 100644 index 0000000000000000000000000000000000000000..b756fd2873e49946118ee05090f3547327bb9256 --- /dev/null +++ b/setting/system_setting/fetch_setting.go @@ -0,0 +1,34 @@ +package system_setting + +import "one-api/setting/config" + +type FetchSetting struct { + EnableSSRFProtection bool `json:"enable_ssrf_protection"` // 是否启用SSRF防护 + AllowPrivateIp bool `json:"allow_private_ip"` + DomainFilterMode bool `json:"domain_filter_mode"` // 域名过滤模式,true: 白名单模式,false: 黑名单模式 + IpFilterMode bool `json:"ip_filter_mode"` // IP过滤模式,true: 白名单模式,false: 黑名单模式 + DomainList []string `json:"domain_list"` // domain format, e.g. example.com, *.example.com + IpList []string `json:"ip_list"` // CIDR format + AllowedPorts []string `json:"allowed_ports"` // port range format, e.g. 80, 443, 8000-9000 + ApplyIPFilterForDomain bool `json:"apply_ip_filter_for_domain"` // 对域名启用IP过滤(实验性) +} + +var defaultFetchSetting = FetchSetting{ + EnableSSRFProtection: true, // 默认开启SSRF防护 + AllowPrivateIp: false, + DomainFilterMode: false, + IpFilterMode: false, + DomainList: []string{}, + IpList: []string{}, + AllowedPorts: []string{"80", "443", "8080", "8443"}, + ApplyIPFilterForDomain: false, +} + +func init() { + // 注册到全局配置管理器 + config.GlobalConfig.Register("fetch_setting", &defaultFetchSetting) +} + +func GetFetchSetting() *FetchSetting { + return &defaultFetchSetting +} diff --git a/setting/system_setting/oidc.go b/setting/system_setting/oidc.go new file mode 100644 index 0000000000000000000000000000000000000000..e3cd6c85b64f3cb23cb8e9fb342c1aea066225e5 --- /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/system_setting/passkey.go b/setting/system_setting/passkey.go new file mode 100644 index 0000000000000000000000000000000000000000..d3b26fc36db0090629d52ce7b567873256f64943 --- /dev/null +++ b/setting/system_setting/passkey.go @@ -0,0 +1,49 @@ +package system_setting + +import ( + "net/url" + "one-api/common" + "one-api/setting/config" + "strings" +) + +type PasskeySettings struct { + Enabled bool `json:"enabled"` + RPDisplayName string `json:"rp_display_name"` + RPID string `json:"rp_id"` + Origins string `json:"origins"` + AllowInsecureOrigin bool `json:"allow_insecure_origin"` + UserVerification string `json:"user_verification"` + AttachmentPreference string `json:"attachment_preference"` +} + +var defaultPasskeySettings = PasskeySettings{ + Enabled: false, + RPDisplayName: common.SystemName, + RPID: "", + Origins: "", + AllowInsecureOrigin: false, + UserVerification: "preferred", + AttachmentPreference: "", +} + +func init() { + config.GlobalConfig.Register("passkey", &defaultPasskeySettings) +} + +func GetPasskeySettings() *PasskeySettings { + if defaultPasskeySettings.RPID == "" && ServerAddress != "" { + // 从ServerAddress提取域名作为RPID + // ServerAddress可能是 "https://newapi.pro" 这种格式 + serverAddr := strings.TrimSpace(ServerAddress) + if parsed, err := url.Parse(serverAddr); err == nil && parsed.Host != "" { + defaultPasskeySettings.RPID = parsed.Host + } else { + defaultPasskeySettings.RPID = serverAddr + } + } + if defaultPasskeySettings.Origins == "" || defaultPasskeySettings.Origins == "[]" { + defaultPasskeySettings.Origins = ServerAddress + } + return &defaultPasskeySettings +} diff --git a/setting/system_setting/system_setting_old.go b/setting/system_setting/system_setting_old.go new file mode 100644 index 0000000000000000000000000000000000000000..7c75f502423235e4bfa9bc0d46824ad19b28a8fc --- /dev/null +++ b/setting/system_setting/system_setting_old.go @@ -0,0 +1,10 @@ +package system_setting + +var ServerAddress = "http://localhost:3000" +var WorkerUrl = "" +var WorkerValidKey = "" +var WorkerAllowHttpImageRequestEnabled = false + +func EnableWorker() bool { + return WorkerUrl != "" +} diff --git a/setting/user_usable_group.go b/setting/user_usable_group.go new file mode 100644 index 0000000000000000000000000000000000000000..b55560848fd5e7cbeb4c209e107f833dd11232a2 --- /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.SysLog("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 0000000000000000000000000000000000000000..a8545f7fdf3bedd5b4536b625bc028f124d5858e --- /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 0000000000000000000000000000000000000000..b482183cdeb90031f6d272a08a9e91fead02ba1a --- /dev/null +++ b/types/error.go @@ -0,0 +1,353 @@ +package types + +import ( + "errors" + "fmt" + "net/http" + "one-api/common" + "strings" +) + +type OpenAIError struct { + Message string `json:"message"` + Type string `json:"type"` + Param string `json:"param"` + Code any `json:"code"` +} + +type ClaudeError struct { + Type string `json:"type,omitempty"` + Message string `json:"message,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" + ErrorTypeUpstreamError ErrorType = "upstream_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" + ErrorCodeGenRelayInfoFailed ErrorCode = "gen_relay_info_failed" + + // channel error + ErrorCodeChannelNoAvailableKey ErrorCode = "channel:no_available_key" + ErrorCodeChannelParamOverrideInvalid ErrorCode = "channel:param_override_invalid" + ErrorCodeChannelHeaderOverrideInvalid ErrorCode = "channel:header_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" + ErrorCodeEmptyResponse ErrorCode = "empty_response" + ErrorCodeAwsInvokeError ErrorCode = "aws_invoke_error" + ErrorCodeModelNotFound ErrorCode = "model_not_found" + + // 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 + skipRetry bool + recordErrorLog *bool + errorType ErrorType + errorCode ErrorCode + StatusCode int +} + +func (e *NewAPIError) GetErrorCode() ErrorCode { + if e == nil { + return "" + } + return e.errorCode +} + +func (e *NewAPIError) GetErrorType() ErrorType { + if e == nil { + return "" + } + return e.errorType +} + +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) MaskSensitiveError() string { + if e == nil { + return "" + } + if e.Err == nil { + return string(e.errorCode) + } + errStr := e.Err.Error() + if e.errorCode == ErrorCodeCountTokenFailed { + return errStr + } + return common.MaskSensitiveInfo(errStr) +} + +func (e *NewAPIError) SetMessage(message string) { + e.Err = errors.New(message) +} + +func (e *NewAPIError) ToOpenAIError() OpenAIError { + var result OpenAIError + switch e.errorType { + case ErrorTypeOpenAIError: + if openAIError, ok := e.RelayError.(OpenAIError); ok { + result = openAIError + } + case ErrorTypeClaudeError: + if claudeError, ok := e.RelayError.(ClaudeError); ok { + result = OpenAIError{ + Message: e.Error(), + Type: claudeError.Type, + Param: "", + Code: e.errorCode, + } + } + default: + result = OpenAIError{ + Message: e.Error(), + Type: string(e.errorType), + Param: "", + Code: e.errorCode, + } + } + if e.errorCode != ErrorCodeCountTokenFailed { + result.Message = common.MaskSensitiveInfo(result.Message) + } + return result +} + +func (e *NewAPIError) ToClaudeError() ClaudeError { + var result ClaudeError + switch e.errorType { + case ErrorTypeOpenAIError: + if openAIError, ok := e.RelayError.(OpenAIError); ok { + result = ClaudeError{ + Message: e.Error(), + Type: fmt.Sprintf("%v", openAIError.Code), + } + } + case ErrorTypeClaudeError: + if claudeError, ok := e.RelayError.(ClaudeError); ok { + result = claudeError + } + default: + result = ClaudeError{ + Message: e.Error(), + Type: string(e.errorType), + } + } + if e.errorCode != ErrorCodeCountTokenFailed { + result.Message = common.MaskSensitiveInfo(result.Message) + } + return result +} + +type NewAPIErrorOptions func(*NewAPIError) + +func NewError(err error, errorCode ErrorCode, ops ...NewAPIErrorOptions) *NewAPIError { + var newErr *NewAPIError + // 保留深层传递的 new err + if errors.As(err, &newErr) { + for _, op := range ops { + op(newErr) + } + return newErr + } + e := &NewAPIError{ + Err: err, + RelayError: nil, + errorType: ErrorTypeNewAPIError, + StatusCode: http.StatusInternalServerError, + errorCode: errorCode, + } + for _, op := range ops { + op(e) + } + return e +} + +func NewOpenAIError(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + var newErr *NewAPIError + // 保留深层传递的 new err + if errors.As(err, &newErr) { + if newErr.RelayError == nil { + openaiError := OpenAIError{ + Message: newErr.Error(), + Type: string(errorCode), + Code: errorCode, + } + newErr.RelayError = openaiError + } + for _, op := range ops { + op(newErr) + } + return newErr + } + openaiError := OpenAIError{ + Message: err.Error(), + Type: string(errorCode), + Code: errorCode, + } + return WithOpenAIError(openaiError, statusCode, ops...) +} + +func InitOpenAIError(errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + openaiError := OpenAIError{ + Type: string(errorCode), + Code: errorCode, + } + return WithOpenAIError(openaiError, statusCode, ops...) +} + +func NewErrorWithStatusCode(err error, errorCode ErrorCode, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + e := &NewAPIError{ + Err: err, + RelayError: OpenAIError{ + Message: err.Error(), + Type: string(errorCode), + }, + errorType: ErrorTypeNewAPIError, + StatusCode: statusCode, + errorCode: errorCode, + } + for _, op := range ops { + op(e) + } + + return e +} + +func WithOpenAIError(openAIError OpenAIError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + code, ok := openAIError.Code.(string) + if !ok { + if openAIError.Code != nil { + code = fmt.Sprintf("%v", openAIError.Code) + } else { + code = "unknown_error" + } + } + if openAIError.Type == "" { + openAIError.Type = "upstream_error" + } + e := &NewAPIError{ + RelayError: openAIError, + errorType: ErrorTypeOpenAIError, + StatusCode: statusCode, + Err: errors.New(openAIError.Message), + errorCode: ErrorCode(code), + } + for _, op := range ops { + op(e) + } + return e +} + +func WithClaudeError(claudeError ClaudeError, statusCode int, ops ...NewAPIErrorOptions) *NewAPIError { + if claudeError.Type == "" { + claudeError.Type = "upstream_error" + } + e := &NewAPIError{ + RelayError: claudeError, + errorType: ErrorTypeClaudeError, + StatusCode: statusCode, + Err: errors.New(claudeError.Message), + errorCode: ErrorCode(claudeError.Type), + } + for _, op := range ops { + op(e) + } + return e +} + +func IsChannelError(err *NewAPIError) bool { + if err == nil { + return false + } + return strings.HasPrefix(string(err.errorCode), "channel:") +} + +func IsSkipRetryError(err *NewAPIError) bool { + if err == nil { + return false + } + + return err.skipRetry +} + +func ErrOptionWithSkipRetry() NewAPIErrorOptions { + return func(e *NewAPIError) { + e.skipRetry = true + } +} + +func ErrOptionWithNoRecordErrorLog() NewAPIErrorOptions { + return func(e *NewAPIError) { + e.recordErrorLog = common.GetPointer(false) + } +} + +func ErrOptionWithHideErrMsg(replaceStr string) NewAPIErrorOptions { + return func(e *NewAPIError) { + if common.DebugEnabled { + fmt.Printf("ErrOptionWithHideErrMsg: %s, origin error: %s", replaceStr, e.Err) + } + e.Err = errors.New(replaceStr) + } +} + +func IsRecordErrorLog(e *NewAPIError) bool { + if e == nil { + return false + } + if e.recordErrorLog == nil { + // default to true if not set + return true + } + return *e.recordErrorLog +} diff --git a/types/file_data.go b/types/file_data.go new file mode 100644 index 0000000000000000000000000000000000000000..75e616478631a4be510ed8d07d62552c3670d2cc --- /dev/null +++ b/types/file_data.go @@ -0,0 +1,8 @@ +package types + +type LocalFileData struct { + MimeType string + Base64Data string + Url string + Size int64 +} diff --git a/types/price_data.go b/types/price_data.go new file mode 100644 index 0000000000000000000000000000000000000000..5dc0d8c26589f7052c5b8e6a49f33d7e6a5f55f7 --- /dev/null +++ b/types/price_data.go @@ -0,0 +1,33 @@ +package types + +import "fmt" + +type GroupRatioInfo struct { + GroupRatio float64 + GroupSpecialRatio float64 + HasSpecialRatio bool +} + +type PriceData struct { + ModelPrice float64 + ModelRatio float64 + CompletionRatio float64 + CacheRatio float64 + CacheCreationRatio float64 + ImageRatio float64 + AudioRatio float64 + AudioCompletionRatio float64 + UsePrice bool + ShouldPreConsumedQuota int + GroupRatioInfo GroupRatioInfo +} + +type PerCallPriceData struct { + ModelPrice float64 + Quota 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, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.ShouldPreConsumedQuota, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio) +} diff --git a/types/relay_format.go b/types/relay_format.go new file mode 100644 index 0000000000000000000000000000000000000000..0e2e4adc840a06bf29709ff7ce8933f82fb98e2f --- /dev/null +++ b/types/relay_format.go @@ -0,0 +1,18 @@ +package types + +type RelayFormat string + +const ( + RelayFormatOpenAI RelayFormat = "openai" + RelayFormatClaude = "claude" + RelayFormatGemini = "gemini" + RelayFormatOpenAIResponses = "openai_responses" + RelayFormatOpenAIAudio = "openai_audio" + RelayFormatOpenAIImage = "openai_image" + RelayFormatOpenAIRealtime = "openai_realtime" + RelayFormatRerank = "rerank" + RelayFormatEmbedding = "embedding" + + RelayFormatTask = "task" + RelayFormatMjProxy = "mj_proxy" +) diff --git a/types/request_meta.go b/types/request_meta.go new file mode 100644 index 0000000000000000000000000000000000000000..44759886703d12d127952b350c14e530921c82ac --- /dev/null +++ b/types/request_meta.go @@ -0,0 +1,46 @@ +package types + +type FileType string + +const ( + FileTypeImage FileType = "image" // Image file type + FileTypeAudio FileType = "audio" // Audio file type + FileTypeVideo FileType = "video" // Video file type + FileTypeFile FileType = "file" // Generic file type +) + +type TokenType string + +const ( + TokenTypeTextNumber TokenType = "text_number" // Text or number tokens + TokenTypeTokenizer TokenType = "tokenizer" // Tokenizer tokens + TokenTypeImage TokenType = "image" // Image tokens +) + +type TokenCountMeta struct { + TokenType TokenType `json:"token_type,omitempty"` // Type of tokens used in the request + CombineText string `json:"combine_text,omitempty"` // Combined text from all messages + ToolsCount int `json:"tools_count,omitempty"` // Number of tools used + NameCount int `json:"name_count,omitempty"` // Number of names in the request + MessagesCount int `json:"messages_count,omitempty"` // Number of messages in the request + Files []*FileMeta `json:"files,omitempty"` // List of files, each with type and content + MaxTokens int `json:"max_tokens,omitempty"` // Maximum tokens allowed in the request + + ImagePriceRatio float64 `json:"image_ratio,omitempty"` // Ratio for image size, if applicable + //IsStreaming bool `json:"is_streaming,omitempty"` // Indicates if the request is streaming +} + +type FileMeta struct { + FileType + MimeType string + OriginData string // url or base64 data + Detail string + ParsedData *LocalFileData +} + +type RequestMeta struct { + OriginalModelName string `json:"original_model_name"` + UserUsingGroup string `json:"user_using_group"` + PromptTokens int `json:"prompt_tokens"` + PreConsumedQuota int `json:"pre_consumed_quota"` +} diff --git a/types/set.go b/types/set.go new file mode 100644 index 0000000000000000000000000000000000000000..929c3c6a8fa2f65ed509d7013420d34706a3f074 --- /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/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 0000000000000000000000000000000000000000..6b491f2e42cab8418cc34c0d7e0717b70d3d78d0 --- /dev/null +++ b/web/.eslintrc.cjs @@ -0,0 +1,42 @@ +module.exports = { + root: true, + env: { browser: true, es2021: true, node: true }, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + plugins: ['header', 'react-hooks'], + overrides: [ + { + files: ['**/*.{js,jsx}'], + rules: { + 'header/header': [ + 2, + 'block', + [ + '', + 'Copyright (C) 2025 QuantumNous', + '', + 'This program is free software: you can redistribute it and/or modify', + 'it under the terms of the GNU Affero General Public License as', + 'published by the Free Software Foundation, either version 3 of the', + 'License, or (at your option) any later version.', + '', + 'This program is distributed in the hope that it will be useful,', + 'but WITHOUT ANY WARRANTY; without even the implied warranty of', + 'MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the', + 'GNU Affero General Public License for more details.', + '', + 'You should have received a copy of the GNU Affero General Public License', + 'along with this program. If not, see .', + '', + 'For commercial licensing, please contact support@quantumnous.com', + '', + ], + ], + 'no-multiple-empty-lines': ['error', { max: 1 }], + }, + }, + ], +}; diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5cc83662f1d583eab1f99c60cb6517317c1efe30 --- /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 0000000000000000000000000000000000000000..8a92f2483651df91919af04d59eb9d752ca030b1 --- /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 0000000000000000000000000000000000000000..198b9d96cd9ed5b9b4265b381320d340b1f5a554 --- /dev/null +++ b/web/bun.lock @@ -0,0 +1,2142 @@ +{ + "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", + "qrcode.react": "^4.2.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", + "eslint": "8.57.0", + "eslint-plugin-header": "^3.1.1", + "eslint-plugin-react-hooks": "^5.2.0", + "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=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ=="], + + "@eslint/js": ["@eslint/js@8.57.0", "", {}, "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g=="], + + "@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=="], + + "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.11.14", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.2", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + + "@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.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "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=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "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=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "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=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "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=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "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=="], + + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], + + "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@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@8.57.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.0", "@humanwhocodes/config-array": "^0.11.14", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ=="], + + "eslint-plugin-header": ["eslint-plugin-header@3.1.1", "", { "peerDependencies": { "eslint": ">=7.7.0" } }, "sha512-9vlKxuJ4qf793CmeeSrZUvVClw6amtpghq3CuWcB5cUNnWHQhgcqy5eF8oVKFk1G3Y/CbchGfEaw3wiIJaNmVg=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "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=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "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=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg=="], + + "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=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "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@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "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=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "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=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "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-path-inside": ["is-path-inside@3.0.3", "", {}, "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ=="], + + "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=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "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=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "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=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "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=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "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=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "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=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "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=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "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-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/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=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "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=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + + "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@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=="], + + "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=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "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@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "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-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "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=="], + + "text-table": ["text-table@0.2.0", "", {}, "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw=="], + + "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=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + + "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=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "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=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "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=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "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/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=="], + + "@iconify/utils/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + + "@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=="], + + "@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/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=="], + + "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=="], + + "esast-util-from-js/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "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=="], + + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "mermaid/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], + + "micromark-extension-mdxjs/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "mlly/acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], + + "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=="], + + "rimraf/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=="], + + "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/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "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/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + + "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=="], + + "@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=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + + "@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/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + + "@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 0000000000000000000000000000000000000000..08bf8ae6f88a35c222cd7acea6b4b01660f1f4f6 --- /dev/null +++ b/web/index.html @@ -0,0 +1,21 @@ + + + + + + + + + New API + + + + + +
+ + + diff --git a/web/jsconfig.json b/web/jsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..10c2274c6da51636e109aff4d2585bddc798e2c7 --- /dev/null +++ b/web/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..ba781f492346875b498f6aced7dd7089ce8f1bf0 --- /dev/null +++ b/web/package.json @@ -0,0 +1,91 @@ +{ + "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", + "qrcode.react": "^4.2.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", + "eslint": "bunx eslint \"**/*.{js,jsx}\" --cache", + "eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache", + "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", + "eslint": "8.57.0", + "eslint-plugin-header": "^3.1.1", + "eslint-plugin-react-hooks": "^5.2.0", + "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 0000000000000000000000000000000000000000..d3c606d3992654197fbeada4afcf1a46adfb4393 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,25 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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..1e3c1162ac2cf9404f12dddbc26df4fd35c748ee --- /dev/null +++ b/web/public/azure_model_name.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84b932315cc40da0bfa209c2ea1997b50a6b29059573a64aa27095bf323769d4 +size 256912 diff --git a/web/public/cover-4.webp b/web/public/cover-4.webp new file mode 100644 index 0000000000000000000000000000000000000000..0e9ecbf0d206c6b1079cc82691beecfb1ae73970 Binary files /dev/null and b/web/public/cover-4.webp differ diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ab5f17bcdb35e96cf7673ca8fa7ba8d6a33bd7ce Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/logo.png b/web/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..851556f62db58d4267e096a39d2e9de88e6688c5 Binary files /dev/null and b/web/public/logo.png differ diff --git a/web/public/ratio.png b/web/public/ratio.png new file mode 100644 index 0000000000000000000000000000000000000000..fbda9172364846709cc203139b595f2ab2dbdc19 --- /dev/null +++ b/web/public/ratio.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:61cc0ee3c629d7779b7752c152b627a136fac1dab28e1ce2f4c355c195245fe6 +size 143438 diff --git a/web/public/robots.txt b/web/public/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..9dd52a51525e4ec0f8473ea7d6414adcd0d982f3 --- /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.jsx b/web/src/App.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c6b968daeaff1519a09259a97b105b9bf6881068 --- /dev/null +++ b/web/src/App.jsx @@ -0,0 +1,329 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { lazy, Suspense, useContext, useMemo } from 'react'; +import { Route, Routes, useLocation } from 'react-router-dom'; +import Loading from './components/common/ui/Loading'; +import User from './pages/User'; +import { AuthRedirect, PrivateRoute, AdminRoute } from './helpers'; +import RegisterForm from './components/auth/RegisterForm'; +import LoginForm from './components/auth/LoginForm'; +import NotFound from './pages/NotFound'; +import Forbidden from './pages/Forbidden'; +import Setting from './pages/Setting'; +import { StatusContext } from './context/Status'; + +import PasswordResetForm from './components/auth/PasswordResetForm'; +import PasswordResetConfirm from './components/auth/PasswordResetConfirm'; +import Channel from './pages/Channel'; +import Token from './pages/Token'; +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'; +import Task from './pages/Task'; +import ModelPage from './pages/Model'; +import Playground from './pages/Playground'; +import OAuth2Callback from './components/auth/OAuth2Callback'; +import PersonalSetting from './components/settings/PersonalSetting'; +import Setup from './pages/Setup'; +import SetupCheck from './components/layout/SetupCheck'; + +const Home = lazy(() => import('./pages/Home')); +const Dashboard = lazy(() => import('./pages/Dashboard')); +const About = lazy(() => import('./pages/About')); + +function App() { + const location = useLocation(); + const [statusState] = useContext(StatusContext); + + // 获取模型广场权限配置 + const pricingRequireAuth = useMemo(() => { + const headerNavModulesConfig = statusState?.status?.HeaderNavModules; + if (headerNavModulesConfig) { + try { + const modules = JSON.parse(headerNavModulesConfig); + + // 处理向后兼容性:如果pricing是boolean,默认不需要登录 + if (typeof modules.pricing === 'boolean') { + return false; // 默认不需要登录鉴权 + } + + // 如果是对象格式,使用requireAuth配置 + return modules.pricing?.requireAuth === true; + } catch (error) { + console.error('解析顶栏模块配置失败:', error); + return false; // 默认不需要登录 + } + } + return false; // 默认不需要登录 + }, [statusState?.status?.HeaderNavModules]); + + 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}> + + + } + /> + {/* 方便使用chat2link直接跳转聊天... */} + + } key={location.pathname}> + + + + } + /> + } /> + + + ); +} + +export default App; diff --git a/web/src/components/auth/LoginForm.jsx b/web/src/components/auth/LoginForm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..4594a3645e9c24395d4d3eab5e9a75a02cecaa9f --- /dev/null +++ b/web/src/components/auth/LoginForm.jsx @@ -0,0 +1,730 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useContext, useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { UserContext } from '../../context/User'; +import { + API, + getLogo, + showError, + showInfo, + showSuccess, + updateAPI, + getSystemName, + setUserData, + onGitHubOAuthClicked, + onOIDCClicked, + onLinuxDOOAuthClicked, + prepareCredentialRequestOptions, + buildAssertionResult, + isPasskeySupported, +} from '../../helpers'; +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, IconKey } from '@douyinfe/semi-icons'; +import OIDCIcon from '../common/logo/OIDCIcon'; +import WeChatIcon from '../common/logo/WeChatIcon'; +import LinuxDoIcon from '../common/logo/LinuxDoIcon'; +import TwoFAVerification from './TwoFAVerification'; +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 [showTwoFA, setShowTwoFA] = useState(false); + const [passkeySupported, setPasskeySupported] = useState(false); + const [passkeyLoading, setPasskeyLoading] = 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(() => { + isPasskeySupported() + .then(setPasskeySupported) + .catch(() => setPasskeySupported(false)); + }, []); + + 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) { + // 检查是否需要2FA验证 + if (data && data.require_2fa) { + setShowTwoFA(true); + setLoginLoading(false); + return; + } + + 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 handlePasskeyLogin = async () => { + if (!passkeySupported) { + showInfo('当前环境无法使用 Passkey 登录'); + return; + } + if (!window.PublicKeyCredential) { + showInfo('当前浏览器不支持 Passkey'); + return; + } + + setPasskeyLoading(true); + try { + const beginRes = await API.post('/api/user/passkey/login/begin'); + const { success, message, data } = beginRes.data; + if (!success) { + showError(message || '无法发起 Passkey 登录'); + return; + } + + const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data); + const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }); + const payload = buildAssertionResult(assertion); + if (!payload) { + showError('Passkey 验证失败,请重试'); + return; + } + + const finishRes = await API.post('/api/user/passkey/login/finish', payload); + const finish = finishRes.data; + if (finish.success) { + userDispatch({ type: 'login', payload: finish.data }); + setUserData(finish.data); + updateAPI(); + showSuccess('登录成功!'); + navigate('/console'); + } else { + showError(finish.message || 'Passkey 登录失败,请重试'); + } + } catch (error) { + if (error?.name === 'AbortError') { + showInfo('已取消 Passkey 登录'); + } else { + showError('Passkey 登录失败,请重试'); + } + } finally { + setPasskeyLoading(false); + } + }; + + // 包装的重置密码点击处理 + const handleResetPasswordClick = () => { + setResetPasswordLoading(true); + navigate('/reset'); + setResetPasswordLoading(false); + }; + + // 包装的其他登录选项点击处理 + const handleOtherLoginOptionsClick = () => { + setOtherLoginOptionsLoading(true); + setShowEmailLogin(false); + setOtherLoginOptionsLoading(false); + }; + + // 2FA验证成功处理 + const handle2FASuccess = (data) => { + userDispatch({ type: 'login', payload: data }); + setUserData(data); + updateAPI(); + showSuccess('登录成功!'); + navigate('/console'); + }; + + // 返回登录页面 + const handleBackToLogin = () => { + setShowTwoFA(false); + setInputs({ username: '', password: '', wechat_verification_code: '' }); + }; + + const renderOAuthOptions = () => { + return ( +
+
+
+ Logo + + {systemName} + +
+ + +
+ + {t('登 录')} + +
+
+
+ {status.wechat_login && ( + + )} + + {status.github_oauth && ( + + )} + + {status.oidc_enabled && ( + + )} + + {status.linuxdo_oauth && ( + + )} + + {status.telegram_oauth && ( +
+ +
+ )} + + {status.passkey_login && passkeySupported && ( + + )} + + + {t('或')} + + + +
+ + {!status.self_use_mode_enabled && ( +
+ + {t('没有账户?')}{' '} + + {t('注册')} + + +
+ )} +
+
+
+
+ ); + }; + + const renderEmailLoginForm = () => { + return ( +
+
+
+ Logo + {systemName} +
+ + +
+ + {t('登 录')} + +
+
+ {status.passkey_login && passkeySupported && ( + + )} +
+ 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('登录')} + centered={true} + okButtonProps={{ + loading: wechatCodeSubmitLoading, + }} + > +
+ 微信二维码 +
+ +
+

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

+
+ +
+ + handleChange('wechat_verification_code', value) + } + /> + +
+ ); + }; + + // 2FA验证弹窗 + const render2FAModal = () => { + return ( + +
+ + + +
+ 两步验证 + + } + visible={showTwoFA} + onCancel={handleBackToLogin} + footer={null} + width={450} + centered + > + +
+ ); + }; + + return ( +
+ {/* 背景模糊晕染球 */} +
+
+
+ {showEmailLogin || + !( + status.github_oauth || + status.oidc_enabled || + status.wechat_login || + status.linuxdo_oauth || + status.telegram_oauth + ) + ? renderEmailLoginForm() + : renderOAuthOptions()} + {renderWeChatLoginModal()} + {render2FAModal()} + + {turnstileEnabled && ( +
+ { + setTurnstileToken(token); + }} + /> +
+ )} +
+
+ ); +}; + +export default LoginForm; diff --git a/web/src/components/auth/OAuth2Callback.jsx b/web/src/components/auth/OAuth2Callback.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c0d7d447ff8e0d344373377e3df5157db251c79c --- /dev/null +++ b/web/src/components/auth/OAuth2Callback.jsx @@ -0,0 +1,95 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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/ui/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.jsx b/web/src/components/auth/PasswordResetConfirm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..54d562015f8883069390199b2d8343b2fd9ef88f --- /dev/null +++ b/web/src/components/auth/PasswordResetConfirm.jsx @@ -0,0 +1,220 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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.jsx b/web/src/components/auth/PasswordResetForm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2674543c21db98ee2b9062a3f474fe5af62a78fd --- /dev/null +++ b/web/src/components/auth/PasswordResetForm.jsx @@ -0,0 +1,193 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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.jsx b/web/src/components/auth/RegisterForm.jsx new file mode 100644 index 0000000000000000000000000000000000000000..bbacdf49106c421a3b427f7d3e018d8d0f745688 --- /dev/null +++ b/web/src/components/auth/RegisterForm.jsx @@ -0,0 +1,642 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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'; +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'; +import OIDCIcon from '../common/logo/OIDCIcon'; +import LinuxDoIcon from '../common/logo/LinuxDoIcon'; +import WeChatIcon from '../common/logo/WeChatIcon'; +import TelegramLoginButton from 'react-telegram-login/src'; +import { UserContext } from '../../context/User'; +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 [disableButton, setDisableButton] = useState(false); + const [countdown, setCountdown] = useState(30); + + 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]); + + 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); // Clean up on unmount + }, [disableButton, countdown]); + + 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('验证码发送成功,请检查你的邮箱!'); + setDisableButton(true); // 发送成功后禁用按钮,开始倒计时 + } 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('登录')} + 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/auth/TwoFAVerification.jsx b/web/src/components/auth/TwoFAVerification.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9189633bc03453d6f5ffbc37f55131ca59f045eb --- /dev/null +++ b/web/src/components/auth/TwoFAVerification.jsx @@ -0,0 +1,244 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import { API, showError, showSuccess } from '../../helpers'; +import { + Button, + Card, + Divider, + Form, + Input, + Typography, +} from '@douyinfe/semi-ui'; +import React, { useState } from 'react'; + +const { Title, Text, Paragraph } = Typography; + +const TwoFAVerification = ({ onSuccess, onBack, isModal = false }) => { + const [loading, setLoading] = useState(false); + const [useBackupCode, setUseBackupCode] = useState(false); + const [verificationCode, setVerificationCode] = useState(''); + + const handleSubmit = async () => { + if (!verificationCode) { + showError('请输入验证码'); + return; + } + // Validate code format + if (useBackupCode && verificationCode.length !== 8) { + showError('备用码必须是8位'); + return; + } else if (!useBackupCode && !/^\d{6}$/.test(verificationCode)) { + showError('验证码必须是6位数字'); + return; + } + + setLoading(true); + try { + const res = await API.post('/api/user/login/2fa', { + code: verificationCode, + }); + + if (res.data.success) { + showSuccess('登录成功'); + // 保存用户信息到本地存储 + localStorage.setItem('user', JSON.stringify(res.data.data)); + if (onSuccess) { + onSuccess(res.data.data); + } + } else { + showError(res.data.message); + } + } catch (error) { + showError('验证失败,请重试'); + } finally { + setLoading(false); + } + }; + + const handleKeyPress = (e) => { + if (e.key === 'Enter') { + handleSubmit(); + } + }; + + if (isModal) { + return ( +
+ + 请输入认证器应用显示的验证码完成登录 + + +
+ + + + + + + +
+ + + {onBack && ( + + )} +
+ +
+ + 提示: +
+ • 验证码每30秒更新一次 +
+ • 如果无法获取验证码,请使用备用码 +
• 每个备用码只能使用一次 +
+
+
+ ); + } + + return ( +
+ +
+ 两步验证 + + 请输入认证器应用显示的验证码完成登录 + +
+ +
+ + + + + + + +
+ + + {onBack && ( + + )} +
+ +
+ + 提示: +
+ • 验证码每30秒更新一次 +
+ • 如果无法获取验证码,请使用备用码 +
• 每个备用码只能使用一次 +
+
+
+
+ ); +}; + +export default TwoFAVerification; diff --git a/web/src/components/common/examples/ChannelKeyViewExample.jsx b/web/src/components/common/examples/ChannelKeyViewExample.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ac93dec62bba561690aebe7afd581498d59036cf --- /dev/null +++ b/web/src/components/common/examples/ChannelKeyViewExample.jsx @@ -0,0 +1,117 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Modal } from '@douyinfe/semi-ui'; +import { useSecureVerification } from '../../../hooks/common/useSecureVerification'; +import { createApiCalls } from '../../../services/secureVerification'; +import SecureVerificationModal from '../modals/SecureVerificationModal'; +import ChannelKeyDisplay from '../ui/ChannelKeyDisplay'; + +/** + * 渠道密钥查看组件使用示例 + * 展示如何使用通用安全验证系统 + */ +const ChannelKeyViewExample = ({ channelId }) => { + const { t } = useTranslation(); + const [keyData, setKeyData] = useState(''); + const [showKeyModal, setShowKeyModal] = useState(false); + + // 使用通用安全验证 Hook + const { + isModalVisible, + verificationMethods, + verificationState, + startVerification, + executeVerification, + cancelVerification, + setVerificationCode, + switchVerificationMethod, + } = useSecureVerification({ + onSuccess: (result) => { + // 验证成功后处理结果 + if (result.success && result.data?.key) { + setKeyData(result.data.key); + setShowKeyModal(true); + } + }, + successMessage: t('密钥获取成功'), + }); + + // 开始查看密钥流程 + const handleViewKey = async () => { + const apiCall = createApiCalls.viewChannelKey(channelId); + + await startVerification(apiCall, { + title: t('查看渠道密钥'), + description: t('为了保护账户安全,请验证您的身份。'), + preferredMethod: 'passkey', // 可以指定首选验证方式 + }); + }; + + return ( + <> + {/* 查看密钥按钮 */} + + + {/* 安全验证模态框 */} + + + {/* 密钥显示模态框 */} + setShowKeyModal(false)} + footer={ + + } + width={700} + style={{ maxWidth: '90vw' }} + > + + + + ); +}; + +export default ChannelKeyViewExample; \ No newline at end of file diff --git a/web/src/components/common/logo/LinuxDoIcon.jsx b/web/src/components/common/logo/LinuxDoIcon.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e411f7d2c1e810e2849908f95c965c31e81b2548 --- /dev/null +++ b/web/src/components/common/logo/LinuxDoIcon.jsx @@ -0,0 +1,56 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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.jsx b/web/src/components/common/logo/OIDCIcon.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7c0d02cf390f1f9f07b770e44bae608e3df60af2 --- /dev/null +++ b/web/src/components/common/logo/OIDCIcon.jsx @@ -0,0 +1,57 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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.jsx b/web/src/components/common/logo/WeChatIcon.jsx new file mode 100644 index 0000000000000000000000000000000000000000..69b88ec5115431c9200670ceae440ef95b045ff8 --- /dev/null +++ b/web/src/components/common/logo/WeChatIcon.jsx @@ -0,0 +1,55 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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.jsx b/web/src/components/common/markdown/MarkdownRenderer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fca024fbd630be356da00152ad0a5bb7edc5e7b7 --- /dev/null +++ b/web/src/components/common/markdown/MarkdownRenderer.jsx @@ -0,0 +1,654 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +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; diff --git a/web/src/components/common/markdown/markdown.css b/web/src/components/common/markdown/markdown.css new file mode 100644 index 0000000000000000000000000000000000000000..3b842ec23ac43c1e2d394b594f63f30f24ea3cff --- /dev/null +++ b/web/src/components/common/markdown/markdown.css @@ -0,0 +1,449 @@ +/* 基础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; +} diff --git a/web/src/components/common/modals/SecureVerificationModal.jsx b/web/src/components/common/modals/SecureVerificationModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..85d834180431cc0ba5ce7b7d15c79599400971ca --- /dev/null +++ b/web/src/components/common/modals/SecureVerificationModal.jsx @@ -0,0 +1,285 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui'; + +/** + * 通用安全验证模态框组件 + * 配合 useSecureVerification Hook 使用 + * @param {Object} props + * @param {boolean} props.visible - 是否显示模态框 + * @param {Object} props.verificationMethods - 可用的验证方式 + * @param {Object} props.verificationState - 当前验证状态 + * @param {Function} props.onVerify - 验证回调 + * @param {Function} props.onCancel - 取消回调 + * @param {Function} props.onCodeChange - 验证码变化回调 + * @param {Function} props.onMethodSwitch - 验证方式切换回调 + * @param {string} props.title - 模态框标题 + * @param {string} props.description - 验证描述文本 + */ +const SecureVerificationModal = ({ + visible, + verificationMethods, + verificationState, + onVerify, + onCancel, + onCodeChange, + onMethodSwitch, + title, + description, +}) => { + const { t } = useTranslation(); + const [isAnimating, setIsAnimating] = useState(false); + const [verifySuccess, setVerifySuccess] = useState(false); + + const { has2FA, hasPasskey, passkeySupported } = verificationMethods; + const { method, loading, code } = verificationState; + + useEffect(() => { + if (visible) { + setIsAnimating(true); + setVerifySuccess(false); + } else { + setIsAnimating(false); + } + }, [visible]); + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && code.trim() && !loading && method === '2fa') { + onVerify(method, code); + } + if (e.key === 'Escape' && !loading) { + onCancel(); + } + }; + + // 如果用户没有启用任何验证方式 + if (visible && !has2FA && !hasPasskey) { + return ( + {t('确定')} + } + width={500} + style={{ maxWidth: '90vw' }} + > +
      +
      + + + +
      + + {t('需要安全验证')} + + + {t('您需要先启用两步验证或 Passkey 才能查看敏感信息。')} + +
      + + {t('请前往个人设置 → 安全设置进行配置。')} + +
      +
      + ); + } + + return ( + +
      + {/* 描述信息 */} + {description && ( + + {description} + + )} + + {/* 验证方式选择 */} + + {has2FA && ( + +
      +
      + + + + } + style={{ width: '100%' }} + /> +
      + + + {t('从认证器应用中获取验证码,或使用备用码')} + + +
      + + +
      +
      +
      + )} + + {hasPasskey && passkeySupported && ( + +
      +
      +
      + + + +
      + + {t('使用 Passkey 验证')} + + + {t('点击验证按钮,使用您的生物特征或安全密钥')} + +
      + +
      + + +
      +
      +
      + )} +
      +
      +
      + ); +}; + +export default SecureVerificationModal; \ No newline at end of file diff --git a/web/src/components/common/modals/TwoFactorAuthModal.jsx b/web/src/components/common/modals/TwoFactorAuthModal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..1e6b79c4771d469f8c0e60f0a7f1834d1a0202df --- /dev/null +++ b/web/src/components/common/modals/TwoFactorAuthModal.jsx @@ -0,0 +1,146 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Modal, Button, Input, Typography } from '@douyinfe/semi-ui'; + +/** + * 可复用的两步验证模态框组件 + * @param {Object} props + * @param {boolean} props.visible - 是否显示模态框 + * @param {string} props.code - 验证码值 + * @param {boolean} props.loading - 是否正在验证 + * @param {Function} props.onCodeChange - 验证码变化回调 + * @param {Function} props.onVerify - 验证回调 + * @param {Function} props.onCancel - 取消回调 + * @param {string} props.title - 模态框标题 + * @param {string} props.description - 验证描述文本 + * @param {string} props.placeholder - 输入框占位文本 + */ +const TwoFactorAuthModal = ({ + visible, + code, + loading, + onCodeChange, + onVerify, + onCancel, + title, + description, + placeholder, +}) => { + const { t } = useTranslation(); + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && code && !loading) { + onVerify(); + } + }; + + return ( + +
      + + + +
      + {title || t('安全验证')} +
      + } + visible={visible} + onCancel={onCancel} + footer={ + <> + + + + } + width={500} + style={{ maxWidth: '90vw' }} + > +
      + {/* 安全提示 */} +
      +
      + + + +
      + + {t('安全验证')} + + + {description || t('为了保护账户安全,请验证您的两步验证码。')} + +
      +
      +
      + + {/* 验证码输入 */} +
      + + {t('验证身份')} + + + + {t('支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。')} + +
      +
      + + ); +}; + +export default TwoFactorAuthModal; diff --git a/web/src/components/common/ui/CardPro.jsx b/web/src/components/common/ui/CardPro.jsx new file mode 100644 index 0000000000000000000000000000000000000000..55df17e9e8bcb232a6b3fb518caad8e193bd3099 --- /dev/null +++ b/web/src/components/common/ui/CardPro.jsx @@ -0,0 +1,200 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState } from 'react'; +import { Card, Divider, Typography, Button } from '@douyinfe/semi-ui'; +import PropTypes from 'prop-types'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { IconEyeOpened, IconEyeClosed } from '@douyinfe/semi-icons'; + +const { Text } = Typography; + +/** + * CardPro 高级卡片组件 + * + * 布局分为6个区域: + * 1. 统计信息区域 (statsArea) + * 2. 描述信息区域 (descriptionArea) + * 3. 类型切换/标签区域 (tabsArea) + * 4. 操作按钮区域 (actionsArea) + * 5. 搜索表单区域 (searchArea) + * 6. 分页区域 (paginationArea) - 固定在卡片底部 + * + * 支持三种布局类型: + * - type1: 操作型 (如TokensTable) - 描述信息 + 操作按钮 + 搜索表单 + * - type2: 查询型 (如LogsTable) - 统计信息 + 搜索表单 + * - type3: 复杂型 (如ChannelsTable) - 描述信息 + 类型切换 + 操作按钮 + 搜索表单 + */ +const CardPro = ({ + type = 'type1', + className = '', + children, + // 各个区域的内容 + statsArea, + descriptionArea, + tabsArea, + actionsArea, + searchArea, + paginationArea, // 新增分页区域 + // 卡片属性 + shadows = '', + bordered = true, + // 自定义样式 + style, + // 国际化函数 + t = (key) => key, + ...props +}) => { + const isMobile = useIsMobile(); + const [showMobileActions, setShowMobileActions] = useState(false); + + const toggleMobileActions = () => { + setShowMobileActions(!showMobileActions); + }; + + const hasMobileHideableContent = actionsArea || searchArea; + + const renderHeader = () => { + const hasContent = + statsArea || descriptionArea || tabsArea || actionsArea || searchArea; + if (!hasContent) return null; + + return ( +
      + {/* 统计信息区域 - 用于type2 */} + {type === 'type2' && statsArea && <>{statsArea}} + + {/* 描述信息区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && descriptionArea && ( + <>{descriptionArea} + )} + + {/* 第一个分隔线 - 在描述信息或统计信息后面 */} + {((type === 'type1' || type === 'type3') && descriptionArea) || + (type === 'type2' && statsArea) ? ( + + ) : null} + + {/* 类型切换/标签区域 - 主要用于type3 */} + {type === 'type3' && tabsArea && <>{tabsArea}} + + {/* 移动端操作切换按钮 */} + {isMobile && hasMobileHideableContent && ( + <> +
      + +
      + + )} + + {/* 操作按钮和搜索表单的容器 */} +
      + {/* 操作按钮区域 - 用于type1和type3 */} + {(type === 'type1' || type === 'type3') && + actionsArea && + (Array.isArray(actionsArea) ? ( + actionsArea.map((area, idx) => ( + + {idx !== 0 && } +
      {area}
      +
      + )) + ) : ( +
      {actionsArea}
      + ))} + + {/* 当同时存在操作区和搜索区时,插入分隔线 */} + {actionsArea && searchArea && } + + {/* 搜索表单区域 - 所有类型都可能有 */} + {searchArea &&
      {searchArea}
      } +
      +
      + ); + }; + + const headerContent = renderHeader(); + + // 渲染分页区域 + const renderFooter = () => { + if (!paginationArea) return null; + + return ( +
      + {paginationArea} +
      + ); + }; + + const footerContent = renderFooter(); + + return ( + + {children} + + ); +}; + +CardPro.propTypes = { + // 布局类型 + type: PropTypes.oneOf(['type1', 'type2', 'type3']), + // 样式相关 + className: PropTypes.string, + style: PropTypes.object, + shadows: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + bordered: PropTypes.bool, + // 内容区域 + statsArea: PropTypes.node, + descriptionArea: PropTypes.node, + tabsArea: PropTypes.node, + actionsArea: PropTypes.oneOfType([ + PropTypes.node, + PropTypes.arrayOf(PropTypes.node), + ]), + searchArea: PropTypes.node, + paginationArea: PropTypes.node, + // 表格内容 + children: PropTypes.node, + // 国际化函数 + t: PropTypes.func, +}; + +export default CardPro; diff --git a/web/src/components/common/ui/CardTable.jsx b/web/src/components/common/ui/CardTable.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0d7d2b6224d5c912bb9954a247607c2073231f7d --- /dev/null +++ b/web/src/components/common/ui/CardTable.jsx @@ -0,0 +1,242 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Table, + Card, + Skeleton, + Pagination, + Empty, + Button, + Collapsible, +} from '@douyinfe/semi-ui'; +import { IconChevronDown, IconChevronUp } from '@douyinfe/semi-icons'; +import PropTypes from 'prop-types'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; +import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; + +/** + * CardTable 响应式表格组件 + * + * 在桌面端渲染 Semi-UI 的 Table 组件,在移动端则将每一行数据渲染成 Card 形式。 + * 该组件与 Table 组件的大部分 API 保持一致,只需将原 Table 换成 CardTable 即可。 + */ +const CardTable = ({ + columns = [], + dataSource = [], + loading = false, + rowKey = 'key', + hidePagination = false, + ...tableProps +}) => { + const isMobile = useIsMobile(); + const { t } = useTranslation(); + + const showSkeleton = useMinimumLoadingTime(loading); + + const getRowKey = (record, index) => { + if (typeof rowKey === 'function') return rowKey(record); + return record[rowKey] !== undefined ? record[rowKey] : index; + }; + + if (!isMobile) { + const finalTableProps = hidePagination + ? { ...tableProps, pagination: false } + : tableProps; + + return ( + + ); + } + + if (showSkeleton) { + const visibleCols = columns.filter((col) => { + if (tableProps?.visibleColumns && col.key) { + return tableProps.visibleColumns[col.key]; + } + return true; + }); + + const renderSkeletonCard = (key) => { + const placeholder = ( +
      + {visibleCols.map((col, idx) => { + if (!col.title) { + return ( +
      + +
      + ); + } + + return ( +
      + + +
      + ); + })} +
      + ); + + return ( + + + + ); + }; + + return ( +
      + {[1, 2, 3].map((i) => renderSkeletonCard(i))} +
      + ); + } + + const isEmpty = !showSkeleton && (!dataSource || dataSource.length === 0); + + const MobileRowCard = ({ record, index }) => { + const [showDetails, setShowDetails] = useState(false); + const rowKeyVal = getRowKey(record, index); + + const hasDetails = + tableProps.expandedRowRender && + (!tableProps.rowExpandable || tableProps.rowExpandable(record)); + + return ( + + {columns.map((col, colIdx) => { + if ( + tableProps?.visibleColumns && + !tableProps.visibleColumns[col.key] + ) { + return null; + } + + const title = col.title; + const cellContent = col.render + ? col.render(record[col.dataIndex], record, index) + : record[col.dataIndex]; + + if (!title) { + return ( +
      + {cellContent} +
      + ); + } + + return ( +
      + + {title} + +
      + {cellContent !== undefined && cellContent !== null + ? cellContent + : '-'} +
      +
      + ); + })} + + {hasDetails && ( + <> + + +
      + {tableProps.expandedRowRender(record, index)} +
      +
      + + )} +
      + ); + }; + + if (isEmpty) { + if (tableProps.empty) return tableProps.empty; + return ( +
      + +
      + ); + } + + return ( +
      + {dataSource.map((record, index) => ( + + ))} + {!hidePagination && tableProps.pagination && dataSource.length > 0 && ( +
      + +
      + )} +
      + ); +}; + +CardTable.propTypes = { + columns: PropTypes.array.isRequired, + dataSource: PropTypes.array, + loading: PropTypes.bool, + rowKey: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), + hidePagination: PropTypes.bool, +}; + +export default CardTable; diff --git a/web/src/components/common/ui/ChannelKeyDisplay.jsx b/web/src/components/common/ui/ChannelKeyDisplay.jsx new file mode 100644 index 0000000000000000000000000000000000000000..905c628d940c1e4b344bd116a0a69f45d4a61469 --- /dev/null +++ b/web/src/components/common/ui/ChannelKeyDisplay.jsx @@ -0,0 +1,280 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, Button, Typography, Tag } from '@douyinfe/semi-ui'; +import { copy, showSuccess } from '../../../helpers'; + +/** + * 解析密钥数据,支持多种格式 + * @param {string} keyData - 密钥数据 + * @param {Function} t - 翻译函数 + * @returns {Array} 解析后的密钥数组 + */ +const parseChannelKeys = (keyData, t) => { + if (!keyData) return []; + + const trimmed = keyData.trim(); + + // 检查是否是JSON数组格式(如Vertex AI) + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.map((item, index) => ({ + id: index, + content: + typeof item === 'string' ? item : JSON.stringify(item, null, 2), + type: typeof item === 'string' ? 'text' : 'json', + label: `${t('密钥')} ${index + 1}`, + })); + } + } catch (e) { + // 如果解析失败,按普通文本处理 + console.warn('Failed to parse JSON keys:', e); + } + } + + // 检查是否是多行密钥(按换行符分割) + const lines = trimmed.split('\n').filter((line) => line.trim()); + if (lines.length > 1) { + return lines.map((line, index) => ({ + id: index, + content: line.trim(), + type: 'text', + label: `${t('密钥')} ${index + 1}`, + })); + } + + // 单个密钥 + return [ + { + id: 0, + content: trimmed, + type: trimmed.startsWith('{') ? 'json' : 'text', + label: t('密钥'), + }, + ]; +}; + +/** + * 可复用的密钥显示组件 + * @param {Object} props + * @param {string} props.keyData - 密钥数据 + * @param {boolean} props.showSuccessIcon - 是否显示成功图标 + * @param {string} props.successText - 成功文本 + * @param {boolean} props.showWarning - 是否显示安全警告 + * @param {string} props.warningText - 警告文本 + */ +const ChannelKeyDisplay = ({ + keyData, + showSuccessIcon = true, + successText, + showWarning = true, + warningText, +}) => { + const { t } = useTranslation(); + + const parsedKeys = parseChannelKeys(keyData, t); + const isMultipleKeys = parsedKeys.length > 1; + + const handleCopyAll = () => { + copy(keyData); + showSuccess(t('所有密钥已复制到剪贴板')); + }; + + const handleCopyKey = (content) => { + copy(content); + showSuccess(t('密钥已复制到剪贴板')); + }; + + return ( +
      + {/* 成功状态 */} + {showSuccessIcon && ( +
      + + + + + {successText || t('验证成功')} + +
      + )} + + {/* 密钥内容 */} +
      +
      + + {isMultipleKeys ? t('渠道密钥列表') : t('渠道密钥')} + + {isMultipleKeys && ( +
      + + {t('共 {{count}} 个密钥', { count: parsedKeys.length })} + + +
      + )} +
      + +
      + {parsedKeys.map((keyItem) => ( + +
      +
      + + {keyItem.label} + +
      + {keyItem.type === 'json' && ( + + {t('JSON')} + + )} + +
      +
      + +
      + + {keyItem.content} + +
      + + {keyItem.type === 'json' && ( + + {t('JSON格式密钥,请确保格式正确')} + + )} +
      +
      + ))} +
      + + {isMultipleKeys && ( +
      + + + + + {t( + '检测到多个密钥,您可以单独复制每个密钥,或点击复制全部获取完整内容。', + )} + +
      + )} +
      + + {/* 安全警告 */} + {showWarning && ( +
      +
      + + + +
      + + {t('安全提醒')} + + + {warningText || + t( + '请妥善保管密钥信息,不要泄露给他人。如有安全疑虑,请及时更换密钥。', + )} + +
      +
      +
      + )} +
      + ); +}; + +export default ChannelKeyDisplay; diff --git a/web/src/components/common/ui/CompactModeToggle.jsx b/web/src/components/common/ui/CompactModeToggle.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fd6c8bbec0b11aa4e3ed86748b2756267e775197 --- /dev/null +++ b/web/src/components/common/ui/CompactModeToggle.jsx @@ -0,0 +1,68 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React from 'react'; +import { Button } from '@douyinfe/semi-ui'; +import PropTypes from 'prop-types'; +import { useIsMobile } from '../../../hooks/common/useIsMobile'; + +/** + * 紧凑模式切换按钮组件 + * 用于在自适应列表和紧凑列表之间切换 + * 在移动端时自动隐藏,因为移动端使用"显示操作项"按钮来控制内容显示 + */ +const CompactModeToggle = ({ + compactMode, + setCompactMode, + t, + size = 'small', + type = 'tertiary', + className = '', + ...props +}) => { + const isMobile = useIsMobile(); + + // 在移动端隐藏紧凑列表切换按钮 + if (isMobile) { + return null; + } + + return ( + + ); +}; + +CompactModeToggle.propTypes = { + compactMode: PropTypes.bool.isRequired, + setCompactMode: PropTypes.func.isRequired, + t: PropTypes.func.isRequired, + size: PropTypes.string, + type: PropTypes.string, + className: PropTypes.string, +}; + +export default CompactModeToggle; diff --git a/web/src/components/common/ui/JSONEditor.jsx b/web/src/components/common/ui/JSONEditor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..966b4038768530f8998a48af3c6d288d4ea553ba --- /dev/null +++ b/web/src/components/common/ui/JSONEditor.jsx @@ -0,0 +1,714 @@ +/* +Copyright (C) 2025 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Button, + Form, + Typography, + Banner, + Tabs, + TabPane, + Card, + Input, + InputNumber, + Switch, + TextArea, + Row, + Col, + Divider, + Tooltip, +} from '@douyinfe/semi-ui'; +import { IconPlus, IconDelete, IconAlertTriangle } from '@douyinfe/semi-icons'; + +const { Text } = Typography; + +// 唯一 ID 生成器,确保在组件生命周期内稳定且递增 +const generateUniqueId = (() => { + let counter = 0; + return () => `kv_${counter++}`; +})(); + +const JSONEditor = ({ + value = '', + onChange, + field, + label, + placeholder, + extraText, + extraFooter, + showClear = true, + template, + templateLabel, + editorType = 'keyValue', + rules = [], + formApi = null, + ...props +}) => { + const { t } = useTranslation(); + + // 将对象转换为键值对数组(包含唯一ID) + const objectToKeyValueArray = useCallback((obj, prevPairs = []) => { + if (!obj || typeof obj !== 'object') return []; + + const entries = Object.entries(obj); + return entries.map(([key, value], index) => { + // 如果上一次转换后同位置的键一致,则沿用其 id,保持 React key 稳定 + const prev = prevPairs[index]; + const shouldReuseId = prev && prev.key === key; + return { + id: shouldReuseId ? prev.id : generateUniqueId(), + key, + value, + }; + }); + }, []); + + // 将键值对数组转换为对象(重复键时后面的会覆盖前面的) + const keyValueArrayToObject = useCallback((arr) => { + const result = {}; + arr.forEach((item) => { + if (item.key) { + result[item.key] = item.value; + } + }); + return result; + }, []); + + // 初始化键值对数组 + const [keyValuePairs, setKeyValuePairs] = useState(() => { + if (typeof value === 'string' && value.trim()) { + try { + const parsed = JSON.parse(value); + return objectToKeyValueArray(parsed); + } catch (error) { + return []; + } + } + if (typeof value === 'object' && value !== null) { + return objectToKeyValueArray(value); + } + return []; + }); + + // 手动模式下的本地文本缓冲 + const [manualText, setManualText] = useState(() => { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') + return JSON.stringify(value, null, 2); + return ''; + }); + + // 根据键数量决定默认编辑模式 + const [editMode, setEditMode] = useState(() => { + if (typeof value === 'string' && value.trim()) { + try { + const parsed = JSON.parse(value); + const keyCount = Object.keys(parsed).length; + return keyCount > 10 ? 'manual' : 'visual'; + } catch (error) { + return 'manual'; + } + } + return 'visual'; + }); + + const [jsonError, setJsonError] = useState(''); + + // 计算重复的键 + const duplicateKeys = useMemo(() => { + const keyCount = {}; + const duplicates = new Set(); + + keyValuePairs.forEach((pair) => { + if (pair.key) { + keyCount[pair.key] = (keyCount[pair.key] || 0) + 1; + if (keyCount[pair.key] > 1) { + duplicates.add(pair.key); + } + } + }); + + return duplicates; + }, [keyValuePairs]); + + // 数据同步 - 当value变化时更新键值对数组 + useEffect(() => { + try { + let parsed = {}; + if (typeof value === 'string' && value.trim()) { + parsed = JSON.parse(value); + } else if (typeof value === 'object' && value !== null) { + parsed = value; + } + + // 只在外部值真正改变时更新,避免循环更新 + const currentObj = keyValueArrayToObject(keyValuePairs); + if (JSON.stringify(parsed) !== JSON.stringify(currentObj)) { + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); + } + setJsonError(''); + } catch (error) { + console.log('JSON解析失败:', error.message); + setJsonError(error.message); + } + }, [value]); + + // 外部 value 变化时,若不在手动模式,则同步手动文本 + useEffect(() => { + if (editMode !== 'manual') { + if (typeof value === 'string') setManualText(value); + else if (value && typeof value === 'object') + setManualText(JSON.stringify(value, null, 2)); + else setManualText(''); + } + }, [value, editMode]); + + // 处理可视化编辑的数据变化 + const handleVisualChange = useCallback( + (newPairs) => { + setKeyValuePairs(newPairs); + const jsonObject = keyValueArrayToObject(newPairs); + const jsonString = + Object.keys(jsonObject).length === 0 + ? '' + : JSON.stringify(jsonObject, null, 2); + + setJsonError(''); + + // 通过formApi设置值 + if (formApi && field) { + formApi.setValue(field, jsonString); + } + + onChange?.(jsonString); + }, + [onChange, formApi, field, keyValueArrayToObject], + ); + + // 处理手动编辑的数据变化 + const handleManualChange = useCallback( + (newValue) => { + setManualText(newValue); + if (newValue && newValue.trim()) { + try { + const parsed = JSON.parse(newValue); + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); + setJsonError(''); + onChange?.(newValue); + } catch (error) { + setJsonError(error.message); + } + } else { + setKeyValuePairs([]); + setJsonError(''); + onChange?.(''); + } + }, + [onChange, objectToKeyValueArray, keyValuePairs], + ); + + // 切换编辑模式 + const toggleEditMode = useCallback(() => { + if (editMode === 'visual') { + const jsonObject = keyValueArrayToObject(keyValuePairs); + setManualText( + Object.keys(jsonObject).length === 0 + ? '' + : JSON.stringify(jsonObject, null, 2), + ); + setEditMode('manual'); + } else { + try { + let parsed = {}; + if (manualText && manualText.trim()) { + parsed = JSON.parse(manualText); + } else if (typeof value === 'string' && value.trim()) { + parsed = JSON.parse(value); + } else if (typeof value === 'object' && value !== null) { + parsed = value; + } + setKeyValuePairs(objectToKeyValueArray(parsed, keyValuePairs)); + setJsonError(''); + setEditMode('visual'); + } catch (error) { + setJsonError(error.message); + return; + } + } + }, [ + editMode, + value, + manualText, + keyValuePairs, + keyValueArrayToObject, + objectToKeyValueArray, + ]); + + // 添加键值对 + const addKeyValue = useCallback(() => { + const newPairs = [...keyValuePairs]; + const existingKeys = newPairs.map((p) => p.key); + let counter = 1; + let newKey = `field_${counter}`; + while (existingKeys.includes(newKey)) { + counter += 1; + newKey = `field_${counter}`; + } + newPairs.push({ + id: generateUniqueId(), + key: newKey, + value: '', + }); + handleVisualChange(newPairs); + }, [keyValuePairs, handleVisualChange]); + + // 删除键值对 + const removeKeyValue = useCallback( + (id) => { + const newPairs = keyValuePairs.filter((pair) => pair.id !== id); + handleVisualChange(newPairs); + }, + [keyValuePairs, handleVisualChange], + ); + + // 更新键名 + const updateKey = useCallback( + (id, newKey) => { + const newPairs = keyValuePairs.map((pair) => + pair.id === id ? { ...pair, key: newKey } : pair, + ); + handleVisualChange(newPairs); + }, + [keyValuePairs, handleVisualChange], + ); + + // 更新值 + const updateValue = useCallback( + (id, newValue) => { + const newPairs = keyValuePairs.map((pair) => + pair.id === id ? { ...pair, value: newValue } : pair, + ); + handleVisualChange(newPairs); + }, + [keyValuePairs, handleVisualChange], + ); + + // 填入模板 + const fillTemplate = useCallback(() => { + if (template) { + const templateString = JSON.stringify(template, null, 2); + + if (formApi && field) { + formApi.setValue(field, templateString); + } + + setManualText(templateString); + setKeyValuePairs(objectToKeyValueArray(template, keyValuePairs)); + onChange?.(templateString); + setJsonError(''); + } + }, [ + template, + onChange, + formApi, + field, + objectToKeyValueArray, + keyValuePairs, + ]); + + // 渲染值输入控件(支持嵌套) + const renderValueInput = (pairId, value) => { + const valueType = typeof value; + + if (valueType === 'boolean') { + return ( +
      + updateValue(pairId, newValue)} + /> + + {value ? t('true') : t('false')} + +
      + ); + } + + if (valueType === 'number') { + return ( + updateValue(pairId, newValue)} + style={{ width: '100%' }} + placeholder={t('输入数字')} + /> + ); + } + + if (valueType === 'object' && value !== null) { + // 简化嵌套对象的处理,使用TextArea + return ( +