Commit 815bc61c by Hackerxiao Committed by GitHub

Merge branch 'QuantumNous:main' into main

parents 82574384 b5a0c822
...@@ -6,4 +6,5 @@ ...@@ -6,4 +6,5 @@
Makefile Makefile
docs docs
.eslintcache .eslintcache
.gocache .gocache
\ No newline at end of file /web/node_modules
\ No newline at end of file
...@@ -9,6 +9,14 @@ ...@@ -9,6 +9,14 @@
# ENABLE_PPROF=true # ENABLE_PPROF=true
# 启用调试模式 # 启用调试模式
# DEBUG=true # DEBUG=true
# Pyroscope 配置
# PYROSCOPE_URL=http://localhost:4040
# PYROSCOPE_APP_NAME=new-api
# PYROSCOPE_BASIC_AUTH_USER=your-user
# PYROSCOPE_BASIC_AUTH_PASSWORD=your-password
# PYROSCOPE_MUTEX_RATE=5
# PYROSCOPE_BLOCK_RATE=5
# HOSTNAME=your-hostname
# 数据库相关配置 # 数据库相关配置
# 数据库连接字符串 # 数据库连接字符串
......
...@@ -16,9 +16,13 @@ new-api ...@@ -16,9 +16,13 @@ new-api
tiktoken_cache tiktoken_cache
.eslintcache .eslintcache
.gocache .gocache
.gomodcache/
.cache .cache
web/bun.lock web/bun.lock
electron/node_modules electron/node_modules
electron/dist electron/dist
data/ data/
.gomodcache/
.gocache-temp
.gopath
...@@ -28,7 +28,7 @@ RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$ ...@@ -28,7 +28,7 @@ RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$
FROM debian:bookworm-slim FROM debian:bookworm-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 \ && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& update-ca-certificates && update-ca-certificates
......
...@@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \ ...@@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \
🎉 After deployment is complete, visit `http://localhost:3000` to start using! 🎉 After deployment is complete, visit `http://localhost:3000` to start using!
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/installation) 📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
--- ---
...@@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \ ...@@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \
<div align="center"> <div align="center">
### 📖 [Official Documentation](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) ### 📖 [Official Documentation](https://docs.newapi.pro/en/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div> </div>
...@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \ ...@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \
| Category | Link | | Category | Link |
|------|------| |------|------|
| 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/installation) | | 🚀 Deployment Guide | [Installation Documentation](https://docs.newapi.pro/en/docs/installation) |
| ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/installation/environment-variables) | | ⚙️ Environment Configuration | [Environment Variables](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables) |
| 📡 API Documentation | [API Documentation](https://docs.newapi.pro/api) | | 📡 API Documentation | [API Documentation](https://docs.newapi.pro/en/docs/api) |
| ❓ FAQ | [FAQ](https://docs.newapi.pro/support/faq) | | ❓ FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) | | 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
--- ---
## ✨ Key Features ## ✨ Key Features
> For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/wiki/features-introduction) > For detailed features, please refer to [Features Introduction](https://docs.newapi.pro/en/docs/guide/wiki/basic-concepts/features-introduction)
### 🎨 Core Functions ### 🎨 Core Functions
...@@ -201,11 +201,11 @@ docker run --name new-api -d --restart always \ ...@@ -201,11 +201,11 @@ docker run --name new-api -d --restart always \
### 🚀 Advanced Features ### 🚀 Advanced Features
**API Format Support:** **API Format Support:**
-[OpenAI Responses](https://docs.newapi.pro/api/openai-responses) -[OpenAI Responses](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
-[OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime) (including Azure) -[OpenAI Realtime API](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session) (including Azure)
-[Claude Messages](https://docs.newapi.pro/api/anthropic-chat) -[Claude Messages](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
-[Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) -[Google Gemini](https://doc.newapi.pro/en/api/google-gemini-chat)
- 🔄 [Rerank Models](https://docs.newapi.pro/api/jinaai-rerank) (Cohere, Jina) - 🔄 [Rerank Models](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) (Cohere, Jina)
**Intelligent Routing:** **Intelligent Routing:**
- ⚖️ Channel weighted random - ⚖️ Channel weighted random
...@@ -246,16 +246,16 @@ docker run --name new-api -d --restart always \ ...@@ -246,16 +246,16 @@ docker run --name new-api -d --restart always \
## 🤖 Model Support ## 🤖 Model Support
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/api) > For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
| Model Type | Description | Documentation | | Model Type | Description | Documentation |
|---------|------|------| |---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - | | 🤖 OpenAI GPTs | gpt-4-gizmo-* series | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://docs.newapi.pro/api/midjourney-proxy-image) | | 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [Documentation](https://doc.newapi.pro/en/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://docs.newapi.pro/api/suno-music) | | 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [Documentation](https://doc.newapi.pro/en/api/suno-music) |
| 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/api/jinaai-rerank) | | 🔄 Rerank | Cohere, Jina | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank) |
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/api/anthropic-chat) | | 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/api/google-gemini-chat/) | | 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
| 🔧 Dify | ChatFlow mode | - | | 🔧 Dify | ChatFlow mode | - |
| 🎯 Custom | Supports complete call address | - | | 🎯 Custom | Supports complete call address | - |
...@@ -264,16 +264,16 @@ docker run --name new-api -d --restart always \ ...@@ -264,16 +264,16 @@ docker run --name new-api -d --restart always \
<details> <details>
<summary>View complete interface list</summary> <summary>View complete interface list</summary>
- [Chat Interface (Chat Completions)](https://docs.newapi.pro/api/openai-chat) - [Chat Interface (Chat Completions)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-chat-completion)
- [Response Interface (Responses)](https://docs.newapi.pro/api/openai-responses) - [Response Interface (Responses)](https://docs.newapi.pro/en/docs/api/ai-model/chat/openai/create-response)
- [Image Interface (Image)](https://docs.newapi.pro/api/openai-image) - [Image Interface (Image)](https://docs.newapi.pro/en/docs/api/ai-model/images/openai/v1-images-generations--post)
- [Audio Interface (Audio)](https://docs.newapi.pro/api/openai-audio) - [Audio Interface (Audio)](https://docs.newapi.pro/en/docs/api/ai-model/audio/openai/create-transcription)
- [Video Interface (Video)](https://docs.newapi.pro/api/openai-video) - [Video Interface (Video)](https://docs.newapi.pro/en/docs/api/ai-model/videos/create-video-generation)
- [Embedding Interface (Embeddings)](https://docs.newapi.pro/api/openai-embeddings) - [Embedding Interface (Embeddings)](https://docs.newapi.pro/en/docs/api/ai-model/embeddings/create-embedding)
- [Rerank Interface (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) - [Rerank Interface (Rerank)](https://docs.newapi.pro/en/docs/api/ai-model/rerank/create-rerank)
- [Realtime Conversation (Realtime)](https://docs.newapi.pro/api/openai-realtime) - [Realtime Conversation (Realtime)](https://docs.newapi.pro/en/docs/api/ai-model/realtime/create-realtime-session)
- [Claude Chat](https://docs.newapi.pro/api/anthropic-chat) - [Claude Chat](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message)
- [Google Gemini Chat](https://docs.newapi.pro/api/google-gemini-chat/) - [Google Gemini Chat](https://doc.newapi.pro/en/api/google-gemini-chat)
</details> </details>
...@@ -305,10 +305,18 @@ docker run --name new-api -d --restart always \ ...@@ -305,10 +305,18 @@ docker run --name new-api -d --restart always \
| `REDIS_CONN_STRING` | Redis connection string | - | | `REDIS_CONN_STRING` | Redis connection string | - |
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` | | `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` | | `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` | | `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Error log switch | `false` | | `ERROR_LOG_ENABLED` | Error log switch | `false` |
| `PYROSCOPE_URL` | Pyroscope server address | - |
| `PYROSCOPE_APP_NAME` | Pyroscope application name | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope basic auth user | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope basic auth password | - |
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex sampling rate | `5` |
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block sampling rate | `5` |
| `HOSTNAME` | Hostname tag for Pyroscope | `new-api` |
📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/installation/environment-variables) 📖 **Complete configuration:** [Environment Variables Documentation](https://docs.newapi.pro/en/docs/installation/config-maintenance/environment-variables)
</details> </details>
...@@ -410,10 +418,10 @@ docker run --name new-api -d --restart always \ ...@@ -410,10 +418,10 @@ docker run --name new-api -d --restart always \
| Resource | Link | | Resource | Link |
|------|------| |------|------|
| 📘 FAQ | [FAQ](https://docs.newapi.pro/support/faq) | | 📘 FAQ | [FAQ](https://docs.newapi.pro/en/docs/support/faq) |
| 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/support/community-interaction) | | 💬 Community Interaction | [Communication Channels](https://docs.newapi.pro/en/docs/support/community-interaction) |
| 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/support/feedback-issues) | | 🐛 Issue Feedback | [Issue Feedback](https://docs.newapi.pro/en/docs/support/feedback-issues) |
| 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/support) | | 📚 Complete Documentation | [Official Documentation](https://docs.newapi.pro/en/docs) |
### 🤝 Contribution Guide ### 🤝 Contribution Guide
...@@ -442,7 +450,7 @@ Welcome all forms of contribution! ...@@ -442,7 +450,7 @@ Welcome all forms of contribution!
If this project is helpful to you, welcome to give us a ⭐️ Star! If this project is helpful to you, welcome to give us a ⭐️ Star!
**[Official Documentation](https://docs.newapi.pro/)****[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)****[Latest Release](https://github.com/Calcium-Ion/new-api/releases)** **[Official Documentation](https://docs.newapi.pro/en/docs)****[Issue Feedback](https://github.com/Calcium-Ion/new-api/issues)****[Latest Release](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub> <sub>Built with ❤️ by QuantumNous</sub>
......
...@@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \ ...@@ -146,7 +146,7 @@ docker run --name new-api -d --restart always \
🎉 部署完成后,访问 `http://localhost:3000` 即可使用! 🎉 部署完成后,访问 `http://localhost:3000` 即可使用!
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/installation) 📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/zh/docs/installation)
--- ---
...@@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \ ...@@ -154,7 +154,7 @@ docker run --name new-api -d --restart always \
<div align="center"> <div align="center">
### 📖 [官方文档](https://docs.newapi.pro/) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api) ### 📖 [官方文档](https://docs.newapi.pro/zh/docs) | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/QuantumNous/new-api)
</div> </div>
...@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \ ...@@ -162,17 +162,17 @@ docker run --name new-api -d --restart always \
| 分类 | 链接 | | 分类 | 链接 |
|------|------| |------|------|
| 🚀 部署指南 | [安装文档](https://docs.newapi.pro/installation) | | 🚀 部署指南 | [安装文档](https://docs.newapi.pro/zh/docs/installation) |
| ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/installation/environment-variables) | | ⚙️ 环境配置 | [环境变量](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables) |
| 📡 接口文档 | [API 文档](https://docs.newapi.pro/api) | | 📡 接口文档 | [API 文档](https://docs.newapi.pro/zh/docs/api) |
| ❓ 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) | | ❓ 常见问题 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) | | 💬 社区交流 | [交流渠道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
--- ---
## ✨ 主要特性 ## ✨ 主要特性
> 详细特性请参考 [特性说明](https://docs.newapi.pro/wiki/features-introduction) > 详细特性请参考 [特性说明](https://docs.newapi.pro/zh/docs/guide/wiki/basic-concepts/features-introduction)
### 🎨 核心功能 ### 🎨 核心功能
...@@ -202,11 +202,11 @@ docker run --name new-api -d --restart always \ ...@@ -202,11 +202,11 @@ docker run --name new-api -d --restart always \
### 🚀 高级功能 ### 🚀 高级功能
**API 格式支持:** **API 格式支持:**
-[OpenAI Responses](https://docs.newapi.pro/api/openai-responses) -[OpenAI Responses](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response)
-[OpenAI Realtime API](https://docs.newapi.pro/api/openai-realtime)(含 Azure) -[OpenAI Realtime API](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session)(含 Azure)
-[Claude Messages](https://docs.newapi.pro/api/anthropic-chat) -[Claude Messages](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message)
-[Google Gemini](https://docs.newapi.pro/api/google-gemini-chat/) -[Google Gemini](https://doc.newapi.pro/api/google-gemini-chat)
- 🔄 [Rerank 模型](https://docs.newapi.pro/api/jinaai-rerank)(Cohere、Jina) - 🔄 [Rerank 模型](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank)(Cohere、Jina)
**智能路由:** **智能路由:**
- ⚖️ 渠道加权随机 - ⚖️ 渠道加权随机
...@@ -247,16 +247,16 @@ docker run --name new-api -d --restart always \ ...@@ -247,16 +247,16 @@ docker run --name new-api -d --restart always \
## 🤖 模型支持 ## 🤖 模型支持
> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/api) > 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/zh/docs/api)
| 模型类型 | 说明 | 文档 | | 模型类型 | 说明 | 文档 |
|---------|------|------| |---------|------|------|
| 🤖 OpenAI GPTs | gpt-4-gizmo-* 系列 | - | | 🤖 OpenAI GPTs | gpt-4-gizmo-* 系列 | - |
| 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://docs.newapi.pro/api/midjourney-proxy-image) | | 🎨 Midjourney-Proxy | [Midjourney-Proxy(Plus)](https://github.com/novicezk/midjourney-proxy) | [文档](https://doc.newapi.pro/api/midjourney-proxy-image) |
| 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://docs.newapi.pro/api/suno-music) | | 🎵 Suno-API | [Suno API](https://github.com/Suno-API/Suno-API) | [文档](https://doc.newapi.pro/api/suno-music) |
| 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/api/jinaai-rerank) | | 🔄 Rerank | Cohere、Jina | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank) |
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/api/anthropic-chat) | | 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message) |
| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/api/google-gemini-chat/) | | 🌐 Gemini | Google Gemini 格式 | [文档](https://doc.newapi.pro/api/google-gemini-chat) |
| 🔧 Dify | ChatFlow 模式 | - | | 🔧 Dify | ChatFlow 模式 | - |
| 🎯 自定义 | 支持完整调用地址 | - | | 🎯 自定义 | 支持完整调用地址 | - |
...@@ -265,16 +265,16 @@ docker run --name new-api -d --restart always \ ...@@ -265,16 +265,16 @@ docker run --name new-api -d --restart always \
<details> <details>
<summary>查看完整接口列表</summary> <summary>查看完整接口列表</summary>
- [聊天接口 (Chat Completions)](https://docs.newapi.pro/api/openai-chat) - [聊天接口 (Chat Completions)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-chat-completion)
- [响应接口 (Responses)](https://docs.newapi.pro/api/openai-responses) - [响应接口 (Responses)](https://docs.newapi.pro/zh/docs/api/ai-model/chat/openai/create-response)
- [图像接口 (Image)](https://docs.newapi.pro/api/openai-image) - [图像接口 (Image)](https://docs.newapi.pro/zh/docs/api/ai-model/images/openai/v1-images-generations--post)
- [音频接口 (Audio)](https://docs.newapi.pro/api/openai-audio) - [音频接口 (Audio)](https://docs.newapi.pro/zh/docs/api/ai-model/audio/openai/create-transcription)
- [视频接口 (Video)](https://docs.newapi.pro/api/openai-video) - [视频接口 (Video)](https://docs.newapi.pro/zh/docs/api/ai-model/videos/create-video-generation)
- [嵌入接口 (Embeddings)](https://docs.newapi.pro/api/openai-embeddings) - [嵌入接口 (Embeddings)](https://docs.newapi.pro/zh/docs/api/ai-model/embeddings/create-embedding)
- [重排序接口 (Rerank)](https://docs.newapi.pro/api/jinaai-rerank) - [重排序接口 (Rerank)](https://docs.newapi.pro/zh/docs/api/ai-model/rerank/create-rerank)
- [实时对话 (Realtime)](https://docs.newapi.pro/api/openai-realtime) - [实时对话 (Realtime)](https://docs.newapi.pro/zh/docs/api/ai-model/realtime/create-realtime-session)
- [Claude 聊天](https://docs.newapi.pro/api/anthropic-chat) - [Claude 聊天](https://docs.newapi.pro/zh/docs/api/ai-model/chat/create-message)
- [Google Gemini 聊天](https://docs.newapi.pro/api/google-gemini-chat) - [Google Gemini 聊天](https://doc.newapi.pro/api/google-gemini-chat)
</details> </details>
...@@ -306,10 +306,18 @@ docker run --name new-api -d --restart always \ ...@@ -306,10 +306,18 @@ docker run --name new-api -d --restart always \
| `REDIS_CONN_STRING` | Redis 连接字符串 | - | | `REDIS_CONN_STRING` | Redis 连接字符串 | - |
| `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` | | `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` |
| `STREAM_SCANNER_MAX_BUFFER_MB` | 流式扫描器单行最大缓冲(MB),图像生成等超大 `data:` 片段(如 4K 图片 base64)需适当调大 | `64` | | `STREAM_SCANNER_MAX_BUFFER_MB` | 流式扫描器单行最大缓冲(MB),图像生成等超大 `data:` 片段(如 4K 图片 base64)需适当调大 | `64` |
| `MAX_REQUEST_BODY_MB` | 请求体最大大小(MB,**解压后**计;防止超大请求/zip bomb 导致内存暴涨),超过将返回 `413` | `32` |
| `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` | | `AZURE_DEFAULT_API_VERSION` | Azure API 版本 | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | 错误日志开关 | `false` | | `ERROR_LOG_ENABLED` | 错误日志开关 | `false` |
| `PYROSCOPE_URL` | Pyroscope 服务地址 | - |
| `PYROSCOPE_APP_NAME` | Pyroscope 应用名 | `new-api` |
| `PYROSCOPE_BASIC_AUTH_USER` | Pyroscope Basic Auth 用户名 | - |
| `PYROSCOPE_BASIC_AUTH_PASSWORD` | Pyroscope Basic Auth 密码 | - |
| `PYROSCOPE_MUTEX_RATE` | Pyroscope mutex 采样率 | `5` |
| `PYROSCOPE_BLOCK_RATE` | Pyroscope block 采样率 | `5` |
| `HOSTNAME` | Pyroscope 标签里的主机名 | `new-api` |
📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/installation/environment-variables) 📖 **完整配置:** [环境变量文档](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables)
</details> </details>
...@@ -411,10 +419,10 @@ docker run --name new-api -d --restart always \ ...@@ -411,10 +419,10 @@ docker run --name new-api -d --restart always \
| 资源 | 链接 | | 资源 | 链接 |
|------|------| |------|------|
| 📘 常见问题 | [FAQ](https://docs.newapi.pro/support/faq) | | 📘 常见问题 | [FAQ](https://docs.newapi.pro/zh/docs/support/faq) |
| 💬 社区交流 | [交流渠道](https://docs.newapi.pro/support/community-interaction) | | 💬 社区交流 | [交流渠道](https://docs.newapi.pro/zh/docs/support/community-interaction) |
| 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/support/feedback-issues) | | 🐛 反馈问题 | [问题反馈](https://docs.newapi.pro/zh/docs/support/feedback-issues) |
| 📚 完整文档 | [官方文档](https://docs.newapi.pro/support) | | 📚 完整文档 | [官方文档](https://docs.newapi.pro/zh/docs) |
### 🤝 贡献指南 ### 🤝 贡献指南
...@@ -443,7 +451,7 @@ docker run --name new-api -d --restart always \ ...@@ -443,7 +451,7 @@ docker run --name new-api -d --restart always \
如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star! 如果这个项目对你有帮助,欢迎给我们一个 ⭐️ Star!
**[官方文档](https://docs.newapi.pro/)****[问题反馈](https://github.com/Calcium-Ion/new-api/issues)****[最新发布](https://github.com/Calcium-Ion/new-api/releases)** **[官方文档](https://docs.newapi.pro/zh/docs)****[问题反馈](https://github.com/Calcium-Ion/new-api/issues)****[最新发布](https://github.com/Calcium-Ion/new-api/releases)**
<sub>Built with ❤️ by QuantumNous</sub> <sub>Built with ❤️ by QuantumNous</sub>
......
...@@ -71,15 +71,66 @@ func getMP3Duration(r io.Reader) (float64, error) { ...@@ -71,15 +71,66 @@ func getMP3Duration(r io.Reader) (float64, error) {
// getWAVDuration 解析 WAV 文件头以获取时长。 // getWAVDuration 解析 WAV 文件头以获取时长。
func getWAVDuration(r io.ReadSeeker) (float64, error) { func getWAVDuration(r io.ReadSeeker) (float64, error) {
// 1. 强制复位指针
r.Seek(0, io.SeekStart)
dec := wav.NewDecoder(r) dec := wav.NewDecoder(r)
// IsValidFile 会读取 fmt 块
if !dec.IsValidFile() { if !dec.IsValidFile() {
return 0, errors.New("invalid wav file") return 0, errors.New("invalid wav file")
} }
d, err := dec.Duration()
if err != nil { // 尝试寻找 data 块
return 0, errors.Wrap(err, "failed to get wav duration") if err := dec.FwdToPCM(); err != nil {
return 0, errors.Wrap(err, "failed to find PCM data chunk")
} }
return d.Seconds(), nil
pcmSize := int64(dec.PCMSize)
// 如果读出来的 Size 是 0,尝试用文件大小反推
if pcmSize == 0 {
// 获取文件总大小
currentPos, _ := r.Seek(0, io.SeekCurrent) // 当前通常在 data chunk header 之后
endPos, _ := r.Seek(0, io.SeekEnd)
fileSize := endPos
// 恢复位置(虽然如果不继续读也没关系)
r.Seek(currentPos, io.SeekStart)
// 数据区大小 ≈ 文件总大小 - 当前指针位置(即Header大小)
// 注意:FwdToPCM 成功后,CurrentPos 应该刚好指向 Data 区数据的开始
// 或者是 Data Chunk ID + Size 之后。
// WAV Header 一般 44 字节。
if fileSize > 44 {
// 如果 FwdToPCM 成功,Reader 应该位于 data 块的数据起始处
// 所以剩余的所有字节理论上都是音频数据
pcmSize = fileSize - currentPos
// 简单的兜底:如果算出来还是负数或0,强制按文件大小-44计算
if pcmSize <= 0 {
pcmSize = fileSize - 44
}
}
}
numChans := int64(dec.NumChans)
bitDepth := int64(dec.BitDepth)
sampleRate := float64(dec.SampleRate)
if sampleRate == 0 || numChans == 0 || bitDepth == 0 {
return 0, errors.New("invalid wav header metadata")
}
bytesPerFrame := numChans * (bitDepth / 8)
if bytesPerFrame == 0 {
return 0, errors.New("invalid byte depth calculation")
}
totalFrames := pcmSize / bytesPerFrame
durationSeconds := float64(totalFrames) / sampleRate
return durationSeconds, nil
} }
// getFLACDuration 解析 FLAC 文件的 STREAMINFO 块。 // getFLACDuration 解析 FLAC 文件的 STREAMINFO 块。
......
...@@ -32,7 +32,7 @@ func SendEmail(subject string, receiver string, content string) error { ...@@ -32,7 +32,7 @@ func SendEmail(subject string, receiver string, content string) error {
} }
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject))) encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", base64.StdEncoding.EncodeToString([]byte(subject)))
mail := []byte(fmt.Sprintf("To: %s\r\n"+ mail := []byte(fmt.Sprintf("To: %s\r\n"+
"From: %s<%s>\r\n"+ "From: %s <%s>\r\n"+
"Subject: %s\r\n"+ "Subject: %s\r\n"+
"Date: %s\r\n"+ "Date: %s\r\n"+
"Message-ID: %s\r\n"+ // 添加 Message-ID 头 "Message-ID: %s\r\n"+ // 添加 Message-ID 头
......
...@@ -2,7 +2,7 @@ package common ...@@ -2,7 +2,7 @@ package common
import ( import (
"bytes" "bytes"
"errors" "fmt"
"io" "io"
"mime" "mime"
"mime/multipart" "mime/multipart"
...@@ -12,24 +12,61 @@ import ( ...@@ -12,24 +12,61 @@ import (
"time" "time"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/pkg/errors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
const KeyRequestBody = "key_request_body" const KeyRequestBody = "key_request_body"
var ErrRequestBodyTooLarge = errors.New("request body too large")
func IsRequestBodyTooLargeError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, ErrRequestBodyTooLarge) {
return true
}
var mbe *http.MaxBytesError
return errors.As(err, &mbe)
}
func GetRequestBody(c *gin.Context) ([]byte, error) { func GetRequestBody(c *gin.Context) ([]byte, error) {
requestBody, _ := c.Get(KeyRequestBody) cached, exists := c.Get(KeyRequestBody)
if requestBody != nil { if exists && cached != nil {
return requestBody.([]byte), nil if b, ok := cached.([]byte); ok {
return b, nil
}
} }
requestBody, err := io.ReadAll(c.Request.Body) maxMB := constant.MaxRequestBodyMB
if maxMB < 0 {
// no limit
body, err := io.ReadAll(c.Request.Body)
_ = c.Request.Body.Close()
if err != nil {
return nil, err
}
c.Set(KeyRequestBody, body)
return body, nil
}
maxBytes := int64(maxMB) << 20
limited := io.LimitReader(c.Request.Body, maxBytes+1)
body, err := io.ReadAll(limited)
if err != nil { if err != nil {
_ = c.Request.Body.Close()
if IsRequestBodyTooLargeError(err) {
return nil, errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB))
}
return nil, err return nil, err
} }
_ = c.Request.Body.Close() _ = c.Request.Body.Close()
c.Set(KeyRequestBody, requestBody) if int64(len(body)) > maxBytes {
return requestBody.([]byte), nil return nil, errors.Wrap(ErrRequestBodyTooLarge, fmt.Sprintf("request body exceeds %d MB", maxMB))
}
c.Set(KeyRequestBody, body)
return body, nil
} }
func UnmarshalBodyReusable(c *gin.Context, v any) error { func UnmarshalBodyReusable(c *gin.Context, v any) error {
......
...@@ -117,6 +117,8 @@ func initConstantEnv() { ...@@ -117,6 +117,8 @@ func initConstantEnv() {
constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true) constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true)
constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20) constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 20)
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64) constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64)
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 64)
// ForceStreamOption 覆盖请求参数,强制返回usage信息 // ForceStreamOption 覆盖请求参数,强制返回usage信息
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true) constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
constant.CountToken = GetEnvOrDefaultBool("CountToken", true) constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
......
...@@ -2,6 +2,15 @@ package common ...@@ -2,6 +2,15 @@ package common
import "net" import "net"
func IsIP(s string) bool {
ip := net.ParseIP(s)
return ip != nil
}
func ParseIP(s string) net.IP {
return net.ParseIP(s)
}
func IsPrivateIP(ip net.IP) bool { func IsPrivateIP(ip net.IP) bool {
if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true return true
...@@ -20,3 +29,23 @@ func IsPrivateIP(ip net.IP) bool { ...@@ -20,3 +29,23 @@ func IsPrivateIP(ip net.IP) bool {
} }
return false return false
} }
func IsIpInCIDRList(ip net.IP, cidrList []string) bool {
for _, cidr := range cidrList {
_, network, err := net.ParseCIDR(cidr)
if err != nil {
// 尝试作为单个IP处理
if whitelistIP := net.ParseIP(cidr); whitelistIP != nil {
if ip.Equal(whitelistIP) {
return true
}
}
continue
}
if network.Contains(ip) {
return true
}
}
return false
}
package common
import (
"runtime"
"github.com/grafana/pyroscope-go"
)
func StartPyroScope() error {
pyroscopeUrl := GetEnvOrDefaultString("PYROSCOPE_URL", "")
if pyroscopeUrl == "" {
return nil
}
pyroscopeAppName := GetEnvOrDefaultString("PYROSCOPE_APP_NAME", "new-api")
pyroscopeBasicAuthUser := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_USER", "")
pyroscopeBasicAuthPassword := GetEnvOrDefaultString("PYROSCOPE_BASIC_AUTH_PASSWORD", "")
pyroscopeHostname := GetEnvOrDefaultString("HOSTNAME", "new-api")
mutexRate := GetEnvOrDefault("PYROSCOPE_MUTEX_RATE", 5)
blockRate := GetEnvOrDefault("PYROSCOPE_BLOCK_RATE", 5)
runtime.SetMutexProfileFraction(mutexRate)
runtime.SetBlockProfileRate(blockRate)
_, err := pyroscope.Start(pyroscope.Config{
ApplicationName: pyroscopeAppName,
ServerAddress: pyroscopeUrl,
BasicAuthUser: pyroscopeBasicAuthUser,
BasicAuthPassword: pyroscopeBasicAuthPassword,
Logger: nil,
Tags: map[string]string{"hostname": pyroscopeHostname},
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU,
pyroscope.ProfileAllocObjects,
pyroscope.ProfileAllocSpace,
pyroscope.ProfileInuseObjects,
pyroscope.ProfileInuseSpace,
pyroscope.ProfileGoroutines,
pyroscope.ProfileMutexCount,
pyroscope.ProfileMutexDuration,
pyroscope.ProfileBlockCount,
pyroscope.ProfileBlockDuration,
},
})
if err != nil {
return err
}
return nil
}
...@@ -186,23 +186,7 @@ func isIPListed(ip net.IP, list []string) bool { ...@@ -186,23 +186,7 @@ func isIPListed(ip net.IP, list []string) bool {
return false return false
} }
for _, whitelistCIDR := range list { return IsIpInCIDRList(ip, 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是否允许访问 // IsIPAccessAllowed 检查IP是否允许访问
......
...@@ -217,11 +217,6 @@ func IntMax(a int, b int) int { ...@@ -217,11 +217,6 @@ func IntMax(a int, b int) int {
} }
} }
func IsIP(s string) bool {
ip := net.ParseIP(s)
return ip != nil
}
func GetUUID() string { func GetUUID() string {
code := uuid.New().String() code := uuid.New().String()
code = strings.Replace(code, "-", "", -1) code = strings.Replace(code, "-", "", -1)
......
...@@ -18,6 +18,7 @@ const ( ...@@ -18,6 +18,7 @@ const (
ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id" ContextKeyTokenSpecificChannelId ContextKey = "specific_channel_id"
ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled"
ContextKeyTokenModelLimit ContextKey = "token_model_limit" ContextKeyTokenModelLimit ContextKey = "token_model_limit"
ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry"
/* channel related keys */ /* channel related keys */
ContextKeyChannelId ContextKey = "channel_id" ContextKeyChannelId ContextKey = "channel_id"
...@@ -37,6 +38,10 @@ const ( ...@@ -37,6 +38,10 @@ const (
ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index" ContextKeyChannelMultiKeyIndex ContextKey = "channel_multi_key_index"
ContextKeyChannelKey ContextKey = "channel_key" ContextKeyChannelKey ContextKey = "channel_key"
ContextKeyAutoGroup ContextKey = "auto_group"
ContextKeyAutoGroupIndex ContextKey = "auto_group_index"
ContextKeyAutoGroupRetryIndex ContextKey = "auto_group_retry_index"
/* user related keys */ /* user related keys */
ContextKeyUserId ContextKey = "id" ContextKeyUserId ContextKey = "id"
ContextKeyUserSetting ContextKey = "user_setting" ContextKeyUserSetting ContextKey = "user_setting"
......
...@@ -9,6 +9,7 @@ var CountToken bool ...@@ -9,6 +9,7 @@ var CountToken bool
var GetMediaToken bool var GetMediaToken bool
var GetMediaTokenNotStream bool var GetMediaTokenNotStream bool
var UpdateTask bool var UpdateTask bool
var MaxRequestBodyMB int
var AzureDefaultAPIVersion string var AzureDefaultAPIVersion string
var GeminiVisionMaxImageNum int var GeminiVisionMaxImageNum int
var NotifyLimitCount int var NotifyLimitCount int
......
...@@ -2,9 +2,9 @@ package controller ...@@ -2,9 +2,9 @@ package controller
import ( import (
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
...@@ -29,7 +29,7 @@ func GetSubscription(c *gin.Context) { ...@@ -29,7 +29,7 @@ func GetSubscription(c *gin.Context) {
expiredTime = 0 expiredTime = 0
} }
if err != nil { if err != nil {
openAIError := dto.OpenAIError{ openAIError := types.OpenAIError{
Message: err.Error(), Message: err.Error(),
Type: "upstream_error", Type: "upstream_error",
} }
...@@ -81,7 +81,7 @@ func GetUsage(c *gin.Context) { ...@@ -81,7 +81,7 @@ func GetUsage(c *gin.Context) {
quota, err = model.GetUserUsedQuota(userId) quota, err = model.GetUserUsedQuota(userId)
} }
if err != nil { if err != nil {
openAIError := dto.OpenAIError{ openAIError := types.OpenAIError{
Message: err.Error(), Message: err.Error(),
Type: "new_api_error", Type: "new_api_error",
} }
......
...@@ -97,6 +97,11 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) ...@@ -97,6 +97,11 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") { if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
requestPath = "/v1/images/generations" requestPath = "/v1/images/generations"
} }
// responses-only models
if strings.Contains(strings.ToLower(testModel), "codex") {
requestPath = "/v1/responses"
}
} }
c.Request = &http.Request{ c.Request = &http.Request{
...@@ -176,7 +181,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) ...@@ -176,7 +181,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
} }
} }
request := buildTestRequest(testModel, endpointType) request := buildTestRequest(testModel, endpointType, channel)
info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil) info, err := relaycommon.GenRelayInfo(c, relayFormat, request, nil)
...@@ -319,6 +324,16 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) ...@@ -319,6 +324,16 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
httpResp = resp.(*http.Response) httpResp = resp.(*http.Response)
if httpResp.StatusCode != http.StatusOK { if httpResp.StatusCode != http.StatusOK {
err := service.RelayErrorHandler(c.Request.Context(), httpResp, true) err := service.RelayErrorHandler(c.Request.Context(), httpResp, true)
common.SysError(fmt.Sprintf(
"channel test bad response: channel_id=%d name=%s type=%d model=%s endpoint_type=%s status=%d err=%v",
channel.Id,
channel.Name,
channel.Type,
testModel,
endpointType,
httpResp.StatusCode,
err,
))
return testResult{ return testResult{
context: c, context: c,
localErr: err, localErr: err,
...@@ -389,7 +404,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string) ...@@ -389,7 +404,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string)
} }
} }
func buildTestRequest(model string, endpointType string) dto.Request { func buildTestRequest(model string, endpointType string, channel *model.Channel) dto.Request {
// 根据端点类型构建不同的测试请求 // 根据端点类型构建不同的测试请求
if endpointType != "" { if endpointType != "" {
switch constant.EndpointType(endpointType) { switch constant.EndpointType(endpointType) {
...@@ -423,7 +438,7 @@ func buildTestRequest(model string, endpointType string) dto.Request { ...@@ -423,7 +438,7 @@ func buildTestRequest(model string, endpointType string) dto.Request {
} }
case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI: case constant.EndpointTypeAnthropic, constant.EndpointTypeGemini, constant.EndpointTypeOpenAI:
// 返回 GeneralOpenAIRequest // 返回 GeneralOpenAIRequest
maxTokens := uint(10) maxTokens := uint(16)
if constant.EndpointType(endpointType) == constant.EndpointTypeGemini { if constant.EndpointType(endpointType) == constant.EndpointTypeGemini {
maxTokens = 3000 maxTokens = 3000
} }
...@@ -453,6 +468,14 @@ func buildTestRequest(model string, endpointType string) dto.Request { ...@@ -453,6 +468,14 @@ func buildTestRequest(model string, endpointType string) dto.Request {
} }
} }
// Responses-only models (e.g. codex series)
if strings.Contains(strings.ToLower(model), "codex") {
return &dto.OpenAIResponsesRequest{
Model: model,
Input: json.RawMessage("\"hi\""),
}
}
// Chat/Completion 请求 - 返回 GeneralOpenAIRequest // Chat/Completion 请求 - 返回 GeneralOpenAIRequest
testRequest := &dto.GeneralOpenAIRequest{ testRequest := &dto.GeneralOpenAIRequest{
Model: model, Model: model,
...@@ -466,7 +489,7 @@ func buildTestRequest(model string, endpointType string) dto.Request { ...@@ -466,7 +489,7 @@ func buildTestRequest(model string, endpointType string) dto.Request {
} }
if strings.HasPrefix(model, "o") { if strings.HasPrefix(model, "o") {
testRequest.MaxCompletionTokens = 10 testRequest.MaxCompletionTokens = 16
} else if strings.Contains(model, "thinking") { } else if strings.Contains(model, "thinking") {
if !strings.Contains(model, "claude") { if !strings.Contains(model, "claude") {
testRequest.MaxTokens = 50 testRequest.MaxTokens = 50
...@@ -474,7 +497,7 @@ func buildTestRequest(model string, endpointType string) dto.Request { ...@@ -474,7 +497,7 @@ func buildTestRequest(model string, endpointType string) dto.Request {
} else if strings.Contains(model, "gemini") { } else if strings.Contains(model, "gemini") {
testRequest.MaxTokens = 3000 testRequest.MaxTokens = 3000
} else { } else {
testRequest.MaxTokens = 10 testRequest.MaxTokens = 16
} }
return testRequest return testRequest
......
...@@ -11,16 +11,18 @@ import ( ...@@ -11,16 +11,18 @@ import (
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/relay/channel/ollama"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type OpenAIModel struct { type OpenAIModel struct {
ID string `json:"id"` ID string `json:"id"`
Object string `json:"object"` Object string `json:"object"`
Created int64 `json:"created"` Created int64 `json:"created"`
OwnedBy string `json:"owned_by"` OwnedBy string `json:"owned_by"`
Metadata map[string]any `json:"metadata,omitempty"`
Permission []struct { Permission []struct {
ID string `json:"id"` ID string `json:"id"`
Object string `json:"object"` Object string `json:"object"`
...@@ -207,6 +209,57 @@ func FetchUpstreamModels(c *gin.Context) { ...@@ -207,6 +209,57 @@ func FetchUpstreamModels(c *gin.Context) {
baseURL = channel.GetBaseURL() baseURL = channel.GetBaseURL()
} }
// 对于 Ollama 渠道,使用特殊处理
if channel.Type == constant.ChannelTypeOllama {
key := strings.Split(channel.Key, "\n")[0]
models, err := ollama.FetchOllamaModels(baseURL, key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()),
})
return
}
result := OpenAIModelsResponse{
Data: make([]OpenAIModel, 0, len(models)),
}
for _, modelInfo := range models {
metadata := map[string]any{}
if modelInfo.Size > 0 {
metadata["size"] = modelInfo.Size
}
if modelInfo.Digest != "" {
metadata["digest"] = modelInfo.Digest
}
if modelInfo.ModifiedAt != "" {
metadata["modified_at"] = modelInfo.ModifiedAt
}
details := modelInfo.Details
if details.ParentModel != "" || details.Format != "" || details.Family != "" || len(details.Families) > 0 || details.ParameterSize != "" || details.QuantizationLevel != "" {
metadata["details"] = modelInfo.Details
}
if len(metadata) == 0 {
metadata = nil
}
result.Data = append(result.Data, OpenAIModel{
ID: modelInfo.Name,
Object: "model",
Created: 0,
OwnedBy: "ollama",
Metadata: metadata,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result.Data,
})
return
}
var url string var url string
switch channel.Type { switch channel.Type {
case constant.ChannelTypeGemini: case constant.ChannelTypeGemini:
...@@ -975,6 +1028,32 @@ func FetchModels(c *gin.Context) { ...@@ -975,6 +1028,32 @@ func FetchModels(c *gin.Context) {
baseURL = constant.ChannelBaseURLs[req.Type] baseURL = constant.ChannelBaseURLs[req.Type]
} }
// remove line breaks and extra spaces.
key := strings.TrimSpace(req.Key)
key = strings.Split(key, "\n")[0]
if req.Type == constant.ChannelTypeOllama {
models, err := ollama.FetchOllamaModels(baseURL, key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()),
})
return
}
names := make([]string, 0, len(models))
for _, modelInfo := range models {
names = append(names, modelInfo.Name)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": names,
})
return
}
client := &http.Client{} client := &http.Client{}
url := fmt.Sprintf("%s/v1/models", baseURL) url := fmt.Sprintf("%s/v1/models", baseURL)
...@@ -987,10 +1066,6 @@ func FetchModels(c *gin.Context) { ...@@ -987,10 +1066,6 @@ func FetchModels(c *gin.Context) {
return 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) request.Header.Set("Authorization", "Bearer "+key)
response, err := client.Do(request) response, err := client.Do(request)
...@@ -1640,3 +1715,262 @@ func ManageMultiKeys(c *gin.Context) { ...@@ -1640,3 +1715,262 @@ func ManageMultiKeys(c *gin.Context) {
return return
} }
} }
// OllamaPullModel 拉取 Ollama 模型
func OllamaPullModel(c *gin.Context) {
var req struct {
ChannelID int `json:"channel_id"`
ModelName string `json:"model_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid request parameters",
})
return
}
if req.ChannelID == 0 || req.ModelName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Channel ID and model name are required",
})
return
}
// 获取渠道信息
channel, err := model.GetChannelById(req.ChannelID, true)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "Channel not found",
})
return
}
// 检查是否是 Ollama 渠道
if channel.Type != constant.ChannelTypeOllama {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "This operation is only supported for Ollama channels",
})
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
key := strings.Split(channel.Key, "\n")[0]
err = ollama.PullOllamaModel(baseURL, key, req.ModelName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": fmt.Sprintf("Failed to pull model: %s", err.Error()),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("Model %s pulled successfully", req.ModelName),
})
}
// OllamaPullModelStream 流式拉取 Ollama 模型
func OllamaPullModelStream(c *gin.Context) {
var req struct {
ChannelID int `json:"channel_id"`
ModelName string `json:"model_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid request parameters",
})
return
}
if req.ChannelID == 0 || req.ModelName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Channel ID and model name are required",
})
return
}
// 获取渠道信息
channel, err := model.GetChannelById(req.ChannelID, true)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "Channel not found",
})
return
}
// 检查是否是 Ollama 渠道
if channel.Type != constant.ChannelTypeOllama {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "This operation is only supported for Ollama channels",
})
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
// 设置 SSE 头部
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
key := strings.Split(channel.Key, "\n")[0]
// 创建进度回调函数
progressCallback := func(progress ollama.OllamaPullResponse) {
data, _ := json.Marshal(progress)
fmt.Fprintf(c.Writer, "data: %s\n\n", string(data))
c.Writer.Flush()
}
// 执行拉取
err = ollama.PullOllamaModelStream(baseURL, key, req.ModelName, progressCallback)
if err != nil {
errorData, _ := json.Marshal(gin.H{
"error": err.Error(),
})
fmt.Fprintf(c.Writer, "data: %s\n\n", string(errorData))
} else {
successData, _ := json.Marshal(gin.H{
"message": fmt.Sprintf("Model %s pulled successfully", req.ModelName),
})
fmt.Fprintf(c.Writer, "data: %s\n\n", string(successData))
}
// 发送结束标志
fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
c.Writer.Flush()
}
// OllamaDeleteModel 删除 Ollama 模型
func OllamaDeleteModel(c *gin.Context) {
var req struct {
ChannelID int `json:"channel_id"`
ModelName string `json:"model_name"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid request parameters",
})
return
}
if req.ChannelID == 0 || req.ModelName == "" {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Channel ID and model name are required",
})
return
}
// 获取渠道信息
channel, err := model.GetChannelById(req.ChannelID, true)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "Channel not found",
})
return
}
// 检查是否是 Ollama 渠道
if channel.Type != constant.ChannelTypeOllama {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "This operation is only supported for Ollama channels",
})
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
key := strings.Split(channel.Key, "\n")[0]
err = ollama.DeleteOllamaModel(baseURL, key, req.ModelName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"message": fmt.Sprintf("Failed to delete model: %s", err.Error()),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": fmt.Sprintf("Model %s deleted successfully", req.ModelName),
})
}
// OllamaVersion 获取 Ollama 服务版本信息
func OllamaVersion(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "Invalid channel id",
})
return
}
channel, err := model.GetChannelById(id, true)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"message": "Channel not found",
})
return
}
if channel.Type != constant.ChannelTypeOllama {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"message": "This operation is only supported for Ollama channels",
})
return
}
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
}
key := strings.Split(channel.Key, "\n")[0]
version, err := ollama.FetchOllamaVersion(baseURL, key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("获取Ollama版本失败: %s", err.Error()),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"version": version,
},
})
}
...@@ -114,7 +114,7 @@ func DiscordOAuth(c *gin.Context) { ...@@ -114,7 +114,7 @@ func DiscordOAuth(c *gin.Context) {
DiscordBind(c) DiscordBind(c)
return return
} }
if !system_setting.GetDiscordSettings().Enabled { if !system_setting.GetDiscordSettings().Enabled {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"message": "管理员未开启通过 Discord 登录以及注册", "message": "管理员未开启通过 Discord 登录以及注册",
......
...@@ -18,6 +18,7 @@ import ( ...@@ -18,6 +18,7 @@ import (
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/samber/lo" "github.com/samber/lo"
) )
...@@ -275,7 +276,7 @@ func RetrieveModel(c *gin.Context, modelType int) { ...@@ -275,7 +276,7 @@ func RetrieveModel(c *gin.Context, modelType int) {
c.JSON(200, aiModel) c.JSON(200, aiModel)
} }
} else { } else {
openAIError := dto.OpenAIError{ openAIError := types.OpenAIError{
Message: fmt.Sprintf("The model '%s' does not exist", modelId), Message: fmt.Sprintf("The model '%s' does not exist", modelId),
Type: "invalid_request_error", Type: "invalid_request_error",
Param: "model", Param: "model",
......
...@@ -249,7 +249,9 @@ func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, v ...@@ -249,7 +249,9 @@ func ensureVendorID(vendorName string, vendorByName map[string]upstreamVendor, v
return 0 return 0
} }
// SyncUpstreamModels 同步上游模型与供应商,仅对「未配置模型」生效 // SyncUpstreamModels 同步上游模型与供应商:
// - 默认仅创建「未配置模型」
// - 可通过 overwrite 选择性覆盖更新本地已有模型的字段(前提:sync_official <> 0)
func SyncUpstreamModels(c *gin.Context) { func SyncUpstreamModels(c *gin.Context) {
var req syncRequest var req syncRequest
// 允许空体 // 允许空体
...@@ -260,12 +262,26 @@ func SyncUpstreamModels(c *gin.Context) { ...@@ -260,12 +262,26 @@ func SyncUpstreamModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
return return
} }
if len(missing) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{ // 若既无缺失模型需要创建,也未指定覆盖更新字段,则无需请求上游数据,直接返回
"created_models": 0, if len(missing) == 0 && len(req.Overwrite) == 0 {
"created_vendors": 0, modelsURL, vendorsURL := getUpstreamURLs(req.Locale)
"skipped_models": []string{}, c.JSON(http.StatusOK, gin.H{
}}) "success": true,
"data": gin.H{
"created_models": 0,
"created_vendors": 0,
"updated_models": 0,
"skipped_models": []string{},
"created_list": []string{},
"updated_list": []string{},
"source": gin.H{
"locale": req.Locale,
"models_url": modelsURL,
"vendors_url": vendorsURL,
},
},
})
return return
} }
...@@ -315,9 +331,9 @@ func SyncUpstreamModels(c *gin.Context) { ...@@ -315,9 +331,9 @@ func SyncUpstreamModels(c *gin.Context) {
createdModels := 0 createdModels := 0
createdVendors := 0 createdVendors := 0
updatedModels := 0 updatedModels := 0
var skipped []string skipped := make([]string, 0)
var createdList []string createdList := make([]string, 0)
var updatedList []string updatedList := make([]string, 0)
// 本地缓存:vendorName -> id // 本地缓存:vendorName -> id
vendorIDCache := make(map[string]int) vendorIDCache := make(map[string]int)
......
...@@ -3,12 +3,10 @@ package controller ...@@ -3,12 +3,10 @@ package controller
import ( import (
"errors" "errors"
"fmt" "fmt"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -31,8 +29,11 @@ func Playground(c *gin.Context) { ...@@ -31,8 +29,11 @@ func Playground(c *gin.Context) {
return return
} }
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatOpenAI, nil, nil)
modelName := c.GetString("original_model") if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
return
}
userId := c.GetInt("id") userId := c.GetInt("id")
...@@ -46,16 +47,10 @@ func Playground(c *gin.Context) { ...@@ -46,16 +47,10 @@ func Playground(c *gin.Context) {
tempToken := &model.Token{ tempToken := &model.Token{
UserId: userId, UserId: userId,
Name: fmt.Sprintf("playground-%s", group), Name: fmt.Sprintf("playground-%s", relayInfo.UsingGroup),
Group: group, Group: relayInfo.UsingGroup,
} }
_ = middleware.SetupContextForToken(c, tempToken) _ = 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) Relay(c, types.RelayFormatOpenAI)
} }
...@@ -2,6 +2,7 @@ package controller ...@@ -2,6 +2,7 @@ package controller
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
...@@ -64,8 +65,8 @@ func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewA ...@@ -64,8 +65,8 @@ func geminiRelayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewA
func Relay(c *gin.Context, relayFormat types.RelayFormat) { func Relay(c *gin.Context, relayFormat types.RelayFormat) {
requestId := c.GetString(common.RequestIdKey) requestId := c.GetString(common.RequestIdKey)
group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) //group := common.GetContextKeyString(c, constant.ContextKeyUsingGroup)
originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel) //originalModel := common.GetContextKeyString(c, constant.ContextKeyOriginalModel)
var ( var (
newAPIError *types.NewAPIError newAPIError *types.NewAPIError
...@@ -104,7 +105,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { ...@@ -104,7 +105,12 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
request, err := helper.GetAndValidateRequest(c, relayFormat) request, err := helper.GetAndValidateRequest(c, relayFormat)
if err != nil { if err != nil {
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest) // Map "request body too large" to 413 so clients can handle it correctly
if common.IsRequestBodyTooLargeError(err) || errors.Is(err, common.ErrRequestBodyTooLarge) {
newAPIError = types.NewErrorWithStatusCode(err, types.ErrorCodeReadRequestBodyFailed, http.StatusRequestEntityTooLarge, types.ErrOptionWithSkipRetry())
} else {
newAPIError = types.NewError(err, types.ErrorCodeInvalidRequest)
}
return return
} }
...@@ -114,9 +120,17 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { ...@@ -114,9 +120,17 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
return return
} }
meta := request.GetTokenCountMeta() needSensitiveCheck := setting.ShouldCheckPromptSensitive()
needCountToken := constant.CountToken
// Avoid building huge CombineText (strings.Join) when token counting and sensitive check are both disabled.
var meta *types.TokenCountMeta
if needSensitiveCheck || needCountToken {
meta = request.GetTokenCountMeta()
} else {
meta = fastTokenCountMetaForPricing(request)
}
if setting.ShouldCheckPromptSensitive() { if needSensitiveCheck && meta != nil {
contains, words := service.CheckSensitiveText(meta.CombineText) contains, words := service.CheckSensitiveText(meta.CombineText)
if contains { if contains {
logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", "))) logger.LogWarn(c, fmt.Sprintf("user sensitive words detected: %s", strings.Join(words, ", ")))
...@@ -157,16 +171,32 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { ...@@ -157,16 +171,32 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
} }
}() }()
for i := 0; i <= common.RetryTimes; i++ { retryParam := &service.RetryParam{
channel, err := getChannel(c, group, originalModel, i) Ctx: c,
if err != nil { TokenGroup: relayInfo.TokenGroup,
logger.LogError(c, err.Error()) ModelName: relayInfo.OriginModelName,
newAPIError = err Retry: common.GetPointer(0),
}
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
channel, channelErr := getChannel(c, relayInfo, retryParam)
if channelErr != nil {
logger.LogError(c, channelErr.Error())
newAPIError = channelErr
break break
} }
addUsedChannel(c, channel.Id) addUsedChannel(c, channel.Id)
requestBody, _ := common.GetRequestBody(c) requestBody, bodyErr := common.GetRequestBody(c)
if bodyErr != nil {
// Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path)
if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) {
newAPIError = types.NewErrorWithStatusCode(bodyErr, types.ErrorCodeReadRequestBodyFailed, http.StatusRequestEntityTooLarge, types.ErrOptionWithSkipRetry())
} else {
newAPIError = types.NewErrorWithStatusCode(bodyErr, types.ErrorCodeReadRequestBodyFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
break
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
switch relayFormat { switch relayFormat {
...@@ -186,7 +216,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { ...@@ -186,7 +216,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError) 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) { if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) {
break break
} }
} }
...@@ -211,8 +241,35 @@ func addUsedChannel(c *gin.Context, channelId int) { ...@@ -211,8 +241,35 @@ func addUsedChannel(c *gin.Context, channelId int) {
c.Set("use_channel", useChannel) c.Set("use_channel", useChannel)
} }
func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*model.Channel, *types.NewAPIError) { func fastTokenCountMetaForPricing(request dto.Request) *types.TokenCountMeta {
if retryCount == 0 { if request == nil {
return &types.TokenCountMeta{}
}
meta := &types.TokenCountMeta{
TokenType: types.TokenTypeTokenizer,
}
switch r := request.(type) {
case *dto.GeneralOpenAIRequest:
if r.MaxCompletionTokens > r.MaxTokens {
meta.MaxTokens = int(r.MaxCompletionTokens)
} else {
meta.MaxTokens = int(r.MaxTokens)
}
case *dto.OpenAIResponsesRequest:
meta.MaxTokens = int(r.MaxOutputTokens)
case *dto.ClaudeRequest:
meta.MaxTokens = int(r.MaxTokens)
case *dto.ImageRequest:
// Pricing for image requests depends on ImagePriceRatio; safe to compute even when CountToken is disabled.
return r.GetTokenCountMeta()
default:
// Best-effort: leave CombineText empty to avoid large allocations.
}
return meta
}
func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service.RetryParam) (*model.Channel, *types.NewAPIError) {
if info.ChannelMeta == nil {
autoBan := c.GetBool("auto_ban") autoBan := c.GetBool("auto_ban")
autoBanInt := 1 autoBanInt := 1
if !autoBan { if !autoBan {
...@@ -225,14 +282,18 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m ...@@ -225,14 +282,18 @@ func getChannel(c *gin.Context, group, originalModel string, retryCount int) (*m
AutoBan: &autoBanInt, AutoBan: &autoBanInt,
}, nil }, nil
} }
channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(c, group, originalModel, retryCount) channel, selectGroup, err := service.CacheGetRandomSatisfiedChannel(retryParam)
info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info)
if err != nil { if err != nil {
return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, originalModel, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
} }
if channel == nil { if channel == nil {
return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, originalModel), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) return nil, types.NewError(fmt.Errorf("分组 %s 下模型 %s 的可用渠道不存在(retry)", selectGroup, info.OriginModelName), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
} }
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, originalModel)
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName)
if newAPIError != nil { if newAPIError != nil {
return nil, newAPIError return nil, newAPIError
} }
...@@ -366,7 +427,7 @@ func RelayMidjourney(c *gin.Context) { ...@@ -366,7 +427,7 @@ func RelayMidjourney(c *gin.Context) {
} }
func RelayNotImplemented(c *gin.Context) { func RelayNotImplemented(c *gin.Context) {
err := dto.OpenAIError{ err := types.OpenAIError{
Message: "API not implemented", Message: "API not implemented",
Type: "new_api_error", Type: "new_api_error",
Param: "", Param: "",
...@@ -378,7 +439,7 @@ func RelayNotImplemented(c *gin.Context) { ...@@ -378,7 +439,7 @@ func RelayNotImplemented(c *gin.Context) {
} }
func RelayNotFound(c *gin.Context) { func RelayNotFound(c *gin.Context) {
err := dto.OpenAIError{ err := types.OpenAIError{
Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path), Message: fmt.Sprintf("Invalid URL (%s %s)", c.Request.Method, c.Request.URL.Path),
Type: "invalid_request_error", Type: "invalid_request_error",
Param: "", Param: "",
...@@ -392,8 +453,6 @@ func RelayNotFound(c *gin.Context) { ...@@ -392,8 +453,6 @@ func RelayNotFound(c *gin.Context) {
func RelayTask(c *gin.Context) { func RelayTask(c *gin.Context) {
retryTimes := common.RetryTimes retryTimes := common.RetryTimes
channelId := c.GetInt("channel_id") channelId := c.GetInt("channel_id")
group := c.GetString("group")
originalModel := c.GetString("original_model")
c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)}) c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)})
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil) relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil { if err != nil {
...@@ -403,8 +462,14 @@ func RelayTask(c *gin.Context) { ...@@ -403,8 +462,14 @@ func RelayTask(c *gin.Context) {
if taskErr == nil { if taskErr == nil {
retryTimes = 0 retryTimes = 0
} }
for i := 0; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && i < retryTimes; i++ { retryParam := &service.RetryParam{
channel, newAPIError := getChannel(c, group, originalModel, i) Ctx: c,
TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName,
Retry: common.GetPointer(0),
}
for ; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && retryParam.GetRetry() < retryTimes; retryParam.IncreaseRetry() {
channel, newAPIError := getChannel(c, relayInfo, retryParam)
if newAPIError != nil { if newAPIError != nil {
logger.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error())) logger.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError) taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError)
...@@ -414,10 +479,18 @@ func RelayTask(c *gin.Context) { ...@@ -414,10 +479,18 @@ func RelayTask(c *gin.Context) {
useChannel := c.GetStringSlice("use_channel") useChannel := c.GetStringSlice("use_channel")
useChannel = append(useChannel, fmt.Sprintf("%d", channelId)) useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
c.Set("use_channel", useChannel) c.Set("use_channel", useChannel)
logger.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, i)) logger.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, retryParam.GetRetry()))
//middleware.SetupContextForSelectedChannel(c, channel, originalModel) //middleware.SetupContextForSelectedChannel(c, channel, originalModel)
requestBody, _ := common.GetRequestBody(c) requestBody, err := common.GetRequestBody(c)
if err != nil {
if common.IsRequestBodyTooLargeError(err) || errors.Is(err, common.ErrRequestBodyTooLarge) {
taskErr = service.TaskErrorWrapperLocal(err, "read_request_body_failed", http.StatusRequestEntityTooLarge)
} else {
taskErr = service.TaskErrorWrapperLocal(err, "read_request_body_failed", http.StatusBadRequest)
}
break
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
taskErr = taskRelayHandler(c, relayInfo) taskErr = taskRelayHandler(c, relayInfo)
} }
......
...@@ -88,7 +88,7 @@ func UpdateSunoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM ...@@ -88,7 +88,7 @@ func UpdateSunoTaskAll(ctx context.Context, taskChannelM map[int][]string, taskM
for channelId, taskIds := range taskChannelM { for channelId, taskIds := range taskChannelM {
err := updateSunoTaskAll(ctx, channelId, taskIds, taskM) err := updateSunoTaskAll(ctx, channelId, taskIds, taskM)
if err != nil { if err != nil {
logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %d", channelId, err.Error())) logger.LogError(ctx, fmt.Sprintf("渠道 #%d 更新异步任务失败: %s", channelId, err.Error()))
} }
} }
return nil return nil
...@@ -141,7 +141,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas ...@@ -141,7 +141,7 @@ func updateSunoTaskAll(ctx context.Context, channelId int, taskIds []string, tas
return err return err
} }
if !responseItems.IsSuccess() { if !responseItems.IsSuccess() {
common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %d", channelId, len(taskIds), string(responseBody))) common.SysLog(fmt.Sprintf("渠道 #%d 未完成的任务有: %d, 成功获取到任务数: %s", channelId, len(taskIds), string(responseBody)))
return err return err
} }
......
package controller package controller
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
...@@ -149,6 +150,24 @@ func AddToken(c *gin.Context) { ...@@ -149,6 +150,24 @@ func AddToken(c *gin.Context) {
}) })
return return
} }
// 非无限额度时,检查额度值是否超出有效范围
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "额度值不能为负数",
})
return
}
maxQuotaValue := int((1000000000 * common.QuotaPerUnit))
if token.RemainQuota > maxQuotaValue {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue),
})
return
}
}
key, err := common.GenerateKey() key, err := common.GenerateKey()
if err != nil { if err != nil {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
...@@ -171,6 +190,7 @@ func AddToken(c *gin.Context) { ...@@ -171,6 +190,7 @@ func AddToken(c *gin.Context) {
ModelLimits: token.ModelLimits, ModelLimits: token.ModelLimits,
AllowIps: token.AllowIps, AllowIps: token.AllowIps,
Group: token.Group, Group: token.Group,
CrossGroupRetry: token.CrossGroupRetry,
} }
err = cleanToken.Insert() err = cleanToken.Insert()
if err != nil { if err != nil {
...@@ -215,6 +235,23 @@ func UpdateToken(c *gin.Context) { ...@@ -215,6 +235,23 @@ func UpdateToken(c *gin.Context) {
}) })
return return
} }
if !token.UnlimitedQuota {
if token.RemainQuota < 0 {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": "额度值不能为负数",
})
return
}
maxQuotaValue := int((1000000000 * common.QuotaPerUnit))
if token.RemainQuota > maxQuotaValue {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": fmt.Sprintf("额度值超出有效范围,最大值为 %d", maxQuotaValue),
})
return
}
}
cleanToken, err := model.GetTokenByIds(token.Id, userId) cleanToken, err := model.GetTokenByIds(token.Id, userId)
if err != nil { if err != nil {
common.ApiError(c, err) common.ApiError(c, err)
...@@ -248,6 +285,7 @@ func UpdateToken(c *gin.Context) { ...@@ -248,6 +285,7 @@ func UpdateToken(c *gin.Context) {
cleanToken.ModelLimits = token.ModelLimits cleanToken.ModelLimits = token.ModelLimits
cleanToken.AllowIps = token.AllowIps cleanToken.AllowIps = token.AllowIps
cleanToken.Group = token.Group cleanToken.Group = token.Group
cleanToken.CrossGroupRetry = token.CrossGroupRetry
} }
err = cleanToken.Update() err = cleanToken.Update()
if err != nil { if err != nil {
...@@ -259,7 +297,6 @@ func UpdateToken(c *gin.Context) { ...@@ -259,7 +297,6 @@ func UpdateToken(c *gin.Context) {
"message": "", "message": "",
"data": cleanToken, "data": cleanToken,
}) })
return
} }
type TokenBatch struct { type TokenBatch struct {
......
...@@ -7,12 +7,12 @@ import ( ...@@ -7,12 +7,12 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log"
"net/http"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting"
"io"
"log"
"net/http"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
......
...@@ -110,18 +110,17 @@ func setupLogin(user *model.User, c *gin.Context) { ...@@ -110,18 +110,17 @@ func setupLogin(user *model.User, c *gin.Context) {
}) })
return 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{ c.JSON(http.StatusOK, gin.H{
"message": "", "message": "",
"success": true, "success": true,
"data": cleanUser, "data": map[string]any{
"id": user.Id,
"username": user.Username,
"display_name": user.DisplayName,
"role": user.Role,
"status": user.Status,
"group": user.Group,
},
}) })
} }
...@@ -764,7 +763,10 @@ func checkUpdatePassword(originalPassword string, newPassword string, userId int ...@@ -764,7 +763,10 @@ func checkUpdatePassword(originalPassword string, newPassword string, userId int
if err != nil { if err != nil {
return return
} }
if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) {
// 密码不为空,需要验证原密码
// 支持第一次账号绑定时原密码为空的情况
if !common.ValidatePasswordAndHash(originalPassword, currentUser.Password) && currentUser.Password != "" {
err = fmt.Errorf("原密码错误") err = fmt.Errorf("原密码错误")
return return
} }
......
Request URL
https://api.io.solutions/v1/io-cloud/clusters/654fc0a9-0d4a-4db4-9b95-3f56189348a2/update-name
Request Method
PUT
{"status":"succeeded","message":"Cluster name updated successfully"}
...@@ -2,6 +2,7 @@ package dto ...@@ -2,6 +2,7 @@ package dto
import ( import (
"encoding/json" "encoding/json"
"strings"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
...@@ -24,11 +25,14 @@ func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta { ...@@ -24,11 +25,14 @@ func (r *AudioRequest) GetTokenCountMeta() *types.TokenCountMeta {
CombineText: r.Input, CombineText: r.Input,
TokenType: types.TokenTypeTextNumber, TokenType: types.TokenTypeTextNumber,
} }
if strings.Contains(r.Model, "gpt") {
meta.TokenType = types.TokenTypeTokenizer
}
return meta return meta
} }
func (r *AudioRequest) IsStream(c *gin.Context) bool { func (r *AudioRequest) IsStream(c *gin.Context) bool {
return false return r.StreamFormat == "sse"
} }
func (r *AudioRequest) SetModelName(modelName string) { func (r *AudioRequest) SetModelName(modelName string) {
......
package dto package dto
import "github.com/QuantumNous/new-api/types" import (
"encoding/json"
type OpenAIError struct { "github.com/QuantumNous/new-api/common"
Message string `json:"message"` "github.com/QuantumNous/new-api/types"
Type string `json:"type"` )
Param string `json:"param"`
Code any `json:"code"` //type OpenAIError struct {
} // Message string `json:"message"`
// Type string `json:"type"`
// Param string `json:"param"`
// Code any `json:"code"`
//}
type OpenAIErrorWithStatusCode struct { type OpenAIErrorWithStatusCode struct {
Error OpenAIError `json:"error"` Error types.OpenAIError `json:"error"`
StatusCode int `json:"status_code"` StatusCode int `json:"status_code"`
LocalError bool LocalError bool
} }
type GeneralErrorResponse struct { type GeneralErrorResponse struct {
Error types.OpenAIError `json:"error"` Error json.RawMessage `json:"error"`
Message string `json:"message"` Message string `json:"message"`
Msg string `json:"msg"` Msg string `json:"msg"`
Err string `json:"err"` Err string `json:"err"`
ErrorMsg string `json:"error_msg"` ErrorMsg string `json:"error_msg"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Header struct { Header struct {
Message string `json:"message"` Message string `json:"message"`
} `json:"header"` } `json:"header"`
...@@ -31,9 +37,35 @@ type GeneralErrorResponse struct { ...@@ -31,9 +37,35 @@ type GeneralErrorResponse struct {
} `json:"response"` } `json:"response"`
} }
func (e GeneralErrorResponse) TryToOpenAIError() *types.OpenAIError {
var openAIError types.OpenAIError
if len(e.Error) > 0 {
err := common.Unmarshal(e.Error, &openAIError)
if err == nil && openAIError.Message != "" {
return &openAIError
}
}
return nil
}
func (e GeneralErrorResponse) ToMessage() string { func (e GeneralErrorResponse) ToMessage() string {
if e.Error.Message != "" { if len(e.Error) > 0 {
return e.Error.Message switch common.GetJsonType(e.Error) {
case "object":
var openAIError types.OpenAIError
err := common.Unmarshal(e.Error, &openAIError)
if err == nil && openAIError.Message != "" {
return openAIError.Message
}
case "string":
var msg string
err := common.Unmarshal(e.Error, &msg)
if err == nil && msg != "" {
return msg
}
default:
return string(e.Error)
}
} }
if e.Message != "" { if e.Message != "" {
return e.Message return e.Message
......
...@@ -22,6 +22,27 @@ type GeminiChatRequest struct { ...@@ -22,6 +22,27 @@ type GeminiChatRequest struct {
CachedContent string `json:"cachedContent,omitempty"` CachedContent string `json:"cachedContent,omitempty"`
} }
// UnmarshalJSON allows GeminiChatRequest to accept both snake_case and camelCase fields.
func (r *GeminiChatRequest) UnmarshalJSON(data []byte) error {
type Alias GeminiChatRequest
var aux struct {
Alias
SystemInstructionSnake *GeminiChatContent `json:"system_instruction,omitempty"`
}
if err := common.Unmarshal(data, &aux); err != nil {
return err
}
*r = GeminiChatRequest(aux.Alias)
if aux.SystemInstructionSnake != nil {
r.SystemInstructions = aux.SystemInstructionSnake
}
return nil
}
type ToolConfig struct { type ToolConfig struct {
FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"` FunctionCallingConfig *FunctionCallingConfig `json:"functionCallingConfig,omitempty"`
RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"` RetrievalConfig *RetrievalConfig `json:"retrievalConfig,omitempty"`
......
...@@ -167,9 +167,9 @@ func (i *ImageRequest) SetModelName(modelName string) { ...@@ -167,9 +167,9 @@ func (i *ImageRequest) SetModelName(modelName string) {
} }
type ImageResponse struct { type ImageResponse struct {
Data []ImageData `json:"data"` Data []ImageData `json:"data"`
Created int64 `json:"created"` Created int64 `json:"created"`
Extra any `json:"extra,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"`
} }
type ImageData struct { type ImageData struct {
Url string `json:"url"` Url string `json:"url"`
......
...@@ -23,6 +23,8 @@ type FormatJsonSchema struct { ...@@ -23,6 +23,8 @@ type FormatJsonSchema struct {
Strict json.RawMessage `json:"strict,omitempty"` Strict json.RawMessage `json:"strict,omitempty"`
} }
// GeneralOpenAIRequest represents a general request structure for OpenAI-compatible APIs.
// 参数增加规范:无引用的参数必须使用json.RawMessage类型,并添加omitempty标签
type GeneralOpenAIRequest struct { type GeneralOpenAIRequest struct {
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
Messages []Message `json:"messages,omitempty"` Messages []Message `json:"messages,omitempty"`
...@@ -82,8 +84,9 @@ type GeneralOpenAIRequest struct { ...@@ -82,8 +84,9 @@ type GeneralOpenAIRequest struct {
Reasoning json.RawMessage `json:"reasoning,omitempty"` Reasoning json.RawMessage `json:"reasoning,omitempty"`
// Ali Qwen Params // Ali Qwen Params
VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"` VlHighResolutionImages json.RawMessage `json:"vl_high_resolution_images,omitempty"`
EnableThinking any `json:"enable_thinking,omitempty"` EnableThinking json.RawMessage `json:"enable_thinking,omitempty"`
ChatTemplateKwargs json.RawMessage `json:"chat_template_kwargs,omitempty"` ChatTemplateKwargs json.RawMessage `json:"chat_template_kwargs,omitempty"`
EnableSearch json.RawMessage `json:"enable_search,omitempty"`
// ollama Params // ollama Params
Think json.RawMessage `json:"think,omitempty"` Think json.RawMessage `json:"think,omitempty"`
// baidu v2 // baidu v2
......
...@@ -27,6 +27,7 @@ require ( ...@@ -27,6 +27,7 @@ require (
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0 github.com/gorilla/websocket v1.5.0
github.com/grafana/pyroscope-go v1.2.7
github.com/jfreymuth/oggvorbis v1.0.5 github.com/jfreymuth/oggvorbis v1.0.5
github.com/jinzhu/copier v0.4.0 github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
...@@ -36,6 +37,7 @@ require ( ...@@ -36,6 +37,7 @@ require (
github.com/samber/lo v1.52.0 github.com/samber/lo v1.52.0
github.com/shirou/gopsutil v3.21.11+incompatible github.com/shirou/gopsutil v3.21.11+incompatible
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v81 v81.4.0 github.com/stripe/stripe-go/v81 v81.4.0
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
github.com/thanhpk/randstr v1.0.6 github.com/thanhpk/randstr v1.0.6
...@@ -62,6 +64,7 @@ require ( ...@@ -62,6 +64,7 @@ require (
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
...@@ -77,11 +80,11 @@ require ( ...@@ -77,11 +80,11 @@ require (
github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-webauthn/x v0.1.25 // indirect github.com/go-webauthn/x v0.1.25 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect github.com/google/go-tpm v0.9.5 // indirect
github.com/gorilla/context v1.1.1 // indirect github.com/gorilla/context v1.1.1 // indirect
github.com/gorilla/securecookie v1.1.1 // indirect github.com/gorilla/securecookie v1.1.1 // indirect
github.com/gorilla/sessions v1.2.1 // indirect github.com/gorilla/sessions v1.2.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/icza/bitio v1.1.0 // indirect github.com/icza/bitio v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
...@@ -91,6 +94,7 @@ require ( ...@@ -91,6 +94,7 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
...@@ -101,7 +105,9 @@ require ( ...@@ -101,7 +105,9 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect
......
...@@ -124,6 +124,11 @@ func main() { ...@@ -124,6 +124,11 @@ func main() {
common.SysLog("pprof enabled") common.SysLog("pprof enabled")
} }
err = common.StartPyroScope()
if err != nil {
common.SysError(fmt.Sprintf("start pyroscope error : %v", err))
}
// Initialize HTTP server // Initialize HTTP server
server := gin.New() server := gin.New()
server.Use(gin.CustomRecovery(func(c *gin.Context, err any) { server.Use(gin.CustomRecovery(func(c *gin.Context, err any) {
...@@ -183,6 +188,7 @@ func InjectUmamiAnalytics() { ...@@ -183,6 +188,7 @@ func InjectUmamiAnalytics() {
analyticsInjectBuilder.WriteString(umamiSiteID) analyticsInjectBuilder.WriteString(umamiSiteID)
analyticsInjectBuilder.WriteString("\"></script>") analyticsInjectBuilder.WriteString("\"></script>")
} }
analyticsInjectBuilder.WriteString("<!--Umami QuantumNous-->\n")
analyticsInject := analyticsInjectBuilder.String() analyticsInject := analyticsInjectBuilder.String()
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--umami-->\n"), []byte(analyticsInject)) indexPage = bytes.ReplaceAll(indexPage, []byte("<!--umami-->\n"), []byte(analyticsInject))
} }
...@@ -204,6 +210,7 @@ func InjectGoogleAnalytics() { ...@@ -204,6 +210,7 @@ func InjectGoogleAnalytics() {
analyticsInjectBuilder.WriteString("');") analyticsInjectBuilder.WriteString("');")
analyticsInjectBuilder.WriteString("</script>") analyticsInjectBuilder.WriteString("</script>")
} }
analyticsInjectBuilder.WriteString("<!--Google Analytics QuantumNous-->\n")
analyticsInject := analyticsInjectBuilder.String() analyticsInject := analyticsInjectBuilder.String()
indexPage = bytes.ReplaceAll(indexPage, []byte("<!--Google Analytics-->\n"), []byte(analyticsInject)) indexPage = bytes.ReplaceAll(indexPage, []byte("<!--Google Analytics-->\n"), []byte(analyticsInject))
} }
......
...@@ -2,12 +2,14 @@ package middleware ...@@ -2,12 +2,14 @@ package middleware
import ( import (
"fmt" "fmt"
"net"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
...@@ -216,10 +218,14 @@ func TokenAuth() func(c *gin.Context) { ...@@ -216,10 +218,14 @@ func TokenAuth() func(c *gin.Context) {
} }
key := c.Request.Header.Get("Authorization") key := c.Request.Header.Get("Authorization")
parts := make([]string, 0) parts := make([]string, 0)
key = strings.TrimPrefix(key, "Bearer ") if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") {
key = strings.TrimSpace(key[7:])
}
if key == "" || key == "midjourney-proxy" { if key == "" || key == "midjourney-proxy" {
key = c.Request.Header.Get("mj-api-secret") key = c.Request.Header.Get("mj-api-secret")
key = strings.TrimPrefix(key, "Bearer ") if strings.HasPrefix(key, "Bearer ") || strings.HasPrefix(key, "bearer ") {
key = strings.TrimSpace(key[7:])
}
key = strings.TrimPrefix(key, "sk-") key = strings.TrimPrefix(key, "sk-")
parts = strings.Split(key, "-") parts = strings.Split(key, "-")
key = parts[0] key = parts[0]
...@@ -240,13 +246,20 @@ func TokenAuth() func(c *gin.Context) { ...@@ -240,13 +246,20 @@ func TokenAuth() func(c *gin.Context) {
return return
} }
allowIpsMap := token.GetIpLimitsMap() allowIps := token.GetIpLimits()
if len(allowIpsMap) != 0 { if len(allowIps) > 0 {
clientIp := c.ClientIP() clientIp := c.ClientIP()
if _, ok := allowIpsMap[clientIp]; !ok { logger.LogDebug(c, "Token has IP restrictions, checking client IP %s", clientIp)
ip := net.ParseIP(clientIp)
if ip == nil {
abortWithOpenAiMessage(c, http.StatusForbidden, "无法解析客户端 IP 地址")
return
}
if common.IsIpInCIDRList(ip, allowIps) == false {
abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中") abortWithOpenAiMessage(c, http.StatusForbidden, "您的 IP 不在令牌允许访问的列表中")
return return
} }
logger.LogDebug(c, "Client IP %s passed the token IP restrictions check", clientIp)
} }
userCache, err := model.GetUserCache(token.UserId) userCache, err := model.GetUserCache(token.UserId)
...@@ -307,7 +320,8 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e ...@@ -307,7 +320,8 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e
} else { } else {
c.Set("token_model_limit_enabled", false) c.Set("token_model_limit_enabled", false)
} }
c.Set("token_group", token.Group) common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group)
common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry)
if len(parts) > 1 { if len(parts) > 1 {
if model.IsAdmin(token.UserId) { if model.IsAdmin(token.UserId) {
c.Set("specific_channel_id", parts[1]) c.Set("specific_channel_id", parts[1])
......
...@@ -97,7 +97,12 @@ func Distribute() func(c *gin.Context) { ...@@ -97,7 +97,12 @@ func Distribute() func(c *gin.Context) {
common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup)
} }
} }
channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(c, usingGroup, modelRequest.Model, 0) channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{
Ctx: c,
ModelName: modelRequest.Model,
TokenGroup: usingGroup,
Retry: common.GetPointer(0),
})
if err != nil { if err != nil {
showGroup := usingGroup showGroup := usingGroup
if usingGroup == "auto" { if usingGroup == "auto" {
...@@ -157,7 +162,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { ...@@ -157,7 +162,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
} }
midjourneyModel, mjErr, success := service.GetMjRequestModel(relayMode, &midjourneyRequest) midjourneyModel, mjErr, success := service.GetMjRequestModel(relayMode, &midjourneyRequest)
if mjErr != nil { if mjErr != nil {
return nil, false, fmt.Errorf(mjErr.Description) return nil, false, fmt.Errorf("%s", mjErr.Description)
} }
if midjourneyModel == "" { if midjourneyModel == "" {
if !success { if !success {
......
...@@ -5,32 +5,69 @@ import ( ...@@ -5,32 +5,69 @@ import (
"io" "io"
"net/http" "net/http"
"github.com/QuantumNous/new-api/constant"
"github.com/andybalholm/brotli" "github.com/andybalholm/brotli"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type readCloser struct {
io.Reader
closeFn func() error
}
func (rc *readCloser) Close() error {
if rc.closeFn != nil {
return rc.closeFn()
}
return nil
}
func DecompressRequestMiddleware() gin.HandlerFunc { func DecompressRequestMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if c.Request.Body == nil || c.Request.Method == http.MethodGet { if c.Request.Body == nil || c.Request.Method == http.MethodGet {
c.Next() c.Next()
return return
} }
maxMB := constant.MaxRequestBodyMB
if maxMB <= 0 {
maxMB = 32
}
maxBytes := int64(maxMB) << 20
origBody := c.Request.Body
wrapMaxBytes := func(body io.ReadCloser) io.ReadCloser {
return http.MaxBytesReader(c.Writer, body, maxBytes)
}
switch c.GetHeader("Content-Encoding") { switch c.GetHeader("Content-Encoding") {
case "gzip": case "gzip":
gzipReader, err := gzip.NewReader(c.Request.Body) gzipReader, err := gzip.NewReader(origBody)
if err != nil { if err != nil {
_ = origBody.Close()
c.AbortWithStatus(http.StatusBadRequest) c.AbortWithStatus(http.StatusBadRequest)
return return
} }
defer gzipReader.Close() // Replace the request body with the decompressed data, and enforce a max size (post-decompression).
c.Request.Body = wrapMaxBytes(&readCloser{
// Replace the request body with the decompressed data Reader: gzipReader,
c.Request.Body = io.NopCloser(gzipReader) closeFn: func() error {
_ = gzipReader.Close()
return origBody.Close()
},
})
c.Request.Header.Del("Content-Encoding") c.Request.Header.Del("Content-Encoding")
case "br": case "br":
reader := brotli.NewReader(c.Request.Body) reader := brotli.NewReader(origBody)
c.Request.Body = io.NopCloser(reader) c.Request.Body = wrapMaxBytes(&readCloser{
Reader: reader,
closeFn: func() error {
return origBody.Close()
},
})
c.Request.Header.Del("Content-Encoding") c.Request.Header.Del("Content-Encoding")
default:
// Even for uncompressed bodies, enforce a max size to avoid huge request allocations.
c.Request.Body = wrapMaxBytes(origBody)
} }
// Continue processing the request // Continue processing the request
......
...@@ -254,6 +254,9 @@ func (channel *Channel) Save() error { ...@@ -254,6 +254,9 @@ func (channel *Channel) Save() error {
} }
func (channel *Channel) SaveWithoutKey() error { func (channel *Channel) SaveWithoutKey() error {
if channel.Id == 0 {
return errors.New("channel ID is 0")
}
return DB.Omit("key").Save(channel).Error return DB.Omit("key").Save(channel).Error
} }
......
...@@ -248,26 +248,26 @@ func InitLogDB() (err error) { ...@@ -248,26 +248,26 @@ func InitLogDB() (err error) {
} }
func migrateDB() error { func migrateDB() error {
err := DB.AutoMigrate( err := DB.AutoMigrate(
&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{},
) )
if err != nil { if err != nil {
return err return err
} }
...@@ -278,29 +278,29 @@ func migrateDBFast() error { ...@@ -278,29 +278,29 @@ func migrateDBFast() error {
var wg sync.WaitGroup var wg sync.WaitGroup
migrations := []struct { migrations := []struct {
model interface{} model interface{}
name string name string
}{ }{
{&Channel{}, "Channel"}, {&Channel{}, "Channel"},
{&Token{}, "Token"}, {&Token{}, "Token"},
{&User{}, "User"}, {&User{}, "User"},
{&PasskeyCredential{}, "PasskeyCredential"}, {&PasskeyCredential{}, "PasskeyCredential"},
{&Option{}, "Option"}, {&Option{}, "Option"},
{&Redemption{}, "Redemption"}, {&Redemption{}, "Redemption"},
{&Ability{}, "Ability"}, {&Ability{}, "Ability"},
{&Log{}, "Log"}, {&Log{}, "Log"},
{&Midjourney{}, "Midjourney"}, {&Midjourney{}, "Midjourney"},
{&TopUp{}, "TopUp"}, {&TopUp{}, "TopUp"},
{&QuotaData{}, "QuotaData"}, {&QuotaData{}, "QuotaData"},
{&Task{}, "Task"}, {&Task{}, "Task"},
{&Model{}, "Model"}, {&Model{}, "Model"},
{&Vendor{}, "Vendor"}, {&Vendor{}, "Vendor"},
{&PrefillGroup{}, "PrefillGroup"}, {&PrefillGroup{}, "PrefillGroup"},
{&Setup{}, "Setup"}, {&Setup{}, "Setup"},
{&TwoFA{}, "TwoFA"}, {&TwoFA{}, "TwoFA"},
{&TwoFABackupCode{}, "TwoFABackupCode"}, {&TwoFABackupCode{}, "TwoFABackupCode"},
} }
// 动态计算migration数量,确保errChan缓冲区足够大 // 动态计算migration数量,确保errChan缓冲区足够大
errChan := make(chan error, len(migrations)) errChan := make(chan error, len(migrations))
......
...@@ -6,7 +6,6 @@ import ( ...@@ -6,7 +6,6 @@ import (
"strings" "strings"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/bytedance/gopkg/util/gopool" "github.com/bytedance/gopkg/util/gopool"
"gorm.io/gorm" "gorm.io/gorm"
) )
...@@ -27,6 +26,7 @@ type Token struct { ...@@ -27,6 +26,7 @@ type Token struct {
AllowIps *string `json:"allow_ips" gorm:"default:''"` AllowIps *string `json:"allow_ips" gorm:"default:''"`
UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota
Group string `json:"group" gorm:"default:''"` Group string `json:"group" gorm:"default:''"`
CrossGroupRetry bool `json:"cross_group_retry" gorm:"default:false"` // 跨分组重试,仅auto分组有效
DeletedAt gorm.DeletedAt `gorm:"index"` DeletedAt gorm.DeletedAt `gorm:"index"`
} }
...@@ -34,26 +34,26 @@ func (token *Token) Clean() { ...@@ -34,26 +34,26 @@ func (token *Token) Clean() {
token.Key = "" token.Key = ""
} }
func (token *Token) GetIpLimitsMap() map[string]any { func (token *Token) GetIpLimits() []string {
// delete empty spaces // delete empty spaces
//split with \n //split with \n
ipLimitsMap := make(map[string]any) ipLimits := make([]string, 0)
if token.AllowIps == nil { if token.AllowIps == nil {
return ipLimitsMap return ipLimits
} }
cleanIps := strings.ReplaceAll(*token.AllowIps, " ", "") cleanIps := strings.ReplaceAll(*token.AllowIps, " ", "")
if cleanIps == "" { if cleanIps == "" {
return ipLimitsMap return ipLimits
} }
ips := strings.Split(cleanIps, "\n") ips := strings.Split(cleanIps, "\n")
for _, ip := range ips { for _, ip := range ips {
ip = strings.TrimSpace(ip) ip = strings.TrimSpace(ip)
ip = strings.ReplaceAll(ip, ",", "") ip = strings.ReplaceAll(ip, ",", "")
if common.IsIP(ip) { if ip != "" {
ipLimitsMap[ip] = true ipLimits = append(ipLimits, ip)
} }
} }
return ipLimitsMap return ipLimits
} }
func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) { func GetAllUserTokens(userId int, startIdx int, num int) ([]*Token, error) {
...@@ -112,7 +112,12 @@ func ValidateUserToken(key string) (token *Token, err error) { ...@@ -112,7 +112,12 @@ func ValidateUserToken(key string) (token *Token, err error) {
} }
return token, nil return token, nil
} }
return nil, errors.New("无效的令牌") common.SysLog("ValidateUserToken: failed to get token: " + err.Error())
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("无效的令牌")
} else {
return nil, errors.New("无效的令牌,数据库查询出错,请联系管理员")
}
} }
func GetTokenByIds(id int, userId int) (*Token, error) { func GetTokenByIds(id int, userId int) (*Token, error) {
...@@ -185,7 +190,7 @@ func (token *Token) Update() (err error) { ...@@ -185,7 +190,7 @@ func (token *Token) Update() (err error) {
} }
}() }()
err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota", err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota",
"model_limits_enabled", "model_limits", "allow_ips", "group").Updates(token).Error "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry").Updates(token).Error
return err return err
} }
......
package ionet
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
const (
DefaultEnterpriseBaseURL = "https://api.io.solutions/enterprise/v1/io-cloud/caas"
DefaultBaseURL = "https://api.io.solutions/v1/io-cloud/caas"
DefaultTimeout = 30 * time.Second
)
// DefaultHTTPClient is the default HTTP client implementation
type DefaultHTTPClient struct {
client *http.Client
}
// NewDefaultHTTPClient creates a new default HTTP client
func NewDefaultHTTPClient(timeout time.Duration) *DefaultHTTPClient {
return &DefaultHTTPClient{
client: &http.Client{
Timeout: timeout,
},
}
}
// Do executes an HTTP request
func (c *DefaultHTTPClient) Do(req *HTTPRequest) (*HTTPResponse, error) {
httpReq, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(req.Body))
if err != nil {
return nil, fmt.Errorf("failed to create HTTP request: %w", err)
}
// Set headers
for key, value := range req.Headers {
httpReq.Header.Set(key, value)
}
resp, err := c.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("HTTP request failed: %w", err)
}
defer resp.Body.Close()
// Read response body
var body bytes.Buffer
_, err = body.ReadFrom(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Convert headers
headers := make(map[string]string)
for key, values := range resp.Header {
if len(values) > 0 {
headers[key] = values[0]
}
}
return &HTTPResponse{
StatusCode: resp.StatusCode,
Headers: headers,
Body: body.Bytes(),
}, nil
}
// NewEnterpriseClient creates a new IO.NET API client targeting the enterprise API base URL.
func NewEnterpriseClient(apiKey string) *Client {
return NewClientWithConfig(apiKey, DefaultEnterpriseBaseURL, nil)
}
// NewClient creates a new IO.NET API client targeting the public API base URL.
func NewClient(apiKey string) *Client {
return NewClientWithConfig(apiKey, DefaultBaseURL, nil)
}
// NewClientWithConfig creates a new IO.NET API client with custom configuration
func NewClientWithConfig(apiKey, baseURL string, httpClient HTTPClient) *Client {
if baseURL == "" {
baseURL = DefaultBaseURL
}
if httpClient == nil {
httpClient = NewDefaultHTTPClient(DefaultTimeout)
}
return &Client{
BaseURL: baseURL,
APIKey: apiKey,
HTTPClient: httpClient,
}
}
// makeRequest performs an HTTP request and handles common response processing
func (c *Client) makeRequest(method, endpoint string, body interface{}) (*HTTPResponse, error) {
var reqBody []byte
var err error
if body != nil {
reqBody, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
}
headers := map[string]string{
"X-API-KEY": c.APIKey,
"Content-Type": "application/json",
}
req := &HTTPRequest{
Method: method,
URL: c.BaseURL + endpoint,
Headers: headers,
Body: reqBody,
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
// Handle API errors
if resp.StatusCode >= 400 {
var apiErr APIError
if len(resp.Body) > 0 {
// Try to parse the actual error format: {"detail": "message"}
var errorResp struct {
Detail string `json:"detail"`
}
if err := json.Unmarshal(resp.Body, &errorResp); err == nil && errorResp.Detail != "" {
apiErr = APIError{
Code: resp.StatusCode,
Message: errorResp.Detail,
}
} else {
// Fallback: use raw body as details
apiErr = APIError{
Code: resp.StatusCode,
Message: fmt.Sprintf("API request failed with status %d", resp.StatusCode),
Details: string(resp.Body),
}
}
} else {
apiErr = APIError{
Code: resp.StatusCode,
Message: fmt.Sprintf("API request failed with status %d", resp.StatusCode),
}
}
return nil, &apiErr
}
return resp, nil
}
// buildQueryParams builds query parameters for GET requests
func buildQueryParams(params map[string]interface{}) string {
if len(params) == 0 {
return ""
}
values := url.Values{}
for key, value := range params {
if value == nil {
continue
}
switch v := value.(type) {
case string:
if v != "" {
values.Add(key, v)
}
case int:
if v != 0 {
values.Add(key, strconv.Itoa(v))
}
case int64:
if v != 0 {
values.Add(key, strconv.FormatInt(v, 10))
}
case float64:
if v != 0 {
values.Add(key, strconv.FormatFloat(v, 'f', -1, 64))
}
case bool:
values.Add(key, strconv.FormatBool(v))
case time.Time:
if !v.IsZero() {
values.Add(key, v.Format(time.RFC3339))
}
case *time.Time:
if v != nil && !v.IsZero() {
values.Add(key, v.Format(time.RFC3339))
}
case []int:
if len(v) > 0 {
if encoded, err := json.Marshal(v); err == nil {
values.Add(key, string(encoded))
}
}
case []string:
if len(v) > 0 {
if encoded, err := json.Marshal(v); err == nil {
values.Add(key, string(encoded))
}
}
default:
values.Add(key, fmt.Sprint(v))
}
}
if len(values) > 0 {
return "?" + values.Encode()
}
return ""
}
package ionet
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/samber/lo"
)
// ListContainers retrieves all containers for a specific deployment
func (c *Client) ListContainers(deploymentID string) (*ContainerList, error) {
if deploymentID == "" {
return nil, fmt.Errorf("deployment ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s/containers", deploymentID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to list containers: %w", err)
}
var containerList ContainerList
if err := decodeDataWithFlexibleTimes(resp.Body, &containerList); err != nil {
return nil, fmt.Errorf("failed to parse containers list: %w", err)
}
return &containerList, nil
}
// GetContainerDetails retrieves detailed information about a specific container
func (c *Client) GetContainerDetails(deploymentID, containerID string) (*Container, error) {
if deploymentID == "" {
return nil, fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return nil, fmt.Errorf("container ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s/container/%s", deploymentID, containerID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get container details: %w", err)
}
// API response format not documented, assuming direct format
var container Container
if err := decodeWithFlexibleTimes(resp.Body, &container); err != nil {
return nil, fmt.Errorf("failed to parse container details: %w", err)
}
return &container, nil
}
// GetContainerJobs retrieves containers jobs for a specific container (similar to containers endpoint)
func (c *Client) GetContainerJobs(deploymentID, containerID string) (*ContainerList, error) {
if deploymentID == "" {
return nil, fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return nil, fmt.Errorf("container ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s/containers-jobs/%s", deploymentID, containerID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get container jobs: %w", err)
}
var containerList ContainerList
if err := decodeDataWithFlexibleTimes(resp.Body, &containerList); err != nil {
return nil, fmt.Errorf("failed to parse container jobs: %w", err)
}
return &containerList, nil
}
// buildLogEndpoint constructs the request path for fetching logs
func buildLogEndpoint(deploymentID, containerID string, opts *GetLogsOptions) (string, error) {
if deploymentID == "" {
return "", fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return "", fmt.Errorf("container ID cannot be empty")
}
params := make(map[string]interface{})
if opts != nil {
if opts.Level != "" {
params["level"] = opts.Level
}
if opts.Stream != "" {
params["stream"] = opts.Stream
}
if opts.Limit > 0 {
params["limit"] = opts.Limit
}
if opts.Cursor != "" {
params["cursor"] = opts.Cursor
}
if opts.Follow {
params["follow"] = true
}
if opts.StartTime != nil {
params["start_time"] = opts.StartTime
}
if opts.EndTime != nil {
params["end_time"] = opts.EndTime
}
}
endpoint := fmt.Sprintf("/deployment/%s/log/%s", deploymentID, containerID)
endpoint += buildQueryParams(params)
return endpoint, nil
}
// GetContainerLogs retrieves logs for containers in a deployment and normalizes them
func (c *Client) GetContainerLogs(deploymentID, containerID string, opts *GetLogsOptions) (*ContainerLogs, error) {
raw, err := c.GetContainerLogsRaw(deploymentID, containerID, opts)
if err != nil {
return nil, err
}
logs := &ContainerLogs{
ContainerID: containerID,
}
if raw == "" {
return logs, nil
}
normalized := strings.ReplaceAll(raw, "\r\n", "\n")
lines := strings.Split(normalized, "\n")
logs.Logs = lo.FilterMap(lines, func(line string, _ int) (LogEntry, bool) {
if strings.TrimSpace(line) == "" {
return LogEntry{}, false
}
return LogEntry{Message: line}, true
})
return logs, nil
}
// GetContainerLogsRaw retrieves the raw text logs for a specific container
func (c *Client) GetContainerLogsRaw(deploymentID, containerID string, opts *GetLogsOptions) (string, error) {
endpoint, err := buildLogEndpoint(deploymentID, containerID, opts)
if err != nil {
return "", err
}
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return "", fmt.Errorf("failed to get container logs: %w", err)
}
return string(resp.Body), nil
}
// StreamContainerLogs streams real-time logs for a specific container
// This method uses a callback function to handle incoming log entries
func (c *Client) StreamContainerLogs(deploymentID, containerID string, opts *GetLogsOptions, callback func(*LogEntry) error) error {
if deploymentID == "" {
return fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return fmt.Errorf("container ID cannot be empty")
}
if callback == nil {
return fmt.Errorf("callback function cannot be nil")
}
// Set follow to true for streaming
if opts == nil {
opts = &GetLogsOptions{}
}
opts.Follow = true
endpoint, err := buildLogEndpoint(deploymentID, containerID, opts)
if err != nil {
return err
}
// Note: This is a simplified implementation. In a real scenario, you might want to use
// Server-Sent Events (SSE) or WebSocket for streaming logs
for {
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to stream container logs: %w", err)
}
var logs ContainerLogs
if err := decodeWithFlexibleTimes(resp.Body, &logs); err != nil {
return fmt.Errorf("failed to parse container logs: %w", err)
}
// Call the callback for each log entry
for _, logEntry := range logs.Logs {
if err := callback(&logEntry); err != nil {
return fmt.Errorf("callback error: %w", err)
}
}
// If there are no more logs or we have a cursor, continue polling
if !logs.HasMore && logs.NextCursor == "" {
break
}
// Update cursor for next request
if logs.NextCursor != "" {
opts.Cursor = logs.NextCursor
endpoint, err = buildLogEndpoint(deploymentID, containerID, opts)
if err != nil {
return err
}
}
// Wait a bit before next poll to avoid overwhelming the API
time.Sleep(2 * time.Second)
}
return nil
}
// RestartContainer restarts a specific container (if supported by the API)
func (c *Client) RestartContainer(deploymentID, containerID string) error {
if deploymentID == "" {
return fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return fmt.Errorf("container ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s/container/%s/restart", deploymentID, containerID)
_, err := c.makeRequest("POST", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to restart container: %w", err)
}
return nil
}
// StopContainer stops a specific container (if supported by the API)
func (c *Client) StopContainer(deploymentID, containerID string) error {
if deploymentID == "" {
return fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return fmt.Errorf("container ID cannot be empty")
}
endpoint := fmt.Sprintf("/deployment/%s/container/%s/stop", deploymentID, containerID)
_, err := c.makeRequest("POST", endpoint, nil)
if err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
return nil
}
// ExecuteInContainer executes a command in a specific container (if supported by the API)
func (c *Client) ExecuteInContainer(deploymentID, containerID string, command []string) (string, error) {
if deploymentID == "" {
return "", fmt.Errorf("deployment ID cannot be empty")
}
if containerID == "" {
return "", fmt.Errorf("container ID cannot be empty")
}
if len(command) == 0 {
return "", fmt.Errorf("command cannot be empty")
}
reqBody := map[string]interface{}{
"command": command,
}
endpoint := fmt.Sprintf("/deployment/%s/container/%s/exec", deploymentID, containerID)
resp, err := c.makeRequest("POST", endpoint, reqBody)
if err != nil {
return "", fmt.Errorf("failed to execute command in container: %w", err)
}
var result map[string]interface{}
if err := json.Unmarshal(resp.Body, &result); err != nil {
return "", fmt.Errorf("failed to parse execution result: %w", err)
}
if output, ok := result["output"].(string); ok {
return output, nil
}
return string(resp.Body), nil
}
package ionet
import (
"encoding/json"
"fmt"
"strings"
"github.com/samber/lo"
)
// GetAvailableReplicas retrieves available replicas per location for specified hardware
func (c *Client) GetAvailableReplicas(hardwareID int, gpuCount int) (*AvailableReplicasResponse, error) {
if hardwareID <= 0 {
return nil, fmt.Errorf("hardware_id must be greater than 0")
}
if gpuCount < 1 {
return nil, fmt.Errorf("gpu_count must be at least 1")
}
params := map[string]interface{}{
"hardware_id": hardwareID,
"hardware_qty": gpuCount,
}
endpoint := "/available-replicas" + buildQueryParams(params)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get available replicas: %w", err)
}
type availableReplicaPayload struct {
ID int `json:"id"`
ISO2 string `json:"iso2"`
Name string `json:"name"`
AvailableReplicas int `json:"available_replicas"`
}
var payload []availableReplicaPayload
if err := decodeData(resp.Body, &payload); err != nil {
return nil, fmt.Errorf("failed to parse available replicas response: %w", err)
}
replicas := lo.Map(payload, func(item availableReplicaPayload, _ int) AvailableReplica {
return AvailableReplica{
LocationID: item.ID,
LocationName: item.Name,
HardwareID: hardwareID,
HardwareName: "",
AvailableCount: item.AvailableReplicas,
MaxGPUs: gpuCount,
}
})
return &AvailableReplicasResponse{Replicas: replicas}, nil
}
// GetMaxGPUsPerContainer retrieves the maximum number of GPUs available per hardware type
func (c *Client) GetMaxGPUsPerContainer() (*MaxGPUResponse, error) {
resp, err := c.makeRequest("GET", "/hardware/max-gpus-per-container", nil)
if err != nil {
return nil, fmt.Errorf("failed to get max GPUs per container: %w", err)
}
var maxGPUResp MaxGPUResponse
if err := decodeData(resp.Body, &maxGPUResp); err != nil {
return nil, fmt.Errorf("failed to parse max GPU response: %w", err)
}
return &maxGPUResp, nil
}
// ListHardwareTypes retrieves available hardware types using the max GPUs endpoint
func (c *Client) ListHardwareTypes() ([]HardwareType, int, error) {
maxGPUResp, err := c.GetMaxGPUsPerContainer()
if err != nil {
return nil, 0, fmt.Errorf("failed to list hardware types: %w", err)
}
mapped := lo.Map(maxGPUResp.Hardware, func(hw MaxGPUInfo, _ int) HardwareType {
name := strings.TrimSpace(hw.HardwareName)
if name == "" {
name = fmt.Sprintf("Hardware %d", hw.HardwareID)
}
return HardwareType{
ID: hw.HardwareID,
Name: name,
GPUType: "",
GPUMemory: 0,
MaxGPUs: hw.MaxGPUsPerContainer,
CPU: "",
Memory: 0,
Storage: 0,
HourlyRate: 0,
Available: hw.Available > 0,
BrandName: strings.TrimSpace(hw.BrandName),
AvailableCount: hw.Available,
}
})
totalAvailable := maxGPUResp.Total
if totalAvailable == 0 {
totalAvailable = lo.SumBy(maxGPUResp.Hardware, func(hw MaxGPUInfo) int {
return hw.Available
})
}
return mapped, totalAvailable, nil
}
// ListLocations retrieves available deployment locations (if supported by the API)
func (c *Client) ListLocations() (*LocationsResponse, error) {
resp, err := c.makeRequest("GET", "/locations", nil)
if err != nil {
return nil, fmt.Errorf("failed to list locations: %w", err)
}
var locations LocationsResponse
if err := decodeData(resp.Body, &locations); err != nil {
return nil, fmt.Errorf("failed to parse locations response: %w", err)
}
locations.Locations = lo.Map(locations.Locations, func(location Location, _ int) Location {
location.ISO2 = strings.ToUpper(strings.TrimSpace(location.ISO2))
return location
})
if locations.Total == 0 {
locations.Total = lo.SumBy(locations.Locations, func(location Location) int {
return location.Available
})
}
return &locations, nil
}
// GetHardwareType retrieves details about a specific hardware type
func (c *Client) GetHardwareType(hardwareID int) (*HardwareType, error) {
if hardwareID <= 0 {
return nil, fmt.Errorf("hardware ID must be greater than 0")
}
endpoint := fmt.Sprintf("/hardware/types/%d", hardwareID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get hardware type: %w", err)
}
// API response format not documented, assuming direct format
var hardwareType HardwareType
if err := json.Unmarshal(resp.Body, &hardwareType); err != nil {
return nil, fmt.Errorf("failed to parse hardware type: %w", err)
}
return &hardwareType, nil
}
// GetLocation retrieves details about a specific location
func (c *Client) GetLocation(locationID int) (*Location, error) {
if locationID <= 0 {
return nil, fmt.Errorf("location ID must be greater than 0")
}
endpoint := fmt.Sprintf("/locations/%d", locationID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get location: %w", err)
}
// API response format not documented, assuming direct format
var location Location
if err := json.Unmarshal(resp.Body, &location); err != nil {
return nil, fmt.Errorf("failed to parse location: %w", err)
}
return &location, nil
}
// GetLocationAvailability retrieves real-time availability for a specific location
func (c *Client) GetLocationAvailability(locationID int) (*LocationAvailability, error) {
if locationID <= 0 {
return nil, fmt.Errorf("location ID must be greater than 0")
}
endpoint := fmt.Sprintf("/locations/%d/availability", locationID)
resp, err := c.makeRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get location availability: %w", err)
}
// API response format not documented, assuming direct format
var availability LocationAvailability
if err := json.Unmarshal(resp.Body, &availability); err != nil {
return nil, fmt.Errorf("failed to parse location availability: %w", err)
}
return &availability, nil
}
package ionet
import (
"encoding/json"
"strings"
"time"
"github.com/samber/lo"
)
// decodeWithFlexibleTimes unmarshals API responses while tolerating timestamp strings
// that omit timezone information by normalizing them to RFC3339Nano.
func decodeWithFlexibleTimes(data []byte, target interface{}) error {
var intermediate interface{}
if err := json.Unmarshal(data, &intermediate); err != nil {
return err
}
normalized := normalizeTimeValues(intermediate)
reencoded, err := json.Marshal(normalized)
if err != nil {
return err
}
return json.Unmarshal(reencoded, target)
}
func decodeData[T any](data []byte, target *T) error {
var wrapper struct {
Data T `json:"data"`
}
if err := json.Unmarshal(data, &wrapper); err != nil {
return err
}
*target = wrapper.Data
return nil
}
func decodeDataWithFlexibleTimes[T any](data []byte, target *T) error {
var wrapper struct {
Data T `json:"data"`
}
if err := decodeWithFlexibleTimes(data, &wrapper); err != nil {
return err
}
*target = wrapper.Data
return nil
}
func normalizeTimeValues(value interface{}) interface{} {
switch v := value.(type) {
case map[string]interface{}:
return lo.MapValues(v, func(val interface{}, _ string) interface{} {
return normalizeTimeValues(val)
})
case []interface{}:
return lo.Map(v, func(item interface{}, _ int) interface{} {
return normalizeTimeValues(item)
})
case string:
if normalized, changed := normalizeTimeString(v); changed {
return normalized
}
return v
default:
return value
}
}
func normalizeTimeString(input string) (string, bool) {
trimmed := strings.TrimSpace(input)
if trimmed == "" {
return input, false
}
if _, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
return trimmed, trimmed != input
}
if _, err := time.Parse(time.RFC3339, trimmed); err == nil {
return trimmed, trimmed != input
}
layouts := []string{
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05.999999",
"2006-01-02T15:04:05",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, trimmed); err == nil {
return parsed.UTC().Format(time.RFC3339Nano), true
}
}
return input, false
}
...@@ -67,8 +67,11 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type ...@@ -67,8 +67,11 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
service.ResetStatusCode(newAPIError, statusCodeMappingStr) service.ResetStatusCode(newAPIError, statusCodeMappingStr)
return newAPIError return newAPIError
} }
if usage.(*dto.Usage).CompletionTokenDetails.AudioTokens > 0 || usage.(*dto.Usage).PromptTokensDetails.AudioTokens > 0 {
postConsumeQuota(c, info, usage.(*dto.Usage), "") service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "")
} else {
postConsumeQuota(c, info, usage.(*dto.Usage))
}
return nil return nil
} }
...@@ -19,6 +19,22 @@ import ( ...@@ -19,6 +19,22 @@ import (
) )
type Adaptor struct { type Adaptor struct {
IsSyncImageModel bool
}
var syncModels = []string{
"z-image",
"qwen-image",
"wan2.6",
}
func isSyncImageModel(modelName string) bool {
for _, m := range syncModels {
if strings.Contains(modelName, m) {
return true
}
}
return false
} }
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) { func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
...@@ -45,10 +61,16 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { ...@@ -45,10 +61,16 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
case constant.RelayModeRerank: case constant.RelayModeRerank:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl) fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
case constant.RelayModeImagesGenerations: case constant.RelayModeImagesGenerations:
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl) if isSyncImageModel(info.OriginModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
} else {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/text2image/image-synthesis", info.ChannelBaseUrl)
}
case constant.RelayModeImagesEdits: case constant.RelayModeImagesEdits:
if isWanModel(info.OriginModelName) { if isOldWanModel(info.OriginModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl) fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image2image/image-synthesis", info.ChannelBaseUrl)
} else if isWanModel(info.OriginModelName) {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/image-generation/generation", info.ChannelBaseUrl)
} else { } else {
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl) fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
} }
...@@ -72,7 +94,11 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel ...@@ -72,7 +94,11 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel
req.Set("X-DashScope-Plugin", c.GetString("plugin")) req.Set("X-DashScope-Plugin", c.GetString("plugin"))
} }
if info.RelayMode == constant.RelayModeImagesGenerations { if info.RelayMode == constant.RelayModeImagesGenerations {
req.Set("X-DashScope-Async", "enable") if isSyncImageModel(info.OriginModelName) {
} else {
req.Set("X-DashScope-Async", "enable")
}
} }
if info.RelayMode == constant.RelayModeImagesEdits { if info.RelayMode == constant.RelayModeImagesEdits {
if isWanModel(info.OriginModelName) { if isWanModel(info.OriginModelName) {
...@@ -108,15 +134,25 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn ...@@ -108,15 +134,25 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) { func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
if info.RelayMode == constant.RelayModeImagesGenerations { if info.RelayMode == constant.RelayModeImagesGenerations {
aliRequest, err := oaiImage2Ali(request) if isSyncImageModel(info.OriginModelName) {
a.IsSyncImageModel = true
}
aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel)
if err != nil { if err != nil {
return nil, fmt.Errorf("convert image request failed: %w", err) return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err)
} }
return aliRequest, nil return aliRequest, nil
} else if info.RelayMode == constant.RelayModeImagesEdits { } else if info.RelayMode == constant.RelayModeImagesEdits {
if isWanModel(info.OriginModelName) { if isOldWanModel(info.OriginModelName) {
return oaiFormEdit2WanxImageEdit(c, info, request) return oaiFormEdit2WanxImageEdit(c, info, request)
} }
if isSyncImageModel(info.OriginModelName) {
if isWanModel(info.OriginModelName) {
a.IsSyncImageModel = false
} else {
a.IsSyncImageModel = true
}
}
// ali image edit https://bailian.console.aliyun.com/?tab=api#/api/?type=model&url=2976416 // 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") { if strings.Contains(c.Request.Header.Get("Content-Type"), "multipart/form-data") {
...@@ -126,9 +162,9 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf ...@@ -126,9 +162,9 @@ func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInf
} }
return aliRequest, nil return aliRequest, nil
} else { } else {
aliRequest, err := oaiImage2Ali(request) aliRequest, err := oaiImage2AliImageRequest(info, request, a.IsSyncImageModel)
if err != nil { if err != nil {
return nil, fmt.Errorf("convert image request failed: %w", err) return nil, fmt.Errorf("convert image request to async ali image request failed: %w", err)
} }
return aliRequest, nil return aliRequest, nil
} }
...@@ -169,13 +205,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom ...@@ -169,13 +205,9 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
default: default:
switch info.RelayMode { switch info.RelayMode {
case constant.RelayModeImagesGenerations: case constant.RelayModeImagesGenerations:
err, usage = aliImageHandler(c, resp, info) err, usage = aliImageHandler(a, c, resp, info)
case constant.RelayModeImagesEdits: case constant.RelayModeImagesEdits:
if isWanModel(info.OriginModelName) { err, usage = aliImageHandler(a, c, resp, info)
err, usage = aliImageHandler(c, resp, info)
} else {
err, usage = aliImageEditHandler(c, resp, info)
}
case constant.RelayModeRerank: case constant.RelayModeRerank:
err, usage = RerankHandler(c, resp, info) err, usage = RerankHandler(c, resp, info)
default: default:
......
package ali package ali
import "github.com/QuantumNous/new-api/dto" import (
"strings"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
type AliMessage struct { type AliMessage struct {
Content any `json:"content"` Content any `json:"content"`
...@@ -65,6 +72,7 @@ type AliUsage struct { ...@@ -65,6 +72,7 @@ type AliUsage struct {
InputTokens int `json:"input_tokens"` InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"` OutputTokens int `json:"output_tokens"`
TotalTokens int `json:"total_tokens"` TotalTokens int `json:"total_tokens"`
ImageCount int `json:"image_count,omitempty"`
} }
type TaskResult struct { type TaskResult struct {
...@@ -75,14 +83,78 @@ type TaskResult struct { ...@@ -75,14 +83,78 @@ type TaskResult struct {
} }
type AliOutput struct { type AliOutput struct {
TaskId string `json:"task_id,omitempty"` TaskId string `json:"task_id,omitempty"`
TaskStatus string `json:"task_status,omitempty"` TaskStatus string `json:"task_status,omitempty"`
Text string `json:"text"` Text string `json:"text"`
FinishReason string `json:"finish_reason"` FinishReason string `json:"finish_reason"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
Results []TaskResult `json:"results,omitempty"` Results []TaskResult `json:"results,omitempty"`
Choices []map[string]any `json:"choices,omitempty"` Choices []struct {
FinishReason string `json:"finish_reason,omitempty"`
Message struct {
Role string `json:"role,omitempty"`
Content []AliMediaContent `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
} `json:"message,omitempty"`
} `json:"choices,omitempty"`
}
func (o *AliOutput) ChoicesToOpenAIImageDate(c *gin.Context, responseFormat string) []dto.ImageData {
var imageData []dto.ImageData
if len(o.Choices) > 0 {
for _, choice := range o.Choices {
var data dto.ImageData
for _, content := range choice.Message.Content {
if content.Image != "" {
if strings.HasPrefix(content.Image, "http") {
var b64Json string
if responseFormat == "b64_json" {
_, b64, err := service.GetImageFromUrl(content.Image)
if err != nil {
logger.LogError(c, "get_image_data_failed: "+err.Error())
continue
}
b64Json = b64
}
data.Url = content.Image
data.B64Json = b64Json
} else {
data.B64Json = content.Image
}
} else if content.Text != "" {
data.RevisedPrompt = content.Text
}
}
imageData = append(imageData, data)
}
}
return imageData
}
func (o *AliOutput) ResultToOpenAIImageDate(c *gin.Context, responseFormat string) []dto.ImageData {
var imageData []dto.ImageData
for _, data := range o.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
}
imageData = append(imageData, dto.ImageData{
Url: data.Url,
B64Json: b64Json,
RevisedPrompt: "",
})
}
return imageData
} }
type AliResponse struct { type AliResponse struct {
...@@ -92,18 +164,26 @@ type AliResponse struct { ...@@ -92,18 +164,26 @@ type AliResponse struct {
} }
type AliImageRequest struct { type AliImageRequest struct {
Model string `json:"model"` Model string `json:"model"`
Input any `json:"input"` Input any `json:"input"`
Parameters any `json:"parameters,omitempty"` Parameters AliImageParameters `json:"parameters,omitempty"`
ResponseFormat string `json:"response_format,omitempty"` ResponseFormat string `json:"response_format,omitempty"`
} }
type AliImageParameters struct { type AliImageParameters struct {
Size string `json:"size,omitempty"` Size string `json:"size,omitempty"`
N int `json:"n,omitempty"` N int `json:"n,omitempty"`
Steps string `json:"steps,omitempty"` Steps string `json:"steps,omitempty"`
Scale string `json:"scale,omitempty"` Scale string `json:"scale,omitempty"`
Watermark *bool `json:"watermark,omitempty"` Watermark *bool `json:"watermark,omitempty"`
PromptExtend *bool `json:"prompt_extend,omitempty"`
}
func (p *AliImageParameters) PromptExtendValue() bool {
if p != nil && p.PromptExtend != nil {
return *p.PromptExtend
}
return false
} }
type AliImageInput struct { type AliImageInput struct {
......
...@@ -21,17 +21,25 @@ import ( ...@@ -21,17 +21,25 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) { func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequest, isSync bool) (*AliImageRequest, error) {
var imageRequest AliImageRequest var imageRequest AliImageRequest
imageRequest.Model = request.Model imageRequest.Model = request.Model
imageRequest.ResponseFormat = request.ResponseFormat imageRequest.ResponseFormat = request.ResponseFormat
logger.LogJson(context.Background(), "oaiImage2Ali request extra", request.Extra) logger.LogJson(context.Background(), "oaiImage2Ali request extra", request.Extra)
logger.LogDebug(context.Background(), "oaiImage2Ali request isSync: "+fmt.Sprintf("%v", isSync))
if request.Extra != nil { if request.Extra != nil {
if val, ok := request.Extra["parameters"]; ok { if val, ok := request.Extra["parameters"]; ok {
err := common.Unmarshal(val, &imageRequest.Parameters) err := common.Unmarshal(val, &imageRequest.Parameters)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid parameters field: %w", err) return nil, fmt.Errorf("invalid parameters field: %w", err)
} }
} else {
// 兼容没有parameters字段的情况,从openai标准字段中提取参数
imageRequest.Parameters = AliImageParameters{
Size: strings.Replace(request.Size, "x", "*", -1),
N: int(request.N),
Watermark: request.Watermark,
}
} }
if val, ok := request.Extra["input"]; ok { if val, ok := request.Extra["input"]; ok {
err := common.Unmarshal(val, &imageRequest.Input) err := common.Unmarshal(val, &imageRequest.Input)
...@@ -41,23 +49,44 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) { ...@@ -41,23 +49,44 @@ func oaiImage2Ali(request dto.ImageRequest) (*AliImageRequest, error) {
} }
} }
if imageRequest.Parameters == nil { if strings.Contains(request.Model, "z-image") {
imageRequest.Parameters = AliImageParameters{ // z-image 开启prompt_extend后,按2倍计费
Size: strings.Replace(request.Size, "x", "*", -1), if imageRequest.Parameters.PromptExtendValue() {
N: int(request.N), info.PriceData.AddOtherRatio("prompt_extend", 2)
Watermark: request.Watermark,
} }
} }
if imageRequest.Input == nil { // 检查n参数
imageRequest.Input = AliImageInput{ if imageRequest.Parameters.N != 0 {
Prompt: request.Prompt, info.PriceData.AddOtherRatio("n", float64(imageRequest.Parameters.N))
}
// 同步图片模型和异步图片模型请求格式不一样
if isSync {
if imageRequest.Input == nil {
imageRequest.Input = AliImageInput{
Messages: []AliMessage{
{
Role: "user",
Content: []AliMediaContent{
{
Text: request.Prompt,
},
},
},
},
}
}
} else {
if imageRequest.Input == nil {
imageRequest.Input = AliImageInput{
Prompt: request.Prompt,
}
} }
} }
return &imageRequest, nil return &imageRequest, nil
} }
func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error) { func getImageBase64sFromForm(c *gin.Context, fieldName string) ([]string, error) {
mf := c.Request.MultipartForm mf := c.Request.MultipartForm
if mf == nil { if mf == nil {
...@@ -199,6 +228,8 @@ func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) ( ...@@ -199,6 +228,8 @@ func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (
var taskResponse AliResponse var taskResponse AliResponse
var responseBody []byte var responseBody []byte
time.Sleep(time.Duration(5) * time.Second)
for { for {
logger.LogDebug(c, fmt.Sprintf("asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)) logger.LogDebug(c, fmt.Sprintf("asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds))
step++ step++
...@@ -238,32 +269,17 @@ func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, originBody [ ...@@ -238,32 +269,17 @@ func responseAli2OpenAIImage(c *gin.Context, response *AliResponse, originBody [
Created: info.StartTime.Unix(), Created: info.StartTime.Unix(),
} }
for _, data := range response.Output.Results { if len(response.Output.Results) > 0 {
var b64Json string imageResponse.Data = response.Output.ResultToOpenAIImageDate(c, responseFormat)
if responseFormat == "b64_json" { } else if len(response.Output.Choices) > 0 {
_, b64, err := service.GetImageFromUrl(data.Url) imageResponse.Data = response.Output.ChoicesToOpenAIImageDate(c, responseFormat)
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.Metadata = originBody
imageResponse.Extra = mapResponse
return &imageResponse return &imageResponse
} }
func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) { func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.Usage) {
responseFormat := c.GetString("response_format") responseFormat := c.GetString("response_format")
var aliTaskResponse AliResponse var aliTaskResponse AliResponse
...@@ -282,66 +298,49 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela ...@@ -282,66 +298,49 @@ func aliImageHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rela
return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil return types.NewError(errors.New(aliTaskResponse.Message), types.ErrorCodeBadResponse), nil
} }
aliResponse, originRespBody, err := asyncTaskWait(c, info, aliTaskResponse.Output.TaskId) var (
if err != nil { aliResponse *AliResponse
return types.NewError(err, types.ErrorCodeBadResponse), nil originRespBody []byte
} )
if aliResponse.Output.TaskStatus != "SUCCEEDED" { if a.IsSyncImageModel {
return types.WithOpenAIError(types.OpenAIError{ aliResponse = &aliTaskResponse
Message: aliResponse.Output.Message, originRespBody = responseBody
Type: "ali_error", } else {
Param: "", // 异步图片模型需要轮询任务结果
Code: aliResponse.Output.Code, aliResponse, originRespBody, err = asyncTaskWait(c, info, aliTaskResponse.Output.TaskId)
}, resp.StatusCode), nil if err != nil {
} return types.NewError(err, types.ErrorCodeBadResponse), nil
}
fullTextResponse := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat) if aliResponse.Output.TaskStatus != "SUCCEEDED" {
jsonResponse, err := common.Marshal(fullTextResponse) return types.WithOpenAIError(types.OpenAIError{
if err != nil { Message: aliResponse.Output.Message,
return types.NewError(err, types.ErrorCodeBadResponseBody), nil Type: "ali_error",
} Param: "",
service.IOCopyBytesGracefully(c, resp, jsonResponse) Code: aliResponse.Output.Code,
return nil, &dto.Usage{} }, resp.StatusCode), nil
} }
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) //logger.LogDebug(c, "ali_async_task_result: "+string(originRespBody))
err = common.Unmarshal(responseBody, &aliResponse) if a.IsSyncImageModel {
if err != nil { logger.LogDebug(c, "ali_sync_image_result: "+string(originRespBody))
return types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError), nil } else {
logger.LogDebug(c, "ali_async_image_result: "+string(originRespBody))
} }
if aliResponse.Message != "" { imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat)
logger.LogError(c, "ali_task_failed: "+aliResponse.Message) // 可能生成多张图片,修正计费数量n
return types.NewError(errors.New(aliResponse.Message), types.ErrorCodeBadResponse), nil if aliResponse.Usage.ImageCount != 0 {
info.PriceData.AddOtherRatio("n", float64(aliResponse.Usage.ImageCount))
} else if len(imageResponses.Data) != 0 {
info.PriceData.AddOtherRatio("n", float64(len(imageResponses.Data)))
} }
var fullTextResponse dto.ImageResponse jsonResponse, err := common.Marshal(imageResponses)
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 { if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody), nil return types.NewError(err, types.ErrorCodeBadResponseBody), nil
} }
service.IOCopyBytesGracefully(c, resp, jsonResponse) service.IOCopyBytesGracefully(c, resp, jsonResponse)
return nil, &dto.Usage{} return nil, &dto.Usage{}
} }
...@@ -26,14 +26,22 @@ func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, requ ...@@ -26,14 +26,22 @@ func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, requ
if wanInput.Images, err = getImageBase64sFromForm(c, "image"); err != nil { if wanInput.Images, err = getImageBase64sFromForm(c, "image"); err != nil {
return nil, fmt.Errorf("get image base64s from form failed: %w", err) return nil, fmt.Errorf("get image base64s from form failed: %w", err)
} }
wanParams := WanImageParameters{ //wanParams := WanImageParameters{
// N: int(request.N),
//}
imageRequest.Input = wanInput
imageRequest.Parameters = AliImageParameters{
N: int(request.N), N: int(request.N),
} }
imageRequest.Input = wanInput info.PriceData.AddOtherRatio("n", float64(imageRequest.Parameters.N))
imageRequest.Parameters = wanParams
return &imageRequest, nil return &imageRequest, nil
} }
func isOldWanModel(modelName string) bool {
return strings.Contains(modelName, "wan") && !strings.Contains(modelName, "wan2.6")
}
func isWanModel(modelName string) bool { func isWanModel(modelName string) bool {
return strings.Contains(modelName, "wan") return strings.Contains(modelName, "wan")
} }
...@@ -18,7 +18,7 @@ var awsModelIDMap = map[string]string{ ...@@ -18,7 +18,7 @@ var awsModelIDMap = map[string]string{
"claude-opus-4-1-20250805": "anthropic.claude-opus-4-1-20250805-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", "claude-sonnet-4-5-20250929": "anthropic.claude-sonnet-4-5-20250929-v1:0",
"claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0", "claude-haiku-4-5-20251001": "anthropic.claude-haiku-4-5-20251001-v1:0",
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0", "claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
// Nova models // Nova models
"nova-micro-v1:0": "amazon.nova-micro-v1:0", "nova-micro-v1:0": "amazon.nova-micro-v1:0",
"nova-lite-v1:0": "amazon.nova-lite-v1:0", "nova-lite-v1:0": "amazon.nova-lite-v1:0",
......
...@@ -18,6 +18,7 @@ import ( ...@@ -18,6 +18,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/bedrockruntime" "github.com/aws/aws-sdk-go-v2/service/bedrockruntime"
...@@ -129,7 +130,7 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor, ...@@ -129,7 +130,7 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor,
Accept: aws.String("application/json"), Accept: aws.String("application/json"),
ContentType: aws.String("application/json"), ContentType: aws.String("application/json"),
} }
awsReq.Body, err = common.Marshal(awsClaudeReq) awsReq.Body, err = buildAwsRequestBody(c, info, awsClaudeReq)
if err != nil { if err != nil {
return nil, types.NewError(errors.Wrap(err, "marshal aws request fail"), types.ErrorCodeBadRequestBody) return nil, types.NewError(errors.Wrap(err, "marshal aws request fail"), types.ErrorCodeBadRequestBody)
} }
...@@ -141,7 +142,7 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor, ...@@ -141,7 +142,7 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor,
Accept: aws.String("application/json"), Accept: aws.String("application/json"),
ContentType: aws.String("application/json"), ContentType: aws.String("application/json"),
} }
awsReq.Body, err = common.Marshal(awsClaudeReq) awsReq.Body, err = buildAwsRequestBody(c, info, awsClaudeReq)
if err != nil { if err != nil {
return nil, types.NewError(errors.Wrap(err, "marshal aws request fail"), types.ErrorCodeBadRequestBody) return nil, types.NewError(errors.Wrap(err, "marshal aws request fail"), types.ErrorCodeBadRequestBody)
} }
...@@ -151,6 +152,24 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor, ...@@ -151,6 +152,24 @@ func doAwsClientRequest(c *gin.Context, info *relaycommon.RelayInfo, a *Adaptor,
} }
} }
// buildAwsRequestBody prepares the payload for AWS requests, applying passthrough rules when enabled.
func buildAwsRequestBody(c *gin.Context, info *relaycommon.RelayInfo, awsClaudeReq any) ([]byte, error) {
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || info.ChannelSetting.PassThroughBodyEnabled {
body, err := common.GetRequestBody(c)
if err != nil {
return nil, errors.Wrap(err, "get request body for pass-through fail")
}
var data map[string]interface{}
if err := common.Unmarshal(body, &data); err != nil {
return nil, errors.Wrap(err, "pass-through unmarshal request body fail")
}
delete(data, "model")
delete(data, "stream")
return common.Marshal(data)
}
return common.Marshal(awsClaudeReq)
}
func getAwsRegionPrefix(awsRegionId string) string { func getAwsRegionPrefix(awsRegionId string) string {
parts := strings.Split(awsRegionId, "-") parts := strings.Split(awsRegionId, "-")
regionPrefix := "" regionPrefix := ""
......
...@@ -150,7 +150,7 @@ func baiduHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respon ...@@ -150,7 +150,7 @@ func baiduHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respon
return types.NewError(err, types.ErrorCodeBadResponseBody), nil return types.NewError(err, types.ErrorCodeBadResponseBody), nil
} }
if baiduResponse.ErrorMsg != "" { if baiduResponse.ErrorMsg != "" {
return types.NewError(fmt.Errorf(baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil return types.NewError(fmt.Errorf("%s", baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil
} }
fullTextResponse := responseBaidu2OpenAI(&baiduResponse) fullTextResponse := responseBaidu2OpenAI(&baiduResponse)
jsonResponse, err := json.Marshal(fullTextResponse) jsonResponse, err := json.Marshal(fullTextResponse)
...@@ -175,7 +175,7 @@ func baiduEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *ht ...@@ -175,7 +175,7 @@ func baiduEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *ht
return types.NewError(err, types.ErrorCodeBadResponseBody), nil return types.NewError(err, types.ErrorCodeBadResponseBody), nil
} }
if baiduResponse.ErrorMsg != "" { if baiduResponse.ErrorMsg != "" {
return types.NewError(fmt.Errorf(baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil return types.NewError(fmt.Errorf("%s", baiduResponse.ErrorMsg), types.ErrorCodeBadResponseBody), nil
} }
fullTextResponse := embeddingResponseBaidu2OpenAI(&baiduResponse) fullTextResponse := embeddingResponseBaidu2OpenAI(&baiduResponse)
jsonResponse, err := json.Marshal(fullTextResponse) jsonResponse, err := json.Marshal(fullTextResponse)
......
...@@ -483,9 +483,11 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse ...@@ -483,9 +483,11 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse
} }
} }
} else if claudeResponse.Type == "message_delta" { } else if claudeResponse.Type == "message_delta" {
finishReason := stopReasonClaude2OpenAI(*claudeResponse.Delta.StopReason) if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
if finishReason != "null" { finishReason := stopReasonClaude2OpenAI(*claudeResponse.Delta.StopReason)
choice.FinishReason = &finishReason if finishReason != "null" {
choice.FinishReason = &finishReason
}
} }
//claudeUsage = &claudeResponse.Usage //claudeUsage = &claudeResponse.Usage
} else if claudeResponse.Type == "message_stop" { } else if claudeResponse.Type == "message_stop" {
......
...@@ -208,7 +208,7 @@ func handleCozeEvent(c *gin.Context, event string, data string, responseText *st ...@@ -208,7 +208,7 @@ func handleCozeEvent(c *gin.Context, event string, data string, responseText *st
return return
} }
common.SysLog(fmt.Sprintf("stream event error: ", errorData.Code, errorData.Message)) common.SysLog(fmt.Sprintf("stream event error: %v %v", errorData.Code, errorData.Message))
} }
} }
......
...@@ -13,6 +13,7 @@ import ( ...@@ -13,6 +13,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common" relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/setting/model_setting" "github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -137,7 +138,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { ...@@ -137,7 +138,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking") info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
} else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") { } else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking") info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
} else if baseModel, level := parseThinkingLevelSuffix(info.UpstreamModelName); level != "" { } else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
info.UpstreamModelName = baseModel info.UpstreamModelName = baseModel
} }
} }
......
...@@ -94,10 +94,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn ...@@ -94,10 +94,10 @@ func GeminiTextGenerationStreamHandler(c *gin.Context, info *relaycommon.RelayIn
helper.SetEventStreamHeaders(c) helper.SetEventStreamHeaders(c)
return geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool { return geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
// 直接发送 GeminiChatResponse 响应
err := helper.StringData(c, data) err := helper.StringData(c, data)
if err != nil { if err != nil {
logger.LogError(c, err.Error()) logger.LogError(c, "failed to write stream data: "+err.Error())
return false
} }
info.SendResponseCount++ info.SendResponseCount++
return true return true
......
...@@ -98,6 +98,7 @@ func clampThinkingBudget(modelName string, budget int) int { ...@@ -98,6 +98,7 @@ func clampThinkingBudget(modelName string, budget int) int {
// "effort": "high" - Allocates a large portion of tokens for reasoning (approximately 80% of max_tokens) // "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": "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) // "effort": "low" - Allocates a smaller portion of tokens (approximately 20% of max_tokens)
// "effort": "minimal" - Allocates a minimal portion of tokens (approximately 5% of max_tokens)
func clampThinkingBudgetByEffort(modelName string, effort string) int { func clampThinkingBudgetByEffort(modelName string, effort string) int {
isNew25Pro := isNew25ProModel(modelName) isNew25Pro := isNew25ProModel(modelName)
is25FlashLite := is25FlashLiteModel(modelName) is25FlashLite := is25FlashLiteModel(modelName)
...@@ -118,18 +119,12 @@ func clampThinkingBudgetByEffort(modelName string, effort string) int { ...@@ -118,18 +119,12 @@ func clampThinkingBudgetByEffort(modelName string, effort string) int {
maxBudget = maxBudget * 50 / 100 maxBudget = maxBudget * 50 / 100
case "low": case "low":
maxBudget = maxBudget * 20 / 100 maxBudget = maxBudget * 20 / 100
case "minimal":
maxBudget = maxBudget * 5 / 100
} }
return clampThinkingBudget(modelName, maxBudget) return clampThinkingBudget(modelName, maxBudget)
} }
func parseThinkingLevelSuffix(modelName string) (string, string) {
base, level, ok := reasoning.TrimEffortSuffix(modelName)
if !ok {
return modelName, ""
}
return base, level
}
func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) { func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.RelayInfo, oaiRequest ...dto.GeneralOpenAIRequest) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
modelName := info.UpstreamModelName modelName := info.UpstreamModelName
...@@ -186,7 +181,7 @@ func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.Rel ...@@ -186,7 +181,7 @@ func ThinkingAdaptor(geminiRequest *dto.GeminiChatRequest, info *relaycommon.Rel
ThinkingBudget: common.GetPointer(0), ThinkingBudget: common.GetPointer(0),
} }
} }
} else if _, level := parseThinkingLevelSuffix(modelName); level != "" { } else if _, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{ geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true, IncludeThoughts: true,
ThinkingLevel: level, ThinkingLevel: level,
...@@ -379,7 +374,7 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i ...@@ -379,7 +374,7 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
var system_content []string var system_content []string
//shouldAddDummyModelMessage := false //shouldAddDummyModelMessage := false
for _, message := range textRequest.Messages { for _, message := range textRequest.Messages {
if message.Role == "system" { if message.Role == "system" || message.Role == "developer" {
system_content = append(system_content, message.StringContent()) system_content = append(system_content, message.StringContent())
continue continue
} else if message.Role == "tool" || message.Role == "function" { } else if message.Role == "tool" || message.Role == "function" {
......
...@@ -67,3 +67,40 @@ type OllamaEmbeddingResponse struct { ...@@ -67,3 +67,40 @@ type OllamaEmbeddingResponse struct {
Embeddings [][]float64 `json:"embeddings"` Embeddings [][]float64 `json:"embeddings"`
PromptEvalCount int `json:"prompt_eval_count,omitempty"` PromptEvalCount int `json:"prompt_eval_count,omitempty"`
} }
type OllamaTagsResponse struct {
Models []OllamaModel `json:"models"`
}
type OllamaModel struct {
Name string `json:"name"`
Size int64 `json:"size"`
Digest string `json:"digest,omitempty"`
ModifiedAt string `json:"modified_at"`
Details OllamaModelDetail `json:"details,omitempty"`
}
type OllamaModelDetail struct {
ParentModel string `json:"parent_model,omitempty"`
Format string `json:"format,omitempty"`
Family string `json:"family,omitempty"`
Families []string `json:"families,omitempty"`
ParameterSize string `json:"parameter_size,omitempty"`
QuantizationLevel string `json:"quantization_level,omitempty"`
}
type OllamaPullRequest struct {
Name string `json:"name"`
Stream bool `json:"stream,omitempty"`
}
type OllamaPullResponse struct {
Status string `json:"status"`
Digest string `json:"digest,omitempty"`
Total int64 `json:"total,omitempty"`
Completed int64 `json:"completed,omitempty"`
}
type OllamaDeleteRequest struct {
Name string `json:"name"`
}
package ollama package ollama
import ( import (
"bufio"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings" "strings"
"time"
"github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
...@@ -283,3 +285,246 @@ func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h ...@@ -283,3 +285,246 @@ func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
service.IOCopyBytesGracefully(c, resp, out) service.IOCopyBytesGracefully(c, resp, out)
return usage, nil return usage, nil
} }
func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) {
url := fmt.Sprintf("%s/api/tags", baseURL)
client := &http.Client{}
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
}
// Ollama 通常不需要 Bearer token,但为了兼容性保留
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("请求失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return nil, fmt.Errorf("服务器返回错误 %d: %s", response.StatusCode, string(body))
}
var tagsResponse OllamaTagsResponse
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %v", err)
}
err = common.Unmarshal(body, &tagsResponse)
if err != nil {
return nil, fmt.Errorf("解析响应失败: %v", err)
}
return tagsResponse.Models, nil
}
// 拉取 Ollama 模型 (非流式)
func PullOllamaModel(baseURL, apiKey, modelName string) error {
url := fmt.Sprintf("%s/api/pull", baseURL)
pullRequest := OllamaPullRequest{
Name: modelName,
Stream: false, // 非流式,简化处理
}
requestBody, err := common.Marshal(pullRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
}
client := &http.Client{
Timeout: 30 * 60 * 1000 * time.Millisecond, // 30分钟超时,支持大模型
}
request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
request.Header.Set("Content-Type", "application/json")
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("拉取模型失败 %d: %s", response.StatusCode, string(body))
}
return nil
}
// 流式拉取 Ollama 模型 (支持进度回调)
func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback func(OllamaPullResponse)) error {
url := fmt.Sprintf("%s/api/pull", baseURL)
pullRequest := OllamaPullRequest{
Name: modelName,
Stream: true, // 启用流式
}
requestBody, err := common.Marshal(pullRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
}
client := &http.Client{
Timeout: 60 * 60 * 1000 * time.Millisecond, // 1小时超时,支持超大模型
}
request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
request.Header.Set("Content-Type", "application/json")
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("拉取模型失败 %d: %s", response.StatusCode, string(body))
}
// 读取流式响应
scanner := bufio.NewScanner(response.Body)
successful := false
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
var pullResponse OllamaPullResponse
if err := common.Unmarshal([]byte(line), &pullResponse); err != nil {
continue // 忽略解析失败的行
}
if progressCallback != nil {
progressCallback(pullResponse)
}
// 检查是否出现错误或完成
if strings.EqualFold(pullResponse.Status, "error") {
return fmt.Errorf("拉取模型失败: %s", strings.TrimSpace(line))
}
if strings.EqualFold(pullResponse.Status, "success") {
successful = true
break
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("读取流式响应失败: %v", err)
}
if !successful {
return fmt.Errorf("拉取模型未完成: 未收到成功状态")
}
return nil
}
// 删除 Ollama 模型
func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
url := fmt.Sprintf("%s/api/delete", baseURL)
deleteRequest := OllamaDeleteRequest{
Name: modelName,
}
requestBody, err := common.Marshal(deleteRequest)
if err != nil {
return fmt.Errorf("序列化请求失败: %v", err)
}
client := &http.Client{}
request, err := http.NewRequest("DELETE", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
request.Header.Set("Content-Type", "application/json")
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("请求失败: %v", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("删除模型失败 %d: %s", response.StatusCode, string(body))
}
return nil
}
func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
trimmedBase := strings.TrimRight(baseURL, "/")
if trimmedBase == "" {
return "", fmt.Errorf("baseURL 为空")
}
url := fmt.Sprintf("%s/api/version", trimmedBase)
client := &http.Client{Timeout: 10 * time.Second}
request, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", fmt.Errorf("创建请求失败: %v", err)
}
if apiKey != "" {
request.Header.Set("Authorization", "Bearer "+apiKey)
}
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("请求失败: %v", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
}
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("查询版本失败 %d: %s", response.StatusCode, string(body))
}
var versionResp struct {
Version string `json:"version"`
}
if err := json.Unmarshal(body, &versionResp); err != nil {
return "", fmt.Errorf("解析响应失败: %v", err)
}
if versionResp.Version == "" {
return "", fmt.Errorf("未返回版本信息")
}
return versionResp.Version, nil
}
...@@ -42,7 +42,7 @@ type Adaptor struct { ...@@ -42,7 +42,7 @@ type Adaptor struct {
// support OAI models: o1-mini/o3-mini/o4-mini/o1/o3 etc... // support OAI models: o1-mini/o3-mini/o4-mini/o1/o3 etc...
// minimal effort only available in gpt-5 // minimal effort only available in gpt-5
func parseReasoningEffortFromModelSuffix(model string) (string, string) { func parseReasoningEffortFromModelSuffix(model string) (string, string) {
effortSuffixes := []string{"-high", "-minimal", "-low", "-medium", "-none"} effortSuffixes := []string{"-high", "-minimal", "-low", "-medium", "-none", "-xhigh"}
for _, suffix := range effortSuffixes { for _, suffix := range effortSuffixes {
if strings.HasSuffix(model, suffix) { if strings.HasSuffix(model, suffix) {
effort := strings.TrimPrefix(suffix, "-") effort := strings.TrimPrefix(suffix, "-")
......
package openai
import (
"bytes"
"fmt"
"io"
"math"
"net/http"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
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.GetEstimatePromptTokens()
usage.TotalTokens = info.GetEstimatePromptTokens()
for k, v := range resp.Header {
c.Writer.Header().Set(k, v[0])
}
c.Writer.WriteHeader(resp.StatusCode)
if info.IsStream {
helper.StreamScannerHandler(c, resp, info, func(data string) bool {
if service.SundaySearch(data, "usage") {
var simpleResponse dto.SimpleResponse
err := common.Unmarshal([]byte(data), &simpleResponse)
if err != nil {
logger.LogError(c, err.Error())
}
if simpleResponse.Usage.TotalTokens != 0 {
usage.PromptTokens = simpleResponse.Usage.InputTokens
usage.CompletionTokens = simpleResponse.OutputTokens
usage.TotalTokens = simpleResponse.TotalTokens
}
}
_ = helper.StringData(c, data)
return true
})
} else {
common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true)
// 读取响应体到缓冲区
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
logger.LogError(c, fmt.Sprintf("failed to read TTS response body: %v", err))
c.Writer.WriteHeaderNow()
return usage
}
// 写入响应到客户端
c.Writer.WriteHeaderNow()
_, err = c.Writer.Write(bodyBytes)
if err != nil {
logger.LogError(c, fmt.Sprintf("failed to write TTS response: %v", err))
}
// 计算音频时长并更新 usage
audioFormat := "mp3" // 默认格式
if audioReq, ok := info.Request.(*dto.AudioRequest); ok && audioReq.ResponseFormat != "" {
audioFormat = audioReq.ResponseFormat
}
var duration float64
var durationErr error
if audioFormat == "pcm" {
// PCM 格式没有文件头,根据 OpenAI TTS 的 PCM 参数计算时长
// 采样率: 24000 Hz, 位深度: 16-bit (2 bytes), 声道数: 1
const sampleRate = 24000
const bytesPerSample = 2
const channels = 1
duration = float64(len(bodyBytes)) / float64(sampleRate*bytesPerSample*channels)
} else {
ext := "." + audioFormat
reader := bytes.NewReader(bodyBytes)
duration, durationErr = common.GetAudioDuration(c.Request.Context(), reader, ext)
}
usage.PromptTokensDetails.TextTokens = usage.PromptTokens
if durationErr != nil {
logger.LogWarn(c, fmt.Sprintf("failed to get audio duration: %v", durationErr))
// 如果无法获取时长,则设置保底的 CompletionTokens,根据body大小计算
sizeInKB := float64(len(bodyBytes)) / 1000.0
estimatedTokens := int(math.Ceil(sizeInKB)) // 粗略估算每KB约等于1 token
usage.CompletionTokens = estimatedTokens
usage.CompletionTokenDetails.AudioTokens = estimatedTokens
} else if duration > 0 {
// 计算 token: ceil(duration) / 60.0 * 1000,即每分钟 1000 tokens
completionTokens := int(math.Round(math.Ceil(duration) / 60.0 * 1000))
usage.CompletionTokens = completionTokens
usage.CompletionTokenDetails.AudioTokens = completionTokens
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
}
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 := common.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
}
}
usage := &dto.Usage{}
usage.PromptTokens = info.GetEstimatePromptTokens()
usage.CompletionTokens = 0
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
return nil, usage
}
...@@ -172,7 +172,7 @@ func handleLastResponse(lastStreamData string, responseId *string, createAt *int ...@@ -172,7 +172,7 @@ func handleLastResponse(lastStreamData string, responseId *string, createAt *int
shouldSendLastResp *bool) error { shouldSendLastResp *bool) error {
var lastStreamResponse dto.ChatCompletionsStreamResponse var lastStreamResponse dto.ChatCompletionsStreamResponse
if err := json.Unmarshal(common.StringToByteSlice(lastStreamData), &lastStreamResponse); err != nil { if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &lastStreamResponse); err != nil {
return err return err
} }
...@@ -208,7 +208,6 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream ...@@ -208,7 +208,6 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
helper.Done(c) helper.Done(c)
case types.RelayFormatClaude: case types.RelayFormatClaude:
info.ClaudeConvertInfo.Done = true
var streamResponse dto.ChatCompletionsStreamResponse var streamResponse dto.ChatCompletionsStreamResponse
if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil { if err := common.Unmarshal(common.StringToByteSlice(lastStreamData), &streamResponse); err != nil {
common.SysLog("error unmarshalling stream response: " + err.Error()) common.SysLog("error unmarshalling stream response: " + err.Error())
...@@ -221,6 +220,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream ...@@ -221,6 +220,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
for _, resp := range claudeResponses { for _, resp := range claudeResponses {
_ = helper.ClaudeData(c, *resp) _ = helper.ClaudeData(c, *resp)
} }
info.ClaudeConvertInfo.Done = true
case types.RelayFormatGemini: case types.RelayFormatGemini:
var streamResponse dto.ChatCompletionsStreamResponse var streamResponse dto.ChatCompletionsStreamResponse
......
package openai package openai
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
...@@ -151,7 +150,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re ...@@ -151,7 +150,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
var streamResp struct { var streamResp struct {
Usage *dto.Usage `json:"usage"` Usage *dto.Usage `json:"usage"`
} }
err := json.Unmarshal([]byte(secondLastStreamData), &streamResp) err := common.Unmarshal([]byte(secondLastStreamData), &streamResp)
if err == nil && streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) { if err == nil && streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) {
usage = streamResp.Usage usage = streamResp.Usage
containStreamUsage = true containStreamUsage = true
...@@ -327,68 +326,6 @@ func streamTTSResponse(c *gin.Context, resp *http.Response) { ...@@ -327,68 +326,6 @@ func streamTTSResponse(c *gin.Context, resp *http.Response) {
} }
} }
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.GetEstimatePromptTokens()
usage.TotalTokens = info.GetEstimatePromptTokens()
for k, v := range resp.Header {
c.Writer.Header().Set(k, v[0])
}
c.Writer.WriteHeader(resp.StatusCode)
isStreaming := resp.ContentLength == -1 || resp.Header.Get("Content-Length") == ""
if isStreaming {
streamTTSResponse(c, resp)
} else {
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
}
}
usage := &dto.Usage{}
usage.PromptTokens = info.GetEstimatePromptTokens()
usage.CompletionTokens = 0
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
return nil, usage
}
func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) { func OpenaiRealtimeHandler(c *gin.Context, info *relaycommon.RelayInfo) (*types.NewAPIError, *dto.RealtimeUsage) {
if info == nil || info.ClientWs == nil || info.TargetWs == nil { if info == nil || info.ClientWs == nil || info.TargetWs == nil {
return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil return types.NewError(fmt.Errorf("invalid websocket connection"), types.ErrorCodeBadResponse), nil
...@@ -659,7 +596,7 @@ func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, res ...@@ -659,7 +596,7 @@ func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, res
if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens != 0 { if usage.PromptTokensDetails.CachedTokens == 0 && usage.PromptCacheHitTokens != 0 {
usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
} }
case constant.ChannelTypeZhipu_v4: case constant.ChannelTypeZhipu_v4, constant.ChannelTypeMoonshot:
if usage.PromptTokensDetails.CachedTokens == 0 { if usage.PromptTokensDetails.CachedTokens == 0 {
if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 { if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
...@@ -687,7 +624,7 @@ func extractCachedTokensFromBody(body []byte) (int, bool) { ...@@ -687,7 +624,7 @@ func extractCachedTokensFromBody(body []byte) (int, bool) {
} `json:"usage"` } `json:"usage"`
} }
if err := json.Unmarshal(body, &payload); err != nil { if err := common.Unmarshal(body, &payload); err != nil {
return 0, false return 0, false
} }
......
...@@ -192,6 +192,10 @@ func sizeToResolution(size string) (string, error) { ...@@ -192,6 +192,10 @@ func sizeToResolution(size string) (string, error) {
func ProcessAliOtherRatios(aliReq *AliVideoRequest) (map[string]float64, error) { func ProcessAliOtherRatios(aliReq *AliVideoRequest) (map[string]float64, error) {
otherRatios := make(map[string]float64) otherRatios := make(map[string]float64)
aliRatios := map[string]map[string]float64{ aliRatios := map[string]map[string]float64{
"wan2.6-i2v": {
"720P": 1,
"1080P": 1 / 0.6,
},
"wan2.5-t2v-preview": { "wan2.5-t2v-preview": {
"480P": 1, "480P": 1,
"720P": 2, "720P": 2,
......
...@@ -196,7 +196,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela ...@@ -196,7 +196,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela
} }
if jResp.Code != 10000 { if jResp.Code != 10000 {
taskErr = service.TaskErrorWrapper(fmt.Errorf(jResp.Message), fmt.Sprintf("%d", jResp.Code), http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(fmt.Errorf("%s", jResp.Message), fmt.Sprintf("%d", jResp.Code), http.StatusInternalServerError)
return return
} }
......
...@@ -186,7 +186,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela ...@@ -186,7 +186,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela
return return
} }
if kResp.Code != 0 { if kResp.Code != 0 {
taskErr = service.TaskErrorWrapperLocal(fmt.Errorf(kResp.Message), "task_failed", http.StatusBadRequest) taskErr = service.TaskErrorWrapperLocal(fmt.Errorf("%s", kResp.Message), "task_failed", http.StatusBadRequest)
return return
} }
ov := dto.NewOpenAIVideo() ov := dto.NewOpenAIVideo()
......
...@@ -105,7 +105,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela ...@@ -105,7 +105,7 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela
return return
} }
if !sunoResponse.IsSuccess() { if !sunoResponse.IsSuccess() {
taskErr = service.TaskErrorWrapper(fmt.Errorf(sunoResponse.Message), sunoResponse.Code, http.StatusInternalServerError) taskErr = service.TaskErrorWrapper(fmt.Errorf("%s", sunoResponse.Message), sunoResponse.Code, http.StatusInternalServerError)
return return
} }
......
...@@ -51,10 +51,43 @@ type Adaptor struct { ...@@ -51,10 +51,43 @@ type Adaptor struct {
} }
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) { func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
// Vertex AI does not support functionResponse.id; keep it stripped here for consistency.
if model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled {
removeFunctionResponseID(request)
}
geminiAdaptor := gemini.Adaptor{} geminiAdaptor := gemini.Adaptor{}
return geminiAdaptor.ConvertGeminiRequest(c, info, request) return geminiAdaptor.ConvertGeminiRequest(c, info, request)
} }
func removeFunctionResponseID(request *dto.GeminiChatRequest) {
if request == nil {
return
}
if len(request.Contents) > 0 {
for i := range request.Contents {
if len(request.Contents[i].Parts) == 0 {
continue
}
for j := range request.Contents[i].Parts {
part := &request.Contents[i].Parts[j]
if part.FunctionResponse == nil {
continue
}
if len(part.FunctionResponse.ID) > 0 {
part.FunctionResponse.ID = nil
}
}
}
}
if len(request.Requests) > 0 {
for i := range request.Requests {
removeFunctionResponseID(&request.Requests[i])
}
}
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) { func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
if v, ok := claudeModelMap[info.UpstreamModelName]; ok { if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
c.Set("request_model", v) c.Set("request_model", v)
......
...@@ -4,6 +4,7 @@ import ( ...@@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/types"
) )
// type ZhipuMessage struct { // type ZhipuMessage struct {
...@@ -37,7 +38,7 @@ type ZhipuV4Response struct { ...@@ -37,7 +38,7 @@ type ZhipuV4Response struct {
Model string `json:"model"` Model string `json:"model"`
TextResponseChoices []dto.OpenAITextResponseChoice `json:"choices"` TextResponseChoices []dto.OpenAITextResponseChoice `json:"choices"`
Usage dto.Usage `json:"usage"` Usage dto.Usage `json:"usage"`
Error dto.OpenAIError `json:"error"` Error types.OpenAIError `json:"error"`
} }
// //
......
...@@ -11,6 +11,7 @@ import ( ...@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/dto"
relayconstant "github.com/QuantumNous/new-api/relay/constant" relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/types" "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
...@@ -81,8 +82,9 @@ type TokenCountMeta struct { ...@@ -81,8 +82,9 @@ type TokenCountMeta struct {
type RelayInfo struct { type RelayInfo struct {
TokenId int TokenId int
TokenKey string TokenKey string
TokenGroup string
UserId int UserId int
UsingGroup string // 使用的分组 UsingGroup string // 使用的分组,当auto跨分组重试时,会变动
UserGroup string // 用户所在分组 UserGroup string // 用户所在分组
TokenUnlimited bool TokenUnlimited bool
StartTime time.Time StartTime time.Time
...@@ -373,6 +375,12 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { ...@@ -373,6 +375,12 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
//channelId := common.GetContextKeyInt(c, constant.ContextKeyChannelId) //channelId := common.GetContextKeyInt(c, constant.ContextKeyChannelId)
//paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride) //paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride)
tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
// 当令牌分组为空时,表示使用用户分组
if tokenGroup == "" {
tokenGroup = common.GetContextKeyString(c, constant.ContextKeyUserGroup)
}
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime) startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() { if startTime.IsZero() {
startTime = time.Now() startTime = time.Now()
...@@ -400,6 +408,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { ...@@ -400,6 +408,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
TokenId: common.GetContextKeyInt(c, constant.ContextKeyTokenId), TokenId: common.GetContextKeyInt(c, constant.ContextKeyTokenId),
TokenKey: common.GetContextKeyString(c, constant.ContextKeyTokenKey), TokenKey: common.GetContextKeyString(c, constant.ContextKeyTokenKey),
TokenUnlimited: common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited), TokenUnlimited: common.GetContextKeyBool(c, constant.ContextKeyTokenUnlimited),
TokenGroup: tokenGroup,
isFirstResponse: true, isFirstResponse: true,
RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path), RelayMode: relayconstant.Path2RelayMode(c.Request.URL.Path),
...@@ -626,3 +635,47 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther ...@@ -626,3 +635,47 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
} }
return jsonDataAfter, nil return jsonDataAfter, nil
} }
// RemoveGeminiDisabledFields removes disabled fields from Gemini request JSON data
// Currently supports removing functionResponse.id field which Vertex AI does not support
func RemoveGeminiDisabledFields(jsonData []byte) ([]byte, error) {
if !model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled {
return jsonData, nil
}
var data map[string]interface{}
if err := common.Unmarshal(jsonData, &data); err != nil {
common.SysError("RemoveGeminiDisabledFields Unmarshal error: " + err.Error())
return jsonData, nil
}
// Process contents array
// Handle both camelCase (functionResponse) and snake_case (function_response)
if contents, ok := data["contents"].([]interface{}); ok {
for _, content := range contents {
if contentMap, ok := content.(map[string]interface{}); ok {
if parts, ok := contentMap["parts"].([]interface{}); ok {
for _, part := range parts {
if partMap, ok := part.(map[string]interface{}); ok {
// Check functionResponse (camelCase)
if funcResp, ok := partMap["functionResponse"].(map[string]interface{}); ok {
delete(funcResp, "id")
}
// Check function_response (snake_case)
if funcResp, ok := partMap["function_response"].(map[string]interface{}); ok {
delete(funcResp, "id")
}
}
}
}
}
}
}
jsonDataAfter, err := common.Marshal(data)
if err != nil {
common.SysError("RemoveGeminiDisabledFields Marshal error: " + err.Error())
return jsonData, nil
}
return jsonDataAfter, nil
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment